Incremental analysis — states & flows

Storyboard for adding an incrementally-processed video / audio / transcript to an already-analysed project. Yesterday: 2 interviews analysed. Today: user tagged, starred, and edited themes. Tomorrow: a 3rd interview arrives.

Authored, not simulated. Icons are indicative; layout is schematic. Copy discipline: [current] exists in codebase · [PROPOSED] needs decision · [?] open question.

Ring 16pt, 2pt stroke · track tertiaryLabelColor, fill controlAccentColor, clockwise from 12. Determinate for .running(fraction:)/.copying(fraction:); hover swaps to xmark.circle.fill secondary-grey (Finder/App Store idiom, not red).
Leading identity icon — SF Symbol from project.icon (auto-assigned from IconPickerPopover.palette, 99 symbols) or the default circle. Dimmed via .secondary when availability ≠ .ready. Icon column 20pt, symbol pointSize 16.
Subtitle-prefix glyphs (only when the variant demands one): xmark.circle.fill error / exclamationmark.triangle.fill warning (filled = must not miss) · checkmark.circle success / info.circle info (outline = quiet) · externaldrive.badge.xmark / network.slash / questionmark.folder cantFind (orange). All .imageScale(.small), tint per MessageKind.tint. Error/warning glyphs are clickable buttons that open ProjectDiagnosticPopover.
Copy [current] exists in the codebase / cited i18n key · [PROPOSED] needs decision · [?] open question.

0 · Construction reference (from ProjectRow.swift)

Anchor for every frame below. Structure, fonts, and copy are lifted verbatim from ProjectRow.body (SwiftUI) + ProjectCellSpec (the AppKit port spec) + ProjectSubtitle.resolve() + SidebarSubtitleText + RunProgressSubtitle.compose().

S0.A · Anatomy of one row
Not a real state — this frame overlays every slot at once for reference. Real rows show at most one prefix glyph AND either error+ring is impossible (state precedence forbids). Font: title .body, count .footnote/.tertiary, subtitle .caption/.secondary.
S0.B · Row anatomy — labelled
HStack(.firstTextBaseline, spacing:6) [
  leadingIcon              // 20pt · SF Symbol · dimmed if !available
  VStack(.leading, sp:1) [
    titleLine   HStack(sp:6) [
      Text(name).body      // .secondary if !available
      Spacer(minLength:4)
      titleRightSlot       // scanning spinner OR count(.footnote/.tertiary)
    ]
    subtitleLine  HStack(sp:4) [
      subtitleContent      // prefix glyph? + text (.caption/.secondary)
      Spacer(minLength:4)
      subtitleRightSlot    // 16pt ring/spinner OR icloud
    ]
  ]
]
.overlay(                  // drop-target hairline
  RoundedRectangle(cr:8)
    .stroke(accent, lw:2)
    .opacity(isDropTarget ? 1 : 0)
    .padding(.horizontal:-6, .vertical:-3)
)
Two-line height for every non-.placeholder variant. Single-line collapse (32pt) for .placeholder. Native source-list pitch measured 22 Jun. Icon width 20pt, symbolConfig pointSize 16.
S0.C · SubtitleVariant precedence — the resolver

ProjectSubtitle.resolve(availability, pipelineState, isStopping, copy, lastRunAt, missingCount, unanalysedCount) → SubtitleVariant

Order (settled 18 Jun 2026):

cantFind (availability)
  › failed / failedDiagnostic / completedPartial
    › running (or .stopping when isStopping)
      › queued(position) / stopped / partial(transcribeOnly) / unreachable(reason)
        › (idle: copying › missing › unanalysed › ready)

Exhaustive with no default — an unmapped PipelineState is a compile error, not a silent fall-through to "ready".

This resolver is the source of truth for every frame below. Copy strings come from SidebarSubtitleText.text(for:); drift-check the mockup against it if it ever falls out of sync.
S0.D · SubtitleDelta — the +N surface already exists

