Mockup · analysis lifecycle

Every row state, and the affordance each one offers

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.

Palette
Artefact — real pixels Commentary — reasoning, not shipped

The row-state ladder

One project, walked through every state it can occupy. Schema E — a clean row shows no status line at all.

sidebar · project row states
Healthy
empty
📁
Onboarding diaries
running
📁
Onboarding diaries
Transcribing · 6 of 14
analysed
📁
Onboarding diaries
Yesterday
14
drifted — new files
📁
Onboarding diaries
+3 unanalysed
14
missing — files removed
📁
Onboarding diaries
2 files missing
14
Distress
completedPartial
📁
Pharmacy handover
Partial completion
11
failed
📁
Pharmacy handover
Ran out of credit
0
stranded → reconciled
📁
Pharmacy handover
Run didn’t finish
0
cantFind
📁
Pharmacy handover
Samsung T7 · missing
inCloud — downloading
📁
Pharmacy handover
Downloading · 62%
stopping
📁
Pharmacy handover
Stopping…
Commentary

Drifted and Missing are different states because they want different verbs

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.

Stranded is a real state, not a bug

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.

The morning after a failed re-analysis

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.

Tuesday 9am · before the fix / after the fix
📁
Ward rounds
9
📁
Onboarding diaries
📁
Pharmacy handover
6
before
📁
Ward rounds
9
📁
Onboarding diaries
Couldn’t read one of the recordings
📁
Pharmacy handover
6
after
Commentary

The tell is a missing number, not a message

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.

Why the left-hand row says nothing

--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.

What the right-hand row restores beyond the line itself

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.

Both rows offer Analyse

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.

The context menu — before and after

Selected project is analysed and idle. Nothing is running.

left: before 19 Aug · then as built · Re-analyse… still proposed
Commentary

“Analyse 3 New Files” is a relabel, not a new command

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.

The empty project was the clearest false affordance in the app

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.

Where this affordance actually earns its place

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.

Never dimmed, in a context menu

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.

Against hiding it behind Option

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.

The state machine

Every state a project row can occupy, and what moves it. Solid = the system moved it; dashed = the researcher did.

project lifecycle · analyse · re-analyse · incremental
start Emptyno deliverable Runningspinner · Stop ⌘. Analysedcount · date Drifted+N unanalysed MissingN files gone UnreachableLocate… Partialreduced fidelity Faileddiagnostic popover Strandedno terminus event Analyse run_completed Re-analyse… partial run_failed Analyse (retry) process dies reconciled on next start new files seen removed / restored unmounted / Locate… Analyse — folds in incrementally, edits survive Re-analyse…
system transition researcher action degraded failure
Commentary

Running is the only hub — every verb passes through it

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.

Stranded exists because a dead process looks exactly like a live one

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.

Missing and Unreachable are reversible; Drifted is not

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.

The Project menu — dim, don’t hide

Same states as the context menu, opposite convention: a menu-bar item stays put and dims. Unimplemented is the exception — it hides in both.

Project menu · left: empty project · centre: analysed · right: running
Commentary

Dimming is a promise that some state enables it

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.

The empty column is the bug, drawn

Left column: Analyse is dimmed because there are no recordings. Today it is enabled and highlightedcanAnalyse checks the project’s kind, its path and the pipeline state, never whether there is any media to analyse.

Locate… earns its dim

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.

The detail pane — three states, not 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.

left: before, folder holds 6 files · centre and right: as built

Drag Interviews Here

Add interview recordings or transcripts to get started.

6 files to analyse

Folder genuinely empty — unchanged

Drag Interviews Here

Add interview recordings or transcripts to get started.

Commentary

The left pane was telling you to add the files you already added

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, not sessions — because only one of those is a fact

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}}”.

The description is cut, not rewritten

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.

Counted before grouping, and minus the notes

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.

Dropping files — the five outcomes, as shipped

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.

Superseded 19 Aug 2026 — kept as the evidence, not as the spec. All five are being removed. Drawing them was what made them reviewable, and the review killed them: four say at drop-time what the drag itself should have said, and the fifth duplicates a status line that was already there. The replacement is the next section. Nothing below this note describes intended behaviour.
in place: dropped on a running project · toast floats over the report, 3s
Pilot round
IKEA with uxfriends
Onboarding v2
Finish or stop the current run before adding more.
the whole vocabulary, one column — left: what triggers it
running Finish or stop the current run before adding more.
failed Use Retry on the toolbar to try this run again.
unreachable This project’s folder isn’t reachable right now.
file-subset Files added. They won’t be in the report until the project is analysed again.
no selection Select a project first, then add files to it.
folder-shaped no toast — the run starts, and the run is the acknowledgement
removed (8s) Removed “IKEA with uxfriends”.Undo
Commentary

Read as a column, they don’t agree with each other

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.

Every one of them violates a rule this project already wrote down

“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.

The drag already knows — which is where the answer probably is

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.

Dropping files — what replaces them

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.

mid-drag over a running project · no highlight · pointer swaps · springs back
Pilot round
IKEA with uxfriends
Onboarding v2
no row highlight — the drop is refused
📄 p07.mp4  
after a drop on a file-subset project · the row says it, and keeps saying it
Pilot round
IKEA with uxfriends
+3 unanalysed
Onboarding v2
left: File menu with nothing selected · right: removal, on the undo stack
Commentary

The HIG answers four of the five, and it costs no words

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 file-subset toast existed because we had deliberately turned off the signal

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.

An undo with a fuse is a toast wearing a menu item

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”.

Remove from Sidebar isn’t offered while a run is going

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.

Retry keeps the home it was given

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.

The three sequences

Same three verbs, unrolled in time. Blue dashed arrows are what comes back to the researcher.

sequences · analyse · incremental · re-analyse
1 · Analyse — the happy path
Researcher Sidebar row PipelineRunner Sidecar Project folder Analyse run(project, clean: false) spawn: run <path> --no-serve · stdin /dev/null refuse_reason() — is this a study folder? scan, depth ≤ 3 run_started spinner + Stop ⌘. stages 1–12 stage progress run_completed session count + date
2 · Incremental — new files fold in
Finder ProjectFolderWatcher Sidebar row Sidecar file added — NSFilePresenter fires debounce 300ms UnanalysedState(newFiles: 3) “+3 unanalysed” Analyse — manifest present, cache honoured cached stages skipped new sessions only run_completed curation survives — cached stages don’t regeneratequotes, so stable keys don’t move (importer.py:216)
3 · Re-analyse — the destructive one
Researcher Row / Project menu Confirmation Sidecar Re-analyse… count what would be lost “47 quotes · 12 tags · 3 renamed speakers” Re-analyse run --clean delete output · full pipeline run_completed nothing to lose → no modal at all; a count of zero isn’t a question
Commentary

Only one of the three has a gate in it

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.

The incremental sequence starts outside the app

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.

Re-analyse deletes before it produces

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”.

The confirmation — measured, not vague

Shown only when there is something to lose. Silence when there isn’t.

Re-analyse… · curation present

Re-analyse “Onboarding diaries”?

Bristlenose will transcribe and analyse all 14 interviews again from scratch.

Your work on this study will be discarded:
  47 edited quotes
  12 tags
  8 starred quotes
  3 renamed speakers
  2 named themes
Commentary

A terse modal measures

“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.

Cancel is the default; the destructive button is not blue

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.

Stars were a hole in this drawing — added 19 Aug 2026

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.

“Nothing to lose” means nothing of theirs

--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.

What is deliberately absent

No “don’t ask again”. The cost is real every time, and the count changes every time.