Companion to docs/design-analysis-lifecycle.md, which carries the state machine,
the sequence diagrams and the failure-mode tables. This is the pixels.
States are the shipped Swift types — ProjectAvailability and
SubtitleVariant. Nothing here invents a state; two affordances are proposed
and are marked as such.
One project, walked through every state it can occupy. Schema E — a clean row shows no status line at all.
UnanalysedState already carries newFiles and missingFiles
separately. New files invite an action — fold them in. Missing files invite none: it is a
statement of fact, and offering a button there would be a call to action for a situation with no
action. Merging them into one “needs attention” state would manufacture that button.
A process that dies without writing a terminus event looks, on disk, exactly like one still running. Reconciling on next start is what turns it into a stated failure. Without that step the row spins forever — which is precisely what an eleven-hour hang looked like on 19 Aug 2026.
Three projects, one relaunch later. Onboarding diaries was re-analysed yesterday
and the run died in ingest. The disk is identical in both sidebars — run_failed is
sitting in the events log either way. The only difference is whether the app reads it.
On the left the row is not merely quiet — it is quieter than its neighbours. Ward rounds and Pharmacy handover carry their session counts; Onboarding diaries carries nothing, because the database that held the count was deleted and the run never got far enough to build a new one. A researcher may well notice the gap. Nothing on screen lets them explain it, and a row with no count and no status line is exactly what a folder dragged in five minutes ago looks like.
--clean removes the manifest, and the state read on relaunch returns .idle
at the manifest guard before it reaches the branch that would consult the events log.
The drift gate then correctly declines to shout — with no database it cannot know what is
unanalysed — so there is no delta either, and the subtitle resolver falls through to the silent
placeholder. Two reasonable rules and one guard in the wrong order produce a row that asserts
nothing ever happened.
The subtitle is the visible part; the reachability is the substantive part. Show Diagnostics… is gated on the row being in a failure state, so on the left it is absent from the context menu and the glyph is absent from the row — the cause is on disk and every surface that would show it has been switched off by the same wrong answer. On the right the glyph is a button, the menu item is back, and the popover names the file that stopped the run.
hasWorkToDo reads the ungated file count, so the retry is available in either case.
On the left it reads as a first analysis of a folder nobody has touched. On the right it reads
as what it is — a retry, after a stated failure.
Selected project is analysed and idle. Nothing is running.
For a folder-shaped project the CLI already rescans the folder at run time, so plain Analyse is the incremental verb — the per-session cache re-transcribes only what’s new and curation survives. Nothing new gets built here. What’s wrong today is that the control gives no clue whether edits survive; naming it for the state answers that in the label instead of in a doc the researcher will never read.
First menu, as it was: the detail pane behind it said “Add interview recordings or transcripts
to get started”, while the context menu’s first and highlighted item offered to analyse them.
canAnalyse checked the project’s kind, its path and the pipeline state —
never whether there was any media. Fixed 19 Aug 2026: hasWorkToDo asks the third
question, so Analyse is absent on an empty project and on an analysed one with nothing new, and
the pane counts the same field rather than asserting emptiness. See §4.1 of the design doc for
the full matrix.
Not as an alternative to drag-drop or File ▸ Add Files… — both of those already fold new
files into a folder-shaped project. It earns its place on the one route that has no drop event
and no menu action to hang off: files added in Finder. That is what
ProjectFolderWatcher is for, and today it detects the situation, reports it as
“+N unanalysed”, and offers nothing — the sheet’s only button is Close.
MenuCommands.swift states the rule in its own comment: menu-bar items dim,
context menus hide instead. So Re-analyse… is present here only when it can run.
Today it is .disabled(true) hardcoded in the Project menu with no state that could
ever enable it — dimming says “not right now” when the truth is “not in this build”, which sends
people hunting for a state that does not exist.
Option-reveal is for alternates you already know about — Save As…, Quit and Discard Windows.
Recovering from a bad run is an ordinary need, not a power-user variant, and putting a
destructive action behind an invisible modifier with no confirmation inverts the safety.
The … already carries the promise that something will be asked.
Every state a project row can occupy, and what moves it. Solid = the system moved it; dashed = the researcher did.
Analyse, incremental and re-analyse are three doors into one room. That is why they can share a pipeline and still need separate labels: the researcher is choosing what they’ll lose on the way in, not which machine runs.
Nothing on disk distinguishes “running” from “died without saying so”. Only the reconciliation on next start closes it, which is why it earns a box rather than being folded into Failed. An eleven-hour spinner on 19 Aug 2026 was this state, unnamed.
Two edges are double-headed — files come back, volumes remount, and the row should return to Analysed on its own. Drifted only leaves via a run. That asymmetry is why Missing gets no button and Drifted does.
Same states as the context menu, opposite convention: a menu-bar item stays put and dims. Unimplemented is the exception — it hides in both.
Every dimmed item above lights up in one of the other two columns — that is what makes the dim
honest. Re-analyse… today is .disabled(true) hardcoded, so it is dim in all
three, which reads as “not yet” and means “not ever, in this build”. That is the difference
between a state-dependent control and a placeholder, and the fix is to hide placeholders.
Left column: Analyse is dimmed because there are no recordings. Today it is
enabled and highlighted — canAnalyse checks the project’s kind, its path
and the pipeline state, never whether there is any media to analyse.
It is meaningless in all three columns shown and lights up only in cantFind — a
state the menu bar can reach but these three columns can’t. Included precisely to show a
correctly-dimmed item beside the incorrectly-dimmed one.
Shown for any folder-shaped project that has never produced a report. It used to say the same thing whether the folder was empty or full. Shipped 19 Aug 2026.
Add interview recordings or transcripts to get started.
Add interview recordings or transcripts to get started.
It was gated on case .idle alone — “has never produced a report” — and never asked
whether the folder had anything in it. Before canAnalyse learned to check, both
surfaces were wrong together and looked coherent; fixing only the menu left it offering
Analyse while the pane denied there was anything to analyse. The menu was the half that
was right, so the pane was brought to it — and to the same measurement, so the two can say
different things but cannot contradict each other.
Files are what we know before stage 1; sessions are what group_into_sessions
decides. Six files from a Teams export can be three sessions, so a session count here would be a
prediction the sidebar could then contradict — the app disagreeing with itself about the size of
the study. It is also the vocabulary already in use for this moment:
unanalysedSheetTitle reads “Unanalysed files in {{project}}”.
With an Analyse button below it, a sentence explaining how to get started restates the
control beside it. The count carries what the button cannot — that we saw the files — and the
button carries the verb. It is the menu’s verb, literally: the button reuses
desktop.menu.project.analyse, so the two surfaces say the same word.
The number is the ingestable subset of the watcher’s own enumeration, so five recordings beside
a notes.txt read “5 files to analyse” — five is what a run then ingests. When the
watcher has not reported yet, the pane shows the drop target rather than guessing a count: a
pane that guessed would be asserting something it had not measured.
Drag-and-drop is an analysis-triggering affordance, and four of its five outcomes
say so with a floating toast. None of this had ever been drawn. Shown here at
ToastSurface’s real geometry so it can be judged rather than imagined.
Two tell you what to do (finish or stop, use Retry), two state a fact (isn’t reachable, files added), one does both. They were written one at a time at their own call sites, five months apart, and never seen side by side — which is precisely what a mockup is for and precisely what this one omitted.
“Existing toast.show(...) call sites are ‘needed feedback, hadn’t designed the
chrome yet’ placeholders. Don’t add new ones.” Four of the five are drop refusals,
and the recorded alternatives are named: a predictive cursor badge during the drag (Finder’s
⊘), persistent inline state on the row, a sheet at the locus of action, or silence. A refusal
that a researcher can only learn about by catching a 3-second float is the weakest of those,
and it is the one we shipped.
Three of these are decidable before the drop: the project is running, the folder is
unreachable, no project is selected. Finder answers that class with the cursor
(NSDragOperation.forbidden → ⊘), before commit and without interrupting. Only the
file-subset case is genuinely post-hoc, because the copy succeeds and the consequence is about
the report, not the drop — and a consequence about the report belongs on the row or the pane
that shows the report’s state, where it survives longer than three seconds.
Four of the five need no message at all: the drag already knows. The fifth is a statement about the report, so it belongs where the report’s state lives.
“Show people whether a destination can accept dragged content… show no visual feedback — or
an explicit ‘not allowed’ image, like the circle.slash from SF Symbols — when it
can’t.” And for macOS: “Consider changing the pointer appearance… the operation not
allowed pointer.” Returning [] from validateDrop buys all of it —
no highlight, the ⊘ pointer, and the item springing back — in every language at once. Today we
return .copy for any project row, accept the drop, then scold; the AppKit comment
admits it, saying policy lives in ContentView.
The drop calls seedKnownBasenames, commented “so the count pill stays hidden —
they’re ‘known,’ not surprise drops”. That is right for a folder-shaped project, where a run
starts at once. On a file-subset project nothing starts, so those files are exactly the drift
the pill is for — and suppressing the persistent signal is what created the appetite for a
transient one. Stop seeding on that branch and the row says +3 unanalysed by
itself, permanently, re-readable.
UndoableRemovalStore avoids NSUndoManager on purpose and expires at
8 s — so ⌘Z expires too, not just the toast. Remove is already non-destructive (the folder
of real files goes on living), so the fuse buys nothing and costs the one guarantee a Mac user
has. On the system undo stack it survives until the next undoable act and lands in Edit ▸ Undo
free. The vanishing row gets the poof the HIG names for exactly this — “scale up and fade
out to give the impression of the item evaporating”.
It used to be offered, refused, and explained by a toast naming the project. Now the context menu omits the item and the Project menu dims it — the same hide-versus-dim split as everywhere else. For a mixed selection the removable rows go and the running one stays, selected and visible: the thing that changed is the message, and no sentence is needed to narrate a row still on screen.
The popover deliberately has no action row: “Retry / Change provider / Re-analyse… live in the project’s natural run affordance”. So the toast pointing at the toolbar was signposting a control one right-click away. It goes; nothing replaces it.
Same three verbs, unrolled in time. Blue dashed arrows are what comes back to the researcher.
Analyse and incremental run straight through; re-analyse stops to ask. That asymmetry is the whole design — and it is why the confirmation must count. A modal that merely warns adds a step to two-thirds of the flows for no information.
Its first message comes from Finder, not the researcher. That is the case with no drop event and no menu action to hang off, which is exactly why the watcher exists — and why the sheet it feeds needs a primary action rather than only Close.
Between --clean and run_completed the project has no old analysis and
no new one. If that run fails, the row lands in Empty — correct by the state machine, and
worth confirming it is what the row actually shows, because the researcher will read it as
“my study is gone”.
Shown only when there is something to lose. Silence when there isn’t.
Bristlenose will transcribe and analyse all 14 interviews again from scratch.
“This cannot be undone” tells a researcher nothing they can weigh. Four counted lines let them decide in one read — and if the counts come back zero, there is nothing to weigh and the modal should not appear at all. That is the difference between a confirmation and a speed bump.
22 Aug 2026 — this drawing was right and the build was not. The heading below states the
rule; the build had it inverted, putting .defaultAction on Cancel so the accent
fill landed on the way out. Apple’s alert guidance says not to make Cancel the default,
to place the default on the trailing side, and to reserve the destructive style for actions
people did not deliberately choose — Empty Trash being their example of the case this sheet is
in. The code was corrected; this drawing’s only error was the hue, red where the platform draws
the default blue, and that is now fixed too. Native render and the before/after:
reanalyse-sheet-pixels.html.
Return cancels. The destructive action carries the system red and sits where the eye lands last.
No --clean, no folder paths, no mention of an output directory — the researcher
should never need a model of where Bristlenose keeps its working files, and the moment a message
tells them to go and delete one, that model has leaked.
The list had four lines and --clean destroys five things. A confirmation that
measures cannot be silent about one of the losses; implementing the picture literally would
have broken the picture’s own purpose. Zero-count kinds are omitted from the rendered list —
a “0 tags” line is noise in something whose whole job is to be read at a glance.
--clean is rmtree, so a re-analysis always destroys the existing
analysis too — which briefly looked like an argument that the silent case can never happen. It
isn’t: derived output is the thing the researcher just asked to have rebuilt, and the sheet’s
opening line already says so. The counted block is about their work, and when there is none the
sheet does not appear.
No “don’t ask again”. The cost is real every time, and the count changes every time.