enum SubtitleDelta { .unanalysed(count:), .missing(count:) }

pickDelta(): missing (data drift) wins over unanalysed (feature gap).

Rendered as a suffix on .ready(date, delta): "Just now · +3 unanalysed". The .unanalysed segment is a Button(.plain) that opens the unanalysed sheet; .missing is plain text.

i18n keys: desktop.chrome.unanalysedSubtitle, desktop.chrome.missingSubtitle — CLDR plural (cs/pl/ru have one/few/many/other).

Load-bearing finding. "+N is a new run that creates work" isn't a from-scratch UX — the sidebar row already has an .unanalysed(count:) delta segment. The design question is what to do at drop time, not how to render "N unanalysed."
0 Construction 1 Baseline 2 Drag-drop 3 Menu 4 Scanning 5 Subtitle 6 Report live 7 +N consent 8 Success 9 Idle-flag 10 Copy fails 11 Transcribe fails 12 Quota 13 Zero quotes 14 Cluster fails 15 Cancel 16 Concurrent Δ Changes ? Questions

1 · Baseline — yesterday's project, today's edits

Two interviews analysed yesterday. Today the user has been renaming themes, starring quotes, editing section titles. No pipeline activity. Resolver returns .ready(date: yesterday, delta: nil), but per user decision [PROPOSED] the resting date is dropped from the row — too much visual noise. Idle rows collapse to single-line (.placeholder).

S1.A · Idle row · single-line [PROPOSED]
Decision 4 Jul 2026: drop the resting date from idle rows — visual noise. Under the current Swift model this is .ready(date, nil) collapsing to .placeholder (single-line, 32pt). The date returns only when the row genuinely has state to show (running, failed, cantFind, delta). Count = unanalysed?.sessionCount. [?] Do we expose "last analysed …" anywhere else (context menu / detail pane) as a fallback for anyone who wanted the date?

2 · Drag-drop onto the project row — happy path entry

User drags show-and-tell-40.mov from Finder onto the project row in the sidebar. Handled by ProjectSidebarOutline.acceptDrop()handleDropOnProject()CopyMachinery.copy().

S2.A · Drag over row · isDropTarget = true
2pt accent-blue stroke overlay via RoundedRectangle.stroke(.accentColor, lineWidth:2), padded -6/-3 to trace the selection pill (ProjectRow.swift:141-147). Rows are single-line at rest (per §1). No subtitle change on hover — the stroke is the affordance.
S2.B · Drop lands · .copying(fraction:)
Exact copy: [current] desktop.chrome.pipeline.copying "Copying · N%" (ProjectRow.swift:304-309). Ring shows byte fraction (ProjectRowActivityIndicator.Kind.copying). Hover swaps ring for grey xmark.circle.fill = cancel. Count stays "2" — session isn't imported yet.
S2.C · .scanning · pre-run
Existing state, not new.scanning in PipelineState. Title-right spinner after 250ms (ProjectRow.swift:194-195, scheduleScanIndicator) — the delay prevents flash for fast scans. The subtitle stays on the resting .ready text. The manifest walk that decides cached-vs-fresh happens here.
S2.D · .running (with stage) · see §5 for ladder
Exact copy: [current] RunProgressSubtitle.compose() — stage verb desktop.chrome.pipeline.stage.transcribe + desktop.chrome.pipeline.sessionsCount + desktop.chrome.pipeline.etaUnderMinute, joined by " · ". Count remains "2" until the new session lands in the manifest (§8).

User with the project selected picks File → Add Files… [PROPOSED] — new menu item.

S3.A · Menubar with menu open
[?] Should "Add Files…" be enabled/disabled based on selection? Grey if no project selected. [?] Include "Add Folder…" or is drag-only enough for folders? [?] Shortcut ⌘⇧A conflicts with system Select-All-Text?
S3.B · NSOpenPanel

