Route C (decided 1 Oct 2026) drawn with the shipped stylesheet and the shipped strings, frame by frame, as data state × surface. Where a frame needs something that does not ship yet, the chrome bar says new and the inventory at the end names it. Nothing else is invented.
The cast. Ward handover app — round 2, six sessions. Three came in from Teams with the platform's own speaker labels (Martin B Storey twice, Kerri Ng once, Jane Smith as an observer); three are recordings dropped in by hand, where nothing names the moderator. The researcher is Martin Storey, whose Mac account name is Martin Storey — not quite the spelling Teams uses, and nothing in the product will try to reconcile the two for him.
Read each frame as a row. A session's moderator slot is one of null · proposed · confirmed. The badge draws the state and nothing else: no origin chip, no "heard in", no sentence. Where a name came from lives in the evidence column and in the picker's behaviour, not on the page.
molecules/person-badge.css unchanged; the tentative treatment is atoms/badge.css's .badge-proposed + .badge-action-pill, the AutoCode idiom the researcher already knows.Hover the second badge (or press Tab into it) to see the pill the product shows. The fourth frame forces the hover for screenshots.
organisms/sessions-grid.css at the width it gets on a laptop with the sidebar closed (the C grid: duration and file stacked, sentiment still shown). The speakers cell decomposes the badge into a code lozenge plus the editable name, as SessionsTable.tsx does; the pencil appears on row hover.Sessions 1–3 arrived from Teams with speaker labels; 4–6 are hand-dropped recordings. The "Moderated by" line is the shipped sessions.moderatedBy over the identities that exist. Hover any row for the pencil; the proposed lozenges show the pill on hover.
Moderated by Martin B Storey and Kerri Ng
Observer: Jane Smith
What the grid is saying. Three rows carry a dashed m1 or m2: the system read a label and minted an identity for it, and is showing its work as tentative. Three rows carry m? with the shipped Moderator placeholder beside it — the same muted-italic role word the grid already shows for any speaker without a name. The researcher can leave all six exactly like this and the data is honest.
Nothing asks. There is no banner, no count of "unidentified", no "is this you?". The heading line names the identities that exist and stops. The null rows are visible because they are rows, not because anything draws attention to them.
The rows behind it (the shape decided as I6; person_id nullable, plus state and evidence):
Why "Martin B Storey" and not "Martin Storey". The identity was minted from the label, so it carries the label's spelling. P3: the product does not infer that this is the account holder. He can right-click the badge and say so (R5), or rename it in place, or leave it. All three are fine.
Row 1's moderator with the pill shown. The pencil is the row's own hover affordance and is drawn here too, as the product would at this moment.
The first thing real pixels found. The pill is positioned to hang past the badge's right edge by 1 rem (atoms/badge.css: ✗ sits where a tag's delete × sits, ✓ overhangs). On a quote card that overhang is whitespace. In the grid the name starts 0.35 rem to the right of the lozenge, so the ✓ compartment lands on the first letter of Martin. Drawn as-is on purpose: it is the true consequence of composing two shipped idioms, and the storyboard's hand-drawn frames could not have shown it. Phase 2 has two cheap fixes — hang the pill to the left inside .bn-session-speaker-entry (the thumbnail cell's padding is empty ground), or open the entry's gap to 1.4 rem while the pill is visible. One rule either way; the recommendation is the first, so the name never moves.
Two controls, two families. The pill is on the code lozenge and acts on the slot (accept or reject this proposal). The pencil is on the name and acts on the identity (retype "Martin B Storey" as "Martin Storey", everywhere it appears). §B2's rule — rename and reassign never share a control — falls out of the grid's existing decomposition without a new element.
Keyboard. The pill's compartments are real buttons with the house inset focus ring; a / d can be bound as on quote cards. The lozenge is focusable (tabindex="0"), so :focus-within reveals the pill without a mouse.
.export-dropdown-menu and its rows from organisms/global-nav.css). On the Mac the same list is an NSMenu raised over the bridge; see Part 6.Click or right-click the m? lozenge. Moderators the study already knows, then the two ways to introduce someone. The study has two identities, neither of them Me yet.
Every row is a shipped class. The list is .export-dropdown-menu; the group label is .export-dropdown-group-label over the shipped word Moderators (transcript.moderators); people are .export-dropdown-scope rows carrying the split badge; the separator and the two plain items are the same classes the Export menu uses. The only placement difference is the anchor — a badge rather than the nav's right edge — which is a Phase 2 call (a popover positioned by the badge, or the row's context menu).
The two strings the owner writes. That's Me (Martin Storey) is §I3's top-level form, seeded from the account name (I4). The create item is drawn in §B9's spelling, New Moderator…; the storyboard used Someone New…, and H1 lists the choice as open — one key, the owner's wording. Both are placeholders here, flagged as such.
Picking a row sets session_speakers(s4).person_id and state=confirmed — one row, this session only (narrow reach). Picking That's Me mints the Me identity on first use (m3 Martin Storey, me=true) and assigns it. Picking New Moderator… turns the row's name field into the inline editor (R6) with an empty value; a heard name would pre-fill it on a proposed slot.
The same list with the current proposal marked. This is where "that's me" happens for the Teams spelling.
The check mark is the shipped .export-dropdown-check gutter the Export menu uses for its checkbox row, hung in the same indent. It marks the identity this slot currently holds, proposed or confirmed. Picking the checked row confirms it (the same act as ✓ on the pill).
That's Me on a proposed slot adopts, it does not duplicate. Recommendation for Phase 2, stated here so it can be disagreed with: when the slot's current identity is a proposed one and no Me identity exists, That's Me marks that identity as Me (me=true), keeps its heard name, and confirms the slot. One human, one m1, and the Teams spelling survives until he retypes it. Anywhere else — a confirmed slot, or a study where Me already has a code — That's Me assigns the Me identity, minting it on first use. Lists then show the §I3 form, Martin B Storey (Me), in the badge's name half, and the top-level item is no longer offered because its row is in the list.
The shipped editing state of .bn-speaker-editable-name: accent edge, page-tint fill, no pencil while editing. Commit mints the identity and assigns it in one act.
Typing into an empty slot is the Someone-New path. There is no identity to rename, so the field can only mean "a person you are introducing". Enter commits: mint m3 Mike Alvarez (origin=typed), set the slot to confirmed. Escape leaves the slot null. The same keystrokes on a named row are a rename of that identity — the field's meaning follows from what the row holds, which is why the grid needs no second control.
Nothing about this frame is new: the editing tint is molecules/editable-text.css's idiom 3 (a name on neutral ground tints), keyed on [contenteditable="true"]; the pencil is hidden while editing by the component, as today.
Martin confirmed sessions 1, 2 and 4 as himself; Kerri's proposal was accepted; Mike was typed in for 5. Session 6 was never touched and still says so.
Moderated by Martin B Storey, Kerri Ng, and Mike Alvarez
Observer: Jane Smith
Every badge is plain now except one. No dashes: the slots are confirmed. Session 6 keeps m? because nobody said otherwise, and the heading line does not mention it. Three sessions share m1 because one human was picked three times — not because the system assumed it.
Re-analyse cannot undo any of this. The importer runs at the end of every pipeline run (I7) and never overwrites a confirmed row; the Re-analyse loss sheet therefore has nothing to count here — "renamed speakers" is a people.yaml-era line, and Phase 1 retires the reason for it.
Session 4 after the pick, then session 3 (confirmed moderator, proposed observer), then session 6 untouched. TranscriptPage.tsx's renderSessionRoles with the badge in each state.
The roles line already says "Moderator" and draws a badge after it — so a null slot renders as Moderator m? with no new string. The segment gutter shows the same code, because within a session the gutter's m is a tag for "the moderator's turns", not an identity; it reads m? until the slot is filled and m1 after, from the same row.
The header is the Mac's too. Under the macOS toggle the type ladder changes (SF Pro, tokens-desktop.css) and the disclosure caret goes — embedded mode keeps the plain Session 4 label because the native session switcher in the titlebar owns switching. Nothing else differs.
Right-click on any of these badges raises the same picker as R4/R5 (web popover, native NSMenu); the transcript page needs no control of its own.
SessionsSidebar.tsx shows the moderator badge beside the first participant only when sessions disagree about who moderated. Left: after import (R2). Right: after the picks (R7).
Plain .badge, plain .badge-proposed. The sidebar never used the split badge, so the proposed state here is the AutoCode treatment with zero composition CSS. The panel box around the list is drawn by the mockup (.toc-sidebar is reveal-gated by .layout, per theme/CLAUDE.md); everything inside it is the shipped entry.
One rule to carry into Phase 2: hasMultipleModerators counts distinct names today. It should count distinct identities, with null counting as one — otherwise a study with one moderator and three untouched sessions hides the m? badges entirely, and the sidebar is the one place the gap would be invisible.
The server builds moderator_header from the names that exist. Table elided.
Moderated by Martin B Storey, Kerri Ng, Mike Alvarez
Observer: Jane Smith
Nothing to design here — the line already lists names and omits what has none. Two things to carry: the dashboard joins with commas where the Sessions lens uses an Oxford list (a pre-existing drift, not this project's); and under route C both lines should read identities, not people[code], which Phase 1's backward-compatible GET /people gives them for free.
templates/export.css applied (.bn-export-mode): no pencils, no pill, no thumbnails. Export is taken from the R2 state on purpose — the proposals are still proposals, and the export states them as fact.
Moderated by Martin B Storey and Kerri Ng
Observer: Jane Smith
The proposed treatment is simply not applied in export mode. The SPA's isExportMode() gate drops the class the same way it drops the pencil, so a client reading the leave-behind sees m1 Martin B Storey as a statement. The grid's m? rows keep their code and the Moderator placeholder: the export shows the same grid the researcher saw, minus the controls.
The one sentence. A null slot on a transcript page in a deliverable reads better as a sentence than as a code, so the roles line takes Moderator not identified — the default recorded on 1 Oct, drawn here as a placeholder for the owner's wording. If the owner would rather the export say Moderator m? like the app, delete the key and the frame on the left is the answer for both.
get_project_overview, the shape an agent readsThe shipped tool lists speakers by code with the gated name map. A null slot is a session fact, so it lands on the session row.
"sessions": {
"count": 6,
"items": [
{"session_id": "s1", "session_date": "2026-01-12T10:00:00", "duration_seconds": 2880, "moderator": "m1"},
{"session_id": "s3", "session_date": "2026-01-14T11:30:00", "duration_seconds": 3780, "moderator": "m2", "observers": ["o1"]},
{"session_id": "s6", "session_date": "2026-01-21T10:30:00", "duration_seconds": 3420, "moderator": null}
]
},
"participants": {
"count": 6,
"speakers": [
{"code": "m1", "role": "researcher", "name": "Martin B Storey"},
{"code": "m2", "role": "researcher", "name": "Kerri Ng"},
{"code": "o1", "role": "observer", "name": "Jane Smith"},
{"code": "p1", "role": "participant", "name": "Sarah Chen"}
]
}A proposed identity is a plain name here too (P1). The agent does not get state; it gets what the researcher would say if asked. A null slot is "moderator": null on the session — present and null, never absent, so an agent cannot read "no key" as "no moderator". One line joins INVARIANTS in grounding.py saying that null means unidentified, not unmoderated; its wording is the owner's.
Export and MCP never need the picker. Both are readers of the confirmed-or-proposed name; both fall silent on null. That is the whole of their involvement.
Round 1 under Rachel, three sessions. people.yaml said m1: Rachel Okonjo and that is all anyone ever knew. I2: every session becomes proposed with origin inherited, and it looks exactly like R2 — the frame has no way to show you the origin, on purpose.
Moderated by Rachel Okonjo
The old name stays everywhere it was shown, drawn tentative. The one-time legacy read turns m1: Rachel Okonjo into one identity (origin=inherited) proposed on every session that carried the code — which, under the old one-name-per-code rule, is all of them. If Rachel really did moderate all three, three ✓ clicks (or none: proposed is honest data). If session 2 was actually Steve, that row's pill, then New Moderator…. Nothing has been lost and nothing has been asserted.
No chip, no "inherited" word on the page. The storyboard's Part 5b drew origin marks; this iteration drops them. The state is what the researcher acts on, and the badge already draws the state. Origin stays queryable in the evidence column for a future provenance surface, if one ever earns its place.
Flip the Platform toggle: every frame above re-renders on the SF Pro ladder, which is what the WKWebView shows. The picker is the one thing the Mac draws itself. I3: wiring first, visual parity later in a Diagnostics side-by-side.
Not drawn, named. The native twin is an NSMenu raised by the host when the webview reports a right-click on a person badge over the bridge (the mechanism §B10 settled for every person menu). Its items are the same strings, in the same order, so the two pickers cannot drift in vocabulary:
| Web popover row | NSMenu item | String |
|---|---|---|
| group label Moderators | section header (disabled item) | transcript.moderators |
split badge m1 Martin B Storey · checked | item, state = .on, title "m1 Martin B Storey" | — (data) |
split badge m2 Kerri Ng | item "m2 Kerri Ng" | — (data) |
| separator | NSMenuItem.separator() | — |
| That's Me (Martin Storey) | item, name from NSFullUserName() | people.thatsMe new |
| New Moderator… | item; on select the webview opens the inline editor (R6) | people.newModerator new |
What parity means later. A native menu cannot draw a split badge, so the row's title is text in the §B9 shape — code, two spaces, name — and that is correct for a menu. The Diagnostics side-by-side (Phase 3) exists to tune the web popover toward the menu's rhythm, not the other way round.
The badge in the native titlebar session switcher is unchanged: it shows participants, never moderators, so route C does not reach it.
/usual-suspects pass reads first.Reused verbatim (class → source; nothing restyled):
| Thing | Shipped home | Used for |
|---|---|---|
| split badge | molecules/person-badge.css · PersonBadge.tsx | every code + name; m? is the code-only form |
| proposed treatment + pill | atoms/badge.css (.badge-proposed, .badge-action-pill) | a proposed slot; ✓ confirms, ✗ returns to null |
| speakers cell | organisms/sessions-grid.css · SessionsTable.tsx | code lozenge + editable name + pencil, unchanged |
| unnamed placeholder | .bn-speaker-editable-name.unnamed · sessions.speakerPlaceholder.moderator | the word beside m? |
| editing state | molecules/editable-text.css ([contenteditable="true"]) | New Moderator… / rename, inline |
| dropdown idiom | organisms/global-nav.css (.export-dropdown-*) | the picker's list, group label, check gutter, separator |
| heading + count line | atoms/section-heading.css · sessions.moderatedBy · sessions.observer_* | the line names identities and omits nulls |
| roles line | templates/transcript.css · transcript.moderator(s) · transcript.observer(s) | Moderator m? with no new string |
| sidebar entry | organisms/sidebar.css (.session-entry*) | plain .badge states |
| export gate | templates/export.css · isExportMode() | pencil, pill and proposed class dropped in the leave-behind |
| display mode | [data-person-display="code"] | code-only survives every state |
| Accept / Deny titles | buttons.accept · buttons.deny | the pill's compartments |
New, and flagged in the frames:
| Item | Size | Where it goes |
|---|---|---|
| composition rule | 3 lines of CSS | molecules/person-badge.css: dashes on the halves, no ring, when .badge-proposed sits on .bn-person-badge |
| picker placement | a Phase 2 call | popover anchored to the badge vs. the row's context menu; the rows themselves are shipped classes either way |
session moderator key | wire shape | get_project_overview session items: a code or null; plus observers |
| strings | 4 keys | below — the owner writes the English; this file carries placeholders |
String plan — four keys, housed in the people block §I4 already reserves, seeded into the 21 full locales per the i18n rules (zh-Hant-HK gets none):
| Key | Placeholder drawn here | Where | Notes |
|---|---|---|---|
| people.thatsMe | That's Me ({{name}}) | picker, top level; NSMenu | §I3's settled form; name from NSFullUserName / GECOS. That's Me… when the account has no name |
| people.me | {{name}} (Me) | picker rows, once Me has a code | §I3's list form; rendered in the badge's name half |
| people.newModerator | New Moderator… | picker, after the separator; NSMenu | §B9's spelling. The storyboard used Someone New…; H1 carries the choice. One key either way; fr/ca/es gender-marking per H1 |
| export.moderatorNotIdentified | Moderator not identified | exported transcript roles line; the m? lozenge's aria-label | the 1 Oct default. Delete it and the export says Moderator m? like the app |
Open for Phase 2 (each has a recommendation in the frame it belongs to):
m? is never hidden there (R9).Human-only checks, once built (nothing in pytest or vitest can see these):
m? + Moderator read as "unknown" or as "broken"? The placeholder word is doing the work here; if it reads as broken, the roles line's sentence may need to come into the app too.Companions. docs/design-people.md §H H9 carries the six-phase delivery plan this iteration precedes; moderator-identity-failure-states.html Part 5b is the argument and the twelve decisions, and its E-frames are superseded by these for anything about pixels. The register row is in docs/mockups/STATUS.md.
Three states, one atom. Confirmed is the split badge as it ships today. Proposed is the same badge wearing
.badge-proposed: the 3 s pulse, the dashed edge, and the ✗ | ✓ pill on hover and on keyboard focus — exactly what an AutoCode-proposed tag looks like on a quote card, so the researcher has already learned it. Null is a code lozenge readingm?with no name half; the shipped:last-childrule gives it the full radius without a new selector.The one new rule.
.badge-proposedadds a dashed border, which on a single-box.badgeis the whole treatment. The split badge is two boxes that already carry a solid border each, so the class on the wrapper draws a dashed ring around a solid pair. Three lines move the dash onto the halves (see the second<style>block). That is the entire CSS cost of this iteration.✓ and ✗ mean what they mean on a tag. Accept confirms the slot with the proposed identity. Deny rejects the proposal: the slot goes back to
nulland the row readsm?; the identity that was minted from the label stays minted (numbers are never reused), it simply covers one session fewer. Titles are the shippedbuttons.accept/buttons.deny.Code-only mode is free. The shipped
data-person-display="code"switch (toolbar above) hides every name half;m?andm1survive it unchanged, which is the point of keeping the gap in the code rather than in a sentence.