Live Transcript components · current vs target · 2026-07-02
Mode
Design review · transcript UX · apps/web

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

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

Current — choppy, full formal names
Ryan Snodgrass
Okay so the cast token is the thing that gates the SSE.
Ryan Snodgrass
The webview handoff only gives us the session cookie, not the token.
Ryan Snodgrass
So the widget still has to bootstrap a cast token before it can subscribe.
Target — one paragraph, short name, in-color
RS
Ryan Snodgrass
Okay so the cast token is the thing that gates the SSE. The webview handoff only gives us the session cookie, not the token. So the widget still has to bootstrap a cast token before it can subscribe.
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 by TranscriptViewer today but not LiveTranscript — that's the whole fix (~15 lines). Full name stays as the existing hover identity tooltip.
  • 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 — mirror TranscriptViewer's mergeCuesIntoTurns. 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 · LiveTranscript

A multi-speaker meeting. Newest turn at full weight; history softened; the freshest segment animates in. Two people named Ryan → collision rule kicks in.

Current
Ryan Snodgrass
Let's keep the widget URL under the transcript namespace.
Ryan Thompson
Agree, transcript slash widget reads cleaner than live-transcript.
Ryan Snodgrass
And it flips to the finished view on stop, same route.
?
Speaker 3
Do we need a new token endpoint for that?
Target
RS
Ryan Snodgrass
Let's keep the widget URL under the transcript namespace.
RT
Ryan T.
Agree, transcript/widget reads cleaner than live-transcript.
RS
Ryan S.
And it flips to the finished view on stop, same route.
3
Speaker 3
Do we need a new token endpoint for that? Or reuse the cast
Implementor notes
  • Two-Ryan collision: once a second Ryan is present, computeSpeakerDisplays renders "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, honors prefers-reduced-motion).
  • Interim caret: the blinking tail is the isFinal=false styling — dead today (Zoom Scribe Batch emits transcript.final only). It lights up automatically if a streaming ASR ever emits transcript.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 embed

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

9:41SageOx
Recording · 12:04 native app chrome
RecordingTranscript
MK
Milkana
The changelog entry needs the screenshot before it ships.
RS
Ryan
Got it — I'll grab the dark-mode capture of the widget.
MK
Milkana
Perfect. And the tag chips should

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.

theme + auth inherited for free · ?theme=darkx-sageox-theme · session cookie via webview-handoff
Implementor notes
  • Data: live segments arrive over the cast SSE /meetings/{rec}/cast/events via useLiveTranscript. That stream is cast-token-gated — the widget bootstraps one with useEnsuredCastToken (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 truncate now essentially never triggers.
  • Height: caller must use max-h-[…dvh], not vh (iOS URL-bar). Current caller uses 32vh → change to dvh.

04 · Phone widget — finished (same route)

…/transcript/widgetTranscriptViewer fallback

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

9:52SageOx
Recorded · 14:22 · ready
RecordingTranscript
RS
Ryan Snodgrass
The token seam is the only real work — everything else is reuse. Let's ship the wiring fix first since it lands on cast too.
MK
Milkana
Makes sense. I'll queue a changelog entry for when the widget is live.

"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.state on the recording (packages/cast/audiostate.go). Widget polls/subscribes; on stopped it swaps LiveTranscriptTranscriptViewer.
  • 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.

Empty — mic-driven "Listening"
Listening · first words in ~4s
Warmup — attribution pending
matching speakers…
We can reuse the join-token path for the widget.
Unknown — honest degrade
AB
Ajit
Lore will back the bubbles eventually, but not this quarter.
2
Speaker 2
And the widget doesn't depend on that at all.
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 honor prefers-reduced-motion.

06 · Anatomy of a speaker turn

SpeakerTurn

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

ElementEncodesStatus
Name — lengthfull → first → "First L." on collisionwire on live
Name — colorspeaker identity → default text colorremoved (DDR-013)
Avatar 24pxidentity (real picture / neutral initials)keep — primary channel
Paragraph coalescingone turn = one speaker runadd (merge)
2px left railspeaker owns the paragraphremoved (DDR-013)
Opacity / fade-inrecency (where's live)add
8-color hashidentity, de-collided per meetingremoved (DDR-013)
Interim caretfinal vs in-progresslatent · 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; the fnv1a hash moves to lib/hash.ts for its non-color use (KB constellation shape).

07 · Name-form primitives

name-display

The 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

FormExampleDerivationUsed
fullRyan Snodgrassresolver name, as-isfirst appearance · tooltips · finished header
familiar the smart oneRyan → Ryan S.first name; +last initial only on a same-first-name collisionlive + finished turn headers (repeats)
firstRyanfirst tokencompact chips · greetings · emails
initialsRSfirst + last initialavatar fallbacks
handle@ryanemail local-parttooltips · mentions

Today familiar (computeSpeakerDisplays) is private to the transcript; initials (getInitials) is one shared fn but re-implemented 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 · hueLight (name on cream)Dark (name on crater)Proposed token
0 · sage humanRyan #324f31 · 7:1Ryan #adcfa3--ox-identity-0
1 · forestMilkanaMilkana--ox-identity-1
2 · indigo agentOxOx--ox-identity-2
3 · goldAjitAjit--ox-identity-3
4 · tealEmoryEmory--ox-identity-4
5 · brickGalexGalex--ox-identity-5
6 · mauveSpeaker 6Speaker 6--ox-identity-6
7 · slate-blueSpeaker 7Speaker 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 · hueLight (on cream)Dark (on crater)
0 · green humanRyan #1f7a3d · 4.8:1Ryan #78d086 · 10:1
1 · tealMilkanaMilkana
2 · blueAjitAjit
3 · indigo agentOxOx
4 · violetEmoryEmory
5 · roseGalexGalex
6 · amberDanaDana
7 · brickKaiKai

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.

StandardDeviationWhy
DDR-010 — earthed, no-neon brand paletteIdentity hues carry more chroma than the earthed setTelling 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 blue8-way distinction needs a full hue-wheel spread the brand ramps can't provide
Reuse functional-ramp stops for colorA 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.

name color only — muddy on cream
RS
Ryan
The token seam is the only real work here.
MK
Milkana
I'll queue the changelog once it's live.
AB
Ajit
Lore backs the bubbles eventually, not now.
OX
Ox
Widget doesn't depend on that at all.
solid fill + rail — distinct on cream
RS
Ryan
The token seam is the only real work here.
MK
Milkana
I'll queue the changelog once it's live.
AB
Ajit
Lore backs the bubbles eventually, not now.
OX
Ox
Widget doesn't depend on that at all.

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 (bd ox-4255b), and retire avatarColor.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 → consume var(). Never hand-hack tokens.css.

08 · The transcript component family ★

rationalized set — layers, not a rewrite

Verdict 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 knobsnot 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
ComponentVerdictNote
SpeakerTurn · TranscriptViewer · LiveTranscript · TranscriptTabkeepalready the right split; add density/headerMode knobs
initialsOf/initials (cast-web ×2) + getInitialsmerge → 1one getInitials, imported by cast-web
fmtClock (TranscriptTab) + formatTimestampmerge → 1canonical in vtt-parser.ts
paragraph-break gap logic (inline + detectParagraphBreaks)merge → 1export one
ElevenLabs ui/transcript-viewer.tsxisolateword-level alignment — different data model, do NOT merge
avatarColor.tsretireoff-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 with data-mode); don't force the earth palette onto /cast chrome. Don't build the unified <Transcript> facade until a 2nd new surface needs it — the composers above are enough today.