Title (semibold): Add sessions to project IKEA with uxfriends

Multi-select: yes [decided]

File-type filter: audio · video · transcript (.txt/.docx/.srt/.vtt) [decided]

Prompt (accept button): Add [decided]

Title interpolates the project name — semibold so the scope is clear at a glance. After picking → same code path as drag-drop → S2.B (Copying) → S2.C (Scanning) → S2.D (Analysing). [?] Title copy in i18n: desktop.chrome.addFilesTitle — placeholder {project}.
S3.C · Same as S2.B onward
Menu → picker → copy → cache-scan → analyse. Converges with drag-drop after intake.
Design decision: menu and drag are the same affordance surfaced two ways. No divergent behaviour beyond intake mechanic.

4 · .scanning — the existing pre-run state

Correction: this is not a state to invent. PipelineState.scanning exists, and titleRightSlot already shows a small ProgressView after a 250ms debounce (ProjectRow.swift:189-195). The subtitle stays on the resting .ready text — resolver §5 has .scanning falling through to the idle chain. The question isn't "what to invent" but "what to do differently for incremental."

S4.A · .scanning · current behaviour
Debounced 250ms title-right spinner. Subtitle unchanged (still shows the resting date). Row height stays two-line. This is what ships today. Read carefully: after a drop, this shape holds for the full pre-run window while the manifest walk decides what's cached — the sub-text "Yesterday" is arguably a lie during that window.
vs
S4.B · One-word verb [PROPOSED]
One-word present participle, matches the state name and the rest of the ladder (Analysing / Transcribing / Copying / Stopping). "Checking what's new…" was three words; "Checking…" alone is arguably more honest — user knows why we're scanning (they just dropped a file). Copy [PROPOSED] desktop.chrome.pipeline.scanning = "Scanning…" or "Checking…" — pick one.
vs
S4.C · Indeterminate ring in subtitle-right [PROPOSED]
Motion in the subtitle-right (16pt) — matches .running/.copying shape, so the row visually "goes to work" the moment scanning starts. Requires promoting .scanning to a subtitleRightSlot activity kind (currently .none per ProjectRowActivityIndicator.Kind.from). [?] Visual continuity vs redundancy with the title-right spinner.

5 · Status-line copy — the SubtitleVariant table

Every subtitle state the resolver can return, with the exact i18n key and the rendered string. Source: SidebarSubtitleText.text(for:) + RunProgressSubtitle.compose() + desktop.chrome.pipeline.*.

