Every state a researcher sees when a project's media are cloud placeholders. Grounded in the 29 Jul 2026 reproduction (Dropbox, three 307–649 MB recordings) — see docs/design-project-storage.md §3.
formatBareDate), exactly as today.
st_blocks reads 0 until it reads 100%. But the file count is knowable, so “1 of 3” is honest and matches the existing “Transcribing · 2 of 3” ladder..ready row shows no subtitle at all and collapses to a single 32pt line. No date, no verb, no tick. Nothing about the cloud, then or later.
formatBareDate) with an optional delta. Neither is right: the first was invented, the second was designed but never once rendered — lastPipelineRunAt has no write site in any build. The delta (+2 new) still shows when there is one; that's an exception, and exceptions are the only thing this line carries.isDownloading true/false, never a rate. This is the one legitimate wall-clock number, and it's a hint, not a failure.
Names the provider because each has a different fix path — Dropbox's menu-bar item, iCloud's Settings pane, the OneDrive client. “The cloud” leaves the researcher nowhere to go. Naming isn't special-casing behaviour; it's attribution, and the sibling state already names the volume.
Hands off to the provider's own docs rather than writing remedy prose. Dropbox documents its sync failures better than we can and keeps them current; every cause we agreed not to guess between — paused sync, quota, selective sync, network, permissions — is on that page. It also collapses 5 providers × 20 locales of maintained instructions into one URL per provider, resolved from the same lookup that gives the name.
| Provider | Shown | Links to | Page |
|---|---|---|---|
| Dropbox | help.dropbox.com | /sync/files-not-syncing | “Dropbox stopped syncing: how to fix…” |
| Google Drive | support.google.com | /drive/answer/2565956 | “Fix problems in Drive for desktop” |
| OneDrive | support.microsoft.com | /en-us/onedrive/fix-onedrive-sync-problems-on-mac | Mac-specific — prefer it over the generic page, the desktop is Mac-only |
| iCloud Drive | support.apple.com | /guide/mac-help/mchl1a02d711/mac | “Work with folders and files in iCloud Drive” — gives Control-click ▸ Download Now directly |
| Box · Proton Drive | — | Both ship File Providers, so both resolve via the stem fallback. No URL — not worth the research time. (Box's top search hit is for the EOL Box Sync client, so it would be the wrong link anyway.) | |
| unknown / other | Omit the line entirely. Never invent a URL. | ||
The four above are the list. Research (29 Jul) didn't add a fifth worth the effort — it confirmed the criterion is ships a macOS File Provider rather than market share, since that's what produces the placeholder at all. Apple has made File Provider the only approved mechanism, so the set only grows, which is the argument for keeping the lookup structural rather than an allowlist.
The cost of an unlisted provider is one missing link, not a broken experience. The stem fallback already gives an Egnyte user “Fetching from Egnyte…”, correctly, with no code change. That's what makes “omit the line” a safe default rather than a gap — and why deciding which providers “matter” isn't a decision we have to get right.
Show the domain, link the deep path. The domain is the whole flavour — it tells the researcher they're going to the provider's own help, not some blog — while the path is noise nobody reads. Shorter than a bare “Learn more…”, and more honest, since it says where they're being sent.
The CLI prints the full URL — no click target to hide it behind, and a truncated URL you can't paste is worse than a long one.
All four verified live 29 Jul 2026. Apple's is instructional where the others are diagnostic — it tells the researcher how to force the download rather than why sync broke, which suits iCloud's actual failure mode (usually “not fetched yet” rather than “sync is wedged”). Link to the most useful page per provider; don't force one category on all of them.
Liveness check at release — and it's the point, not the cost. Third-party URLs rot and we don't control them, so they need checking alongside the other release gates. But the reason to bother is that this link fires at the moment a researcher is most stuck: a run has failed, they don't know why, and the thing they need is owned by someone else. A working link there is a small effort that shows the tool thought about them being stuck. A dead one, at exactly that moment, says the opposite — which is why it's worth a gate rather than good intentions.
Says “downloaded, but couldn’t be read” — ruling out the cloud explicitly, because that's the confusion this whole change exists to prevent. And it says what didn't fail: a researcher who knows the other two are fine won't re-run the whole study.
A run finished. Three interviews analysed, one couldn't be read. The row has to say both things, and the subtitle only fits one. Five options, quietest first, drawn at the real geometry so the judgement was about pixels rather than prose. Option 5 won — jump to it for the shipping design.
Read options 1–4 as history. They're drawn in the pre-29-Jul world where every row carried a date (Schema A). That's since been retired — a clean row now shows no status line at all — so only option 5 is drawn as the app will look. Kept because the comparison is what produced the decision.
.completedPartial is a PipelineState, so it outranks the idle tier — ProjectSubtitle.resolve returns before the date/delta chain is ever consulted (ProjectSubtitle.swift:156-186), and ProjectRow renders the warning glyph plus the localised “Partial completion” header (ProjectRow.swift:255-257). So the drift is already dropped on a partial. The question isn't which to build — it's whether to change what's there.Every number is read from source. Sidebar 220pt (its ideal; min 180, max 300 — ContentView.swift:358). Rows 32pt single / 46pt two-line, 7.5pt top inset, 20pt icon column, 13pt title, 10pt count, 10pt subtitle — all from ProjectCellSpec.swift, which is itself reverse-engineered verbatim from ProjectRow. The glyph is exclamationmark.triangle.fill in system orange (MessageKind.warning); Tabler stands in for SF Symbols, at the right size. Rows are drawn unselected and with two ordinary neighbours — a row is never judged alone, and “nagging” is a property of the stack.
220pt · ideal
180pt · minimum
.inCloud. Both facts survive at both widths, and the drift stays clickable.
220pt · ideal
180pt · minimum
subtitleText(prefix:) and diagnosticSubtitle both put the clickable glyph there, so this reuses the existing vocabulary rather than adding one. Colour carries severity; the text carries the actionable half.
220pt · ideal
180pt · minimum
220pt · ideal
180pt · minimum
220pt · ideal — drawn under Schema E
180pt · minimum
+3 new, 1 failure is 78.4pt; the realistic worst case +24 new, 11 failures is 94.6pt. Even today's longer copy (+3 unanalysed, 1 failure, 114.1pt) fits at both. Dropping the date is what bought the room: with it, the same line was 170.5pt and overran the ideal width.⚠ +3 new, 1 failure: the drift, then the failure clause, in one subtitle behind the orange warning glyph. No date — see below.
lastPipelineRunAt has never had a write site in any build, so the bare date has never once rendered — months of use, nobody filed it. An Appearance pref to bring it back is noted for a future experiment, not built; the revisit trigger is multi-user, not multi-project.
lastPipelineRunAt must still be written even though the date is no longer shown — ProjectIndex.swift:892 gates the +N new delta on it being non-nil, so skipping the write leaves the sidebar permanently blank while looking finished. (2) The drift copy stays as it is. Shortening +{{count}} unanalysed to +{{count}} new was considered and rejected — they aren't synonyms. Unanalysed is a state; new is a claim about arrival time that the code only infers, and several things land in that set without being new (the failed file above, an unsupported format, anything excluded from a re-run). No locale change.Why this beats the quieter options. A partial run is two facts and both are live: three interviews are ready to read, and one needs attention. Options 1 and 2 tell the researcher only the first and hide the second behind a colour; options 3 and 4 tell them only the second and drop the news that anything worked at all. The composed line is the only one where the row is honest about the state of the study without being opened.
Under Schema E it also gets a second, pre-attentive channel for free: it is the only two-line row in the list. Most runs succeed, so the sidebar is a uniform stack of single-line rows and the exception is found by shape before a word is read. The height difference isn't raggedness to be normalised — it's the row having more to say.
Same glyph family, opposite severity. One says wait; one says cannot proceed.
FileNotFoundError, row dims. Outranks everything. Resolves only when the human plugs something in — unlike a fetch, which resolves itself.isCloudEvicted, which already runs per file and currently discards its answer.“The other two interviews analysed normally” only works when there are exactly three and one failed. Four shapes, and the last one changes the claim, not just the numbers.
AudioToolError's own docstring already makes this call: “a broken tool must never be mislabelled as ‘your interview has no audio’.”
t(key, count: n), so “1 interview / 2 interviews” is the framework's job in all 20 locales. Don't hand-roll the s.
Scanned the spec and the shipped code. Keys follow the live convention — key_one / key_other with {{count}}, as interviewCount_* and missingSubtitle_* already do.
| Key | String | Plural? |
|---|---|---|
| cloudAtRest | {{count}} in {{provider}} | No noun — safe. Omit entirely at 0 rather than rendering “0 in Dropbox”. |
| cloudFetching | Fetching from {{provider}} · {{done}} of {{total}} | No noun — safe |
| cloudFetchSlow | Still fetching from {{provider}} | No count — safe |
| cloudFetchFailed | Couldn’t fetch from {{provider}} | No count — safe |
| cloudFetchFailedBody_one/other | The file is stored… / These files are stored… | Yes — verb agreement, not just the noun |
| unreadableCount_one/other | Couldn’t read 1 interview / {{count}} interviews | Yes |
| unreadableBody_one/other | This file downloaded… / These files downloaded… | Yes |
| unreadableOthersOk_one/other | The other interview analysed normally. / The other {{count}} interviews… | Yes |
| unreadableAll_other only | Couldn’t read any of the {{count}} interviews | No _one. See trap 1 |
| …listOverflow | and {{count}} more | “more” is invariant — safe |
| …duration | didn’t download in {{duration}} | Pre-formatted string, never a raw number. See trap 2 |
unreadableAll only ever needs _other. A missing _one here is correct, not an oversight — worth a comment in the locale file so a future sweep doesn't “fix” it.
fs.py rendered after {int(timeout // 60)} minutes, which produced “after 1 minutes” at 60 s and “after 0 minutes” at anything under a minute. Now count_noun(), with a seconds fallback below a minute — the convention the CLAUDE.md already mandated and I'd read that morning.
_few and _many categories on top of _one/_other. i18next selects correctly on its own, but the translator has to supply those forms — an en-only _one/_other pair will silently fall back and read wrong for “2 interviews” vs “5 interviews” in Polish. Flag it on the translation ticket rather than discovering it in review.
t(key, count:); the Python side uses count_noun() from utils/text.py. AudioToolError messages cross that boundary — they're raised in Python and rendered on the row — so they follow the Python convention. Don't hand-roll s on either side.
| State | Row subtitle | Kind | Detail pane / notes |
|---|---|---|---|
| At rest, evicted | 3 in Dropbox | info | Appended to the normal subtitle. Cloud glyph trailing. Counts files → partial vs whole legible. |
| Fetching | Fetching from Dropbox · 1 of 3 | info | Indeterminate spinner. No ETA — none is derivable. |
| Fetch slow (~3 min) | Still fetching from Dropbox | warning | Run continues. Only legitimate wall-clock number. |
| Fetch failed (30 min) | Couldn’t fetch from Dropbox | error | Pane names the file: “The file is stored online only and didn’t download in 30 minutes.” + provider help link, shown as the bare domain. Try again · Show in Finder |
| File damaged | Couldn’t read 1 interview | error | Pane names the file: “This file downloaded, but couldn’t be read. It may be damaged or in a format Bristlenose doesn’t support. The other two interviews analysed normally.” Only reachable once bytes are resident. |
| Volume ejected | Can’t find this project — connect “T7” | warning | Existing. Outranks all activity states. |
| Files missing | 2 interviews missing from the folder | warning | Existing. Deleted, not evicted. |
can’t-find → failed → fetch-failed → running → stopped/partial → copying → fetching → fetch-slow → files-missing → unanalysed → ready
Fetch-failed sits with the terminal failures; fetching and fetch-slow sit with the self-resolving states, below activity — they're weather, not a dead end. Unchanged from the shipped chain apart from the three new entries.
⚠ +3 new, 1 failure, drawn under Schema E — clean rows show no status line, so this is the only two-line row in the list. Overrides “one winner, never composed” for this state; 78.4pt against 163pt available, fits at the 180pt minimum too. A change to shipped behaviour — today’s .completedPartial renders a generic “Partial completion” and drops the drift.cloud_provider_for() already returns.The row counts; the pane names.
A ~220px sidebar row can't hold Screen Recording 2026-01-27 at 23.37.37.mov, and naming one file doesn't scale to three. So the row carries a count, the tooltip carries the list, and the detail pane names each file with the remedy. Applies to both failure panes above, and matches the existing convention that status truncates to the row tooltip.
State what was observed. Name what you're waiting on. Don't guess the cause.
BN knows two things about a slow fetch: isDownloading is true, and how long it's been. It does not know whether the cause is paused sync, quota, network, selective-sync config, or free disk. “Is Dropbox running?” presents one of those as a finding, and sends the researcher to check the thing that's most likely fine.
That's the same defect as “ffprobe timed out” implying corruption, one notch quieter — a message asserting more than the system actually established. Naming the provider is the whole job: it points at the right app without pretending to know what's wrong inside it, and a working professional takes it from there.