Transcription
The live transcript (UC-VOICE-01 AC-2), port-at-parity per the PRD verdict — display-only; capture belongs to speech-input, so no browser dependency and nothing to substitute. Segments colorized by playback position (active text-primary, past text-muted-foreground, future text-muted-foreground/60 — upstream byte-classes), press-to-seek to the segment start ONLY when onSeek is wired (otherwise a no-op — onSeek is not invented), empty/whitespace segments never render (upstream byte-trap), render-prop children preserved for custom segment rendering, and the hook throw outside the root at byte parity. The verdict's interim/final distinction ships as a caller-supplied interimText display seam (declared addition — upstream has no interim part). Speaker labels, events, and search/highlight not shipped: the upstream part set carries none.
- v0.2.1
- component
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/transcription.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/transcription.jsonDependencies
No required npm packages — this item installs through the registry dependencies below.
https://reactnativereusables.com/r/nativewind/text.json |
https://reactnativereusables.com/r/uniwind/text.json |
AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92
Source
import { Text } from '@/registry/nativewind/components/ui/text';
import { cn } from '@/registry/nativewind/lib/utils';
import * as React from 'react';
import { Platform, View } from 'react-native';
import {
SEGMENT_STATE_CLASS,
type SegmentPlaybackState,
type TranscriptionSegmentData,
filterRenderableSegments,
segmentState,
} from './transcription.logic';
/**
* Transcription — the live transcript (UC-VOICE-01 AC-2: "watch their speech appear
* as a live transcript"), segments colorized by playback position and tappable to
* seek (UC-VOICE-02's transcript follows the audio).
*
* THE PRD VERDICT IS PORT-AT-PARITY: "Display-only live transcript text with an
* interim and final distinction; capture belongs to speech-input, so this component
* itself has no browser dependency." There is NOTHING to substitute: capture is
* speech-input's seam, so this ships dependency-free at full parity.
*
* THE UPSTREAM PART SET, from the KB, ported part for part: the Transcription root
* (segments / currentTime / onSeek / render-prop children) and the TranscriptionSegment
* (state-styled, click-to-seek). Both of the web's segment-state classes and BOTH
* byte-traps survive: segments with empty or whitespace text are never rendered
* (transcription.logic drops them before indexing), and TranscriptionSegment outside
* the root throws 'Transcription components must be used within Transcription'.
* Pressing a segment seeks to its start ONLY when onSeek is wired — without it the
* press is a no-op (onSeek is not invented, the KB's documented behavior). The web's
* data-slot/data-active/data-index attributes have no React Native carrier (data-*
* selectors compile to nothing on the native path); the state is expressed by the
* class map and announced through accessibility state instead.
*
* LAYOUT IS THE FLOWING INLINE PARAGRAPH (remediation row 6, design/style-parity-
* remediation.md): the web renders segments in `flex flex-wrap gap-1` — one wrapping
* paragraph, segments separated by space (web transcription.tsx:62). The prior port
* stacked each segment as its own row View, which read as a list, not a transcript.
* The default rendering is now ONE wrapping parent Text whose children are per-segment
* nested Texts separated by a single space string — true RN inline flow, wrapping like
* the web. Presses ride the nested Texts exactly as before (the inline-citation chip
* precedent: a Text with onPress inside a text flow). Per the wave-13 standing rule
* (parent Text classes do NOT cascade to nested spans) every segment carries its own
* EXPLICIT state color from SEGMENT_STATE_CLASS — no inheritance reliance, and no /NN
* modifiers (the web's text-muted-foreground/60 dim converges to the fixed
* neutral-400/neutral-500 pair, see the logic module). The custom render-prop path
* keeps a View host: its children are arbitrary nodes the caller owns, and a View
* cannot nest inside a Text.
*
* THE INTERIM / FINAL DISTINCTION is the verdict's own product surface and upstream
* has no part for it, so it ships as a DECLARED ADDITION: the caller passes the
* in-flight dictation as `interimText` and moves it into `segments` when the engine
* finalizes. It renders inline after the finals in the future-segment color plus
* italic — visually provisional, semantically "not yet real". Caller-owned state,
* display-only here; nothing listens.
*
* NOT SHIPPED, on the record: speaker labels and events — the KB's upstream part set
* carries neither (the brief's guess); segments are text/start/end only. Search and
* highlight — the verdict does not name them. Timestamps in the default segment —
* upstream's default renders text only; they remain reachable through the render-prop
* children with formatSegmentTime() from the logic module, exactly the web's
* custom-children call.
*
* Composition (default rendering):
* <Transcription segments={segments} currentTime={t} onSeek={seek} />
* Composition (custom segment rendering, the web's render-prop):
* <Transcription segments={segments} currentTime={t} onSeek={seek}>
* {(segment, index) => (
* <TranscriptionSegment key={index} segment={segment} index={index} />
* )}
* </Transcription>
*/
type TranscriptionContextValue = {
/** Filtered — empty and whitespace segments never reach a child (the KB trap). */
segments: readonly TranscriptionSegmentData[];
currentTime: number;
/** False when onSeek is unwired — presses are then no-ops, never invented seeks. */
canSeek: boolean;
seekToSegment: (segment: TranscriptionSegmentData) => void;
};
const TranscriptionContext = React.createContext<TranscriptionContextValue | null>(null);
/**
* The upstream trap, byte-verbatim: a part used outside the root throws
* 'Transcription components must be used within Transcription'.
*/
function useTranscription(): TranscriptionContextValue {
const ctx = React.useContext(TranscriptionContext);
if (!ctx) {
throw new Error('Transcription components must be used within Transcription');
}
return ctx;
}
export type TranscriptionProps = {
/** Finalized segments, caller-owned — append as your engine finalizes them. */
segments: readonly TranscriptionSegmentData[];
/** The playback clock in seconds; defaults to 0 (the web root's default). */
currentTime?: number;
/** Seek to a segment's start. ABSENT → segment presses are no-ops. */
onSeek?: (timeSecond: number) => void;
/** The in-flight dictation — the interim half of the verdict's distinction. */
interimText?: string;
/**
* The web's render-prop children: (segment, index) => ReactNode. Omit for the
* default state-styled TranscriptionSegment rendering.
*/
children?: (segment: TranscriptionSegmentData, index: number) => React.ReactNode;
className?: string;
};
function Transcription({
segments,
currentTime = 0,
onSeek,
interimText,
children,
className,
}: TranscriptionProps) {
// Empty transcript + no interim renders nothing — the web root with no segments
// renders nothing either; a blank is the honest display of no transcript.
const renderable = React.useMemo(() => filterRenderableSegments(segments), [segments]);
const contextValue = React.useMemo<TranscriptionContextValue>(
() => ({
segments: renderable,
currentTime,
canSeek: Boolean(onSeek),
seekToSegment: (segment) => onSeek?.(segment.startSecond),
}),
[renderable, currentTime, onSeek],
);
return (
<TranscriptionContext.Provider value={contextValue}>
{children ? (
// The render-prop path keeps a View host — the web's flex-wrap div. Its
// children are caller-owned nodes and may be non-text, which cannot nest
// inside a Text on native. The interim continues after the caller's
// segments here too — the distinction is a property of the transcript,
// not of one rendering branch (review fix: the restructure dropped it).
<View className={cn('gap-2', className)}>
{renderable.map((segment, index) => children(segment, index))}
{interimText ? <InterimSegment text={interimText} /> : null}
</View>
) : (
// Remediation row 6 — ONE wrapping parent Text: per-segment nested Texts
// separated by a single space string, the RN inline-flow equivalent of the
// web's `flex flex-wrap gap-1 text-sm leading-relaxed` paragraph (review
// fix: the paragraph converges to the web's text-sm). Metrics (size/
// leading) live on the parent; every nested segment carries its OWN
// explicit color class (wave-13: parent classes do not cascade to nested
// spans).
<Text className={cn('text-sm leading-relaxed', className)}>
{renderable.map((segment, index) => (
<React.Fragment key={`${segment.startSecond}-${segment.endSecond}-${index}`}>
{index > 0 ? ' ' : null}
<TranscriptionSegment segment={segment} index={index} />
</React.Fragment>
))}
{interimText ? (
<React.Fragment key="interim">
{renderable.length > 0 ? ' ' : null}
<InterimSegment text={interimText} />
</React.Fragment>
) : null}
</Text>
)}
</TranscriptionContext.Provider>
);
}
type TranscriptionSegmentProps = {
segment: TranscriptionSegmentData;
/** Accepted for upstream API parity — the web's data-index has no RN carrier. */
index?: number;
className?: string;
};
/**
* One transcript segment — the web's button, rendered as the house text pressable
* (the inline-citation chip's form: a Text with onPress nested in the flow Text).
* Remediation row 6: the segment carries its OWN explicit state color — the wave-13
* rule (no reliance on parent Text inheritance) and the no-/NN standing rule both
* live in SEGMENT_STATE_CLASS. Size/leading come from the flow's parent Text; the
* old stacked-row py-1 padding is gone — padding on an inline nested Text is not an
* RN layout concept and would fight the flow. Without onSeek the press is a no-op
* and the segment renders as plain text — exactly the web's cursor-default.
*/
function TranscriptionSegment({ segment, className }: TranscriptionSegmentProps) {
const { currentTime, canSeek, seekToSegment } = useTranscription();
const state: SegmentPlaybackState = segmentState(segment, currentTime);
const active = state === 'active';
return (
<Text
onPress={canSeek ? () => seekToSegment(segment) : undefined}
accessibilityRole={canSeek ? 'button' : 'text'}
accessibilityHint={canSeek ? 'Jumps playback to this point' : undefined}
accessibilityState={{ selected: active }}
className={cn(
SEGMENT_STATE_CLASS[state],
// The KB's segment-style byte-note: seekable → cursor-pointer (web only;
// native has no cursor and the class would be inert).
canSeek && Platform.select({ web: 'cursor-pointer' }),
className,
)}
>
{segment.text}
</Text>
);
}
type InterimSegmentProps = {
text: string;
};
/**
* The interim half of the distinction, rendered INLINE after the finals (remediation
* row 6) in the future-segment color (explicit, from the same map the segments use)
* plus italic — visibly provisional. Announced as in-progress so a screen reader
* never presents a guess as final text.
*/
function InterimSegment({ text }: InterimSegmentProps) {
return (
<Text
accessibilityLabel={`Transcribing: ${text}`}
className={cn('italic', SEGMENT_STATE_CLASS.future)}
>
{text}
</Text>
);
}
export { Transcription, TranscriptionSegment, useTranscription };
/**
* Transcription — pure logic. Zero react-native imports, so the Vitest tier owns it
* (see the header of sources.logic.ts for why component files themselves cannot load
* under Node).
*
* THE PRD VERDICT IS PORT-AT-PARITY: "Display-only live transcript text with an
* interim and final distinction; capture belongs to speech-input, so this component
* itself has no browser dependency." The segment type and the three playback states
* are the web original's, byte-for-byte (the KB's documented transcription behavior).
* The state CLASSES are too, with ONE declared convergence: the FUTURE dim — the
* web's text-muted-foreground/60 is a banned /NN modifier, so it substitutes the
* fixed text-neutral-400 dark:text-neutral-500 pair (rationale on
* SEGMENT_STATE_CLASS below). The interim distinction is the verdict's own words
* and ships as a caller-supplied `interimText` display seam — upstream has no
* interim part, so that addition is declared in the component header, not smuggled
* in here.
*/
/** The web original's TranscriptionSegment, field for field. Seconds, not ms. */
export type TranscriptionSegmentData = {
text: string;
startSecond: number;
endSecond: number;
};
/**
* THE KB'S DOCUMENTED DROP: "segments whose text is whitespace or '' are not passed
* to children (' ' and '' dropped; only 'Hello'/'world' remain)". The filter runs
* BEFORE indexing, so the render-prop's index is the index of what renders, not of
* what arrived. Non-string text (a malformed segment) drops too — no-throw.
*/
export function filterRenderableSegments(
segments: readonly TranscriptionSegmentData[],
): TranscriptionSegmentData[] {
return segments.filter(
(segment): segment is TranscriptionSegmentData =>
typeof segment?.text === 'string' && segment.text.trim().length > 0,
);
}
export type SegmentPlaybackState = 'past' | 'active' | 'future';
/**
* The web original's three states, byte-behavior: ACTIVE when currentTime is within
* the range (INCLUSIVE of both ends), past strictly after endSecond, future strictly
* before startSecond. Garbage currentTime falls back to 0 (the web default), which
* makes a transcript at rest show its first segment as the active one when it starts
* at 0 — upstream behavior with `currentTime` defaulted.
*/
export function segmentState(
segment: Pick<TranscriptionSegmentData, 'startSecond' | 'endSecond'>,
currentTime: number,
): SegmentPlaybackState {
const t = Number.isFinite(currentTime) ? currentTime : 0;
if (t < segment.startSecond) return 'future';
if (t > segment.endSecond) return 'past';
return 'active';
}
/**
* The state → class map. Active and past are the web original's roles verbatim
* (text-primary / text-muted-foreground). The FUTURE state converges under remediation
* row 6 (design/style-parity-remediation.md): the web writes text-muted-foreground/60,
* but /NN color-opacity modifiers are a standing house ban (they never compiled
* reliably — the wave-11 finding) and segments are nested Texts that must each carry
* an EXPLICIT color. text-neutral-400 / dark:text-neutral-500 is the nearest fixed
* step to the web's 60%-dimmed muted-foreground in both schemes (light muted-foreground
* ≈ neutral-500, dark ≈ neutral-400; one step dimmer each). A data-map string, hence
* safelisted (apps/harness/src/class-safelist.tsx).
*/
export const SEGMENT_STATE_CLASS: Record<SegmentPlaybackState, string> = {
active: 'text-primary',
past: 'text-muted-foreground',
future: 'text-neutral-400 dark:text-neutral-500',
};
/**
* A segment timestamp for consumers composing custom children — the web's
* `{segment.startSecond}s` alternative rendering, given a real clock (m:ss, floor).
* Negative and non-finite input yields "0:00", never NaN.
*
* The arithmetic duplicates audio-player.logic's formatPlaybackTime ON PURPOSE:
* registry items install independently, so transcription cannot import another
* item's logic module — a formatter is cheaper than a phantom dependency.
*/
export function formatSegmentTime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return '0:00';
return formatPlaybackTimeFromSeconds(seconds);
}
function formatPlaybackTimeFromSeconds(totalSeconds: number): string {
const whole = Math.floor(totalSeconds);
const ss = String(whole % 60).padStart(2, '0');
const minutes = Math.floor(whole / 60) % 60;
const hours = Math.floor(whole / 3600);
if (hours === 0) return `${minutes}:${ss}`;
return `${hours}:${String(minutes).padStart(2, '0')}:${ss}`;
}
Props
| Name | Type | Default | Description |
|---|---|---|---|
| segments | readonly TranscriptionSegmentData[] | — | Finalized segments, caller-owned — append as your engine finalizes them. |
| currentTime | number | undefined | 0 | The playback clock in seconds; defaults to 0 (the web root's default). |
| onSeek | ((timeSecond: number) => void) | undefined | — | Seek to a segment's start. ABSENT → segment presses are no-ops. |
| interimText | string | undefined | — | The in-flight dictation — the interim half of the verdict's distinction. |
| children | ((segment: TranscriptionSegmentData, index: number) => React.ReactNode) | undefined | — | The web's render-prop children: (segment, index) => ReactNode. Omit for the default state-styled TranscriptionSegment rendering. |
| className | string | undefined | — | — |
Theming
Themed via text-muted-foreground, text-primary.
Usage
() => (
<Transcription
segments={[
{ text: 'Install the full 56-item set through the real CLI.', startSecond: 0, endSecond: 3.2 },
{ text: 'Then prove it cold-boots on both platforms.', startSecond: 3.4, endSecond: 6.1 },
]}
currentTime={3.8}
>
<TranscriptionSegment segment={{ text: 'Install the full 56-item set through the real CLI.', startSecond: 0, endSecond: 3.2 }} />
<TranscriptionSegment segment={{ text: 'Then prove it cold-boots on both platforms.', startSecond: 3.4, endSecond: 6.1 }} />
</Transcription>
)