S5.A · Existing variants — all [current]
Varianti18n key / sourceRendered
.ready(date, nil)formatBareDate(date)Just now / Today / Yesterday / 2 Jul / Jun 2025
.ready(date, .unanalysed(3))+ desktop.chrome.unanalysedSubtitleYesterday · +3 unanalysed (clickable)
.ready(date, .missing(1))+ desktop.chrome.missingSubtitleYesterday · 1 missing
.deltaOnly(.unanalysed(3))desktop.chrome.unanalysedSubtitle+3 unanalysed (no date anchor)
.copying(0.42)desktop.chrome.pipeline.copyingCopying · 42%
.copyCancellingdesktop.chrome.copyCancellingCancelling…
.running (no signal)desktop.chrome.pipeline.analysingAnalysing…
.running (attached orphan)desktop.chrome.pipeline.resumingResuming…
.running (stage, N/M, ETA)stage.transcribe · sessionsCount · etaMinutesTranscribing · 7 of 8 · ~1 min left
.running (stage, sub-minute)… + etaUnderMinuteIdentifying speakers · 2 of 3 · <1 min left
.running (ETA only)analysing · etaMinutesAnalysing · ~2 min left
.stoppingdesktop.chrome.pipeline.stoppingStopping…
.queued(position: 2)desktop.chrome.pipeline.queuedPositionQueued · position 2
.stoppeddesktop.chrome.pipeline.stoppedStopped
.partial(transcribeOnly:true)desktop.chrome.pipeline.transcribedTranscribed
.partial(transcribeOnly:false)desktop.chrome.pipeline.partialRunPartial run
.unreachable(reason)Python-supplied verbatim<reason>
.failed(summary)Python-supplied verbatim (✕ prefix opens popover)<summary>
.failedDiagnosticdesktop.pipeline.diagnostic.header.failedRun failed
.completedPartialdesktop.pipeline.diagnostic.header.completed_partialPartial completion
.cantFind(.unmountedVolume)desktop.availability.unmountedVolumeSamsung T7 · unmounted
.cantFind(.networkUnreachable)desktop.availability.networkUnreachableserver.local · unreachable
.cantFind(.moved)desktop.availability.missingCan't find this project
Every string is either an i18n key (localised across 21 locales) or Python-supplied. Column budget is ~22 EN chars before DE/ES swell truncates. The .unanalysed(count:) delta is the load-bearing finding — the "N new files, pending" surface already exists.
S5.B · Proposed additions for incremental — [PROPOSED]
VariantProposed keyRendered
.scanning (with pending)desktop.chrome.pipeline.scanningChecking what's new…
.running (re-cluster hint)desktop.chrome.pipeline.stage.clusterGrouping quotes · ~1 min left
.partial (new session failed)desktop.chrome.pipeline.newSessionFailed1 session failed
.partial (added zero quotes)desktop.chrome.pipeline.noQuotesAddedNo quotes added
Kept intentionally short — the 22-char budget doesn't leave room for "new interview" anthropomorphism. [?] "Grouping quotes" vs bare "Analysing" for the recluster phase — surface or hide? [?] "No quotes added" vs silent — depends on §13 answer.

6 · Report during the run — stays live

Locked decision: existing report stays live during incremental. User keeps reading, tagging, editing. Question: does the report get any signalling that new data is arriving, or does it stay silent until settle?

S6.A · Report unchanged during run
IKEA with uxfriends
2 sessions · 47 quotes
Frustrations
"The instructions are impossible to follow"
Sarah · Session 1 · 04:12 · ★
"I gave up and looked it up on YouTube"
Marco · Session 2 · 12:03
Delight
"The end result made it worth it"
Sarah · Session 1 · 22:41
Report reads as it did yesterday. No banner, no chrome, no hint that a run is in flight. Sidebar carries the only signal. Simple. Confirms report-stays-live.
S6.B · Report with subtle "run in flight" note [PROPOSED]
IKEA with uxfriends
analysing · 2 sessions · 47 quotes
Frustrations
"The instructions are impossible to follow"
Sarah · Session 1 · 04:12 · ★
"I gave up and looked it up on YouTube"
Marco · Session 2 · 12:03
Small spinner + verb in the metadata line. [?] Does this belong here, or is sidebar-only cleaner? [?] Redundancy vs discoverability tradeoff.

Settled 4 Jul 2026: auto-run on drop, no consent surface. Matches today's drop-then-auto-run policy for folder-shaped projects (ContentView.swift:1285). The drop gesture IS the consent — the user chose which project, which files. Pending-state (variant B) and modal (variant C) both rejected as friction the user doesn't need.

S7.A · Drop → run · no interstitial
The run subtitle carries the existing progress ladder from §2.D — no "new interview" anthropomorphism needed in the row (the count going 2→3 on completion carries that signal, see §8). Undo path if the drop was accidental: hover-cancel × (the existing Finder/App Store idiom on the ring).

8 · Success completion — the settle

Analysis finished. Report has quietly updated in place. Sidebar returns to idle. Count-delta signalling is phase 2 — this frame is what phase 1 ships.

