Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ Semantic Versioning independently of the version-1 trace and program schemas.

## Unreleased

### Fixed (narration review)

- Coordinate browser-global speech synthesis between multiple Throughline players; newer narration takes over while displaced players pause and stop owning cancellation.
- Pause with an accessible error on unexpected speech interruption instead of stalling autoplay or advancing a failed utterance.
- Restart narration for repeated seeks to the same step and replace stale speech when authored guidance changes.
- Keep the dual-audio 360px breakpoint while restoring a generic three-row fallback at <=300px content widths, covering players with no audio, only narration, only step tones, or both. Verify embedded layouts with actual browser element bounds.

### Added (opt-in caption narration)

- Offer muted-by-default browser caption narration separately from step tones, using speech-synchronized playback without new runtime dependencies.
- Pause/resume speaking with playback; cancel on seek, scenario change and unmount; preserve the original timer when narration is off.


### Changed (development preview sound)

- Expose the existing opt-in Web Audio speaker control in every local playground example and scenario comparison, initially muted. No default audio in the published React player and no new audio dependencies.
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,44 @@ from the diagram. The screen-reader step announcement remains active
regardless of whether the disclosure is expanded. Details remain open
between steps until closed, without changing playback state.

## Opt-in caption narration

The optional React-only `narration` prop exposes a **Narrate captions**
button next to the independent `sonification` sound-effects toggle.
The button is **off initially**, even when a host offers it. Its first
activation speaks the current authored step from a user gesture using
the browser's Speech Synthesis API. It reads the authored step label,
notes, matching chapter and causal explanation, and transfer summaries.
It does not invent responses, state changes or causal explanations.
Very long utterances are shortened with an explicit spoken notice; the
full authored text remains available in Details.

With narration enabled, **Play advances only after speech finishes**.
Pause pauses the current voice; Play resumes it. Manual seeking or
stepping cancels the previous voice and narrates the selected step.
Switching scenarios cancels the old voice and starts muted. Disabling
narration returns to timed playback and leaves step tones independent.
Playback speed applies to the next utterance, not the current one.
A manual seek to the **same** step (including Restart chapter) restarts its
narration. If an author updates guidance while the current frame is shown,
the old utterance is invalidated and the corrected explanation is spoken.

Speech voices, available hardware, and user-activation policies depend
on the browser and OS. The button is disabled where speech synthesis is
unavailable; speech errors pause synchronized playback and are announced.
No text is uploaded by Throughline, but the browser may use a network
speech service. The core stays React- and browser-independent.

**Multiple embedded players:** Browser speech synthesis has one global queue.
Throughline coordinates narration across its player instances: when B begins
speaking, B takes over and A is paused, unmutes no further audio, and reports
that another player is speaking. A's later seek/unmount does not interrupt B.
A may explicitly re-enable narration to take over again. External scripts
calling the browser speech API are not under Throughline's control; an
unexpected `interrupted` or `canceled` utterance is treated as an error:
narration-driven playback pauses and shows a recoverable message. This
behavior does not imply exclusive ownership of third-party speech.

## Persistent workflow progress

The React player retains a cumulative trail through the **selected** step.
Expand Down
35 changes: 35 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,43 @@ uses this internal boundary without adding a package export, trace schema,
viewport control, or public selection API. Pan/zoom, automatic camera
following and user-driven collapse are not part of TL-I12.

## Optional spoken explanation

The React-only narration controller synthesizes authored caption text
(frame label, notes, guided chapter/causal content and explicit sends).
No new trace event or compiler schema is introduced.

While narration is enabled, speech completion drives playback ticks
instead of a fixed interval. Only the active utterance may advance
the selected trace; seeking (including to the same step) and changed
authored guidance invalidate stale callbacks. Pause/resume
controls the voice, and unavailable or failed synthesis pauses the
narration clock with an accessible error. When narration is disabled,
the existing timed playback returns. Step tones remain independent.

Browser speechSynthesis is a shared page resource. Throughline maintains a
per-engine, browser-local owner coordinator: the newest Throughline player
to begin narration takes over the global speech queue, pauses/disables the
previous player and announces displacement. Only the current owner may
call global `cancel()`, `pause()` or `resume()`; a displaced player's later
seek, toggle or unmount cannot cancel the new owner's speech. Deliberate
cancellation detaches event handlers before calling the browser API, while
unexpected `canceled`/`interrupted` errors trigger an accessible error
and pause rather than advancing the timeline. After a natural speech end,
ownership is released without global cancellation. This mechanism does not
coordinate with arbitrary third-party scripts using speechSynthesis and
cannot guarantee browser/OS voice isolation. Some voices may use
browser-managed remote services.

