Skip to content
C.W.K.
Lesson 05 of 05 · published

The Daily-Use Review

~16 min · usability, appkit, chrome-parity, review

Level 0New Tab
0 XP0/44 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

A review measured against the browsers Dad left

A browser can pass every test and still feel wrong in the first minute. Todo #603 was a usability review of Voyager in daily use, row by row, each row a place where it behaved differently from Chrome or Safari. Its rows are numbered B1 and B2, M1 to M6, and m1 to m10, and the commit bodies call the m rows the minor ones. All of them landed on 2026-10-22 in seven commits: six name the rows they closed, and a seventh closed two gaps that a review of the day's own changes found.

RowsWhat changedTaught in
B1the departure question at most once a day, in a toasttrack 1
M1, m7the address field completes, selects, shortens, pastes and goeshere
M2, M3Pippa herself stays live; an ended page reloads oncelesson 4
M4, B2family apps get the microphone with no prompt; Settings names where the family hub livestrack 6
M5, m6idle tabs sleep; the session is written off the main threadlessons 2 and 3
M6, m8a full strip keeps every tab reachable; tabs drag outhere
m1 to m5, m9, m10find counts, history lists, downloads, dialogs, other apps, mutehere

The address field, Chrome's way

Before M1, typing "x" and pressing Return searched for "x". Chrome would have gone to the site Dad visits most. Now the field completes inline from history: the site of his most visited page whose host begins with what he typed ("you" becomes youtube.com, with a port kept for a family host), or, once the text has a slash, the page whose address begins with it. "www." is skipped unless typed, a typed scheme completes only its own pages, and a search or a whole site already typed is never stretched. The completion appears only while Dad types at the end of the text; a deletion or an edit in the middle is never completed again.

The rest is small and felt every minute. The first click selects the whole address, so typing replaces it. Outside the field the address is shown short: no https://, no leading www., no slash on a site's root, while plain http:// keeps its scheme so an insecure page still says so. Inside the field the whole address shows, because what Dad copies must be the address itself. The field's menu offers Paste and Go, or Paste and Search for words. A magnifier appears when a page sits at a zoom other than the default.

"3 of 17"

WebKit's find moves from match to match but answers only whether there was one. The count comes from the page itself: a script in Voyager's own content world walks the visible text, skipping scripts, styles and hidden nodes as WebKit's find does, and finds where the selection sits. The label then follows three cases, shown in the first code block. Find runs as Dad types, after a 150 ms pause, and starts from the match already selected so a longer word stays put. ⌘E puts the selection into the bar and into the find pasteboard that every Mac app shares.

Sound, downloads, dialogs and other apps

  • m9: a tab that plays sound shows a speaker that mutes it. WebKit has no public mute, so Voyager mutes the page's media elements from its own script in every frame. Sound made with no element is neither heard nor muted, and the engine evaluation says so.
  • m2: back and forward held for 0.4 seconds, or right-clicked, list the page's own history.
  • m3: a download says it started, arrived or failed, and the Dock's Downloads stack bounces with the file.
  • m4: a dialog from a tab behind waits until that tab is in front instead of pulling it forward.
  • m5: a mail link opens Mail; any other app link asks once, with "Always allow <site> to open <app> links" remembered per site and scheme, never from a private window.

A strip that never loses a tab

Before M6, a tab past the strip's right edge was reachable only by ⌘9 or ⌃Tab. Now tabs narrow to 34 points times the interface scale. Under 64 times the scale a tab shows only its icon, and the tab in front only its close button, as Chrome's narrowest tabs do. Past that the strip draws a run of tabs, computed by TabRun, a pure function in the parent: as many as fit, moved so the tab in front is in view, never leaving room empty at the end. A chevron lists every tab. And m8 lets a tab be dragged out into a window of its own, or onto another window.

Two AppKit lessons from just before

