Live Transcript — make it beautiful, paragraph-style, real-time
The transcript primitives (SpeakerTurn, computeSpeakerDisplays) are already well-built and shipping on the finished viewer. They just never got wired onto the live path — so live shows full formal names stamped every ~45 s with no paragraph grouping. These mockups show the current state next to the target for both /cast and the embedded phone widget.
⚠ Superseded — the per-speaker color system below was removed
This mockup explored a per-speaker color system: a higher-chroma --ox-identity-0…7 ramp on speaker names, a solid avatar fill, and a 2px rail. On review that whole color channel was removed — identity is carried by avatar + name only. Color is the wrong tool: 8 hues run out past ~4 speakers, they aren't stable per person across diarizer labels (S1 / Unknown A / “Emory” / “Emory Clark”), they aren't accessible on their own, and the 8 permanent tokens diluted the earthed brand palette. See DDR-013 (decisions/013-identity-speaker-palette.md). The color sections below (§06 palette, §07 B/C/D) are kept only as archaeology of the reversed proposal; the transcript-layout work (short names, paragraph coalescing, animate-in) stands.
01 · The gap, in one look
LiveTranscript.tsx · LiveRecordingSurfaces.tsxSame three utterances from one speaker, ~45 s apart. Left is what renders today; right is the target. Nothing new is invented — the right pane uses primitives that already exist.
Implementor notes
- Name: right pane calls
computeSpeakerDisplays— full"Ryan Snodgrass"on first appearance,"Ryan"after,"Ryan S."only if two Ryans collide. It's imported byTranscriptViewertoday but notLiveTranscript— that's the whole fix (~15 lines). Full name stays as the existing hoveridentitytooltip. - Color on the name: already correct in
SpeakerTurn.tsx:90(transcriptColorClass(identityKey)). Left pane draws it in plain text only to show what "no wiring" looks like. - Coalescing: merge adjacent same-speaker segments into one
SpeakerTurn— mirrorTranscriptViewer'smergeCuesIntoTurns. One avatar + one name per speaker run; segments flow as one<p>. Optional 2 px left rail in the speaker hex.
02 · Live transcript on /cast
/cast/meeting/[rec_id]packages/cast-web · LiveTranscriptA multi-speaker meeting. Newest turn at full weight; history softened; the freshest segment animates in. Two people named Ryan → collision rule kicks in.
Implementor notes
- Two-Ryan collision: once a second Ryan is present,
computeSpeakerDisplaysrenders"Ryan S."/"Ryan T."on every non-first turn so they never read as one person. First turn of each still shows the full name. - Recency: newest turn full opacity; older turns
opacity:.74. Freshest segment gets a 260 ms fade + 4 px rise (motion-sageox.md, no bounce, honorsprefers-reduced-motion). - Interim caret: the blinking tail is the
isFinal=falsestyling — dead today (Zoom Scribe Batch emitstranscript.finalonly). It lights up automatically if a streaming ASR ever emitstranscript.partial; no component change. - Unknown: unresolved speaker → stable numbered
"Speaker 3"in italic muted, still color-keyed on the"unknown"sentinel. Never leaks a raw id.
03 · Phone widget — live, embedded
…/recordings/[recId]/transcript/widget?native=1CloudReaderView embedThe same LiveTranscript mounted chrome-less in a ~360 px webview panel during an active recording. Native chrome (rec state, elapsed, stop) stays on the app screen — the widget shows only transcript.
Zero duplicate chrome
No "recording in progress" banner or stop button inside the widget — those live on the native screen. A second source of truth is the anti-pattern the mobile ask called out. The widget is transcript-only.
Implementor notes
- Data: live segments arrive over the cast SSE
/meetings/{rec}/cast/eventsviauseLiveTranscript. That stream is cast-token-gated — the widget bootstraps one withuseEnsuredCastToken(already exists). This is the one real server seam. - Width: 24 px avatar + short first name fits ~360 px with room to spare; the header
truncatenow essentially never triggers. - Height: caller must use
max-h-[…dvh], notvh(iOS URL-bar). Current caller uses32vh→ change todvh.
04 · Phone widget — finished (same route)
…/transcript/widgetTranscriptViewer fallbackWhen audio_recording.state === 'stopped', the same URL flips to the finished, cleaned transcript — no route rename. This is why transcript/widget beats live-transcript: the surface is mode-agnostic.
"Cleaned" ≠ different component
The finished view is the same SpeakerTurn primitive — so live → processing → ready never flashes a restyle. Disfluency removal ("uhm"), merged sentences, and precise timestamps are the finished viewer's job; the live view is honest about being partial.
Implementor notes
- State source:
metadata.audio_recording.stateon the recording (packages/cast/audiostate.go). Widget polls/subscribes; onstoppedit swapsLiveTranscript→TranscriptViewer. - Finished viewer already has search, keyframes, seek — the widget can mount a slim variant for the panel.
05 · The in-between states
ListeningRow · warmup · unknown"Live and incomplete" is the steady state, not an edge case. These are already built and good — keep them.
Implementor notes
- Listening: dot heights driven by real mic RMS (
audioLevel) — real input, real motion. Countdown from the estimated first-segment time. - Unknown never blanks: stable
"Speaker N"numbered by first appearance, still color-keyed. Looping states honorprefers-reduced-motion.
06 · Anatomy of a speaker turn
SpeakerTurnOne SpeakerTurn = 24 px avatar · short name · body. Identity is the avatar (real profile picture for known members, neutral initials otherwise) plus the name — not color.
Speaker color removed (DDR-013)
The 8-slot speaker-color palette and its AA-fix work that lived here were removed on review. The contrast bug the original palette had was real, but the fix is to stop using color for speaker identity, not to ship a better ramp. The fnv1a hash survives for non-color uses (KB constellation bubble shape).
| Element | Encodes | Status |
|---|---|---|
| Name — length | full → first → "First L." on collision | wire on live |
| Name — color | removed (DDR-013) | |
| Avatar 24px | identity (real picture / neutral initials) | keep — primary channel |
| Paragraph coalescing | one turn = one speaker run | add (merge) |
| removed (DDR-013) | ||
| Opacity / fade-in | recency (where's live) | add |
| removed (DDR-013) | ||
| Interim caret | final vs in-progress | latent · needs streaming ASR |
Implementor notes
- Color removed (DDR-013):
speaker-palette.ts, the--ox-identity-*tokens, and the de-collision helper are gone. Identity = avatar + name; thefnv1ahash moves tolib/hash.tsfor its non-color use (KB constellation shape).
07 · Name-form primitives
name-displayThe part of the original §07 that survives review: a shared vocabulary of name forms (full / familiar / first / initials / handle). The color primitives that followed (§B/C/D — the --ox-identity tokens, the ramp, the fill-encoding) were removed and are collapsed below as archaeology of the reversed proposal (DDR-013).
A · Name forms — one named vocabulary
| Form | Example | Derivation | Used |
|---|---|---|---|
full | Ryan Snodgrass | resolver name, as-is | first appearance · tooltips · finished header |
familiar the smart one | Ryan → Ryan S. | first name; +last initial only on a same-first-name collision | live + finished turn headers (repeats) |
first | Ryan | first token | compact chips · greetings · emails |
initials | RS | first + last initial | avatar fallbacks |
handle | @ryan | email local-part | tooltips · mentions |
Today familiar (computeSpeakerDisplays) is private to the transcript; initials (getInitials) is one shared fn but re-implemented 3× inline in cast-web. Proposal: one name-display.ts that names all five and is the only place a name gets shortened.
Reversed color proposal (§B/C/D) — archaeology, removed per DDR-013
B · Identity color — one token, flips by mode
Each row shows the speaker's name rendered on both surfaces at once — the AA-verified stop per mode. Same FNV-1a slot; humans anchor sage, agents anchor indigo.
| Slot · hue | Light (name on cream) | Dark (name on crater) | Proposed token |
|---|---|---|---|
| 0 · sage human | Ryan #324f31 · 7:1 | Ryan #adcfa3 | --ox-identity-0 |
| 1 · forest | Milkana | Milkana | --ox-identity-1 |
| 2 · indigo agent | Ox | Ox | --ox-identity-2 |
| 3 · gold | Ajit | Ajit | --ox-identity-3 |
| 4 · teal | Emory | Emory | --ox-identity-4 |
| 5 · brick | Galex | Galex | --ox-identity-5 |
| 6 · mauve | Speaker 6 | Speaker 6 | --ox-identity-6 |
| 7 · slate-blue | Speaker 7 | Speaker 7 | --ox-identity-7 |
Why tokens, not raw hex — the real bug
identityColorHex(id) returns one hex with no mode awareness, so raw consumers (KB constellation, VoiceprintRing, DeviceAvatar) ship failing AA on one mode — sage 2.46:1 & gold 2.23:1 in light, forest 2.7:1 & indigo 3.53:1 in dark. Only the transcript dodges it by hand-pairing Tailwind -700 dark:-300. Fix: mint --ox-identity-0..7 in @sageox/design with the AA-verified light/dark stops above; every consumer reads var(--ox-identity-N) and flips for free. The identity palette has no DDR today — this is new design-system work.
C · Ratified people ramp — more chroma (Ryan) + AA both modes Emory formalizes as DDR
Git check: Emory never purpose-tuned this palette — the stops above are the functional ramps' -700/-800, AA but muddy (8 dark earth tones that converge on cream; sage = forest). A dedicated identity ramp spreads the hue wheel and sets a per-mode lightness so speakers separate at a glance. All 16 stops verified ≥4.5:1 (light on cream / dark on crater).
| Slot · hue | Light (on cream) | Dark (on crater) |
|---|---|---|
| 0 · green human | Ryan #1f7a3d · 4.8:1 | Ryan #78d086 · 10:1 |
| 1 · teal | Milkana | Milkana |
| 2 · blue | Ajit | Ajit |
| 3 · indigo agent | Ox | Ox |
| 4 · violet | Emory | Emory |
| 5 · rose | Galex | Galex |
| 6 · amber | Dana | Dana |
| 7 · brick | Kai | Kai |
Ratified (Ryan): people identity uses more chroma than the earthed brand set — a different job (telling humans apart, not brand chrome). Anchors: green = human, indigo = agent. All 16 stops WCAG-verified; the lock test (T6) guards them. Emory formalizes as the DDR + --ox-identity-N tokens.
Deviations from the design system — explicit, for Emory to weigh
This palette intentionally breaks three standards. Every deviation is scoped to per-person identity and does not touch brand chrome.
| Standard | Deviation | Why |
|---|---|---|
| DDR-010 — earthed, no-neon brand palette | Identity hues carry more chroma than the earthed set | Telling 8 humans apart is a different job than brand chrome; earthed hues converge on cream (worst on light) |
| The 6 functional brand ramps (sage/progress/warning/public/error/info) | Introduces hues outside the ramps — violet, rose, cyan, royal blue | 8-way distinction needs a full hue-wheel spread the brand ramps can't provide |
| Reuse functional-ramp stops for color | A separate token family --ox-identity-* | Mode-aware AA + one source for every identity surface; decoupled from functional semantics (a speaker's color must not read as "warning" or "public") |
Containment (why this is safe): --ox-identity-* is identity-only — never a CTA, severity, or chrome color, and it excludes the sage CTA fill. The earthed brand voice is unchanged everywhere except the per-person identity mark.
D · Why light is harder — carry identity on the FILL, not the text ★
First-principles: AA on cream forces dark text (L*≈40); the eye discriminates hue poorly at low lightness, so light-mode names converge while dark-mode pastels (8–11:1, high lightness) pop. Fix = redundant encoding (DESIGN.md §11 r3): the solid avatar + a 2px rail are the primary identity channel, name-color is reinforcement. A filled chip isn't AA-as-text-constrained, so it reads hue far better on cream. Both panes below are forced to light mode.
Second lever if still tight: vary lightness across slots (some darker, some lighter, all ≥4.5:1) so lightness becomes a 2nd discriminating channel on top of hue. But the fill+rail does most of the work.
Implementor notes
- Converge 4 consumers onto the token:
identity-color.ts,speaker-palette.ts,MemberAvatar(bdox-4255b), and retireavatarColor.ts(12 off-brand raw-Tailwind hues + a weaker sum-of-charcodes hash → same user gets a different color). - Entity-agnostic: the hash takes any id string, so
--ox-identity(team_…)colors teams too — no team-specific helper needed. - Add the token correctly (freshness gate #1991): edit
@sageox/design/tokens→ regenerate → bump the package → consumevar(). Never hand-hacktokens.css.
08 · The transcript component family ★
rationalized set — layers, not a rewriteVerdict from the inventory: the bones are already good. SpeakerTurn is the shared atom; parsers + palette are single-source; live vs finished are cleanly split. Rationalize = name the layers, dedup 3 stragglers, add flex knobs — not a rewrite, not a premature mega-unifier.
SURFACE TranscriptViewerWithSearch · LiveRecordingSurfaces · TranscriptTab · transcript-viewer (ElevenLabs, isolated) │ COMPOSER <Transcript> (finished · processing) <LiveTranscript> (live) <TranscriptWithEpisode> (distill) │ knobs: density · headerMode · timestamps · search · container-query PRIMITIVE <SpeakerTurn> · name-display (forms) · identity-color (--ox-identity-N) · <Avatar> · <Timestamp> │ DATA/HOOKS useLiveTranscript · useSpeakerAttribution · useTranscriptSearch · useCastEventStream · parseVTT · mergeCuesIntoTurns
| Component | Verdict | Note |
|---|---|---|
| SpeakerTurn · TranscriptViewer · LiveTranscript · TranscriptTab | keep | already the right split; add density/headerMode knobs |
initialsOf/initials (cast-web ×2) + getInitials | merge → 1 | one getInitials, imported by cast-web |
fmtClock (TranscriptTab) + formatTimestamp | merge → 1 | canonical in vtt-parser.ts |
paragraph-break gap logic (inline + detectParagraphBreaks) | merge → 1 | export one |
ElevenLabs ui/transcript-viewer.tsx | isolate | word-level alignment — different data model, do NOT merge |
avatarColor.ts | retire | off-brand; fold into identity-color token |
The flexibility test — the phone widget proves it
The transcript/widget (§03) is not a new component — it's <LiveTranscript density="compact" headerMode="coalesced" /> composed from the same primitives as /cast and the finished page. One family, four surfaces. Header modes: per-turn · coalesced (the paragraph target) · headerless-continuation.
Implementor notes
- 3 dedups, low-risk, independent PRs: initials (3→1), timestamp fmt (2→1), paragraph-break (export one). Each removes real duplication without touching render.
- Boundaries: cast-web keeps its own
--color-cast-*ramp (intentional, flips withdata-mode); don't force the earth palette onto/castchrome. Don't build the unified<Transcript>facade until a 2nd new surface needs it — the composers above are enough today.