@spexcode/transcript-ui 0.7.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ import { type AnyTurn } from './segments.js';
2
+ export declare function LiveTail({ turns, lastSaid, revision, className }: {
3
+ turns: readonly AnyTurn[] | null | undefined;
4
+ lastSaid?: string | null;
5
+ revision?: string;
6
+ className?: string;
7
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,26 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useMemo } from 'react';
3
+ import { alreadySaid, isRunning, liveSlice } from './segments.js';
4
+ import { TranscriptTurns } from './TranscriptView.js';
5
+ import { useDisclosure } from './useDisclosure.js';
6
+ // THE OPEN INTERVAL'S COLLAPSED FACE: the current turn — the newest prose and every call after it — drawn in
7
+ // the transcript's own grammar from the same merged payload the expanded view renders in full. `lastSaid` is
8
+ // what the host's own record already shows (a declared note); the tail elides the prose that repeats it. The
9
+ // caret marks words still being said: it sits at the end of the newest prose only while that prose is the
10
+ // newest thing in the turn — once a call follows, the words are finished and the running call is the mark.
11
+ export function LiveTail({ turns, lastSaid = null, revision, className = '' }) {
12
+ const [openIds, toggle] = useDisclosure();
13
+ const slice = useMemo(() => (turns ? liveSlice(turns) : []), [turns]);
14
+ if (!slice.length)
15
+ return null;
16
+ const repeated = alreadySaid(slice[0].text, lastSaid);
17
+ const running = slice.some((turn) => (turn.tools || []).some((tool) => isRunning(tool, true)));
18
+ if (repeated && !running)
19
+ return null;
20
+ const shown = repeated ? [{ ...slice[0], text: undefined }, ...slice.slice(1)] : slice;
21
+ if (!shown.some((turn) => turn.text || turn.tools?.length))
22
+ return null;
23
+ const last = shown[shown.length - 1];
24
+ const speaking = !!last.text && !last.tools?.length;
25
+ return (_jsx("div", { className: `tx tx-live${speaking ? ' is-speaking' : ''}${className ? ` ${className}` : ''}`, "data-revision": revision, children: _jsx(TranscriptTurns, { turns: shown, openIds: openIds, onToggle: toggle, live: true }) }));
26
+ }
@@ -0,0 +1,6 @@
1
+ export declare function Quote({ who, ts, text, className }: {
2
+ who?: string | null;
3
+ ts?: number | string | null;
4
+ text: string;
5
+ className?: string;
6
+ }): import("react").JSX.Element;
package/dist/Quote.js ADDED
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { useTranscriptUi } from './context.js';
4
+ import { timeOf } from './vocabulary.js';
5
+ // A long quote is clamped at first sight — the conversation is about what came after it. The trigger is the
6
+ // text itself rather than a measured height, so the row never reflows after paint.
7
+ const isLongQuote = (text) => text.length > 700 || (text.match(/\n/g) || []).length > 10;
8
+ // THE PERSON IS QUOTED: a bubble off to its own side, with one corner squared. The name sits on the bubble
9
+ // when the host knows it; the time shows only in a narrow pane, where there is no ruler beside the flow.
10
+ export function Quote({ who = null, ts = null, text, className = '' }) {
11
+ const { renderText, labels } = useTranscriptUi();
12
+ const [open, setOpen] = useState(false);
13
+ const clamped = !open && isLongQuote(text);
14
+ return (_jsxs("div", { className: `tx tx-quote${clamped ? ' is-clamped' : ''}${className ? ` ${className}` : ''}`, children: [(who || ts) && (_jsxs("div", { className: "tx-quote-head", children: [who && _jsx("span", { className: "tx-quote-who", children: who }), ts != null && _jsx("time", { className: "tx-time", children: timeOf(ts) })] })), _jsx("div", { className: "tx-quote-text", children: renderText(text) }), clamped && _jsx("button", { type: "button", className: "tx-quote-more", onClick: () => setOpen(true), children: labels.more })] }));
15
+ }
@@ -0,0 +1,14 @@
1
+ import { type AnyTool } from './vocabulary.js';
2
+ export declare function ToolLine({ tool, open, onToggle, live }: {
3
+ tool: AnyTool;
4
+ open: boolean;
5
+ onToggle: () => void;
6
+ live?: boolean;
7
+ }): import("react").JSX.Element;
8
+ export declare function ToolRun({ tools, openIds, onToggle, live, fold }: {
9
+ tools?: readonly AnyTool[];
10
+ openIds: ReadonlySet<string>;
11
+ onToggle: (id: string) => void;
12
+ live?: boolean;
13
+ fold?: boolean;
14
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,62 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { useTranscriptUi } from './context.js';
4
+ import { Caret, Spinner } from './icons.js';
5
+ import { isRunning } from './segments.js';
6
+ import { runKinds, splitTarget, toolTarget, toolVerb } from './vocabulary.js';
7
+ // A LIVE FRAME WITHHOLDS OUTPUT BODIES: a recorded result is `null` on the wire, its size told, and the body
8
+ // is fetched once when a person opens the call, through the host's loader.
9
+ function WithheldOutput({ tool }) {
10
+ const { loadToolOutput, labels } = useTranscriptUi();
11
+ const [fetched, setFetched] = useState(null);
12
+ useEffect(() => {
13
+ if (!loadToolOutput)
14
+ return undefined;
15
+ let live = true;
16
+ loadToolOutput(tool.id).then((result) => { if (live)
17
+ setFetched(result); }, (error) => { if (live)
18
+ setFetched({ ok: false, error: String(error?.message || error) }); });
19
+ return () => { live = false; };
20
+ }, [loadToolOutput, tool.id]);
21
+ if (!loadToolOutput)
22
+ return null;
23
+ if (!fetched)
24
+ return _jsx("div", { className: "tx-tool-out tx-tool-out-state", children: labels.loading });
25
+ if (!fetched.ok)
26
+ return _jsx("div", { className: "tx-tool-out tx-tool-out-state is-error", children: fetched.error });
27
+ return _jsx("pre", { className: "tx-tool-out", children: fetched.output ?? '' });
28
+ }
29
+ // One tool call as a SENTENCE, not a card: verb, target, and the size of what came back. It is
30
+ // `inline-flex` so a dozen of them read as a list of things that happened rather than a dozen boxes. There
31
+ // is no success mark, because the transcript carries no per-tool status — the past-tense verb is the whole
32
+ // claim. A running call wears a small spinner and the word.
33
+ export function ToolLine({ tool, open, onToggle, live = false }) {
34
+ const { labels, vocabulary } = useTranscriptUi();
35
+ const target = toolTarget(tool.input, vocabulary);
36
+ const { lead, trail } = splitTarget(target);
37
+ const lines = tool.outputLines || 0;
38
+ const withheld = tool.output === null;
39
+ const canOpen = !!tool.input || tool.output !== undefined || withheld;
40
+ const running = isRunning(tool, live);
41
+ const row = (_jsxs(_Fragment, { children: [_jsx("span", { className: "tx-tool-verb", children: toolVerb(tool.name, vocabulary) }), lead && _jsx("span", { className: "tx-tool-target", children: lead }), trail && _jsx("span", { className: "tx-tool-trail", children: trail }), lines > 0 && _jsx("span", { className: "tx-tool-size", children: labels.lines(lines) }), running && _jsxs("span", { className: "tx-tool-running", children: [_jsx(Spinner, {}), labels.running] }), canOpen && _jsx(Caret, { open: open, className: "tx-tool-caret" })] }));
42
+ return (_jsxs("div", { className: `tx-tool${running ? ' is-running' : ''}`, children: [canOpen
43
+ ? _jsx("button", { type: "button", onClick: onToggle, "aria-expanded": open, className: "tx-tool-row is-openable", children: row })
44
+ : _jsx("div", { className: "tx-tool-row", children: row }), open && canOpen && _jsxs(_Fragment, { children: [tool.input && _jsx("pre", { className: "tx-tool-in", children: tool.input }), withheld
45
+ ? _jsx(WithheldOutput, { tool: tool })
46
+ : tool.output !== undefined && _jsx("pre", { className: "tx-tool-out", children: tool.output })] })] }));
47
+ }
48
+ // A turn's tool calls are consecutive by construction, so "a run" is just "this turn's calls". `runMin` or
49
+ // more fold to one row; fewer stay sentences, where the verb and target are worth reading on sight. `fold`
50
+ // is off for the work in progress: calls still landing are sentences whatever their number.
51
+ export function ToolRun({ tools, openIds, onToggle, live = false, fold = true }) {
52
+ const { labels, vocabulary, runMin, fold: policy } = useTranscriptUi();
53
+ if (!tools?.length)
54
+ return null;
55
+ const line = (tool) => _jsx(ToolLine, { tool: tool, open: openIds.has(tool.id), onToggle: () => onToggle(tool.id), live: live }, tool.id);
56
+ if (!fold || policy === 'none' || tools.length < runMin)
57
+ return _jsx("div", { className: "tx-tools", children: tools.map(line) });
58
+ const id = `run:${tools[0].id}`;
59
+ const open = openIds.has(id);
60
+ const running = tools.some((tool) => isRunning(tool, live));
61
+ return _jsx("div", { className: "tx-tools", children: _jsxs("div", { className: `tx-tool${running ? ' is-running' : ''}`, children: [_jsxs("button", { type: "button", className: "tx-tool-row is-openable is-run", "aria-expanded": open, onClick: () => onToggle(id), children: [_jsx("span", { className: "tx-tool-verb", children: labels.toolUses(tools.length) }), _jsx("span", { className: "tx-tool-trail", children: runKinds(tools, vocabulary) }), _jsx(Caret, { open: open, className: "tx-tool-caret" })] }), open && _jsx("div", { className: "tx-tool-kids", children: tools.map(line) })] }) });
62
+ }
@@ -0,0 +1,35 @@
1
+ import { type AnyTurn, type Segment, type WorkSegment as Work } from './segments.js';
2
+ type Disclosure = {
3
+ openIds: ReadonlySet<string>;
4
+ onToggle: (id: string) => void;
5
+ };
6
+ export declare function TurnBody({ turn, openIds, onToggle, live, fold }: {
7
+ turn: AnyTurn;
8
+ live: boolean;
9
+ fold?: boolean;
10
+ } & Disclosure): import("react").JSX.Element;
11
+ export declare function WorkSegmentView({ segment, openIds, onToggle, live }: {
12
+ segment: Work;
13
+ live: boolean;
14
+ } & Disclosure): import("react").JSX.Element;
15
+ export declare function SegmentView({ segment, ...rest }: {
16
+ segment: Segment;
17
+ live: boolean;
18
+ } & Disclosure): import("react").JSX.Element;
19
+ export declare function TranscriptTurns({ turns, openIds, onToggle, live }: {
20
+ turns: readonly AnyTurn[];
21
+ live?: boolean;
22
+ } & Disclosure): import("react").JSX.Element;
23
+ export type TranscriptPayload = Readonly<{
24
+ turns: readonly AnyTurn[];
25
+ truncated?: boolean;
26
+ omittedTurns?: number;
27
+ omittedBytes?: number;
28
+ outOfOrderEvents?: number;
29
+ }>;
30
+ export declare function TranscriptView({ data, live, className }: {
31
+ data: TranscriptPayload | null | undefined;
32
+ live?: boolean;
33
+ className?: string;
34
+ }): import("react").JSX.Element;
35
+ export {};
@@ -0,0 +1,43 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useTranscriptUi } from './context.js';
3
+ import { Caret } from './icons.js';
4
+ import { Quote } from './Quote.js';
5
+ import { segments } from './segments.js';
6
+ import { ToolRun } from './ToolLine.js';
7
+ import { useDisclosure } from './useDisclosure.js';
8
+ import { runKinds } from './vocabulary.js';
9
+ // the agent IS the page: full measure, no bubble, no tint
10
+ export function TurnBody({ turn, openIds, onToggle, live, fold = true }) {
11
+ const { renderText } = useTranscriptUi();
12
+ return _jsxs("div", { className: "tx-say", children: [turn.text && _jsx("div", { className: "tx-say-text", children: renderText(turn.text) }), _jsx(ToolRun, { tools: turn.tools, openIds: openIds, onToggle: onToggle, live: live, fold: fold })] });
13
+ }
14
+ export function WorkSegmentView({ segment, openIds, onToggle, live }) {
15
+ const { labels, vocabulary } = useTranscriptUi();
16
+ const id = `seg:${segment.work[0]?.id || segment.answer?.id}`;
17
+ const open = openIds.has(id);
18
+ const kinds = runKinds(segment.work.flatMap((turn) => turn.tools ?? []), vocabulary);
19
+ const foldedCalls = segment.work.reduce((n, turn) => n + (turn.tools?.length || 0), 0);
20
+ // history folds its runs; the work in progress (a live segment's calls after its newest prose) does not
21
+ const history = !segment.now || !!segment.answer;
22
+ return _jsxs(_Fragment, { children: [segment.folded ? (_jsxs("div", { className: "tx-work", children: [_jsxs("button", { type: "button", className: "tx-work-row", "aria-expanded": open, onClick: () => onToggle(id), children: [_jsx("span", { className: "tx-work-lead", children: labels.toolUses(foldedCalls) }), kinds && _jsx("span", { className: "tx-work-detail", children: kinds }), _jsx(Caret, { open: open, className: "tx-work-caret" })] }), open && _jsx("div", { className: "tx-work-body", children: segment.work.map((turn) => _jsx(TurnBody, { turn: turn, openIds: openIds, onToggle: onToggle, live: live }, turn.id)) })] })) : segment.work.map((turn) => _jsx(TurnBody, { turn: turn, openIds: openIds, onToggle: onToggle, live: live, fold: history }, turn.id)), segment.answer && _jsx(TurnBody, { turn: segment.answer, openIds: openIds, onToggle: onToggle, live: live, fold: !segment.now }), segment.after.map((turn) => _jsx(TurnBody, { turn: turn, openIds: openIds, onToggle: onToggle, live: live, fold: !segment.now }, turn.id))] });
23
+ }
24
+ export function SegmentView({ segment, ...rest }) {
25
+ if (segment.kind === 'quote')
26
+ return _jsx(Quote, { ts: segment.turn.at, text: segment.turn.text || '', className: "tx-quote-nested" });
27
+ return _jsx(WorkSegmentView, { segment: segment, ...rest });
28
+ }
29
+ // THE TURNS, in the grammar: quotes where the host wants them, work segments folded behind their answers
30
+ export function TranscriptTurns({ turns, openIds, onToggle, live = false }) {
31
+ const { fold, runMin, userTurns } = useTranscriptUi();
32
+ return _jsx(_Fragment, { children: segments(turns, { live, fold, runMin, userTurns }).map((segment, index) => _jsx(SegmentView, { segment: segment, openIds: openIds, onToggle: onToggle, live: live }, segment.kind === 'quote' ? `q:${segment.turn.id}` : `w:${segment.work[0]?.id ?? segment.answer?.id ?? index}`)) });
33
+ }
34
+ // THE WHOLE PAYLOAD: one interval's turns, the same shape a closed read returns and a merged live stream
35
+ // holds, so one renderer draws history and the tail alike. `live` adds exactly one truth — a call without a
36
+ // recorded result is still running — and keeps the work in progress unfolded.
37
+ export function TranscriptView({ data, live = false, className = '' }) {
38
+ const { labels } = useTranscriptUi();
39
+ const [openIds, toggle] = useDisclosure();
40
+ if (!data?.turns?.length)
41
+ return _jsx("div", { className: `tx tx-empty${className ? ` ${className}` : ''}`, children: labels.empty });
42
+ return _jsxs("div", { className: `tx tx-flow${className ? ` ${className}` : ''}`, children: [_jsx(TranscriptTurns, { turns: data.turns, openIds: openIds, onToggle: toggle, live: live }), data.truncated && _jsx("div", { className: "tx-truncated", children: labels.truncated({ omittedTurns: data.omittedTurns || 0, omittedBytes: data.omittedBytes || 0, outOfOrderEvents: data.outOfOrderEvents || 0 }) })] });
43
+ }
@@ -0,0 +1,41 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type Vocabulary } from './vocabulary.js';
3
+ import type { FoldPolicy, UserTurnPolicy } from './segments.js';
4
+ export type ToolOutputResult = {
5
+ ok: true;
6
+ output: string | null;
7
+ } | {
8
+ ok: false;
9
+ error: string;
10
+ };
11
+ export type Labels = Readonly<{
12
+ loading: string;
13
+ running: string;
14
+ more: string;
15
+ toolUses: (n: number) => string;
16
+ lines: (n: number) => string;
17
+ empty: string;
18
+ truncated: (info: {
19
+ omittedTurns: number;
20
+ omittedBytes: number;
21
+ outOfOrderEvents: number;
22
+ }) => string;
23
+ }>;
24
+ export declare const defaultLabels: Labels;
25
+ export type TranscriptUiOptions = Readonly<{
26
+ renderText: (text: string) => ReactNode;
27
+ loadToolOutput: ((toolId: string) => Promise<ToolOutputResult>) | null;
28
+ labels: Labels;
29
+ vocabulary: Vocabulary;
30
+ fold: FoldPolicy;
31
+ runMin: number;
32
+ userTurns: UserTurnPolicy;
33
+ }>;
34
+ export declare function PlainText({ text }: {
35
+ text: string;
36
+ }): import("react").JSX.Element;
37
+ export declare const defaultOptions: TranscriptUiOptions;
38
+ export declare const useTranscriptUi: () => TranscriptUiOptions;
39
+ export declare function TranscriptUi({ children, ...overrides }: Partial<TranscriptUiOptions> & {
40
+ children: ReactNode;
41
+ }): import("react").JSX.Element;
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext } from 'react';
3
+ import { defaultVocabulary } from './vocabulary.js';
4
+ export const defaultLabels = {
5
+ loading: 'loading…',
6
+ running: 'running',
7
+ more: 'more',
8
+ toolUses: (n) => `${n} tool use${n === 1 ? '' : 's'}`,
9
+ lines: (n) => `${n} line${n === 1 ? '' : 's'}`,
10
+ empty: 'nothing in this interval',
11
+ truncated: ({ omittedTurns, omittedBytes, outOfOrderEvents }) => `truncated: ${omittedTurns} turns and ${omittedBytes} bytes omitted${outOfOrderEvents ? `, ${outOfOrderEvents} records out of order` : ''}`,
12
+ };
13
+ // the default prose renderer: paragraphs on blank lines, line breaks kept — a message was typed, not laid out
14
+ export function PlainText({ text }) {
15
+ const paragraphs = text.split(/\n{2,}/);
16
+ return _jsx("div", { className: "tx-prose", children: paragraphs.map((paragraph, index) => (_jsx("p", { children: paragraph.split('\n').flatMap((line, at) => at ? [_jsx("br", {}, `b${at}`), line] : [line]) }, index))) });
17
+ }
18
+ export const defaultOptions = {
19
+ renderText: (text) => _jsx(PlainText, { text: text }),
20
+ loadToolOutput: null,
21
+ labels: defaultLabels,
22
+ vocabulary: defaultVocabulary,
23
+ fold: 'segments',
24
+ runMin: 3,
25
+ userTurns: 'boundary',
26
+ };
27
+ const Context = createContext(defaultOptions);
28
+ export const useTranscriptUi = () => useContext(Context);
29
+ export function TranscriptUi({ children, ...overrides }) {
30
+ const parent = useContext(Context);
31
+ const value = { ...parent, ...Object.fromEntries(Object.entries(overrides).filter(([, v]) => v !== undefined)) };
32
+ return _jsx(Context.Provider, { value: value, children: children });
33
+ }
@@ -0,0 +1,9 @@
1
+ export declare function Caret({ open, size, className }: {
2
+ open?: boolean;
3
+ size?: number;
4
+ className?: string;
5
+ }): import("react").JSX.Element;
6
+ export declare function Spinner({ size, className }: {
7
+ size?: number;
8
+ className?: string;
9
+ }): import("react").JSX.Element;
package/dist/icons.js ADDED
@@ -0,0 +1,9 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // The two marks the transcript needs, as inline SVG: a chevron that trails every disclosure and a spinner
3
+ // for a running call. Stroke icons, one weight, no emoji — a host's icon set may replace them through CSS.
4
+ export function Caret({ open = false, size = 12, className = '' }) {
5
+ return (_jsx("svg", { className: `tx-caret${open ? ' is-open' : ''}${className ? ` ${className}` : ''}`, width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("polyline", { points: "9 6 15 12 9 18" }) }));
6
+ }
7
+ export function Spinner({ size = 11, className = '' }) {
8
+ return (_jsx("svg", { className: `tx-spin${className ? ` ${className}` : ''}`, width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": "true", children: _jsx("path", { d: "M12 2a10 10 0 1 0 10 10" }) }));
9
+ }
@@ -0,0 +1,10 @@
1
+ export * from './vocabulary.js';
2
+ export * from './segments.js';
3
+ export * from './context.js';
4
+ export * from './icons.js';
5
+ export * from './useDisclosure.js';
6
+ export * from './useTranscriptFrames.js';
7
+ export * from './Quote.js';
8
+ export * from './ToolLine.js';
9
+ export * from './TranscriptView.js';
10
+ export * from './LiveTail.js';
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export * from './vocabulary.js';
2
+ export * from './segments.js';
3
+ export * from './context.js';
4
+ export * from './icons.js';
5
+ export * from './useDisclosure.js';
6
+ export * from './useTranscriptFrames.js';
7
+ export * from './Quote.js';
8
+ export * from './ToolLine.js';
9
+ export * from './TranscriptView.js';
10
+ export * from './LiveTail.js';
@@ -0,0 +1,29 @@
1
+ import type { StreamTurn, TranscriptTurn } from '@spexcode/transcript/frames';
2
+ import type { AnyTool } from './vocabulary.js';
3
+ export type AnyTurn = TranscriptTurn | StreamTurn;
4
+ export declare const isRunning: (tool: AnyTool, live: boolean) => boolean;
5
+ export declare const alreadySaid: (text: string | null | undefined, said: string | null | undefined) => boolean;
6
+ export type WorkSegment = Readonly<{
7
+ kind: 'work';
8
+ work: readonly AnyTurn[];
9
+ answer: AnyTurn | null;
10
+ after: readonly AnyTurn[];
11
+ calls: number;
12
+ folded: boolean;
13
+ now: boolean;
14
+ }>;
15
+ export type QuoteSegment = Readonly<{
16
+ kind: 'quote';
17
+ turn: AnyTurn;
18
+ }>;
19
+ export type Segment = WorkSegment | QuoteSegment;
20
+ export type FoldPolicy = 'segments' | 'runs' | 'none';
21
+ export type UserTurnPolicy = 'quote' | 'boundary';
22
+ export declare function segments(turns: readonly AnyTurn[], options?: {
23
+ live?: boolean;
24
+ fold?: FoldPolicy;
25
+ runMin?: number;
26
+ userTurns?: UserTurnPolicy;
27
+ }): Segment[];
28
+ export declare function currentTurn(turns: readonly AnyTurn[]): AnyTurn[];
29
+ export declare function liveSlice(turns: readonly AnyTurn[]): AnyTurn[];
@@ -0,0 +1,79 @@
1
+ // a call is running until the harness recorded its result; only a LIVE reading may say so — a closed
2
+ // interval that ends before the result was written is history, not something still happening
3
+ export const isRunning = (tool, live) => live && tool.output === undefined;
4
+ // THE TRANSCRIPT SAYS NOTHING THE RECORD ALREADY SAID. An adopter whose record draws a message or a declared
5
+ // note as a row of its own elides the same sentence inside the transcript. Either side may be the other's
6
+ // prefix (a note is often clipped), so the test is a prefix match over squashed whitespace.
7
+ const squash = (text) => (text || '').replace(/\s+/g, ' ').trim();
8
+ export const alreadySaid = (text, said) => {
9
+ const a = squash(text).replace(/(\.\.\.|…)$/, '');
10
+ const b = squash(said);
11
+ return !!a && !!b && (b.startsWith(a) || a.startsWith(b));
12
+ };
13
+ // WHERE THE FOLD BELONGS, decided by what a real transcript looks like. Measured against a real session: 39
14
+ // calls spread across 21 assistant turns, one or two each — the repetition is BETWEEN turns, not within
15
+ // them. So the unit is the work SEGMENT: a consecutive run of assistant turns, ending at the last one that
16
+ // actually says something. Everything before that is how the answer was produced; the last turn is the
17
+ // answer. Collapse the process, keep the result.
18
+ //
19
+ // THE WORK IN PROGRESS NEVER FOLDS. The last segment of a LIVE payload is what is happening now: its calls
20
+ // after the newest prose — or all of them, while there is no prose yet — draw as sentences whatever their
21
+ // number. They fold the moment the agent speaks.
22
+ //
23
+ // A user turn is a BOUNDARY: it ends the current run of agent work. Whether it is also DRAWN is the adopter's
24
+ // call — a host whose own record already shows every message (SpexCode's conversation) hides it; a host for
25
+ // which the transcript is the whole conversation quotes it.
26
+ export function segments(turns, options = {}) {
27
+ const { live = false, fold = 'segments', runMin = 3, userTurns = 'boundary' } = options;
28
+ const out = [];
29
+ let run = [];
30
+ const flush = () => {
31
+ if (!run.length)
32
+ return;
33
+ const calls = run.reduce((n, turn) => n + (turn.tools?.length || 0), 0);
34
+ let lead = run.length - 1;
35
+ while (lead > 0 && !run[lead].text)
36
+ lead -= 1;
37
+ const answer = run[lead]?.text ? run[lead] : null;
38
+ const work = answer ? run.slice(0, lead) : run;
39
+ const after = answer ? run.slice(lead + 1) : [];
40
+ out.push({ kind: 'work', work, answer, after, calls, folded: fold === 'segments' && calls >= runMin && work.length > 0, now: false });
41
+ run = [];
42
+ };
43
+ for (const turn of turns) {
44
+ if (turn.role === 'user') {
45
+ flush();
46
+ if (userTurns === 'quote' && turn.text)
47
+ out.push({ kind: 'quote', turn });
48
+ continue;
49
+ }
50
+ run.push(turn);
51
+ }
52
+ flush();
53
+ const last = out[out.length - 1];
54
+ if (live && last?.kind === 'work') {
55
+ const now = { ...last, now: true, folded: last.folded && !!last.answer };
56
+ out[out.length - 1] = now;
57
+ }
58
+ return out;
59
+ }
60
+ // the current turn: everything after the newest human message (or the whole payload when the stretch was
61
+ // opened by the agent itself and no message sits in it)
62
+ export function currentTurn(turns) {
63
+ let start = 0;
64
+ turns.forEach((turn, index) => { if (turn.role === 'user')
65
+ start = index + 1; });
66
+ return turns.slice(start);
67
+ }
68
+ // the compact view of "now": the newest prose and every call after it — the process that produced earlier
69
+ // prose has already folded into history. Before any prose, the calls themselves are the news.
70
+ export function liveSlice(turns) {
71
+ const turn = currentTurn(turns);
72
+ let lead = -1;
73
+ for (let index = turn.length - 1; index >= 0; index--)
74
+ if (turn[index].text) {
75
+ lead = index;
76
+ break;
77
+ }
78
+ return lead < 0 ? turn : turn.slice(lead);
79
+ }
@@ -0,0 +1 @@
1
+ export declare function useDisclosure(): [ReadonlySet<string>, (id: string) => void];
@@ -0,0 +1,15 @@
1
+ import { useCallback, useState } from 'react';
2
+ // one disclosure set per rendered payload: what the reader opened stays open across live refreshes of the
3
+ // same interval, because ids are the transcript's own (tool ids, turn ids), not render positions
4
+ export function useDisclosure() {
5
+ const [openIds, setOpenIds] = useState(() => new Set());
6
+ const toggle = useCallback((id) => setOpenIds((prev) => {
7
+ const next = new Set(prev);
8
+ if (next.has(id))
9
+ next.delete(id);
10
+ else
11
+ next.add(id);
12
+ return next;
13
+ }), []);
14
+ return [openIds, toggle];
15
+ }
@@ -0,0 +1,6 @@
1
+ import { type MergedFrame, type TranscriptFrame } from '@spexcode/transcript/frames';
2
+ export declare function useTranscriptFrames(): {
3
+ payload: MergedFrame['payload'] | null;
4
+ receive: (frame: TranscriptFrame) => void;
5
+ reset: () => void;
6
+ };
@@ -0,0 +1,16 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+ import { mergeTranscriptFrame } from '@spexcode/transcript/frames';
3
+ // THE SUBSCRIBER'S HOOK. Hand it every frame a transport delivers (SSE, IPC, a socket — the hook does not
4
+ // care) and read one complete payload back: the frame protocol's own merge keeps the held turns, so a host
5
+ // writes no merging code and cannot drift from the producer. `reset` is for a stream that starts over.
6
+ export function useTranscriptFrames() {
7
+ const held = useRef({ turns: [] });
8
+ const [payload, setPayload] = useState(null);
9
+ const receive = useCallback((frame) => {
10
+ const merged = mergeTranscriptFrame(held.current, frame);
11
+ held.current = merged.state;
12
+ setPayload(merged.payload);
13
+ }, []);
14
+ const reset = useCallback(() => { held.current = { turns: [] }; setPayload(null); }, []);
15
+ return { payload, receive, reset };
16
+ }
@@ -0,0 +1,40 @@
1
+ import type { StreamTool, TranscriptTool } from '@spexcode/transcript/frames';
2
+ export type AnyTool = TranscriptTool | StreamTool;
3
+ export type Vocabulary = Readonly<{
4
+ verbs: Readonly<Record<string, string>>;
5
+ quiet: ReadonlySet<string>;
6
+ targetKeys: readonly string[];
7
+ }>;
8
+ export declare const defaultVocabulary: Vocabulary;
9
+ export declare function extendVocabulary(base: Vocabulary, extra: Partial<{
10
+ verbs: Record<string, string>;
11
+ quiet: Iterable<string>;
12
+ targetKeys: readonly string[];
13
+ }>): Vocabulary;
14
+ export declare const toolVerb: (name: string | undefined, vocabulary?: Readonly<{
15
+ verbs: Readonly<Record<string, string>>;
16
+ quiet: ReadonlySet<string>;
17
+ targetKeys: readonly string[];
18
+ }>) => string;
19
+ export declare const isQuietTool: (name: string, vocabulary?: Readonly<{
20
+ verbs: Readonly<Record<string, string>>;
21
+ quiet: ReadonlySet<string>;
22
+ targetKeys: readonly string[];
23
+ }>) => boolean;
24
+ export declare function toolTarget(input: string | undefined, vocabulary?: Readonly<{
25
+ verbs: Readonly<Record<string, string>>;
26
+ quiet: ReadonlySet<string>;
27
+ targetKeys: readonly string[];
28
+ }>): string | null;
29
+ export declare function splitTarget(target: string | null): {
30
+ lead: string | null;
31
+ trail: string | null;
32
+ };
33
+ export declare const RUN_MIN = 3;
34
+ export declare function runKinds(tools: readonly AnyTool[], vocabulary?: Readonly<{
35
+ verbs: Readonly<Record<string, string>>;
36
+ quiet: ReadonlySet<string>;
37
+ targetKeys: readonly string[];
38
+ }>): string;
39
+ export declare function elapsed(ms: number): string | null;
40
+ export declare const timeOf: (ts: number | string) => string;
@@ -0,0 +1,84 @@
1
+ export const defaultVocabulary = {
2
+ verbs: {
3
+ Read: 'Read', NotebookRead: 'Read',
4
+ Grep: 'Searched', Glob: 'Searched', WebSearch: 'Searched the web',
5
+ Bash: 'Ran', BashOutput: 'Read output',
6
+ Edit: 'Edited', MultiEdit: 'Edited', NotebookEdit: 'Edited',
7
+ Write: 'Wrote',
8
+ WebFetch: 'Fetched',
9
+ Task: 'Delegated to',
10
+ TodoWrite: 'Updated the plan',
11
+ },
12
+ quiet: new Set(['Read', 'NotebookRead', 'Grep', 'Glob', 'WebFetch', 'WebSearch']),
13
+ targetKeys: ['file_path', 'filePath', 'path', 'notebook_path', 'pattern', 'query', 'command', 'cmd', 'url', 'description'],
14
+ };
15
+ export function extendVocabulary(base, extra) {
16
+ return {
17
+ verbs: { ...base.verbs, ...(extra.verbs ?? {}) },
18
+ quiet: new Set([...base.quiet, ...(extra.quiet ?? [])]),
19
+ targetKeys: extra.targetKeys ? [...extra.targetKeys, ...base.targetKeys.filter((key) => !extra.targetKeys.includes(key))] : base.targetKeys,
20
+ };
21
+ }
22
+ export const toolVerb = (name, vocabulary = defaultVocabulary) => (name && vocabulary.verbs[name]) || name || 'tool';
23
+ export const isQuietTool = (name, vocabulary = defaultVocabulary) => vocabulary.quiet.has(name);
24
+ // The target, from the call's own arguments. `input` is the raw JSON of the arguments (or a bare string), so
25
+ // this reads the field the tool actually names and shows NOTHING when it cannot — a wrong target is worse
26
+ // than no target, and a truncated blob of JSON is not a target at all.
27
+ export function toolTarget(input, vocabulary = defaultVocabulary) {
28
+ if (typeof input !== 'string' || !input)
29
+ return null;
30
+ let parsed = null;
31
+ try {
32
+ parsed = JSON.parse(input);
33
+ }
34
+ catch {
35
+ return input.length <= 80 ? input : null;
36
+ }
37
+ if (!parsed || typeof parsed !== 'object')
38
+ return typeof parsed === 'string' ? parsed : null;
39
+ for (const key of vocabulary.targetKeys) {
40
+ const value = parsed[key];
41
+ if (typeof value === 'string' && value.trim())
42
+ return value.trim();
43
+ }
44
+ return null;
45
+ }
46
+ // A path reads better as its basename with the directory trailing quietly behind it.
47
+ export function splitTarget(target) {
48
+ if (!target || !target.includes('/') || /\s/.test(target))
49
+ return { lead: target, trail: null };
50
+ const cut = target.lastIndexOf('/');
51
+ return { lead: target.slice(cut + 1) || target, trail: target.slice(0, cut) };
52
+ }
53
+ // HOW A RUN COLLAPSES, without claiming anything about what the calls DID. Measured against a real
54
+ // transcript: 39 consecutive calls, every one of them `Bash`; grouping only the names known to be read-only
55
+ // collapsed none of them. So the rule infers nothing: a run collapses because it is a RUN — `runMin` or more
56
+ // calls in a row — and the label says only what is on the record.
57
+ export const RUN_MIN = 3;
58
+ // What KINDS ran, for a row whose count is already stated beside it: the verb when they share one, the kinds
59
+ // when they do not — never the number again.
60
+ export function runKinds(tools, vocabulary = defaultVocabulary) {
61
+ const counts = new Map();
62
+ for (const tool of tools) {
63
+ const verb = toolVerb(tool.name, vocabulary);
64
+ counts.set(verb, (counts.get(verb) || 0) + 1);
65
+ }
66
+ const entries = [...counts.entries()];
67
+ if (entries.length === 1)
68
+ return entries[0][0].toLowerCase();
69
+ return entries.slice(0, 3).map(([verb, n]) => `${n} ${verb.toLowerCase()}`).join(', ');
70
+ }
71
+ // Elapsed time in a shape a reader can absorb at a glance: at most two units, biggest first.
72
+ export function elapsed(ms) {
73
+ if (!Number.isFinite(ms) || ms < 0)
74
+ return null;
75
+ const s = Math.round(ms / 1000);
76
+ if (s < 60)
77
+ return `${s}s`;
78
+ const m = Math.floor(s / 60);
79
+ const h = Math.floor(m / 60);
80
+ if (h > 0)
81
+ return m % 60 ? `${h}h ${m % 60}m` : `${h}h`;
82
+ return s % 60 ? `${m}m ${s % 60}s` : `${m}m`;
83
+ }
84
+ export const timeOf = (ts) => new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@spexcode/transcript-ui",
3
+ "version": "0.7.0-next.1",
4
+ "type": "module",
5
+ "description": "React components that draw a normalized agent transcript — the person quoted, the agent as the page, a tool call as a sentence, the work folded behind its answer — with the fold, the prose renderer, the tool vocabulary, the labels and the design tokens all tunable.",
6
+ "files": [
7
+ "dist",
8
+ "styles.css"
9
+ ],
10
+ "exports": {
11
+ ".": "./dist/index.js",
12
+ "./styles.css": "./styles.css",
13
+ "./package.json": "./package.json"
14
+ },
15
+ "engines": {
16
+ "node": ">=18"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "scripts": {
22
+ "build": "node ../../scripts/build-dist.mjs",
23
+ "prepublishOnly": "node ../../scripts/release-publish.mjs --from-package-publish",
24
+ "test": "npm run build && tsx --test src/*.test.tsx"
25
+ },
26
+ "dependencies": {
27
+ "@spexcode/transcript": "0.7.0-next.1"
28
+ },
29
+ "peerDependencies": {
30
+ "react": "^18.2.0 || ^19.0.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/react": "^18.3.12",
34
+ "@types/react-dom": "^18.3.1",
35
+ "react": "^18.3.1",
36
+ "react-dom": "^18.3.1",
37
+ "tsx": "^4.19.2",
38
+ "typescript": "^5.6.3"
39
+ }
40
+ }
package/styles.css ADDED
@@ -0,0 +1,122 @@
1
+ /* @spexcode/transcript-ui — the transcript's grammar as one stylesheet.
2
+ Every colour, face, size and space is a `--tx-*` token with a fallback chain: a host that defines the
3
+ same-named bare token (`--ink`, `--mono`, `--type-prose`…) is inherited automatically; a host that
4
+ defines nothing gets the dark defaults; a host that wants something else sets `--tx-*` on `.tx` or any
5
+ ancestor. Class names are the second customisation surface and are stable. No emoji, no icon font. */
6
+ .tx {
7
+ --tx-ink: var(--ink, #d1d1d1);
8
+ --tx-ink2: var(--ink2, #e6e6e6);
9
+ --tx-muted: var(--muted, #999999);
10
+ --tx-blue: var(--blue, #6c99bb);
11
+ --tx-orange: var(--orange, #d5763f);
12
+ --tx-red: var(--red, #d04255);
13
+ --tx-paper: var(--paper, #262626);
14
+ --tx-panel2: var(--panel2, #2c2c2c);
15
+ --tx-edge: var(--edge, rgba(120, 120, 120, .45));
16
+ --tx-wash-hover: var(--wash-hover, rgba(255, 255, 255, .06));
17
+ --tx-mono: var(--mono, 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace);
18
+ --tx-ui-font: var(--ui-font, var(--tx-mono));
19
+ --tx-type-caption: var(--type-caption, 11px);
20
+ --tx-type-meta: var(--type-meta, 12px);
21
+ --tx-type-prose: var(--type-prose, 14px);
22
+ --tx-leading-body: var(--leading-body, 1.65);
23
+ --tx-weight-medium: var(--weight-medium, 500);
24
+ --tx-weight-semibold: var(--weight-semibold, 600);
25
+ --tx-radius: var(--radius, 6px);
26
+ --tx-space-1: 4px; --tx-space-2: 6px; --tx-space-3: 8px; --tx-space-4: 12px; --tx-space-5: 16px; --tx-space-6: 22px;
27
+ --tx-dur-rise: var(--dur-rise, 120ms);
28
+ min-width: 0;
29
+ color: var(--tx-ink);
30
+ }
31
+
32
+ /* THE FLOW: turns stacked at paragraph distance. Consecutive tool-only turns are ONE list of calls — the
33
+ harness draws a turn boundary around every call it makes, and that boundary is not a paragraph break. */
34
+ .tx-flow { display: flex; flex-direction: column; gap: var(--tx-space-6); }
35
+ .tx-flow > .tx-say:not(:has(.tx-say-text)) + .tx-say:not(:has(.tx-say-text)) { margin-top: calc(2px - var(--tx-space-6)); }
36
+ .tx-work-body > .tx-say:not(:has(.tx-say-text)) + .tx-say:not(:has(.tx-say-text)) { margin-top: calc(2px - 14px); }
37
+ .tx-empty, .tx-truncated { color: var(--tx-muted); font-family: var(--tx-ui-font); font-size: var(--tx-type-meta); overflow-wrap: anywhere; }
38
+
39
+ /* THE AGENT IS THE PAGE: full measure, no bubble, no tint. */
40
+ .tx-say { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
41
+ .tx-say-text { max-width: 100%; color: var(--tx-ink); font-size: var(--tx-type-prose); line-height: var(--tx-leading-body); overflow-wrap: anywhere; }
42
+ .tx-prose > p { margin: 0; }
43
+ .tx-prose > p + p { margin-top: .75em; }
44
+
45
+ /* THE PERSON IS QUOTED: a bubble off to its own side, one corner squared, capped well under the measure. */
46
+ .tx-quote {
47
+ --tx-quote-bg: color-mix(in srgb, var(--tx-panel2) 72%, var(--tx-paper));
48
+ position: relative; justify-self: end; margin-left: auto; min-width: 0; max-width: 80%;
49
+ padding: var(--tx-space-4) var(--tx-space-5); color: var(--tx-ink2); background: var(--tx-quote-bg);
50
+ border-radius: calc(var(--tx-radius) * 2); border-top-right-radius: 2px;
51
+ }
52
+ .tx-quote-nested { max-width: 88%; }
53
+ .tx-quote-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin: 0 0 3px; font-family: var(--tx-ui-font); }
54
+ .tx-quote-who { font-size: var(--tx-type-caption); font-weight: var(--tx-weight-semibold); color: var(--tx-blue); }
55
+ .tx-time { display: none; margin-left: auto; flex: none; font-family: var(--tx-ui-font); font-size: var(--tx-type-caption); color: var(--tx-muted); font-variant-numeric: tabular-nums; }
56
+ .tx-quote-text { font-size: var(--tx-type-prose); line-height: var(--tx-leading-body); overflow-wrap: anywhere; }
57
+ .tx-quote.is-clamped { max-height: 13em; overflow: hidden; }
58
+ .tx-quote.is-clamped::after { content: ""; position: absolute; inset: auto 0 0; height: 4.5em; background: linear-gradient(transparent, var(--tx-quote-bg)); pointer-events: none; }
59
+ .tx-quote-more { position: absolute; right: 10px; bottom: 6px; z-index: 1; padding: 0; color: var(--tx-blue); background: none; border: 0; font: inherit; font-family: var(--tx-ui-font); font-size: var(--tx-type-caption); cursor: pointer; }
60
+
61
+ /* THE WORK SEGMENT'S ONE LINE: a bounded sentence — the count, the kinds, the chevron trailing. */
62
+ .tx-work { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; }
63
+ .tx-work-row {
64
+ display: inline-flex; align-items: baseline; gap: 7px; max-width: 100%; padding: var(--tx-space-2) var(--tx-space-3); margin-left: calc(var(--tx-space-3) * -1);
65
+ color: var(--tx-muted); background: none; border: 0; border-radius: var(--tx-radius);
66
+ font-family: var(--tx-ui-font); font-size: var(--tx-type-meta); text-align: left; cursor: pointer;
67
+ }
68
+ .tx-work-row:hover { background: var(--tx-wash-hover); color: var(--tx-ink2); }
69
+ .tx-work-lead { flex: none; color: var(--tx-ink2); font-weight: var(--tx-weight-medium); }
70
+ .tx-work-detail { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
71
+ .tx-work-body { align-self: stretch; display: flex; flex-direction: column; gap: 14px; padding-left: 10px; border-left: 1px solid var(--tx-edge); }
72
+
73
+ /* A TOOL CALL IS A SENTENCE, not a card: inline-flex, exactly as wide as what it says, capped so a long shell
74
+ command never stretches the row into a full-width bar. The verb IS the status: no tick, no badge. */
75
+ .tx-tools { display: flex; flex-direction: column; gap: 2px; align-items: flex-start; min-width: 0; max-width: 100%; }
76
+ .tx-tool { display: flex; flex-direction: column; gap: 3px; min-width: 0; max-width: 100%; }
77
+ .tx-tool-row {
78
+ display: inline-flex; align-items: baseline; gap: 6px; min-width: 0; max-width: min(100%, 56ch);
79
+ padding: 2px 0; color: var(--tx-muted); background: none; border: 0;
80
+ font-family: var(--tx-ui-font); font-size: var(--tx-type-meta); text-align: left;
81
+ }
82
+ .tx-tool-row.is-openable { cursor: pointer; }
83
+ .tx-tool-row.is-openable:hover { color: var(--tx-ink2); }
84
+ .tx-tool-verb { flex: none; color: var(--tx-ink2); font-weight: var(--tx-weight-medium); }
85
+ .tx-tool-target { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--tx-ink); }
86
+ .tx-tool-trail { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--tx-muted); }
87
+ .tx-tool-size { flex: none; color: var(--tx-muted); font-variant-numeric: tabular-nums; }
88
+ .tx-tool-row.is-run .tx-tool-verb { color: var(--tx-muted); font-weight: inherit; }
89
+ .tx-tool-kids { display: flex; flex-direction: column; gap: 2px; align-items: flex-start; margin-left: 8px; padding-left: 10px; border-left: 1px solid var(--tx-edge); }
90
+ .tx-tool-in, .tx-tool-out {
91
+ max-width: 100%; max-height: 220px; overflow: auto; margin: 0 0 2px; padding: 7px 9px;
92
+ color: var(--tx-ink); background: var(--tx-panel2); border-radius: var(--tx-radius);
93
+ font: inherit; font-family: var(--tx-mono); font-size: var(--tx-type-meta);
94
+ white-space: pre-wrap; overflow-wrap: anywhere;
95
+ }
96
+ .tx-tool-out.tx-tool-out-state { font-family: var(--tx-ui-font); font-size: var(--tx-type-meta); color: var(--tx-muted); }
97
+ .tx-tool-out.tx-tool-out-state.is-error { color: var(--tx-red); }
98
+
99
+ /* THE CHEVRON trails every disclosure; the same shape says open everywhere. */
100
+ .tx-caret { flex: none; align-self: center; transition: transform .12s ease; }
101
+ .tx-caret.is-open { transform: rotate(90deg); }
102
+
103
+ /* LIVE: a running call wears a spinner and the word; the newest prose rises in; the caret blinks at the end
104
+ of words still being said. Reduced motion keeps the marks and drops the movement. */
105
+ .tx-tool.is-running .tx-tool-verb { color: var(--tx-ink2); }
106
+ .tx-tool-running { display: inline-flex; align-items: center; gap: 4px; flex: none; color: var(--tx-orange); font-size: var(--tx-type-caption); }
107
+ .tx-spin { animation: tx-spin 1s linear infinite; }
108
+ @keyframes tx-spin { to { transform: rotate(360deg); } }
109
+ .tx-live { margin-top: var(--tx-space-2); min-width: 0; }
110
+ .tx-live .tx-say-text { animation: tx-rise var(--tx-dur-rise) ease; }
111
+ @keyframes tx-rise { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: none; } }
112
+ .tx-live.is-speaking > .tx-say:last-child .tx-say-text > :first-child > :last-child::after { content: '▍'; margin-left: 2px; color: var(--tx-muted); animation: tx-caret 1s steps(2, start) infinite; }
113
+ @keyframes tx-caret { to { visibility: hidden; } }
114
+ @media (prefers-reduced-motion: reduce) {
115
+ .tx-live .tx-say-text, .tx-live.is-speaking > .tx-say:last-child .tx-say-text > :first-child > :last-child::after, .tx-spin, .tx-caret { animation: none; transition: none; }
116
+ }
117
+
118
+ /* A NARROW PANE: the quote widens and shows its own time, because there is no ruler beside the flow. */
119
+ @container (max-width: 560px) {
120
+ .tx-quote { max-width: 88%; }
121
+ .tx-time { display: inline; }
122
+ }