S8.A · Fresh success · .new(count: 1) [PROPOSED]
Decision 4 Jul 2026: post-completion subtitle reads "+N new sessions" (not "Just now") — the sidebar carries the news, not the date. Requires a new SubtitleDelta.new(count:) variant [PROPOSED]; siblings .unanalysed/.missing. CLDR plural key desktop.chrome.newSubtitle. Row temporarily two-line while the delta is active.
S8.B · User engages · fade to placeholder
Engagement measure [PROPOSED]: user scrolls in Quotes / opens the new session / clicks into any new content → subtitle fades and row collapses back to the §1 single-line resting state. Row height animates from two-line → single. [?] Exact triggers: scroll a threshold N of new quotes into view? Open the new session's transcript? Any interaction inside the project?
·
S8.C · Report has updated in place
IKEA with uxfriends
3 sessions · 68 quotes
Frustrations
"The instructions are impossible to follow"
Sarah · Session 1 · 04:12 · ★
"I gave up and looked it up on YouTube"
Marco · Session 2 · 12:03
"Half the screws weren't labelled"
Priya · Session 3 · 07:55
3rd session's quotes interleaved (subject to §9 idle-flag hold). User's ★ on Sarah's quote preserved. Together with the sidebar hint in S8.A, this gives "just a little clue there's some new stuff to see" without any modal / banner / toast.

9 · Mid-edit reflow — one flag, no UX surface

Settled 4 Jul 2026. No banner, no modal, no reload button. A single idle flag gates when the report reflow applies. Sidebar updates freely — it's objective run state (ring, count, subtitle). Report reflow waits for the user to be idle. Both things can be true.

S9.A · The rule

Idle when: no focused editable input AND no keystroke/mousedown/scroll in the report area for 8s.

Active-override: a focused text input is always active, even without keystrokes (catches "user typing slowly / mid-thought").

Absolute cap: 2–3 min. After that we apply the reflow anyway (guards abandoned focus).

Scope: app-wide (single-window today; upgrade to per-window when multi-window ships).

Sidebar is not gated. Ring, count, subtitle update in real time regardless. The lock is only on the report reflow surface.

Not-viewing case: if the user is on Project B and Project A's run finishes, A's data model updates immediately — the user isn't looking at A, nothing to disturb. When they later switch to A, it's already fresh. Free win of the "one visible report at a time" architecture.
S9.B · Editing, run in flight
Frustrations
"The instructions are impossible"
Sarah · S1 · editing
Sidebar ring active, user editing. No collision yet. Idle flag = active (focused input).
S9.C · Run finishes · sidebar updates, report holds
Frustrations
"The instructions are impossible to follow"
Sarah · S1 · editing
Sidebar told the truth immediately. Ring done, count 2→3, subtitle "Just now." Report is unchanged — still shows the 2-session shape the user is editing against. Idle flag = active, reflow queued.
S9.D · User blurs · timer starts · reflow lands
Frustrations
"The instructions are impossible to follow"
Sarah · S1 · ★
"Half the screws weren't labelled"
Priya · S3 · new
User blurs the field (their edit saves). 8s of idleness — the reflow lands atomically. New quote from Priya appears. Star preserved. No banner, no button, no announcement. User's next glance at the report sees the fresh shape; the sidebar had already told them it was ready.
S9.E · Timer heuristic — what resets and what doesn't
SignalEffect on flag
Keystroke in editable fieldReset timer AND mark active
Focused text input (no keystrokes)Active-override (never idle)
Mousedown / mouseup in report areaReset timer
Wheel / scroll in report containerReset timer
Drag session (dirty NSDragOperation)Active-override
Sidebar click, toolbar click, menu barNo effect (not report activity)
Mousemove aloneNo effect (too twitchy)
App loses key window / focusNo effect (backgrounded ≠ editing)
Project switch awayApply held reflow to background project immediately
Absolute cap reached (2–3 min)Apply reflow regardless
[?] Exact timer length — 8s is a starting guess. [?] Absolute cap — 2 min conservative, 3 min lenient. [?] Should the report signal "there's a fresh update queued" in any way, or is the sidebar's "Just now" enough? Recommend zero signal for phase 1 and revisit if cohort testers say they missed it.

