@tinytars/frame 0.1.28 → 0.1.30

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.
@@ -38,7 +38,7 @@
38
38
  // About is app-level, not provider/patient-specific, so it's offered in both modes.
39
39
  onAbout?: () => void;
40
40
  // Copy, all domain-flavorable — defaults are generic, callers override with their own vocabulary
41
- // (e.g. LexiTar's "patient"/"roster"/"Translation") rather than this package assuming one.
41
+ // (e.g. "patient"/"roster") rather than this package assuming one.
42
42
  providerBadgeLabel?: string;
43
43
  subjectFallback?: string;
44
44
  viewingSubjectLabel?: string;
package/Onboarding.svelte CHANGED
@@ -22,7 +22,7 @@
22
22
  // (what to ask, and each field's own validity rule) is entirely caller-supplied — a number field
23
23
  // is optional and omitted from the create payload when left blank or when the user skips; a
24
24
  // select field always has a value and is always included. Everything the caller collects is
25
- // stored wherever it wants (LexiTar puts it in the encrypted vault, never the server).
25
+ // stored wherever it wants (e.g. an encrypted vault, never the server).
26
26
  let {
27
27
  productName,
28
28
  subheading = "Welcome. A couple of details help tailor your results — or skip and add them later.",
@@ -0,0 +1,25 @@
1
+ <script lang="ts">
2
+ import { deriveLegalLinks, type OrgIdentity } from "@tinytars/frame/brand";
3
+
4
+ let { org }: { org: OrgIdentity } = $props();
5
+ const links = $derived(deriveLegalLinks(org.legalBase, org.legalPaths));
6
+ const year = new Date().getFullYear();
7
+ </script>
8
+
9
+ <footer class="frame-footer">
10
+ <div class="frame-footer__row">
11
+ <div>
12
+ {org.status}
13
+ <br />© {year} {org.name}
14
+ </div>
15
+ <nav class="frame-footer__links" aria-label="Legal">
16
+ {#each links as link (link.href)}
17
+ {#if org.legalBase}
18
+ <a href={link.href} target="_blank" rel="noopener">{link.label}</a>
19
+ {:else}
20
+ <a href={link.href}>{link.label}</a>
21
+ {/if}
22
+ {/each}
23
+ </nav>
24
+ </div>
25
+ </footer>
@@ -3,6 +3,7 @@
3
3
  import LeafActionMenu from "@tinytars/frame/LeafActionMenu.svelte";
4
4
  import type { LeafMenuItem } from "@tinytars/frame/menu-items";
5
5
  import { speechRegistry, isSpeechSupported } from "./speech-registry.svelte";
6
+ import { currentRetell } from "./retell-registry.svelte";
6
7
 
7
8
  // The one persona treatment: a tinted bubble with a head row (uppercase persona tag on the
8
9
  // left; an optional meta/date and an optional action icon, e.g. download, on the right). Colors
@@ -24,58 +25,100 @@
24
25
  onTogglePin?: () => void;
25
26
  pinDisplay?: "auto" | "hidden";
26
27
  actions?: BubbleAction[];
28
+ // Opaque to this package: handed to the app's configured SpeechEngine.
29
+ voice?: string;
30
+ // False where the body is already a chosen telling (e.g. a chat reply in the picked persona).
31
+ retellable?: boolean;
27
32
  children?: Snippet;
28
33
  }
29
- let { persona, label, meta, id, pinned = false, onTogglePin, pinDisplay = "auto", actions, children }: Props = $props();
34
+ let { persona, label, meta, id, pinned = false, onTogglePin, pinDisplay = "auto", actions, voice, retellable = true, children }: Props = $props();
30
35
 
31
- // Every assistant-generated bubble gets Speak for free: reads bodyEl's own rendered text at click
32
- // time rather than a prop threaded through every one of this component's call sites, so no
33
- // existing caller needs to change. speechRegistry is the shared "only one thing speaks at a time"
34
- // singleton (mirrors menu-registry.svelte.ts); clicking the currently-speaking bubble's own button
35
- // again toggles it off (speechRegistry.speak handles that toggle internally).
36
+ // Every assistant-generated bubble gets read-aloud for free. It reads bodyEl's rendered text at
37
+ // click time instead of taking a prop, so no caller has to change. speechRegistry is the shared
38
+ // player, and SpeechControls keeps playback controllable after this bubble unmounts.
36
39
  const uid = $props.id();
37
40
  const bubbleId = `${persona}-${uid}`;
38
41
  const speakable = $derived(persona === "assistant" && isSpeechSupported());
39
- const speaking = $derived(speechRegistry.isSpeaking(bubbleId));
42
+ const speech = $derived(speechRegistry.statusOf(bubbleId));
43
+ const speakTitle = $derived(speech === "playing" ? "Pause reading" : speech === "paused" ? "Resume reading" : "Read aloud");
40
44
  let bodyEl: HTMLDivElement | undefined;
45
+
46
+ // A retelling replaces the body in place, so read-aloud and the label follow what is on screen.
47
+ // It lasts only while the app still offers that retelling.
48
+ const retell = $derived(persona === "assistant" && retellable && children ? currentRetell() : null);
49
+ let retold = $state<{ by: string; text: string } | null>(null);
50
+ let retelling = $state(false);
51
+ const shownRetold = $derived(retold && retell && retold.by === retell.label ? retold : null);
52
+ const shownLabel = $derived(shownRetold ? shownRetold.by : label);
53
+ async function toggleRetell() {
54
+ if (shownRetold) { retold = null; return; }
55
+ const r = retell!;
56
+ retelling = true;
57
+ const text = await r.retell(bodyEl?.textContent ?? "").catch(() => null);
58
+ retelling = false;
59
+ retold = text ? { by: r.label, text } : null;
60
+ }
61
+ const menuItems = $derived<BubbleAction[]>([
62
+ ...(actions ?? []),
63
+ ...(retell ? [{
64
+ key: "retell",
65
+ label: retelling ? `${retell.label}…` : shownRetold ? "Show original" : retell.label,
66
+ disabled: retelling,
67
+ onClick: toggleRetell,
68
+ }] : []),
69
+ ]);
70
+
41
71
  function toggleSpeak() {
42
- speechRegistry.speak(bubbleId, bodyEl?.textContent ?? "");
72
+ speechRegistry.toggle(bubbleId, bodyEl?.textContent ?? "", meta ? `${shownLabel} · ${meta}` : shownLabel, shownRetold ? retell?.voice : voice);
43
73
  }
44
74
  </script>
45
75
 
46
- <div class="persona-bubble p-{persona}" {id}>
76
+ <div class="persona-bubble p-{persona}" {id} data-speech-id={speakable ? bubbleId : undefined}>
47
77
  <div class="persona-head">
48
78
  <span class="persona-head-left">
49
- <span class="persona-tag">{label}</span>
79
+ <span class="persona-tag">{shownLabel}</span>
50
80
  </span>
51
- {#if meta || actions?.length || onTogglePin || speakable}
81
+ {#if meta || menuItems.length || onTogglePin || speakable}
52
82
  <span class="persona-head-right">
53
83
  {#if meta}<span class="persona-meta">{meta}</span>{/if}
54
84
  {#if speakable}
55
85
  <button
56
86
  type="button"
57
87
  class="persona-speak"
58
- class:speaking
59
- title={speaking ? "Stop reading" : "Read aloud"}
60
- aria-label={speaking ? "Stop reading" : "Read aloud"}
61
- aria-pressed={speaking}
88
+ class:active={speech !== "idle"}
89
+ title={speakTitle}
90
+ aria-label={speakTitle}
62
91
  onclick={toggleSpeak}
63
- >{speaking ? "" : "🔊"}</button>
92
+ >{speech === "playing" ? "⏸\uFE0E" : "▶\uFE0E"}</button>
93
+ {#if speech !== "idle"}
94
+ <button
95
+ type="button"
96
+ class="persona-speak"
97
+ title="Stop reading"
98
+ aria-label="Stop reading"
99
+ onclick={() => speechRegistry.stop()}
100
+ >⏹&#xFE0E;</button>
101
+ {/if}
64
102
  {/if}
65
- {#if actions?.length || onTogglePin}
66
- <LeafActionMenu items={actions ?? []} {pinned} {onTogglePin} {pinDisplay} />
103
+ {#if menuItems.length || onTogglePin}
104
+ <LeafActionMenu items={menuItems} {pinned} {onTogglePin} {pinDisplay} />
67
105
  {/if}
68
106
  </span>
69
107
  {/if}
70
108
  </div>
71
- {#if children}<div class="persona-body" bind:this={bodyEl}>{@render children()}</div>{/if}
109
+ {#if children}
110
+ <div class="persona-body" bind:this={bodyEl}>
111
+ {#if shownRetold}<p class="persona-retold">{shownRetold.text}</p>{:else}{@render children()}{/if}
112
+ </div>
113
+ {/if}
72
114
  </div>
73
115
 
74
116
  <style>
117
+ .persona-retold { margin: 0; white-space: pre-wrap; }
75
118
  .persona-speak {
76
119
  flex-shrink: 0; border: none; background: none; cursor: pointer;
77
120
  font-size: 0.95rem; line-height: 1; padding: 0.3rem; border-radius: 6px; color: var(--muted);
78
121
  }
79
122
  .persona-speak:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--fg); }
80
- .persona-speak.speaking { color: var(--accent); }
123
+ .persona-speak.active { color: var(--accent); }
81
124
  </style>
@@ -0,0 +1,47 @@
1
+ <script lang="ts">
2
+ import { speechRegistry } from "./speech-registry.svelte";
3
+
4
+ // The global now-playing pill. Reading keeps going after its bubble unmounts (tab or leaf switch),
5
+ // so this is the control that is always reachable. It renders nothing while idle.
6
+ const now = $derived(speechRegistry.current());
7
+ const playing = $derived(now.status === "playing");
8
+
9
+ function reveal() {
10
+ document.querySelector(`[data-speech-id="${now.id}"]`)?.scrollIntoView({ behavior: "smooth", block: "center" });
11
+ }
12
+ </script>
13
+
14
+ {#if now.status !== "idle"}
15
+ <div class="speech-controls" role="region" aria-label="Read aloud">
16
+ <button type="button" class="sc-label" title="Show what is being read" onclick={reveal}>{now.label}</button>
17
+ <span class="sc-progress" aria-label="Sentence {now.index + 1} of {now.total}">{now.index + 1} / {now.total}</span>
18
+ <button
19
+ type="button"
20
+ class="sc-btn"
21
+ title={playing ? "Pause reading" : "Resume reading"}
22
+ aria-label={playing ? "Pause reading" : "Resume reading"}
23
+ onclick={() => (playing ? speechRegistry.pause() : speechRegistry.resume())}
24
+ >{playing ? "⏸︎" : "▶︎"}</button>
25
+ <button type="button" class="sc-btn" title="Stop reading" aria-label="Stop reading" onclick={() => speechRegistry.stop()}>⏹&#xFE0E;</button>
26
+ </div>
27
+ {/if}
28
+
29
+ <style>
30
+ .speech-controls {
31
+ position: fixed; bottom: 1rem; right: 1rem; z-index: 40;
32
+ display: flex; align-items: center; gap: 0.35rem; max-width: calc(100vw - 2rem);
33
+ padding: 0.3rem 0.4rem 0.3rem 0.8rem; border-radius: 999px;
34
+ background: var(--panel); color: var(--fg); border: 1px solid var(--border);
35
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18); font-size: 0.85rem;
36
+ }
37
+ .sc-label {
38
+ border: none; background: none; color: inherit; cursor: pointer; padding: 0;
39
+ font: inherit; font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
40
+ }
41
+ .sc-progress { color: var(--muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
42
+ .sc-btn {
43
+ border: none; background: none; cursor: pointer; color: var(--accent);
44
+ font-size: 0.95rem; line-height: 1; padding: 0.35rem; border-radius: 999px;
45
+ }
46
+ .sc-btn:hover { background: color-mix(in srgb, var(--accent) 12%, transparent); }
47
+ </style>
@@ -5,7 +5,7 @@
5
5
  export type AttachMode = "camera" | "library" | "files";
6
6
 
7
7
  // Everything. A leaf's Attach is storage, not import: whatever you attach is kept, shown, and — if
8
- // it is a PDF or a text file — read so LexiTar can quote it. Anything else simply attaches and sits
8
+ // it is a PDF or a text file — read so the host app can quote it. Anything else simply attaches and sits
9
9
  // there, which is strictly better than a file picker that greys the file out with no explanation.
10
10
  //
11
11
  // The narrow "image/*,application/pdf,.xlsx,.xls" this replaces meant a .docx, a .csv or a .heic
package/brand.ts CHANGED
@@ -1,8 +1,15 @@
1
- // Domain-neutral half of the brand split: the {path,label} -> absolute-link derivation, usable
2
- // by any consuming app's own brand constants. The LexiTar-specific values (PRODUCT_NAME,
3
- // MEDICAL_DISCLAIMER, ...) stay in packages/brand, which is not a dependency any other app takes.
1
+ // The shape of an organization's brand, not its values: each host app declares its own
2
+ // OrgIdentity (see ARCHITECTURE.md, "Entity neutrality").
4
3
  export type LegalLink = { path: string; label: string };
5
4
 
5
+ export type OrgIdentity = {
6
+ name: string;
7
+ status: string;
8
+ /** Origin the legal pages live on; "" when they are served by the host app itself. */
9
+ legalBase: string;
10
+ legalPaths: LegalLink[];
11
+ };
12
+
6
13
  export function deriveLegalLinks(base: string, paths: LegalLink[]): { href: string; label: string }[] {
7
14
  return paths.map((l) => ({ href: `${base}${l.path}`, label: l.label }));
8
15
  }
package/footer.css ADDED
@@ -0,0 +1,37 @@
1
+ /* Markup contract for OrgFooter.svelte, and for any non-Svelte host (e.g. an Astro site) rendering
2
+ the same .frame-footer markup. Every value falls back to a default, so it works without theme.css;
3
+ a host themes it through the --frame-footer-* properties or overrides the classes directly. */
4
+ .frame-footer {
5
+ border-top: 1px solid var(--border, #e5e7eb);
6
+ color: var(--muted, #6b7280);
7
+ font-size: var(--frame-footer-font-size, 0.85rem);
8
+ font-family: var(--frame-font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);
9
+ background: var(--frame-footer-bg, #fff);
10
+ }
11
+
12
+ .frame-footer__row {
13
+ max-width: var(--frame-footer-max, 64rem);
14
+ margin: 0 auto;
15
+ padding: 1.25rem 1.25rem;
16
+ display: flex;
17
+ flex-wrap: wrap;
18
+ align-items: center;
19
+ justify-content: space-between;
20
+ gap: 0.75rem;
21
+ }
22
+
23
+ .frame-footer__links {
24
+ display: inline-flex;
25
+ flex-wrap: wrap;
26
+ gap: 1rem;
27
+ }
28
+
29
+ .frame-footer__links a {
30
+ color: var(--muted, #6b7280);
31
+ text-decoration: none;
32
+ }
33
+
34
+ .frame-footer__links a:hover {
35
+ color: var(--frame-footer-accent, currentColor);
36
+ text-decoration: underline;
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinytars/frame",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "Domain-neutral Svelte app-shell: session/auth controllers, account chrome, and menu/card/modal primitives built on @tinytars/vault.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,6 +24,7 @@
24
24
  "files": [
25
25
  "*.ts",
26
26
  "!*.test.ts",
27
+ "!vitest.config.ts",
27
28
  "*.svelte",
28
29
  "*.css"
29
30
  ],
@@ -45,6 +46,7 @@
45
46
  "./ExportTab.svelte": "./ExportTab.svelte",
46
47
  "./Field.svelte": "./Field.svelte",
47
48
  "./filter": "./filter.ts",
49
+ "./footer.css": "./footer.css",
48
50
  "./FormGrid.svelte": "./FormGrid.svelte",
49
51
  "./group-filter": "./group-filter.ts",
50
52
  "./LeafActionMenu.svelte": "./LeafActionMenu.svelte",
@@ -55,6 +57,7 @@
55
57
  "./Modal.svelte": "./Modal.svelte",
56
58
  "./ModalActions.svelte": "./ModalActions.svelte",
57
59
  "./Onboarding.svelte": "./Onboarding.svelte",
60
+ "./OrgFooter.svelte": "./OrgFooter.svelte",
58
61
  "./pdf-render": "./pdf-render.ts",
59
62
  "./PdfThumbnail.svelte": "./PdfThumbnail.svelte",
60
63
  "./persisted-json": "./persisted-json.ts",
@@ -67,6 +70,8 @@
67
70
  "./SidebarGroupList.svelte": "./SidebarGroupList.svelte",
68
71
  "./SidebarLeafList.svelte": "./SidebarLeafList.svelte",
69
72
  "./speech-registry.svelte": "./speech-registry.svelte.ts",
73
+ "./retell-registry.svelte": "./retell-registry.svelte.ts",
74
+ "./SpeechControls.svelte": "./SpeechControls.svelte",
70
75
  "./support-access.svelte": "./support-access.svelte.ts",
71
76
  "./theme.css": "./theme.css",
72
77
  "./time-ago": "./time-ago.ts",
@@ -0,0 +1,18 @@
1
+ // An app may offer a second telling of any assistant bubble (e.g. the same content in another voice).
2
+ // PersonaBubble adds a menu item for it while one is configured; this package knows nothing of what
3
+ // the retelling says, only its label and the voice it is read aloud in.
4
+ export interface Retell {
5
+ label: string;
6
+ voice?: string;
7
+ retell(text: string): Promise<string | null>;
8
+ }
9
+
10
+ const state = $state<{ current: Retell | null }>({ current: null });
11
+
12
+ export function configureRetell(r: Retell | null): void {
13
+ state.current = r;
14
+ }
15
+
16
+ export function currentRetell(): Retell | null {
17
+ return state.current;
18
+ }
@@ -1,50 +1,189 @@
1
- // The one shared "who's currently speaking" registry, mirroring menu-registry.svelte.ts's
2
- // singleton shape: starting a new utterance stops whatever else was speaking, and clicking the
3
- // currently-speaking bubble's own button again stops it (toggle) — the same "only one thing
4
- // active" convention a popover-heavy UI wants for this too.
1
+ // The one shared read-aloud player. It mirrors menu-registry.svelte.ts's singleton shape: playing one
2
+ // thing stops whatever else was playing.
5
3
  //
6
- // speechSynthesis is a real, global, OS-level engine it can finish an utterance (or fail) entirely
7
- // on its own, not just via a manual stop() call. `onend`/`onerror` on the utterance are what keep
8
- // `speakingId` in sync with reality in that case; a plain isSpeaking() flag flipped only at the two
9
- // call sites (speak/stop) would go stale the instant a normal utterance finishes.
10
- const state = $state<{ speakingId: string | null }>({ speakingId: null });
4
+ // Text is spoken one chunk (roughly one sentence) at a time, and each chunk's `end` starts the next.
5
+ // Chromium silently cuts off a single long utterance after ~15s, and chunking is also what makes
6
+ // progress and resume possible. Pause is cancel() plus a remembered index, not
7
+ // speechSynthesis.pause(), which is a no-op or broken on Android Chrome and some Linux voices. Resume
8
+ // restarts the current chunk.
9
+ //
10
+ // The engine finishes or fails utterances on its own. cancel()'s `end` also arrives as a separate
11
+ // task, after the call that caused it. So every utterance captures the `generation` it was spoken
12
+ // under. Each pause, stop, or new play bumps that counter, which makes a late event from a
13
+ // superseded chunk a no-op.
14
+ //
15
+ // An app may configure a SpeechEngine: each chunk is then synthesized to audio (a neural voice) and
16
+ // played through an HTMLAudioElement, the next chunk prefetched while the current one plays. If the
17
+ // engine or playback fails, the rest of that playback falls back to the browser voice.
18
+ export type SpeechStatus = "idle" | "playing" | "paused";
19
+
20
+ export interface SpeechEngine {
21
+ synthesize(text: string, voice?: string): Promise<Blob>;
22
+ normalize?(text: string): string;
23
+ }
24
+
25
+ const MAX_CHUNK = 200;
26
+
27
+ const state = $state({
28
+ id: null as string | null,
29
+ label: "",
30
+ status: "idle" as SpeechStatus,
31
+ chunks: [] as string[],
32
+ index: 0,
33
+ });
34
+ let generation = 0;
35
+ let engine: SpeechEngine | null = null;
36
+ let voice: string | undefined;
37
+ let neural = false;
38
+ let audio: HTMLAudioElement | null = null;
39
+ let clips = new Map<number, Promise<string>>();
40
+
41
+ export function configureSpeech(e: SpeechEngine | null): void {
42
+ engine = e;
43
+ }
11
44
 
12
45
  function synth(): SpeechSynthesis | undefined {
13
46
  return typeof window !== "undefined" ? window.speechSynthesis : undefined;
14
47
  }
15
48
 
16
49
  export function isSpeechSupported(): boolean {
17
- return !!synth();
50
+ return !!engine || !!synth();
51
+ }
52
+
53
+ function pack(parts: string[]): string[] {
54
+ const out: string[] = [];
55
+ let buf = "";
56
+ for (const p of parts) {
57
+ const next = buf ? `${buf} ${p}` : p;
58
+ if (buf && next.length > MAX_CHUNK) {
59
+ out.push(buf);
60
+ buf = p;
61
+ } else {
62
+ buf = next;
63
+ }
64
+ }
65
+ if (buf) out.push(buf);
66
+ return out;
67
+ }
68
+
69
+ export function speechChunks(text: string): string[] {
70
+ const sentences = Array.from(new Intl.Segmenter(undefined, { granularity: "sentence" }).segment(text), (s) => s.segment.trim())
71
+ .filter(Boolean);
72
+ return pack(sentences.flatMap((s) => (s.length > MAX_CHUNK ? pack(s.split(/\s+/)) : [s])));
73
+ }
74
+
75
+ function halt() {
76
+ generation++;
77
+ synth()?.cancel();
78
+ audio?.pause();
79
+ audio = null;
80
+ }
81
+
82
+ function releaseClips() {
83
+ for (const p of clips.values()) p.then((url) => URL.revokeObjectURL(url), () => {});
84
+ clips = new Map();
85
+ }
86
+
87
+ function reset() {
88
+ releaseClips();
89
+ neural = false;
90
+ state.id = null;
91
+ state.label = "";
92
+ state.status = "idle";
93
+ state.chunks = [];
94
+ state.index = 0;
95
+ }
96
+
97
+ function advance(gen: number) {
98
+ if (gen !== generation) return;
99
+ if (state.index + 1 < state.chunks.length) {
100
+ state.index++;
101
+ speakCurrent();
102
+ } else {
103
+ reset();
104
+ }
105
+ }
106
+
107
+ function speakCurrent() {
108
+ const gen = ++generation;
109
+ if (neural) speakNeural(gen);
110
+ else speakBrowser(gen);
111
+ }
112
+
113
+ function speakBrowser(gen: number) {
114
+ const utterance = new SpeechSynthesisUtterance(state.chunks[state.index]);
115
+ utterance.onend = () => advance(gen);
116
+ utterance.onerror = () => { if (gen === generation) reset(); };
117
+ synth()!.speak(utterance);
118
+ }
119
+
120
+ function clip(i: number): Promise<string> {
121
+ let p = clips.get(i);
122
+ if (!p) {
123
+ p = engine!.synthesize(state.chunks[i], voice).then((blob) => URL.createObjectURL(blob));
124
+ clips.set(i, p);
125
+ }
126
+ return p;
127
+ }
128
+
129
+ function speakNeural(gen: number) {
130
+ const i = state.index;
131
+ clip(i).then((url) => {
132
+ if (gen !== generation) return;
133
+ const a = new Audio(url);
134
+ audio = a;
135
+ a.onended = () => advance(gen);
136
+ a.onerror = () => fallBack(gen);
137
+ a.play().catch(() => fallBack(gen));
138
+ if (i + 1 < state.chunks.length) clip(i + 1).catch(() => {});
139
+ }, () => fallBack(gen));
140
+ }
141
+
142
+ function fallBack(gen: number) {
143
+ if (gen !== generation) return;
144
+ neural = false;
145
+ audio = null;
146
+ if (synth()) speakBrowser(gen);
147
+ else reset();
18
148
  }
19
149
 
20
150
  export const speechRegistry = {
21
- isSpeaking(id: string): boolean {
22
- return state.speakingId === id;
151
+ statusOf(id: string): SpeechStatus {
152
+ return state.id === id ? state.status : "idle";
23
153
  },
24
- speak(id: string, text: string): void {
25
- const s = synth();
26
- if (!s || !text.trim()) return;
27
- // cancel()'s `end` event is dispatched as its own task, never inside this call — which is what
28
- // makes the toggle below reachable, since it compares against a speakingId this cancel has not
29
- // had the chance to clear. The old speakingId is cleared by the assignment further down (or by
30
- // the toggle); each handler guards on its own id so a late `end` cannot clear a newer utterance.
31
- // (An earlier version of this comment claimed the opposite, that cancel() fires onend
32
- // SYNCHRONOUSLY and that this is what clears the old id. Were that true the toggle would be
33
- // dead code and a second click would re-speak.)
34
- s.cancel();
35
- if (state.speakingId === id) {
36
- // Toggle: clicking the already-speaking bubble's own button again just stops it.
37
- state.speakingId = null;
38
- return;
39
- }
40
- const utterance = new SpeechSynthesisUtterance(text);
41
- utterance.onend = () => { if (state.speakingId === id) state.speakingId = null; };
42
- utterance.onerror = () => { if (state.speakingId === id) state.speakingId = null; };
43
- state.speakingId = id;
44
- s.speak(utterance);
154
+ current() {
155
+ return { id: state.id, label: state.label, status: state.status, index: state.index, total: state.chunks.length };
156
+ },
157
+ play(id: string, text: string, label: string, withVoice?: string): void {
158
+ const chunks = speechChunks(engine?.normalize ? engine.normalize(text) : text);
159
+ if (!isSpeechSupported() || !chunks.length) return;
160
+ halt();
161
+ releaseClips();
162
+ voice = withVoice;
163
+ neural = !!engine;
164
+ Object.assign(state, { id, label, status: "playing", chunks, index: 0 });
165
+ speakCurrent();
166
+ },
167
+ pause(): void {
168
+ if (state.status !== "playing") return;
169
+ halt();
170
+ state.status = "paused";
171
+ },
172
+ resume(): void {
173
+ if (state.status !== "paused" || !isSpeechSupported()) return;
174
+ state.status = "playing";
175
+ speakCurrent();
176
+ },
177
+ toggle(id: string, text: string, label: string, withVoice?: string): void {
178
+ if (state.id !== id) this.play(id, text, label, withVoice);
179
+ else if (state.status === "playing") this.pause();
180
+ else this.resume();
45
181
  },
46
182
  stop(): void {
47
- synth()?.cancel();
48
- state.speakingId = null;
183
+ halt();
184
+ reset();
49
185
  },
50
186
  };
187
+
188
+ // Nothing keeps speaking into a page that is being navigated away from or put in the bfcache.
189
+ if (typeof window !== "undefined") window.addEventListener("pagehide", () => speechRegistry.stop());
package/theme.css CHANGED
@@ -13,7 +13,7 @@
13
13
  --surface-2: #f4f4f5;
14
14
  --panel: #fbfcfd;
15
15
  --code-bg: #f4f6f8;
16
- --tt-footer-accent: var(--accent);
16
+ --frame-footer-accent: var(--accent);
17
17
  --band: rgba(11, 107, 203, 0.08);
18
18
  --safe: #16a34a;
19
19
  --safe-band: rgba(22, 163, 74, 0.10);