@supersuit/artifacts 0.8.0 → 0.10.0
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.
- package/CHANGELOG.md +32 -0
- package/README.md +1 -1
- package/lib/artifacts/confidential.d.ts +5 -1
- package/lib/artifacts/confidential.js +5 -2
- package/lib/reader/artifact-reader.d.ts +5 -1
- package/lib/reader/artifact-reader.js +23 -17
- package/lib/reader/browser-reader.d.ts +8 -0
- package/lib/reader/browser-reader.js +171 -0
- package/lib/reader/index.d.ts +4 -0
- package/lib/reader/index.js +6 -0
- package/lib/reader/player-bar.d.ts +18 -0
- package/lib/reader/player-bar.js +16 -0
- package/lib/reader/speech-plan.d.ts +20 -0
- package/lib/reader/speech-plan.js +67 -0
- package/lib/routes/artifacts.d.ts +3 -0
- package/lib/routes/artifacts.js +5 -2
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,38 @@
|
|
|
3
3
|
`@supersuit/artifacts`. One entry per version, newest first. Each entry says what changed, how a
|
|
4
4
|
host can tell whether it is affected (DETECTOR), what a host does about it (REMEDY), and the tests.
|
|
5
5
|
|
|
6
|
+
## 0.10.0 (2026-09-28)
|
|
7
|
+
|
|
8
|
+
**Every page can be read aloud.** A page with no recorded narration, or whose timings will not
|
|
9
|
+
load, now shows the same player bar and reads itself with the browser's own voice
|
|
10
|
+
(`speechSynthesis`), lighting each word from the engine's word-boundary events. It speaks one
|
|
11
|
+
sentence at a time, so a drifting engine (Safari on long passages) re-syncs at every sentence.
|
|
12
|
+
Clicking a word starts reading there. Browsers without speech synthesis show no bar, as before.
|
|
13
|
+
Pages with recorded narration are unchanged.
|
|
14
|
+
|
|
15
|
+
- New exports from `./reader`: `BrowserReader`, `PlayerBar`, `planSentences`, `wordAt`,
|
|
16
|
+
`sentenceOf`, `fromWord`. `./reader` is now a barrel (`src/reader/index.ts`); everything
|
|
17
|
+
importable before still is.
|
|
18
|
+
- `wrapWords` is idempotent, so two readers can never wrap a page twice.
|
|
19
|
+
- DETECTOR: an un-narrated page with no player bar.
|
|
20
|
+
- REMEDY: bump; nothing to republish.
|
|
21
|
+
- Tests: `speech-plan.test.ts` (sentence plan, 60-word cap, block starts, boundary mapping),
|
|
22
|
+
`browser-reader.test.tsx` against a stubbed `speechSynthesis`, route tests for which reader
|
|
23
|
+
mounts, and the packed fixture's un-narrated page mounting the browser reader.
|
|
24
|
+
|
|
25
|
+
## 0.9.0 (2026-09-27)
|
|
26
|
+
|
|
27
|
+
**A host can word its own sign-in door.** `signInNote` on `createArtifactRoutes` replaces the
|
|
28
|
+
door's line on a gated page. The default still names the Freedom account, so every host that
|
|
29
|
+
passes nothing reads exactly as before. A host that signs readers in itself, rather than through
|
|
30
|
+
a Freedom account, needs the door to say so, or readers are told to use an account they do not
|
|
31
|
+
have.
|
|
32
|
+
|
|
33
|
+
- DETECTOR: a host whose `signInOrigin` is not a Freedom account service, and whose door still
|
|
34
|
+
says "your Freedom account".
|
|
35
|
+
- REMEDY: bump and pass `signInNote`.
|
|
36
|
+
- Tests: `gated.test.tsx` pins the default line and a host's own line replacing it.
|
|
37
|
+
|
|
6
38
|
## 0.8.0 (2026-09-27)
|
|
7
39
|
|
|
8
40
|
**Pages play audio clips.** `` renders a captioned player with native
|
package/README.md
CHANGED
|
@@ -151,7 +151,7 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
|
|
|
151
151
|
| `id` | Republish in place instead of minting a new page |
|
|
152
152
|
| `cover` | An image URL (absolute or site-relative) used as the unfurl image instead of the share card |
|
|
153
153
|
| `audience` | Free text stored with the page |
|
|
154
|
-
| `voice`, `narration`, `timings`, `narrationHash` | Read-aloud: the audio and word timings a publisher generated. Absent, no player |
|
|
154
|
+
| `voice`, `narration`, `timings`, `narrationHash` | Read-aloud: the audio and word timings a publisher generated. Absent (or the timings will not load), the page is read by the browser's own voice instead, lighting the same words; a browser with no `speechSynthesis` shows no player |
|
|
155
155
|
| `password` | Shuts the body behind a door (below) |
|
|
156
156
|
| `access` | `freedom`, `invite` or `public`: a confidential page (below) |
|
|
157
157
|
| `theme` | `light`, `dark` or `system` (follow the reader's device). Overrides the brand pack's mode for this page |
|
|
@@ -25,9 +25,13 @@ export declare function Watermark({ email }: {
|
|
|
25
25
|
}): import("react").JSX.Element;
|
|
26
26
|
/** Printing shows this and nothing else, whichever way the print dialog was reached. */
|
|
27
27
|
export declare const NO_PRINT_CSS = "@media print { body * { visibility: hidden !important; } body::after { content: \"This page is confidential and cannot be printed.\"; visibility: visible; position: fixed; top: 40%; left: 0; right: 0; text-align: center; font: 16px sans-serif; color: #000; } }";
|
|
28
|
-
|
|
28
|
+
/** The door's default line, for a host whose readers sign in with a Freedom account. A host
|
|
29
|
+
* that signs readers in some other way passes `signInNote` to say so in its own words. */
|
|
30
|
+
export declare const DEFAULT_SIGN_IN_NOTE = "This page is confidential. Sign in with the Google account your Freedom account uses, or the address it was shared with.";
|
|
31
|
+
export declare function SignInDoor({ brand, href, note }: {
|
|
29
32
|
brand: BrandPack;
|
|
30
33
|
href?: string;
|
|
34
|
+
note?: string;
|
|
31
35
|
}): import("react").JSX.Element;
|
|
32
36
|
export declare function NotAllowedDoor({ brand, reader, signOutUrl }: {
|
|
33
37
|
brand: BrandPack;
|
|
@@ -19,12 +19,15 @@ export function Watermark({ email }) {
|
|
|
19
19
|
}
|
|
20
20
|
/** Printing shows this and nothing else, whichever way the print dialog was reached. */
|
|
21
21
|
export const NO_PRINT_CSS = `@media print { body * { visibility: hidden !important; } body::after { content: "This page is confidential and cannot be printed."; visibility: visible; position: fixed; top: 40%; left: 0; right: 0; text-align: center; font: 16px sans-serif; color: #000; } }`;
|
|
22
|
-
|
|
22
|
+
/** The door's default line, for a host whose readers sign in with a Freedom account. A host
|
|
23
|
+
* that signs readers in some other way passes `signInNote` to say so in its own words. */
|
|
24
|
+
export const DEFAULT_SIGN_IN_NOTE = 'This page is confidential. Sign in with the Google account your Freedom account uses, or the address it was shared with.';
|
|
25
|
+
export function SignInDoor({ brand, href, note = DEFAULT_SIGN_IN_NOTE }) {
|
|
23
26
|
// No href means the host configured no sign-in authority: the door stays, with no way through.
|
|
24
27
|
if (!href) {
|
|
25
28
|
return (_jsx("div", { "data-nospeak": true, className: "mx-auto max-w-sm px-6 pb-24 text-center", children: _jsx("p", { className: "text-sm opacity-70", children: "This page is confidential, and this site cannot sign readers in yet." }) }));
|
|
26
29
|
}
|
|
27
|
-
return (_jsxs("div", { "data-nospeak": true, className: "mx-auto max-w-sm px-6 pb-24 text-center", children: [_jsx("p", { className: "text-sm opacity-70", children:
|
|
30
|
+
return (_jsxs("div", { "data-nospeak": true, className: "mx-auto max-w-sm px-6 pb-24 text-center", children: [_jsx("p", { className: "text-sm opacity-70", children: note }), _jsx("a", { href: href, className: "mt-5 inline-block rounded-lg px-5 py-2 text-sm font-medium", style: { background: brand.accent, color: brand.ground }, children: "Sign in to read" })] }));
|
|
28
31
|
}
|
|
29
32
|
export function NotAllowedDoor({ brand, reader, signOutUrl }) {
|
|
30
33
|
return (_jsxs("div", { "data-nospeak": true, className: "mx-auto max-w-sm px-6 pb-24 text-center text-sm", children: [_jsxs("p", { className: "opacity-80", children: ["You are signed in as ", reader.email, ", and this page has not been shared with that address. If it was sent to a different address of yours, sign out and use that one."] }), _jsx("p", { className: "mt-3 opacity-60", children: "This attempt was recorded." }), _jsx("a", { href: signOutUrl, className: "mt-5 inline-block underline", style: { color: brand.accent }, children: "Sign out" })] }));
|
|
@@ -3,7 +3,11 @@ export type WordTiming = {
|
|
|
3
3
|
s: number;
|
|
4
4
|
e: number;
|
|
5
5
|
};
|
|
6
|
-
/**
|
|
6
|
+
/** The elements that hold a run of text; a word whose block differs from the previous word's
|
|
7
|
+
* starts a new sentence in the browser read-aloud. */
|
|
8
|
+
export declare const BLOCK = "p, li, td, th, h1, h2, h3, h4, h5, h6, blockquote, aside, dt, dd, figcaption, div";
|
|
9
|
+
/** Wrap every word inside `root` (except skipped subtrees) in a span; return them in order.
|
|
10
|
+
* Idempotent: a root already wrapped returns the spans it already has. */
|
|
7
11
|
export declare function wrapWords(root: HTMLElement): HTMLSpanElement[];
|
|
8
12
|
/** The karaoke word a click lands on, or null. A defined term is a control that opens its
|
|
9
13
|
* definition, so a tap on it must never seek or start the narration. */
|
|
@@ -1,22 +1,33 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx,
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
// Speechify-style read-along for a published artifact. The narration was generated
|
|
4
4
|
// from the same rendered text this page shows (see lib/artifacts/narration.ts), so
|
|
5
5
|
// the highlighter walks the DOM in reading order, wraps each word, and aligns that
|
|
6
6
|
// sequence to the word timings by normalized token, resyncing within a short window
|
|
7
7
|
// when the two disagree. Click a word to seek; the bar at the bottom plays and paces.
|
|
8
8
|
import { useEffect, useRef, useState } from 'react';
|
|
9
|
+
import { nextRate, PlayerBar } from './player-bar.js';
|
|
9
10
|
const SKIP = '[data-nospeak], [data-artifact-links], pre, [data-footnotes], sup, img, style, script';
|
|
10
|
-
|
|
11
|
+
/** The elements that hold a run of text; a word whose block differs from the previous word's
|
|
12
|
+
* starts a new sentence in the browser read-aloud. */
|
|
13
|
+
export const BLOCK = 'p, li, td, th, h1, h2, h3, h4, h5, h6, blockquote, aside, dt, dd, figcaption, div';
|
|
11
14
|
function normalize(w) {
|
|
12
15
|
return w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
|
|
13
16
|
}
|
|
14
|
-
|
|
17
|
+
// Set on a root once its words are wrapped. Wrapping twice would nest a span inside every span
|
|
18
|
+
// (React's development double-mount, or a host that mounts both readers), and the second set of
|
|
19
|
+
// spans would never be the ones the first reader lights.
|
|
20
|
+
const WRAPPED = 'data-artifact-words';
|
|
21
|
+
/** Wrap every word inside `root` (except skipped subtrees) in a span; return them in order.
|
|
22
|
+
* Idempotent: a root already wrapped returns the spans it already has. */
|
|
15
23
|
export function wrapWords(root) {
|
|
24
|
+
if (root.hasAttribute(WRAPPED))
|
|
25
|
+
return Array.from(root.querySelectorAll('.artifact-word'));
|
|
26
|
+
root.setAttribute(WRAPPED, '');
|
|
16
27
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
17
28
|
acceptNode: (n) => {
|
|
18
29
|
const el = n.parentElement;
|
|
19
|
-
if (!el || el.closest(SKIP))
|
|
30
|
+
if (!el || el.closest(SKIP) || el.closest('.artifact-word, .artifact-word-tail'))
|
|
20
31
|
return NodeFilter.FILTER_REJECT;
|
|
21
32
|
return n.nodeValue && n.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
|
|
22
33
|
},
|
|
@@ -184,21 +195,16 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
|
|
|
184
195
|
else
|
|
185
196
|
a.pause();
|
|
186
197
|
};
|
|
187
|
-
return (_jsxs(_Fragment, { children: [_jsx("
|
|
188
|
-
.artifact-word { border-radius: 3px; transition: background-color 120ms; }
|
|
189
|
-
.artifact-word:hover { background: var(--a-surface-strong, rgba(255,255,255,0.08)); cursor: pointer; }
|
|
190
|
-
.artifact-word-lit { background: color-mix(in srgb, ${accent} 35%, transparent); color: var(--a-strong, #fff); }
|
|
191
|
-
` }), _jsx("audio", { ref: audioRef, src: src, preload: "metadata", onPlay: () => {
|
|
198
|
+
return (_jsxs(_Fragment, { children: [_jsx("audio", { ref: audioRef, src: src, preload: "metadata", onPlay: () => {
|
|
192
199
|
startedRef.current = true;
|
|
193
200
|
setPlaying(true);
|
|
194
201
|
}, onSeeking: () => {
|
|
195
202
|
startedRef.current = true;
|
|
196
|
-
}, onPause: () => setPlaying(false), onLoadedMetadata: (e) => setDur(e.target.duration), onRateChange: (e) => setRate(e.target.playbackRate) }), _jsx(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}, className: "shrink-0 rounded border border-[color:var(--a-line-strong)] px-2 py-1 text-xs text-[color:var(--a-body)]", "aria-label": "Playback speed", children: [rate, "x"] })] }) }), _jsx("div", { className: "h-20", "aria-hidden": true })] }));
|
|
203
|
+
}, onPause: () => setPlaying(false), onLoadedMetadata: (e) => setDur(e.target.duration), onRateChange: (e) => setRate(e.target.playbackRate) }), _jsx(PlayerBar, { playing: playing, onToggle: toggle, label: narrator, accent: accent, ground: ground, value: t, max: dur, step: 0.1, onSeek: (v) => {
|
|
204
|
+
if (audioRef.current)
|
|
205
|
+
audioRef.current.currentTime = v;
|
|
206
|
+
}, progress: `${fmt(t)} / ${fmt(dur)}`, rate: rate, onRate: () => {
|
|
207
|
+
if (audioRef.current)
|
|
208
|
+
audioRef.current.playbackRate = nextRate(rate);
|
|
209
|
+
} })] }));
|
|
204
210
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const BROWSER_READER_LABEL = "Read aloud by your browser";
|
|
2
|
+
export declare function BrowserReader({ rootId, label, accent, ground, }: {
|
|
3
|
+
rootId: string;
|
|
4
|
+
/** The line under the play button. */
|
|
5
|
+
label?: string;
|
|
6
|
+
accent: string;
|
|
7
|
+
ground: string;
|
|
8
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
// Read-along for a page with no recorded narration: the browser's own voice (speechSynthesis)
|
|
4
|
+
// reads the same word spans the recorded reader lights, and its word-boundary events light them.
|
|
5
|
+
// It sounds worse than a recorded narrator and sends nothing anywhere.
|
|
6
|
+
//
|
|
7
|
+
// One utterance per sentence (see speech-plan.ts). Safari's boundary charIndex drifts on long
|
|
8
|
+
// passages; every new utterance restarts it at zero against offsets we know, so a drift cannot
|
|
9
|
+
// outlast the sentence it began in. Every utterance carries the generation it was spoken in, and
|
|
10
|
+
// an event from an older generation (a sentence cancelled by a click, a seek or a speed change,
|
|
11
|
+
// whose end or error the engine still reports) is ignored.
|
|
12
|
+
//
|
|
13
|
+
// The mount is rendered hidden and empty on the server and on the first client render, so the
|
|
14
|
+
// markup always hydrates; it shows only once the browser is known to have speechSynthesis. It
|
|
15
|
+
// never carries data-artifact-player while hidden, because the popovers measure that element to
|
|
16
|
+
// keep clear of the bar, and a hidden element measures as the whole screen.
|
|
17
|
+
import { useEffect, useRef, useState } from 'react';
|
|
18
|
+
import { BLOCK, seekableWord, wrapWords } from './artifact-reader.js';
|
|
19
|
+
import { nextRate, PlayerBar } from './player-bar.js';
|
|
20
|
+
import { fromWord, planSentences, wordAt } from './speech-plan.js';
|
|
21
|
+
export const BROWSER_READER_LABEL = 'Read aloud by your browser';
|
|
22
|
+
function spokenWords(spans) {
|
|
23
|
+
const words = [];
|
|
24
|
+
const starts = new Set();
|
|
25
|
+
let prevBlock = null;
|
|
26
|
+
spans.forEach((span, i) => {
|
|
27
|
+
// Punctuation glued on across an element edge ("**bold**.") is a tail, not a word; it is
|
|
28
|
+
// spoken with the word it follows, which is also what lets it end a sentence.
|
|
29
|
+
const tail = span.nextSibling instanceof HTMLElement && span.nextSibling.classList.contains('artifact-word-tail') ? span.nextSibling.textContent ?? '' : '';
|
|
30
|
+
words.push((span.textContent ?? '') + tail);
|
|
31
|
+
const block = span.closest(BLOCK);
|
|
32
|
+
if (i > 0 && block !== prevBlock)
|
|
33
|
+
starts.add(i);
|
|
34
|
+
prevBlock = block;
|
|
35
|
+
});
|
|
36
|
+
return { words, starts };
|
|
37
|
+
}
|
|
38
|
+
export function BrowserReader({ rootId, label = BROWSER_READER_LABEL, accent, ground, }) {
|
|
39
|
+
const [ready, setReady] = useState(false);
|
|
40
|
+
const [playing, setPlaying] = useState(false);
|
|
41
|
+
const [current, setCurrent] = useState(-1);
|
|
42
|
+
const [count, setCount] = useState(0);
|
|
43
|
+
const [rate, setRate] = useState(1);
|
|
44
|
+
const spansRef = useRef([]);
|
|
45
|
+
const planRef = useRef([]);
|
|
46
|
+
const genRef = useRef(0);
|
|
47
|
+
const rateRef = useRef(1);
|
|
48
|
+
const currentRef = useRef(-1);
|
|
49
|
+
const playingRef = useRef(false);
|
|
50
|
+
// True from the first play until the page is finished: pause and resume act on a live queue.
|
|
51
|
+
const startedRef = useRef(false);
|
|
52
|
+
const litRef = useRef(null);
|
|
53
|
+
const setPlay = (p) => {
|
|
54
|
+
playingRef.current = p;
|
|
55
|
+
setPlaying(p);
|
|
56
|
+
};
|
|
57
|
+
const light = (i) => {
|
|
58
|
+
const el = i >= 0 ? spansRef.current[i] ?? null : null;
|
|
59
|
+
if (el !== litRef.current) {
|
|
60
|
+
litRef.current?.classList.remove('artifact-word-lit');
|
|
61
|
+
el?.classList.add('artifact-word-lit');
|
|
62
|
+
litRef.current = el;
|
|
63
|
+
if (el && playingRef.current) {
|
|
64
|
+
const r = el.getBoundingClientRect();
|
|
65
|
+
if (r.top < 80 || r.bottom > window.innerHeight - 140)
|
|
66
|
+
el.scrollIntoView?.({ block: 'center', behavior: 'smooth' });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
currentRef.current = i;
|
|
70
|
+
setCurrent(i);
|
|
71
|
+
};
|
|
72
|
+
const finish = () => {
|
|
73
|
+
startedRef.current = false;
|
|
74
|
+
setPlay(false);
|
|
75
|
+
light(-1);
|
|
76
|
+
};
|
|
77
|
+
const speak = (s, gen) => {
|
|
78
|
+
const synth = window.speechSynthesis;
|
|
79
|
+
const u = new SpeechSynthesisUtterance(s.text);
|
|
80
|
+
u.rate = rateRef.current;
|
|
81
|
+
u.onboundary = (e) => {
|
|
82
|
+
if (gen !== genRef.current || e.name !== 'word')
|
|
83
|
+
return;
|
|
84
|
+
light(wordAt(s, e.charIndex));
|
|
85
|
+
};
|
|
86
|
+
u.onend = () => {
|
|
87
|
+
if (gen !== genRef.current)
|
|
88
|
+
return;
|
|
89
|
+
const next = s.first + s.offsets.length;
|
|
90
|
+
if (next < spansRef.current.length)
|
|
91
|
+
speak(fromWord(planRef.current, next), gen);
|
|
92
|
+
else
|
|
93
|
+
finish();
|
|
94
|
+
};
|
|
95
|
+
u.onerror = () => {
|
|
96
|
+
if (gen === genRef.current)
|
|
97
|
+
finish();
|
|
98
|
+
};
|
|
99
|
+
// Light the sentence's first word now: some engines send no boundary events at all, and the
|
|
100
|
+
// page should still show where it is.
|
|
101
|
+
light(s.first);
|
|
102
|
+
synth.speak(u);
|
|
103
|
+
};
|
|
104
|
+
/** Cancel whatever is speaking and read from word `i` on. */
|
|
105
|
+
const speakFrom = (i) => {
|
|
106
|
+
if (!planRef.current.length)
|
|
107
|
+
return;
|
|
108
|
+
const synth = window.speechSynthesis;
|
|
109
|
+
const gen = ++genRef.current;
|
|
110
|
+
synth.cancel();
|
|
111
|
+
// An engine paused before the cancel stays paused for the next utterance in some browsers.
|
|
112
|
+
synth.resume();
|
|
113
|
+
startedRef.current = true;
|
|
114
|
+
setPlay(true);
|
|
115
|
+
speak(fromWord(planRef.current, Math.max(0, Math.min(i, spansRef.current.length - 1))), gen);
|
|
116
|
+
};
|
|
117
|
+
const speakFromRef = useRef(speakFrom);
|
|
118
|
+
speakFromRef.current = speakFrom;
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (typeof window === 'undefined' || !('speechSynthesis' in window))
|
|
121
|
+
return;
|
|
122
|
+
const root = document.getElementById(rootId);
|
|
123
|
+
if (!root)
|
|
124
|
+
return;
|
|
125
|
+
const spans = wrapWords(root);
|
|
126
|
+
if (!spans.length)
|
|
127
|
+
return;
|
|
128
|
+
const { words, starts } = spokenWords(spans);
|
|
129
|
+
spansRef.current = spans;
|
|
130
|
+
planRef.current = planSentences(words, starts);
|
|
131
|
+
setCount(spans.length);
|
|
132
|
+
setReady(true);
|
|
133
|
+
const onClick = (e) => {
|
|
134
|
+
const el = seekableWord(e.target);
|
|
135
|
+
if (!el)
|
|
136
|
+
return;
|
|
137
|
+
const idx = spansRef.current.indexOf(el);
|
|
138
|
+
if (idx !== -1)
|
|
139
|
+
speakFromRef.current(idx);
|
|
140
|
+
};
|
|
141
|
+
root.addEventListener('click', onClick);
|
|
142
|
+
return () => {
|
|
143
|
+
root.removeEventListener('click', onClick);
|
|
144
|
+
genRef.current++;
|
|
145
|
+
window.speechSynthesis.cancel();
|
|
146
|
+
};
|
|
147
|
+
}, [rootId]);
|
|
148
|
+
const toggle = () => {
|
|
149
|
+
const synth = window.speechSynthesis;
|
|
150
|
+
if (!startedRef.current)
|
|
151
|
+
speakFrom(0);
|
|
152
|
+
else if (playingRef.current) {
|
|
153
|
+
synth.pause();
|
|
154
|
+
setPlay(false);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
synth.resume();
|
|
158
|
+
setPlay(true);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
if (!ready)
|
|
162
|
+
return _jsx("div", { "data-artifact-reader": "browser", hidden: true });
|
|
163
|
+
return (_jsx("div", { "data-artifact-reader": "browser", children: _jsx(PlayerBar, { playing: playing, onToggle: toggle, label: label, accent: accent, ground: ground, value: Math.max(current, 0), max: Math.max(count - 1, 0), step: 1, onSeek: (v) => speakFrom(v), progress: `${current + 1} / ${count}`, rate: rate, onRate: () => {
|
|
164
|
+
const r = nextRate(rateRef.current);
|
|
165
|
+
rateRef.current = r;
|
|
166
|
+
setRate(r);
|
|
167
|
+
// The engine fixes the rate when an utterance starts, so restart at the current word.
|
|
168
|
+
if (startedRef.current && playingRef.current)
|
|
169
|
+
speakFrom(Math.max(currentRef.current, 0));
|
|
170
|
+
} }) }));
|
|
171
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { ArtifactReader, wrapWords, seekableWord, litWordIndex, type WordTiming } from './artifact-reader.js';
|
|
2
|
+
export { BrowserReader } from './browser-reader.js';
|
|
3
|
+
export { PlayerBar } from './player-bar.js';
|
|
4
|
+
export { planSentences, wordAt, sentenceOf, fromWord, MAX_SENTENCE_WORDS, type Sentence } from './speech-plan.js';
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The `./reader` entry: both readers and the bar they share. Each component module carries its
|
|
2
|
+
// own 'use client'; this barrel is plain re-exports so it can be imported from either side.
|
|
3
|
+
export { ArtifactReader, wrapWords, seekableWord, litWordIndex } from './artifact-reader.js';
|
|
4
|
+
export { BrowserReader } from './browser-reader.js';
|
|
5
|
+
export { PlayerBar } from './player-bar.js';
|
|
6
|
+
export { planSentences, wordAt, sentenceOf, fromWord, MAX_SENTENCE_WORDS } from './speech-plan.js';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare function PlayerBar({ playing, onToggle, label, accent, ground, value, max, step, onSeek, progress, rate, onRate, }: {
|
|
2
|
+
playing: boolean;
|
|
3
|
+
onToggle: () => void;
|
|
4
|
+
/** The line under the play button. */
|
|
5
|
+
label: string;
|
|
6
|
+
accent: string;
|
|
7
|
+
ground: string;
|
|
8
|
+
value: number;
|
|
9
|
+
max: number;
|
|
10
|
+
step: number;
|
|
11
|
+
onSeek: (value: number) => void;
|
|
12
|
+
/** The text at the right of the bar: a time for recorded audio, a word count for the browser. */
|
|
13
|
+
progress: string;
|
|
14
|
+
rate: number;
|
|
15
|
+
onRate: () => void;
|
|
16
|
+
}): import("react").JSX.Element;
|
|
17
|
+
/** The speeds the speed button steps through, in order, wrapping back to 1. */
|
|
18
|
+
export declare function nextRate(rate: number): number;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
// The bar at the bottom of a page that can be listened to, shared by the recorded-narration
|
|
4
|
+
// reader and the browser read-aloud, so a page looks the same whichever one is speaking. It is
|
|
5
|
+
// pure chrome: each reader owns its engine and hands the bar what to show and what to call.
|
|
6
|
+
export function PlayerBar({ playing, onToggle, label, accent, ground, value, max, step, onSeek, progress, rate, onRate, }) {
|
|
7
|
+
return (_jsxs(_Fragment, { children: [_jsx("style", { children: `
|
|
8
|
+
.artifact-word { border-radius: 3px; transition: background-color 120ms; }
|
|
9
|
+
.artifact-word:hover { background: var(--a-surface-strong, rgba(255,255,255,0.08)); cursor: pointer; }
|
|
10
|
+
.artifact-word-lit { background: color-mix(in srgb, ${accent} 35%, transparent); color: var(--a-strong, #fff); }
|
|
11
|
+
` }), _jsx("div", { "data-artifact-player": true, className: "fixed inset-x-0 bottom-0 z-40 border-t border-[color:var(--a-line)] backdrop-blur", style: { background: `color-mix(in srgb, ${ground} 90%, transparent)` }, children: _jsxs("div", { className: "mx-auto flex max-w-2xl items-center gap-4 px-6 py-3", children: [_jsx("button", { type: "button", onClick: onToggle, "aria-label": playing ? 'Pause narration' : 'Play narration', className: "flex h-11 w-11 shrink-0 items-center justify-center rounded-full", style: { background: accent, color: ground }, children: playing ? (_jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("rect", { x: "6", y: "5", width: "4", height: "14" }), _jsx("rect", { x: "14", y: "5", width: "4", height: "14" })] })) : (_jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M8 5v14l11-7z" }) })) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "text-[11px] uppercase tracking-[0.2em]", style: { color: accent }, children: label }), _jsx("input", { type: "range", min: 0, max: max || 0, step: step, value: Math.min(value, max || 0), onChange: (e) => onSeek(Number(e.target.value)), "aria-label": "Seek", className: "mt-1 w-full", style: { accentColor: accent } })] }), _jsx("div", { className: "w-16 shrink-0 text-right text-xs tabular-nums text-[color:var(--a-muted)]", children: progress }), _jsxs("button", { type: "button", onClick: onRate, className: "shrink-0 rounded border border-[color:var(--a-line-strong)] px-2 py-1 text-xs text-[color:var(--a-body)]", "aria-label": "Playback speed", children: [rate, "x"] })] }) }), _jsx("div", { className: "h-20", "aria-hidden": true })] }));
|
|
12
|
+
}
|
|
13
|
+
/** The speeds the speed button steps through, in order, wrapping back to 1. */
|
|
14
|
+
export function nextRate(rate) {
|
|
15
|
+
return rate >= 1.5 ? 1 : rate + 0.25;
|
|
16
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type Sentence = {
|
|
2
|
+
/** The words joined by single spaces: exactly what is handed to the speech engine. */
|
|
3
|
+
text: string;
|
|
4
|
+
/** Index of the sentence's first word in the page's word list. */
|
|
5
|
+
first: number;
|
|
6
|
+
/** offsets[i] is the char offset, inside `text`, of word `first + i`. */
|
|
7
|
+
offsets: number[];
|
|
8
|
+
};
|
|
9
|
+
export declare const MAX_SENTENCE_WORDS = 60;
|
|
10
|
+
/** Split a page's words into sentences. `starts` holds word indexes that begin a new block
|
|
11
|
+
* (a heading, a paragraph, a list item), which always begin a new sentence. */
|
|
12
|
+
export declare function planSentences(words: string[], starts?: ReadonlySet<number>): Sentence[];
|
|
13
|
+
/** The absolute word index a boundary event's `charIndex` falls in: the last word whose offset
|
|
14
|
+
* is at or before it. */
|
|
15
|
+
export declare function wordAt(s: Sentence, charIndex: number): number;
|
|
16
|
+
/** The index of the sentence holding `wordIndex`. */
|
|
17
|
+
export declare function sentenceOf(plan: Sentence[], wordIndex: number): number;
|
|
18
|
+
/** The sentence holding `wordIndex`, cut so it starts AT that word, with offsets rebased to the
|
|
19
|
+
* cut text. What is spoken when a reader clicks a word in the middle of a sentence. */
|
|
20
|
+
export declare function fromWord(plan: Sentence[], wordIndex: number): Sentence;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// How the browser read-aloud splits a page into utterances, and maps a speech engine's
|
|
2
|
+
// word-boundary events back to the page's word spans. Pure, so it is tested without a browser.
|
|
3
|
+
//
|
|
4
|
+
// One utterance per sentence is the drift bound: Safari's boundary charIndex wanders on long
|
|
5
|
+
// passages, and every new utterance restarts it at zero against a sentence whose word offsets
|
|
6
|
+
// are known. The cap keeps a page with no punctuation (a list, a table) from becoming one long
|
|
7
|
+
// utterance, which is also where Chrome is known to stop speaking part-way.
|
|
8
|
+
export const MAX_SENTENCE_WORDS = 60;
|
|
9
|
+
// A word ends a sentence when it ends in . ! ? or :, optionally followed by closing quotes or
|
|
10
|
+
// brackets. "10:30" and "example.com" do not, because the punctuation is not at the end.
|
|
11
|
+
const ENDS_SENTENCE = /[.!?:]["'”’)\]]*$/;
|
|
12
|
+
/** Split a page's words into sentences. `starts` holds word indexes that begin a new block
|
|
13
|
+
* (a heading, a paragraph, a list item), which always begin a new sentence. */
|
|
14
|
+
export function planSentences(words, starts) {
|
|
15
|
+
const plan = [];
|
|
16
|
+
let cur = null;
|
|
17
|
+
for (let i = 0; i < words.length; i++) {
|
|
18
|
+
if (cur && (cur.offsets.length >= MAX_SENTENCE_WORDS || starts?.has(i))) {
|
|
19
|
+
plan.push(cur);
|
|
20
|
+
cur = null;
|
|
21
|
+
}
|
|
22
|
+
if (!cur)
|
|
23
|
+
cur = { text: '', first: i, offsets: [] };
|
|
24
|
+
if (cur.offsets.length)
|
|
25
|
+
cur.text += ' ';
|
|
26
|
+
cur.offsets.push(cur.text.length);
|
|
27
|
+
cur.text += words[i];
|
|
28
|
+
if (ENDS_SENTENCE.test(words[i])) {
|
|
29
|
+
plan.push(cur);
|
|
30
|
+
cur = null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (cur)
|
|
34
|
+
plan.push(cur);
|
|
35
|
+
return plan;
|
|
36
|
+
}
|
|
37
|
+
/** The absolute word index a boundary event's `charIndex` falls in: the last word whose offset
|
|
38
|
+
* is at or before it. */
|
|
39
|
+
export function wordAt(s, charIndex) {
|
|
40
|
+
let i = 0;
|
|
41
|
+
for (let k = 0; k < s.offsets.length; k++) {
|
|
42
|
+
if (s.offsets[k] <= charIndex)
|
|
43
|
+
i = k;
|
|
44
|
+
else
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
return s.first + i;
|
|
48
|
+
}
|
|
49
|
+
/** The index of the sentence holding `wordIndex`. */
|
|
50
|
+
export function sentenceOf(plan, wordIndex) {
|
|
51
|
+
let idx = 0;
|
|
52
|
+
for (let k = 0; k < plan.length; k++) {
|
|
53
|
+
if (plan[k].first <= wordIndex)
|
|
54
|
+
idx = k;
|
|
55
|
+
else
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
return idx;
|
|
59
|
+
}
|
|
60
|
+
/** The sentence holding `wordIndex`, cut so it starts AT that word, with offsets rebased to the
|
|
61
|
+
* cut text. What is spoken when a reader clicks a word in the middle of a sentence. */
|
|
62
|
+
export function fromWord(plan, wordIndex) {
|
|
63
|
+
const s = plan[sentenceOf(plan, wordIndex)];
|
|
64
|
+
const k = Math.max(0, Math.min(wordIndex - s.first, s.offsets.length - 1));
|
|
65
|
+
const base = s.offsets[k];
|
|
66
|
+
return { text: s.text.slice(base), first: s.first + k, offsets: s.offsets.slice(k).map((o) => o - base) };
|
|
67
|
+
}
|
|
@@ -32,6 +32,9 @@ export type ArtifactRoutesConfig = {
|
|
|
32
32
|
/** The sign-in authority's origin; readers go to `<signInOrigin>/artifact/sign-in?to=<page>`.
|
|
33
33
|
* No default: without it a gated page shows its door with no way through (fails closed). */
|
|
34
34
|
signInOrigin?: string;
|
|
35
|
+
/** The line on the sign-in door, for a host whose readers do not sign in with a Freedom
|
|
36
|
+
* account. Default names the Freedom account. */
|
|
37
|
+
signInNote?: string;
|
|
35
38
|
/** Who the banner says grants access, e.g. "Example Co". Default the brand name. */
|
|
36
39
|
owner?: string;
|
|
37
40
|
/** The address the state routes' rate limit counts by. Default reads the first hop of
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -16,6 +16,7 @@ import { themedPack } from '../brand/theme.js';
|
|
|
16
16
|
import { showToc, tocOf, TocInline, TocRail } from '../artifacts/toc.js';
|
|
17
17
|
import { renderShareCard } from '../brand/share-card.js';
|
|
18
18
|
import { ArtifactReader } from '../reader/artifact-reader.js';
|
|
19
|
+
import { BrowserReader } from '../reader/browser-reader.js';
|
|
19
20
|
import { ReaderWatch } from '../reader/reader-watch.js';
|
|
20
21
|
import { UpdatedTime } from '../reader/updated-time.js';
|
|
21
22
|
import { VersionHistory } from '../reader/version-history.js';
|
|
@@ -151,7 +152,7 @@ export function createArtifactRoutes(config) {
|
|
|
151
152
|
void config.readers.flag({ artifactId: id, reader: d.reader, kind: 'refused' }).catch(() => { });
|
|
152
153
|
return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [header, d.why === 'not-allowed'
|
|
153
154
|
? _jsx(NotAllowedDoor, { brand: brand, reader: d.reader, signOutUrl: signOutUrl(id) })
|
|
154
|
-
: _jsx(SignInDoor, { brand: brand, href: signInOrigin ? signInUrl(signInOrigin, here) : undefined })] }));
|
|
155
|
+
: _jsx(SignInDoor, { brand: brand, href: signInOrigin ? signInUrl(signInOrigin, here) : undefined, note: config.signInNote })] }));
|
|
155
156
|
}
|
|
156
157
|
const r = reader;
|
|
157
158
|
// The agreement comes before the body, every reader, once per page. No agreement, no body.
|
|
@@ -202,7 +203,9 @@ export function createArtifactRoutes(config) {
|
|
|
202
203
|
}
|
|
203
204
|
}
|
|
204
205
|
const toc = showToc(a.markdown, a.toc) ? tocOf(a.markdown) : [];
|
|
205
|
-
return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsxs("div", { id: "artifact-narration-root", children: [_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary }), _jsx(VersionLine, { a: a, n: a.version ?? (a.versions?.length ?? 0) + 1, at: a.updatedAt, history: await historyOf(a.id) })] }), a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null, _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc.length ? _jsx(TocInline, { items: toc }) : null, _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined })] })] }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) :
|
|
206
|
+
return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsxs("div", { id: "artifact-narration-root", children: [_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary }), _jsx(VersionLine, { a: a, n: a.version ?? (a.versions?.length ?? 0) + 1, at: a.updatedAt, history: await historyOf(a.id) })] }), a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null, _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc.length ? _jsx(TocInline, { items: toc }) : null, _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined })] })] }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : (
|
|
207
|
+
// No recorded narration, or its timings would not load: the browser reads the page.
|
|
208
|
+
_jsx(BrowserReader, { rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground }))] }));
|
|
206
209
|
}
|
|
207
210
|
const cookieLine = (value, maxAge) => `${GRANT_COOKIE}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
|
|
208
211
|
/** GET /api/reader/enter?pass=<pass>&to=/<id>: where the sign-in authority sends a signed-in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Publish markdown as branded, read-aloud pages from a Next.js app: the artifacts store, the route factory, the read-along reader, brand packs, password and confidential pages, and share cards.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"default": "./lib/brand/index.js"
|
|
39
39
|
},
|
|
40
40
|
"./reader": {
|
|
41
|
-
"types": "./lib/reader/
|
|
42
|
-
"default": "./lib/reader/
|
|
41
|
+
"types": "./lib/reader/index.d.ts",
|
|
42
|
+
"default": "./lib/reader/index.js"
|
|
43
43
|
},
|
|
44
44
|
"./gate": {
|
|
45
45
|
"types": "./lib/gate.d.ts",
|