10 · Failure — copy fails (disk full / permission)

File never made it into the folder. Pipeline never started.

S10.A · Pre-copy disk-space precheck (existing)

[current] CopyMachinery raises an .alert sheet on the project row's window before the copy starts, if free space is insufficient. Never a toast — feedback_native_chrome_vocabulary forbids them.

Row state stays at .ready because the copy never started — nothing to say in the subtitle.

This handles the "we can tell in advance" case. Failure during copy (fs errors, permission denied, iCloud eviction mid-copy) is the harder case — Swift's Task.checkCancellation raises per-file, so a partial copy leaves the source dir clean-ish. [?] Does a mid-copy failure land in a distinct subtitle state, or does the row silently revert to .ready?
S10.B · Mid-copy failure → row-persistent [PROPOSED]
Uses MessageKind.warning vocabulary (exclamationmark.triangle.fill, orange) — clickable to open a diagnostic explaining which files landed and which didn't. Same shape as .completedPartial. Would need a new SubtitleVariant case (e.g. .copyFailed(summary:)). [?] Persistence: until user dismisses / next successful action / timeout?

11 · Failure — new file fails to transcribe (old data intact)

Copy succeeded. Cache-scan happy. Transcription of the new file fails (Whisper error, corrupt audio). Yesterday's report is fine.

S11.A · .completedPartial after failure
[current] exact copy from desktop.pipeline.diagnostic.header.completed_partial. Prefix glyph exclamationmark.triangle.fill orange, clickable (diagnosticSubtitle(kind: .warning) in ProjectRow.swift:255-257) — opens popover. Count stays "2" — session didn't land. [?] Post-TF, does the subtitle carry a hint like "1 session failed" — or does it stay short and rely on the popover?
S11.B · ProjectDiagnosticPopover
Partial completion
1 session failed · show-and-tell-40.mov
whisper: audio decode failed at 00:12:04 · possibly corrupt file
Header + glyph rendered per MessageKind.warning vocabulary — ProjectDiagnosticPopover.swift:172-173. Retry/Remove are per-file additions [PROPOSED]. [?] Does "Remove file" delete from disk or just from the manifest?

12 · Failure — provider quota hit mid-run

Transcription done. Clustering stage calls Claude, hits quota / rate limit / auth error. Everything upstream is fine.

S12.A · .failedDiagnostic
[current] exact copy from desktop.pipeline.diagnostic.header.failed. Glyph xmark.circle.fill red, clickable (diagnosticSubtitle(kind: .error)). Budget is ~22 EN chars — the sidebar row says "eyes needed"; the popover carries the detail. [?] Should .failed (with Python-supplied summary) render differently from .failedDiagnostic for incremental — e.g. "Quotes not grouped" as the summary line?
S12.B · Diagnostic — Cause.category = QUOTA
[current] Rate-limited by Claude
Quote clustering couldn't finish.
Cause: QUOTA · provider: anthropic · stage: cluster
[PROPOSED] Transcript for the new interview is saved. You can retry when limits reset.
Existing category vocabulary from Cause.category (events.py:135-157). Recovery links via pipeline.py:332-375. Incremental-specific: a QUOTA hit mid-run may leave the new session transcribed but not clustered — the report is now "3 sessions, 2 clustered." [?] Does the report show the un-grouped quotes in an "Uncategorised" bin, or hide them?

13 · Edge — new file adds no quotes

Run succeeds end-to-end. New session transcribed. Quote-extraction found nothing quotable (silent interview, all off-topic, moderator monologue).

