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:
- Only a real browser counts. An app must open both web addresses and web pages, the rule from the previous lesson.
- At most once a calendar day. Every departure is still written; only the question is rationed.
- 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
markAskedonly 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 anaskedrow nobody saw. - The day survives a relaunch.
DepartureLog.read()deriveslastAskedAtfrom the newestaskedevent 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.