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

Asking Once a Day, Without Moving the Page

~13 min · instrumentation, ux, appkit, layout

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

444 questions, one answer

The first version of the departure log asked why every time Dad came back. Return to Voyager after a trip to Comet, and a bar appeared under the toolbar: "You went to Comet at 12:34. What was missing here?" The bar stayed until he closed it, and because it sat in the column of bars above the page, it pushed the page down while it was there.

The log recorded what happened next, which is the useful thing about an instrument that records everything. Over sixteen days the question came 444 times and was answered once. Invariant 10 now carries that number in its own text, so nobody reintroduces the old behaviour without reading why it went.

One answer in 444 says little about Comet. It says a lot about the question. When asking costs the user something every time, the cheapest response is to dismiss it, and the instrument ends up measuring patience.

Three changes in one commit

The fix arrived on 2026-10-22 as row B1 of a usability review (todo #603), in commit b2371a8. It changed three things together:

  1. Only a real browser counts. An app must open both web addresses and web pages, the rule from the previous lesson.
  2. At most once a calendar day. Every departure is still written; only the question is rationed.
  3. A toast, not a bar. The question floats over the page's top right corner, never moves the page, never takes the keys, and goes away by itself.

Once a day, in the tracker

DepartureTracker is a plain value in the parent, with no AppKit in it. When Voyager comes to the front, voyagerCameForward(at:calendar:) closes the open stay and returns the departure to ask about, or nothing. It returns nothing when a question was already shown on the same calendar day as now. Otherwise it returns the newest departure nobody has been asked about.

Three details make that rule hold up in real use, and one of them has a seam:

  • Handed over, then recorded. The shell calls markAsked only when a window in front took the question. If no window was up, the question stays owed. A test checks the tracker's half of that: the same departure is returned again until it is marked. The shell's half is looser than its comment promises. ask(about:) returns early when that window already holds a departure toast, yet the closure still reports the question shown, so a second departure can be recorded as asked without its question ever appearing. It is a narrow corner, because the first toast has to stay up past midnight, held there by a pointer resting on it or a half-typed reason. When it happens, the log holds an asked row nobody saw.
  • The day survives a relaunch. DepartureLog.read() derives lastAskedAt from the newest asked event in the file, and the tracker starts from it. Quitting Voyager does not reset the allowance.
  • An empty answer is no answer. reason(_:for:at:) trims whitespace and returns nothing for an empty string, so a stray Return writes no reason. Letting the toast go writes nothing either. That departure stays in the log as asked, with no reason, and the doctor counts it.

The trade is deliberate. The log is complete, and the question is a sample: at most one departure a day gets asked, always the most recent one waiting. A complete record with a rare question beats a constant question that teaches the user to ignore it.

Why a toast cannot move the page

"Never moves the page" could have been a promise kept by care. In Voyager it is kept by structure, and the structure is the window's layout(). The content view keeps a list called bars: the tab strip, the toolbar, the bookmarks bar, the notice bar and the find bar. Layout walks that list from the top, and each visible bar takes its height away from what is left. The page gets the remainder. The toasts are not in that list. They are laid out last, on top of the page's top right corner, sized to fit, and they take nothing from the page's height.

That is the whole fix for the jumping page. The old question lived in the notice bar, which is a bar, so it subtracted height. The new one lives in a view that subtracts none.

The notice bar still exists, and it now keeps only what must stay until Dad answers: a page whose process ended, a load that failed, a session that could not be read, a login to save. Anything that needs only a moment is a toast.

Never takes the keys, and goes by itself

A toast appears without focus, so whatever Dad was typing keeps going into the page. If he clicks into the toast's field and the toast then goes away, the keys go back to the page. Its timer counts down only while nobody is on it: the clock pauses while the pointer rests on the toast or while Dad is typing in it, and a toast with a reason typed in does not expire. The departure question gets 12 seconds; a download that has just started gets 5. A window shows three toasts at most, the oldest leaving first, and only one departure question at a time. The × is labelled "Not now".

Humane instrumentation

Put together, the policy is a short list you can reuse:

  • Record every event, whether or not you ask about it.
  • Ask rarely, and ask about the most recent case, while it is still fresh.
  • Never block, never move what the user is reading, never steal input.
  • Let silence be an answer the record can hold.

One more thing the commit got right is how it reported itself. Its validation listed a build and 19 departure tests passing, and then said plainly: not yet run in the installed app. The parity contract only accepts evidence from the installed bundle, so that sentence kept the fix from being counted as proven before it was.

Code

cwkVoyager, Sources/VoyagerCore/DepartureLog.swift: once a calendar day, recorded only after it is shown, and an empty answer is no answer (verbatim, lines 211-234)·swift
    /// Voyager came to the front. Returns the departure to ask about, if one
    /// is waiting and nothing was asked yet that day; it is asked about once,
    /// so the caller records `asked`. Asked on every return, the question came
    /// 444 times in sixteen days and was answered once (2026-10-22): now it
    /// comes at most once a day, and every departure is still in the log.
    public mutating func voyagerCameForward(at now: Date = Date(), calendar: Calendar = .current) -> Departure? {
        open = nil
        if let lastAsked, calendar.isDate(lastAsked, inSameDayAs: now) { return nil }
        return unasked
    }

    /// The question was shown.
    public mutating func markAsked(_ id: String, at: Date) -> DepartureEvent? {
        guard unasked?.id == id else { return nil }
        unasked = nil
        lastAsked = at
        return .asked(id: id, at: at)
    }

    /// Dad's answer. An empty one is no answer.
    public func reason(_ text: String, for id: String, at: Date) -> DepartureEvent? {
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        return trimmed.isEmpty ? nil : .reason(id: id, at: at, text: trimmed)
    }
cwkVoyager, Sources/VoyagerApp/BrowserWindowController.swift: bars take height from the page, toasts are laid over it and take none (verbatim, lines 1849-1882)·swift
    override func layout() {
        super.layout()
        var top = bounds.height
        for bar in bars where !bar.isHidden {
            let height: CGFloat
            switch bar {
            case let strip as TabStripView: height = strip.height
            case let toolbar as ToolbarView: height = toolbar.height
            case let bookmarks as BookmarksBarView: height = bookmarks.height
            case let find as FindBarView: height = find.height
            default: height = bar.fittingSize.height
            }
            top -= height
            bar.frame = NSRect(x: 0, y: top, width: bounds.width, height: height)
        }
        let height = max(top, 0)
        var pageWidth = bounds.width
        if let side, !side.isHidden {
            // The page keeps at least 400 points however wide Pippa was left.
            let width = min(side.width, max(bounds.width - 400, PippaPanelView.minimumWidth))
            pageWidth = max(bounds.width - width, 0)
            side.frame = NSRect(x: pageWidth, y: 0, width: bounds.width - pageWidth, height: height)
        }
        page?.frame = NSRect(x: 0, y: 0, width: pageWidth, height: height)
        if !status.isHidden {
            status.sizeToFit()
            status.frame = NSRect(x: 0, y: 0, width: min(status.frame.width, pageWidth * 0.8), height: status.frame.height + 2)
        }
        if let toasts, !toasts.isHidden {
            let size = toasts.fittingSize
            let width = min(size.width, max(pageWidth - 24, 0))
            toasts.frame = NSRect(x: max(pageWidth - width - 12, 0), y: max(height - size.height - 10, 0), width: width, height: size.height)
        }
    }
cwkVoyager, Sources/VoyagerApp/ToastStack.swift: the toast's clock runs only while nobody is on it (verbatim, lines 103-113)·swift
        .onHover { hovering = $0 }
        .task(id: toast.id) {
            // Its time runs only while nobody is on it.
            var left = toast.seconds
            while left > 0 || hovering || typing || !reason.isEmpty {
                try? await Task.sleep(for: .milliseconds(250))
                if Task.isCancelled { return }
                if !hovering && !typing { left -= 0.25 }
            }
            onDismiss(toast)
        }

External links

Exercise

Find a prompt, survey or confirmation in software you build that interrupts the user. Measure it: how often it is shown and how often it gets a real answer. Then redesign it with the four rules from this lesson: record every event regardless, ask at most once per period about the most recent case, never block or move content, and treat dismissal as an answer the record keeps. Implement the rationing as a pure function that takes the last-asked time, now and a calendar, and test it across a midnight boundary.
Hint
If you cannot measure the answer rate yet, add the measurement first; Voyager only knew "444 asked, 1 answered" because the log recorded every ask. For the layout half, check whether your interrupting view is part of the stack that sizes your content. If it is, it will move the content, however carefully you animate it.

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.