S13.A · Silent success · .ready(date: now, delta: nil)
Count went 2→3 (session added) but report has zero new quotes. Row shows nothing to distinguish "big change" from "nothing extracted." Arguably dishonest.
vs
S13.B · Info-noted [PROPOSED]
Uses MessageKind.info vocabulary (info.circle outline, blue) — not clickable per the existing SubtitleVariant.isDiagnostic gate (info doesn't open the popover). Would need a new resolver leaf. [?] Info-clickable to open the new session? [?] Persist how long — until user opens the new session?

14 · Failure — clustering / thematic re-run fails, report incoherent risk

Transcription of new file succeeded. Re-clustering starts (global, per axis 4) and dies partway. The report was going to change; now it can't.

S14.A · Rollback · .completedPartial
Reuses [current] .completedPartial shape. Popover carries the "clustering failed, showing previous themes" detail. Count reflects the new session (transcribed successfully). [?] Do we show the new session's orphan quotes in an "Uncategorised" bin, or hide them?
vs
S14.B · Partial-reflow subtitle [PROPOSED]
Distinct subtitle text; still uses MessageKind.warning. Would need a new key or an override on .completedPartial. Value: more specific hint than the generic header. [?] Belongs in the resolver or as a detail on the popover?

15 · User cancels mid-incremental

Ring showed as a hover-cancel × (Finder idiom, ProjectRowActivityIndicator.swift). User clicks it. What state does the project end in?

S15.A · Hover · ring → grey ×
[current] Hover swap: xmark.circle.fill at .secondaryLabelColor (Finder/App Store idiom — not red). Same 16pt frame as the ring, no reflow (ProjectRowActivityIndicator.swift:113-115).
S15.B · Click · .stopping
[current] desktop.chrome.pipeline.stopping. Immediate ack — outranks progress in the resolver (ProjectSubtitle.swift:157-159). SIGINT→SIGTERM→SIGKILL escalation lives in PipelineRunner.scheduleOrphanCancelEscalation.
S15.C · .stopped with .unanalysed?
[current] desktop.chrome.pipeline.stopped. But the file is still in the folder — under incremental world, should the resolver instead skip .stopped and go to .ready(date: prev, delta: .unanalysed(1))? [?] Does cancel prompt "keep file / remove file"? [?] Auto-analyse next launch, or wait for the click on "+1 unanalysed"?

16 · Concurrency — drop while running

Project A analysing. User drags another file onto Project A's row.

S16.A · Reject · current behaviour
Today's gate (ContentView.swift:1222-1239) refuses drops on .running. Drop-target stroke rendered red to signal rejection. [?] Does a toast/sheet accompany or is the visual enough? No toast per feedback_native_chrome_vocabulary — so probably just the stroke + sheet if anything.
vs
S16.B · .queued(position: 1) · new usage [PROPOSED]
.queued(position:) already exists (see §5.A) — currently used for the single-slot queue across projects. New usage: intra-project queue for a dropped-during-run file. [?] How is the queued file represented if the current run fails — orphaned, or auto-promoted to .unanalysed?
vs
S16.C · Merge · pipeline extends [PROPOSED]
"N of M" jumped 1/1 → 1/2 and ETA re-calibrated. Risk: ring might rewind (monotonic-clamped math would hold it at max-so-far). Value: single re-cluster pass. [?] Pipeline mid-intake is a Python-side capability question — out of scope here.

Δ · Changes from the first pass (source-of-truth alignment)

Row construction, glyph vocabulary, and copy were reverse-engineered from ProjectRow.swift + ProjectCellSpec + ProjectSubtitle.swift + SidebarSubtitleText + SidebarActivityRing + ProjectRowActivityIndicator + RunProgressSubtitle + MessageKind + ProjectAvailability + RandomProjectIcon.

Open questions

Drop targets (scope 1)

Baseline & date (§1) — decided, one follow-up

Scanning verb (§4)

Subtitle copy (§5)

Report during run (§6)

+N consent (§7) — decided

Success completion (§8) — new-sessions-until-engaged pattern

Mid-edit reflow (§9) — settled shape, remaining knobs

Failure modes (§10–§14)

Cancel (§15)

Concurrency (§16)

Cross-cutting