Two fixes from earlier that month belong with these rows. In the title bar, AppKit tells the window server that only controls own their presses, so the top 28 points of every plain-view tab moved the window instead of the tab (todo #424, 2026-10-13). TabItemView became an NSControl, and it takes the first click of a window behind, as Chrome's tabs do. Then the mouse: WebKit acts on neither the side buttons nor the swipe that Logi Options+ sends in their place, so one local event monitor takes both before the page sees them and runs the window's own Back and Forward (todo #460, 2026-10-16). HistoryStep is the pure part that maps a button number or a swipe to a step.

Principle: Copy the convention exactly, edges included. Dad's hands learned Chrome and Safari over years. A browser that is right in the middle and different at the edges is the one he leaves.

Code

cwkVoyager, Sources/VoyagerCore/EngineContract.swift: what the find bar says (verbatim, lines 279-300)·swift
/// Where a find stands among a page's matches, for "3 of 17". WebKit's find
/// moves from match to match but says only whether there was one; the page
/// counts its own visible text for the rest.
public struct FindMatches: Sendable, Equatable {
    /// The match selected now, from 1; 0 when none is.
    public var index: Int
    public var count: Int

    public init(index: Int, count: Int) {
        self.index = index
        self.count = count
    }

    /// What the find bar says after a search: "3 of 17", "No matches", or
    /// nothing when the engine found a match the page cannot count (one in a
    /// frame of another site).
    public static func label(found: Bool, matches: FindMatches?) -> String {
        guard found else { return "No matches" }
        guard let matches, matches.count > 0, matches.index > 0 else { return "" }
        return "\(min(matches.index, matches.count)) of \(matches.count)"
    }
}
cwkVoyager, Sources/VoyagerCore/HistoryStep.swift: a mouse button or a swipe becomes one step (verbatim, lines 9-37)·swift
/// Back or forward, one step.
public enum HistoryStep: Equatable, Sendable {
    case back
    case forward

    /// A mouse button as AppKit numbers them, from 0: 3 is button 4, back, and
    /// 4 is button 5, forward. Every other button is someone else's (2, the
    /// middle one, closes a tab in the strip and opens a link in a new tab).
    public init?(mouseButton number: Int) {
        switch number {
        case 3: self = .back
        case 4: self = .forward
        default: return nil
        }
    }

    /// A swipe event's horizontal part: 1 is a swipe to the left and reads as
    /// back, -1 to the right and reads as forward, as Safari, Chrome and
    /// Firefox read it. A swipe up or down is not a history step.
    public init?(swipeDeltaX deltaX: Double) {
        if deltaX > 0.5 {
            self = .back
        } else if deltaX < -0.5 {
            self = .forward
        } else {
            return nil
        }
    }
}
cwkVoyager, Sources/VoyagerCore/TabRun.swift: which tabs a full strip draws (verbatim, lines 1-26)·swift
// Which tabs a strip too full for all of them draws (todo #603, M6). The
// strip narrows tabs to their icons first; past that it draws a run of them.

/// A run of tabs that fits: from `first` as many as fit, moved so the tab in
/// front is in view when `keep` names it, and never leaving room empty at the
/// end (after tabs closed, the run slides back).
public enum TabRun {
    public static func shown(widths: [Double], gap: Double, available: Double, first: Int, keep selected: Int?) -> Range<Int> {
        guard !widths.isEmpty else { return 0..<0 }
        func end(from start: Int) -> Int {
            var end = start, used = 0.0
            while end < widths.count, used + widths[end] <= available {
                used += widths[end] + gap
                end += 1
            }
            return max(end, min(start + 1, widths.count))
        }
        var start = min(max(first, 0), widths.count - 1)
        if let selected, widths.indices.contains(selected) {
            start = min(start, selected)
            while end(from: start) <= selected { start += 1 }
        }
        while start > 0, end(from: start - 1) == widths.count { start -= 1 }
        return start..<end(from: start)
    }
}

External links

Exercise

Run your own daily-use review of an app you build, against the app your users came from. Use it for one real day and write a row every time it behaves differently from the app they know, with a number and a grade (blocking, major, minor). Then pick one row and fix it the way this lesson does: move the decision into a pure function, test its edges, and keep the platform code that calls it as small as you can.
Hint
Watch the edges, where habits live: the first click in a field, a key pressed with no selection, a full toolbar, a list at its end, a mouse with extra buttons. Note what the other app does exactly before you decide anything. A row that says 'Chrome selects the whole address on the first click' can be fixed and tested; a row that says 'the address bar feels off' cannot.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.