## Footer layout and bounded explanations

The optional narration and step-tone controls share responsive rules.
Dual-audio players use a dedicated utilities row at player content widths up
to 360px; **all** players use the generic three-row fallback at content
widths up to 300px, regardless of whether audio features are offered.
Both breakpoints use the measured player container rather than the viewport,
preserving accessible controls in narrow embedded layouts.

The React player composes its navigation, chapter selection, timeline,
and transport in one bottom bar adjacent to the diagram. Container
queries use the player container's width, not global browser size,
Expand Down
7 changes: 6 additions & 1 deletion playground/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ function App() {
scenarios={scenarios}
initialScenarioId="retry-recovery"
theme={theme}
narration
sonification={{}}
/>
) : current ? (
Expand All @@ -99,6 +100,7 @@ function App() {
title={current.title}
theme={theme}
sonification={{}}
narration
guidance={current.id === "distributed-checkout" ? CHECKOUT_GUIDANCE : undefined}
/>
) : null}
Expand All @@ -110,7 +112,10 @@ function App() {
</details>
<p className="preview-footnote">
Use the player controls to step, pan, zoom, collapse groups, inspect state
and switch outcomes. Sound is available in every example: select the speaker
and switch outcomes. Narration is available in every example:
select Narrate captions to hear the authored explanation.
Play waits for speech to finish before advancing. The separate
step-tone sound control is still available: select the speaker
icon to unmute, then Play or step forward to hear short synthesized tones.
It starts muted and does not narrate the workflow. Resize the browser or
choose the 375px view to find layout problems. Changes to the source reload
Expand Down
244 changes: 244 additions & 0 deletions src/react/__tests__/narration-review.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { afterEach, expect, it, vi } from "vitest";
import { page, userEvent } from "vitest/browser";
import { render } from "vitest-browser-react";
import { compile } from "../../domains/registry.js";
import type { ThroughlineGuidance } from "../guidance.js";
import { ThroughlinePlayer } from "../index.js";
import "../../styles/throughline.css";

const trace = compile(
'thl flow\nnode a "Client" = 0\nstep "Request"\n set a = 1\n note "Request started"\nstep "Response"\n set a = 2\n note "Response received"',
);

function speechBoundary() {
class FakeUtterance {
rate = 1;
onend: (() => void) | null = null;
onerror: ((event: { error: string }) => void) | null = null;
constructor(public text: string) {}
}
const voices: FakeUtterance[] = [];
let active: FakeUtterance | null = null;
const synth = {
paused: false,
speak: vi.fn((voice: FakeUtterance) => {
active = voice;
voices.push(voice);
}),
cancel: vi.fn(() => {
const voice = active;
active = null;
voice?.onerror?.({ error: "interrupted" });
}),
pause: vi.fn(() => { synth.paused = true; }),
resume: vi.fn(() => { synth.paused = false; }),
};
vi.stubGlobal("SpeechSynthesisUtterance", FakeUtterance);
vi.stubGlobal("speechSynthesis", synth);
return { voices, synth };
}
afterEach(() => vi.unstubAllGlobals());

it("reports an external interruption and pauses without completing the step", async () => {
const speech = speechBoundary();
const screen = await render(<ThroughlinePlayer trace={trace} narration />);
const narrator = screen.getByRole("button", { name: "Narrate captions" });
await narrator.click();
await screen.getByRole("button", { name: "Play", exact: true }).click();
const abandoned = speech.voices[0].onend;
speech.voices[0].onerror?.({ error: "interrupted" });
await expect.element(screen.getByRole("alert")).toHaveTextContent("Speech stopped (interrupted)");
await expect.element(screen.getByRole("button", { name: "Play", exact: true })).toBeEnabled();
await expect.element(screen.getByRole("status")).toHaveTextContent("Step 1 of 3");
abandoned?.();
await expect.element(screen.getByRole("status")).toHaveTextContent("Step 1 of 3");
await narrator.click();
await narrator.click();
expect(speech.voices).toHaveLength(2);
});

it("takes over speech across two players and never cancels B when displaced A seeks or unmounts", async () => {
const speech = speechBoundary();
const screen = await render(
<div>
<ThroughlinePlayer key="a" title="First" trace={trace} narration />
<ThroughlinePlayer key="b" title="Second" trace={trace} narration />
</div>,
);
const all = [...document.querySelectorAll<HTMLElement>(".thl")];
expect(all).toHaveLength(2);
const a = all[0];
const b = all[1];
const aSound = a.querySelector<HTMLButtonElement>(".thl-narration")!;
const bSound = b.querySelector<HTMLButtonElement>(".thl-narration")!;
const aPlay = a.querySelector<HTMLButtonElement>(".thl-play")!;
await userEvent.click(aSound);
await userEvent.click(aPlay);
expect(speech.voices).toHaveLength(1);
const stale = speech.voices[0].onend;
await userEvent.click(bSound);
expect(speech.voices).toHaveLength(2);
await expect.poll(() => aSound.getAttribute("aria-pressed")).toBe("false");
await expect.poll(() => aPlay.getAttribute("aria-label")).toBe("Play");
expect(a.querySelector('[role="alert"]')?.textContent).toContain("another Throughline player");
expect(bSound.getAttribute("aria-pressed")).toBe("true");
stale?.();
expect(a.querySelector('[role="status"]')?.textContent).toContain("Step 1 of 3");
const count = speech.synth.cancel.mock.calls.length;
await userEvent.click(a.querySelector<HTMLButtonElement>('button[aria-label="Step forward"]')!);
expect(speech.synth.cancel.mock.calls.length).toBe(count);
expect(bSound.getAttribute("aria-pressed")).toBe("true");
await screen.rerender(
<div><ThroughlinePlayer key="b" title="Second" trace={trace} narration /></div>,
);
expect(speech.synth.cancel.mock.calls.length).toBe(count);
expect(speech.voices).toHaveLength(2);
expect(document.querySelector(".thl-narration")?.getAttribute("aria-pressed")).toBe("true");
});

it("preserves B's active voice when the unmounted A had already yielded ownership", async () => {
const speech = speechBoundary();
const screen = await render(
<div>
<ThroughlinePlayer key="a" title="A" trace={trace} narration />
<ThroughlinePlayer key="b" title="B" trace={trace} narration />
</div>,
);
const players = [...document.querySelectorAll<HTMLElement>(".thl")];
await userEvent.click(players[0].querySelector<HTMLButtonElement>(".thl-narration")!);
await userEvent.click(players[1].querySelector<HTMLButtonElement>(".thl-narration")!);
const count = speech.synth.cancel.mock.calls.length;
await screen.rerender(
<div><ThroughlinePlayer key="b" title="B" trace={trace} narration /></div>,
);
expect(speech.synth.cancel.mock.calls.length).toBe(count);
expect(speech.voices).toHaveLength(2);
await screen.getByRole("button", { name: "Step forward" }).click();
expect(speech.voices).toHaveLength(3);
});

it("replaces stale guidance on the same frame without allowing the old voice to advance", async () => {
const speech = speechBoundary();
const first: ThroughlineGuidance = {
chapters: [{ id: "one", title: "Original instructions", start: 0, end: 2 }],
};
const updated: ThroughlineGuidance = {
chapters: [{ id: "one", title: "Corrected instructions", start: 0, end: 2 }],
};
const screen = await render(<ThroughlinePlayer trace={trace} narration guidance={first} />);
await screen.getByRole("button", { name: "Narrate captions" }).click();
await screen.getByRole("button", { name: "Play", exact: true }).click();
expect(speech.voices[0].text).toContain("Original instructions");
const stale = speech.voices[0].onend;
await screen.rerender(<ThroughlinePlayer trace={trace} narration guidance={updated} />);
expect(speech.voices).toHaveLength(2);
expect(speech.voices[1].text).toContain("Corrected instructions");
expect(speech.voices[1].text).not.toContain("Original instructions");
stale?.();
await expect.element(screen.getByRole("status")).toHaveTextContent("Step 1 of 3");
speech.voices[1].onend?.();
await expect.element(screen.getByRole("status")).toHaveTextContent("Step 2 of 3");
});

it("restarts same-frame narration, including after the old utterance completed", async () => {
const speech = speechBoundary();
const guidance: ThroughlineGuidance = {
chapters: [{ id: "opening", title: "Opening chapter", start: 0, end: 2 }],
};
const screen = await render(<ThroughlinePlayer trace={trace} narration guidance={guidance} />);
await screen.getByRole("button", { name: "Narrate captions" }).click();
const restart = screen.getByRole("button", { name: "Restart chapter" });
const old = speech.voices[0].onend;
await restart.click();
expect(speech.voices).toHaveLength(2);
old?.();
await expect.element(screen.getByRole("status")).toHaveTextContent("Step 1 of 3");
speech.voices[1].onend?.();
await restart.click();
expect(speech.voices).toHaveLength(3);
expect(speech.voices[2].text).toContain("Opening chapter");
});

it("keeps two audio controls within embedded content widths of 300–320px", async () => {
await page.viewport(1100, 850);
speechBoundary();
const widths = [348, 356, 366, 368, 380, 420];
const screen = await render(
<div style={{ width: "348px" }}>
<ThroughlinePlayer trace={trace} narration sonification={{}} />
</div>,
);
for (const width of widths) {
await screen.rerender(
<div style={{ width: width + "px" }}>
<ThroughlinePlayer trace={trace} narration sonification={{}} />
</div>,
);
const player = document.querySelector<HTMLElement>(".thl")!;
const controls = player.querySelector<HTMLElement>(".thl-controls")!;
const bounds = controls.getBoundingClientRect();
await expect.poll(() => Math.round(player.getBoundingClientRect().width)).toBe(width);
expect(player.scrollWidth, "player at " + width).toBeLessThanOrEqual(player.clientWidth);
for (const item of controls.querySelectorAll<HTMLElement>("button, select, input")) {
const rect = item.getBoundingClientRect();
expect(rect.left, "left control at " + width).toBeGreaterThanOrEqual(bounds.left - 1);
expect(rect.right, "right control at " + width).toBeLessThanOrEqual(bounds.right + 1);
}
if (width <= 400) {
const transport = controls.querySelector(".thl-transport")!.getBoundingClientRect();
const utilities = controls.querySelector(".thl-utilities")!.getBoundingClientRect();
expect(utilities.top, "dedicated utility row at " + width).toBeGreaterThan(transport.top);
}
}
});

it("keeps controls visible with neither, one, or both optional audio features at narrow embed widths", async () => {
await page.viewport(1100, 850);
speechBoundary();
const configurations = [
{ name: "no audio", narration: false, sonification: false },
{ name: "narration only", narration: true, sonification: false },
{ name: "sound effects only", narration: false, sonification: true },
{ name: "both audio controls", narration: true, sonification: true },
] as const;
const widths = [260, 280, 296, 300, 320, 348, 368, 380, 420];
const screen = await render(
<div style={{ width: "320px" }}>
<ThroughlinePlayer trace={trace} />
</div>,
);
for (const configuration of configurations) {
for (const width of widths) {
await screen.rerender(
<div style={{ width: width + "px" }}>
<ThroughlinePlayer
trace={trace}
narration={configuration.narration}
sonification={configuration.sonification ? {} : undefined}
/>
</div>,
);
const label = configuration.name + " at " + width + "px";
const player = document.querySelector<HTMLElement>(".thl")!;
const controls = player.querySelector<HTMLElement>(".thl-controls")!;
const bounds = controls.getBoundingClientRect();
await expect.poll(() => Math.round(player.getBoundingClientRect().width)).toBe(width);
expect(player.scrollWidth, label).toBeLessThanOrEqual(player.clientWidth);
for (const item of controls.querySelectorAll<HTMLElement>("button, select, input")) {
const rect = item.getBoundingClientRect();
expect(rect.left, label + " left").toBeGreaterThanOrEqual(bounds.left - 1);
expect(rect.right, label + " right").toBeLessThanOrEqual(bounds.right + 1);
}
const contentWidth = width - 48;
const mustSplit = contentWidth <= 300 ||
(configuration.narration && configuration.sonification && contentWidth <= 360);
const transport = controls.querySelector(".thl-transport")!.getBoundingClientRect();
const utilities = controls.querySelector(".thl-utilities")!.getBoundingClientRect();
if (mustSplit) {
expect(utilities.top, label + " utility row").toBeGreaterThan(transport.top);
} else {
expect(Math.abs(utilities.top - transport.top), label + " aligned rows").toBeLessThan(2);
}
}
}
});
Loading
Loading