@supersuit/artifacts 0.24.0 → 0.26.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 CHANGED
@@ -3,6 +3,54 @@
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.26.0
7
+
8
+ **Read-along that leaves the reader in charge.** A tap on any word started the audio, and while it
9
+ played the page scrolled back to the lit word every time it left the screen, pulling a reader who
10
+ had scrolled ahead back to the voice.
11
+
12
+ - A tap on a word while the audio is paused shows a small **Play from here** bar beside the word
13
+ (it moves with the page, closes with × or Escape); pressing it plays from that word. A tap while
14
+ it plays jumps straight there.
15
+ - The page follows the voice until the reader scrolls on their own (wheel, touch, or a scrolling
16
+ key: arrows, Page Up/Down, Home, End, space outside a text field). It then stays where they are,
17
+ and a floating **Back to the voice** button scrolls back to the lit word and resumes following.
18
+ Pressing play, or tapping a word while it plays, also resumes following.
19
+ - New export `isUserScrollKey`.
20
+ - DETECTOR: on a narrated page, pause and tap a word. Audio starting at once means 0.25.0 or
21
+ earlier.
22
+ - REMEDY: update the dependency. No config change.
23
+ - Tests: `artifact-reader-controls.test.tsx` (offer when paused and play from the word; jump when
24
+ playing; scrolling while playing releases the page and the button brings it back; scrolling while
25
+ paused changes nothing; the scroll keys). `test:packed` green.
26
+
27
+ ## 0.25.0
28
+
29
+ **A page can play a Supersuit Session demo inline: the ```simulator block.** A book or guide that
30
+ wanted to show a session had to send readers away to another site.
31
+
32
+ ```simulator
33
+ scenario: diane-meeting-prep
34
+ caption: Step into the hour before Diane's first meeting. Press Start.
35
+ height: 760px # optional, default 720px
36
+ ```
37
+
38
+ - Same syntax as the Superskills book's reader, so one block works in both. `scenario` is
39
+ lowercase letters, digits and dashes; `caption` is required; `height` is `NNNpx`. A malformed
40
+ block is refused at publish with the line, like a checklist.
41
+ - The HOST names the demo: new `ArtifactRoutesConfig.simulatorEmbed` (an https embed page, e.g.
42
+ `https://example.com/simulator/embed`). The block draws as a lazy-loaded frame on
43
+ `<embed>?scenario=<id>&timeline=off`, with the caption under it. No default: a host without a
44
+ demo draws the caption alone, never the block's source as code.
45
+ - New exports `parseSimulatorFence`, `simulatorSrc`, `SimulatorBlock`; `ArtifactMarkdown` takes
46
+ `simulator?: { embed }`.
47
+ - DETECTOR: publish a page with a ```simulator block. A code block showing `scenario:` means the
48
+ host is on 0.24.0 or earlier, or has not set `simulatorEmbed`.
49
+ - REMEDY: update the dependency and set `simulatorEmbed` in the host's routes config.
50
+ - Tests: `simulator.test.ts` (parse, defaults, refusals with lines, https-only embed),
51
+ `widgets.test.ts` (refused at publish, passes when well formed), `render.test.tsx` (frame when the
52
+ host names a demo, caption only when not, never a code block). `test:packed` green.
53
+
6
54
  ## 0.24.0
7
55
 
8
56
  **A reader is asked for their name when they comment, so the owner can credit the feedback.**
@@ -13,6 +13,7 @@ export { parseStateConfig, effectiveWriters, slotVisibility, checkValue, shapeCh
13
13
  export { createStateStore, createMemoryStateStore, readerKeyFor, type StateStore, type StateEntry, type Writer } from './state-store.js';
14
14
  export { stateView, responsesOf, responsesCsv, type SlotView, type Tally, type SharedEntry, type Response as StateResponse } from './state-view.js';
15
15
  export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, mergeChecklistState, checklistsOf, checklistSendIds, checklistAnswerIds, checklistSyncs, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS, type Heading, type NotesWidget, type NoteValue, type PlacedNote } from './widgets.js';
16
+ export { parseSimulatorFence, simulatorSrc, type SimulatorBlock } from './simulator.js';
16
17
  export { CHECKLIST_SLOT, checklistStorageKey, checkChecklistValue, parseChecklistFence, type ChecklistBlock, type ChecklistItem, type ChecklistValue } from './checklist.js';
17
18
  export { textAnchorFrom, findText, blockId, parseAnchor, CONTEXT_CHARS, MAX_QUOTE_CHARS, BLOCK_ID, type Anchor, type TextAnchor, type RegionAnchor } from './anchor.js';
18
19
  export { COMMENTS_SLOT, COMMENTS_MODES, COMMENT_TRANSCRIPTS, MAX_COMMENT_CHARS, mergeCommentsState, validateComment, type CommentsMode, type CommentsVisible, type CommentValue } from './comments.js';
@@ -13,6 +13,7 @@ export { parseStateConfig, effectiveWriters, slotVisibility, checkValue, shapeCh
13
13
  export { createStateStore, createMemoryStateStore, readerKeyFor } from './state-store.js';
14
14
  export { stateView, responsesOf, responsesCsv } from './state-view.js';
15
15
  export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, mergeChecklistState, checklistsOf, checklistSendIds, checklistAnswerIds, checklistSyncs, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS } from './widgets.js';
16
+ export { parseSimulatorFence, simulatorSrc } from './simulator.js';
16
17
  export { CHECKLIST_SLOT, checklistStorageKey, checkChecklistValue, parseChecklistFence } from './checklist.js';
17
18
  export { textAnchorFrom, findText, blockId, parseAnchor, CONTEXT_CHARS, MAX_QUOTE_CHARS, BLOCK_ID } from './anchor.js';
18
19
  export { COMMENTS_SLOT, COMMENTS_MODES, COMMENT_TRANSCRIPTS, MAX_COMMENT_CHARS, mergeCommentsState, validateComment } from './comments.js';
@@ -4,6 +4,9 @@ import { type Heading } from './widgets.js';
4
4
  import { type Definition } from './definitions.js';
5
5
  export declare function isReachableHref(href: unknown): href is string;
6
6
  export declare function artifactUrlTransform(url: string): string;
7
+ type SimulatorCtx = {
8
+ embed: string;
9
+ };
7
10
  /** A pack's figure resolver (BrandPack.figure). */
8
11
  export type FigureFor = (src: string, alt: string) => ComponentType<FigureProps> | undefined;
9
12
  /** The page's top-level sections: every heading among the body's top-level blocks at the
@@ -11,7 +14,7 @@ export type FigureFor = (src: string, alt: string) => ComponentType<FigureProps>
11
14
  export declare function sectionOutline(markdown: string, headings?: Heading[]): SectionOutline[];
12
15
  /** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
13
16
  * page carries a ```notes block. */
14
- export declare function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, }: {
17
+ export declare function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, simulator, }: {
15
18
  markdown: string;
16
19
  /** The page a ```checklist keeps its ticks under, and whether its Send can reach the owner
17
20
  * (a host that keeps answers, on a page that declares the slot). On a page whose checklists
@@ -33,4 +36,7 @@ export declare function ArtifactMarkdown({ markdown, notes, checklist, definitio
33
36
  };
34
37
  /** Terms to define inline (front matter `definitions:`): the first occurrence of each is underlined. */
35
38
  definitions?: Definition[];
39
+ /** The host's Supersuit Session demo, for ```simulator blocks. Without it a block shows its caption only. */
40
+ simulator?: SimulatorCtx;
36
41
  }): import("react").JSX.Element;
42
+ export {};
@@ -16,6 +16,7 @@ import { unified } from 'unified';
16
16
  import { blocksOf, checklistSyncs, checklistsOf, hasNotesWidget, headingsOf } from './widgets.js';
17
17
  import { HeadingNotes, NoteToggle, NotesEarlier, NotesProvider } from '../widgets/notes.js';
18
18
  import { Checklist } from '../widgets/checklist.js';
19
+ import { parseSimulatorFence, simulatorSrc } from './simulator.js';
19
20
  import { VideoAutoplay } from '../reader/video-autoplay.js';
20
21
  import { CodeCopy } from '../reader/code-copy.js';
21
22
  import { DefinitionLayer } from '../reader/definition-layer.js';
@@ -94,6 +95,15 @@ function InlineAudio({ src, label }) {
94
95
  const html = `<audio src="${escapeAttr(src)}" aria-label="${escapeAttr(label)}" data-artifact-audio controls preload="none" class="block w-full"></audio>`;
95
96
  return (_jsxs("span", { className: "my-6 block rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-4 py-3", children: [label ? _jsx("span", { className: "mb-2 block text-sm text-[color:var(--a-strong)]", children: label }) : null, _jsx("span", { className: "block", dangerouslySetInnerHTML: { __html: html } })] }));
96
97
  }
98
+ /** A ```simulator block: the demo in a frame when the host has one, its caption alone when it does
99
+ * not, and nothing when the block does not parse (a page published before validation existed). */
100
+ function SimulatorFence({ source, ctx }) {
101
+ const r = parseSimulatorFence(source.replace(/\n$/, ''), 0);
102
+ if (!r.ok)
103
+ return null;
104
+ const src = ctx ? simulatorSrc(ctx.embed, r.block.scenario) : null;
105
+ return (_jsxs("figure", { "data-artifact-simulator": true, className: "my-8", children: [src ? (_jsx("iframe", { src: src, title: r.block.caption, loading: "lazy", allow: "microphone; autoplay; fullscreen", className: "block w-full rounded-xl border border-[color:var(--a-line)]", style: { height: r.block.height, border: 0 } })) : null, _jsx("figcaption", { className: "mt-2 text-sm opacity-80", children: r.block.caption })] }));
106
+ }
97
107
  /** The one image a paragraph holds and nothing else (whitespace aside), or null. */
98
108
  function soleImage(node) {
99
109
  const kids = (node?.children ?? []).filter((c) => !(c.type === 'text' && !c.value?.trim()));
@@ -122,7 +132,7 @@ function ChecklistFence({ line, ctx }) {
122
132
  return null;
123
133
  return (_jsx(Checklist, { items: c.items.map((it) => ({ id: it.id, title: checklistMarkdown(it.text, true), description: it.description ? checklistMarkdown(it.description, false) : null })), artifactId: ctx.artifactId, send: ctx.send && ctx.artifactId && !ctx.sync ? c.send : undefined, sync: ctx.sync && !!ctx.artifactId, owner: ctx.owner, accent: ctx.accent }));
124
134
  }
125
- function baseComponents(block, figure, checklists) {
135
+ function baseComponents(block, figure, checklists, simulator) {
126
136
  return {
127
137
  p: ({ node, children }) => {
128
138
  // An image alone in its paragraph that the pack draws itself replaces the paragraph, so the
@@ -162,6 +172,8 @@ function baseComponents(block, figure, checklists) {
162
172
  // answers) it draws nothing: its settings are not prose and never shown as code.
163
173
  if (lang === 'notes')
164
174
  return null;
175
+ if (lang === 'simulator')
176
+ return _jsx(SimulatorFence, { source: textOf(children), ctx: simulator });
165
177
  if (!className) {
166
178
  return _jsx("code", { className: "rounded bg-[color:var(--a-surface-strong)] px-1.5 py-0.5 text-[0.9em] text-[color:var(--a-strong)]", children: children });
167
179
  }
@@ -171,7 +183,7 @@ function baseComponents(block, figure, checklists) {
171
183
  // A links fence renders its own block; do not wrap it in <pre>.
172
184
  const inner = Array.isArray(children) ? children[0] : children;
173
185
  const cls = inner?.props?.className ?? '';
174
- if (/language-(links|notes|checklist)/.test(cls))
186
+ if (/language-(links|notes|checklist|simulator)/.test(cls))
175
187
  return _jsx(_Fragment, { children: children });
176
188
  return (_jsx("pre", { ...block(node), "data-artifact-code": true,
177
189
  // whitespace-pre-wrap, because a fence here usually holds PROSE meant to be pasted
@@ -204,20 +216,20 @@ const HEADING_CLASS = {
204
216
  };
205
217
  /** Components for any page: every heading gets its slug as an id, the one headingsOf derived,
206
218
  * so a contents link and a note both land on it. */
207
- function pageComponents(headings, block, figure, checklists) {
219
+ function pageComponents(headings, block, figure, checklists, simulator) {
208
220
  const byLine = new Map(headings.map((h) => [h.line, h]));
209
221
  const heading = (depth) => function Heading({ node, children }) {
210
222
  const Tag = `h${depth}`;
211
223
  const h = byLine.get(node?.position?.start.line ?? -1);
212
224
  return _jsx(Tag, { id: h?.slug, ...block(node), className: HEADING_CLASS[depth], children: children });
213
225
  };
214
- return { ...baseComponents(block, figure, checklists), h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6) };
226
+ return { ...baseComponents(block, figure, checklists, simulator), h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6) };
215
227
  }
216
228
  /** Components for a page with a notes block: every heading gets its slug as an id, a note
217
229
  * control, and its notes after it. The heading is found by its source line, so the slug is the
218
230
  * one headingsOf derived, the same one a note stores. */
219
- function notesComponents(headings, block, figure, checklists) {
220
- const components = baseComponents(block, figure, checklists);
231
+ function notesComponents(headings, block, figure, checklists, simulator) {
232
+ const components = baseComponents(block, figure, checklists, simulator);
221
233
  const byLine = new Map(headings.map((h) => [h.line, h]));
222
234
  const heading = (depth) => function NotedHeading({ node, children }) {
223
235
  const Tag = `h${depth}`;
@@ -296,7 +308,7 @@ const DEFINED_TERM_CSS = '.artifact-defined-term{text-decoration-line:underline;
296
308
  'clip:rect(0,0,0,0);white-space:nowrap;border:0}';
297
309
  /** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
298
310
  * page carries a ```notes block. */
299
- export function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, }) {
311
+ export function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, simulator, }) {
300
312
  const on = !!notes && hasNotesWidget(markdown);
301
313
  const headings = headingsOf(markdown);
302
314
  const blocks = blocksOf(markdown);
@@ -313,7 +325,7 @@ export function ArtifactMarkdown({ markdown, notes, checklist, definitions, Sect
313
325
  // page whose checklists sync, that same slot is the shared set, so it turns sync on instead.
314
326
  ? { byLine: lists, artifactId: checklist?.artifactId, send: !!checklist?.send, sync: !!checklist?.send && checklistSyncs(lists.values()), owner: checklist?.owner, accent: checklist?.accent }
315
327
  : undefined;
316
- const components = on ? notesComponents(headings, block, figure, checklists) : pageComponents(headings, block, figure, checklists);
328
+ const components = on ? notesComponents(headings, block, figure, checklists, simulator) : pageComponents(headings, block, figure, checklists, simulator);
317
329
  if (outline.length)
318
330
  components.section = sectionComponent(Section, outline, block);
319
331
  const body = (_jsx(ReactMarkdown, { remarkPlugins: plugins, components: components, urlTransform: artifactUrlTransform, children: markdown }));
@@ -0,0 +1,17 @@
1
+ export type SimulatorBlock = {
2
+ scenario: string;
3
+ caption: string;
4
+ height: string;
5
+ };
6
+ /** Parses a simulator fence's body. `line` is the line the fence opens on, so every error names
7
+ * the line the author sees. */
8
+ export declare function parseSimulatorFence(value: string, line: number): {
9
+ ok: true;
10
+ block: SimulatorBlock;
11
+ } | {
12
+ ok: false;
13
+ error: string;
14
+ };
15
+ /** The address the player loads: the host's embed page, the scenario, the timeline off. Null for
16
+ * an embed address that is not https, so a misconfigured host frames nothing. */
17
+ export declare function simulatorSrc(embed: string, scenario: string): string | null;
@@ -0,0 +1,59 @@
1
+ // The ```simulator fence: a Supersuit Session demo played inline on the page. The block names a
2
+ // scenario and a caption; the HOST names the demo's embed page (ArtifactRoutesConfig.simulatorEmbed),
3
+ // so this public package points at nobody's service by default, and a host without a demo draws
4
+ // the caption alone rather than the block's source as code.
5
+ //
6
+ // ```simulator
7
+ // scenario: diane-meeting-prep
8
+ // caption: Step into the hour before Diane's first meeting. Press Start.
9
+ // height: 760px # optional, default 720px
10
+ // ```
11
+ //
12
+ // The same syntax the Superskills book's reader takes, so one block works in both.
13
+ const SCENARIO = /^[a-z0-9-]+$/;
14
+ const HEIGHT = /^\d{3,4}px$/;
15
+ const KEYS = ['scenario', 'caption', 'height'];
16
+ /** Parses a simulator fence's body. `line` is the line the fence opens on, so every error names
17
+ * the line the author sees. */
18
+ export function parseSimulatorFence(value, line) {
19
+ const got = {};
20
+ const rows = value ? value.split('\n') : [];
21
+ for (let i = 0; i < rows.length; i++) {
22
+ const raw = rows[i].trim();
23
+ if (!raw || raw.startsWith('#'))
24
+ continue;
25
+ const at = line + 1 + i;
26
+ const m = /^([A-Za-z_-]+)\s*:\s*(.*)$/.exec(raw);
27
+ if (!m)
28
+ return { ok: false, error: `line ${at}: the simulator block takes lines like "scenario: <id>"` };
29
+ const key = m[1];
30
+ if (!KEYS.includes(key))
31
+ return { ok: false, error: `line ${at}: the simulator block has an unknown key: ${m[1]}` };
32
+ got[key] = { v: m[2].replace(/\s+#.*$/, '').trim(), at };
33
+ }
34
+ if (!got.scenario)
35
+ return { ok: false, error: `line ${line}: the simulator block needs a scenario` };
36
+ if (!SCENARIO.test(got.scenario.v))
37
+ return { ok: false, error: `line ${got.scenario.at}: a simulator scenario is lowercase letters, digits and dashes` };
38
+ if (!got.caption?.v)
39
+ return { ok: false, error: `line ${line}: the simulator block needs a caption, the line a reader sees under the demo` };
40
+ if (got.height && !HEIGHT.test(got.height.v))
41
+ return { ok: false, error: `line ${got.height.at}: a simulator height is a number of pixels, like 760px` };
42
+ return { ok: true, block: { scenario: got.scenario.v, caption: got.caption.v, height: got.height?.v ?? '720px' } };
43
+ }
44
+ /** The address the player loads: the host's embed page, the scenario, the timeline off. Null for
45
+ * an embed address that is not https, so a misconfigured host frames nothing. */
46
+ export function simulatorSrc(embed, scenario) {
47
+ let u;
48
+ try {
49
+ u = new URL(embed);
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ if (u.protocol !== 'https:')
55
+ return null;
56
+ u.searchParams.set('scenario', scenario);
57
+ u.searchParams.set('timeline', 'off');
58
+ return u.toString();
59
+ }
@@ -16,6 +16,7 @@ import remarkParse from 'remark-parse';
16
16
  import remarkGfm from 'remark-gfm';
17
17
  import { blockId } from './anchor.js';
18
18
  import { MAX_NOTE_CHARS, NOTES_SLOT } from './notes-place.js';
19
+ import { parseSimulatorFence } from './simulator.js';
19
20
  import { CHECKLIST_SLOT, assignChecklistIds, parseChecklistFence } from './checklist.js';
20
21
  export { MAX_NOTE_CHARS, NOTES_SLOT, placeNotes } from './notes-place.js';
21
22
  /** Widgets the design names and this version does not draw yet. A fence using one is refused at
@@ -100,6 +101,13 @@ export function scanWidgets(body, offset) {
100
101
  }
101
102
  notes = w;
102
103
  }
104
+ for (const f of fences) {
105
+ if (f.lang !== 'simulator')
106
+ continue;
107
+ const r = parseSimulatorFence(f.value, (f.position?.start.line ?? 1) + offset);
108
+ if (!r.ok)
109
+ return r;
110
+ }
103
111
  const lists = scanChecklists(fences, offset);
104
112
  if (!lists.ok)
105
113
  return lists;
@@ -15,6 +15,9 @@ export declare function seekableWord(target: Element | null): HTMLSpanElement |
15
15
  /** The word to light at `now`, or -1. Nothing lights until narration has started: the first
16
16
  * word begins at 0, so an untouched page would otherwise sit with its first word highlighted. */
17
17
  export declare function litWordIndex(words: WordTiming[], now: number, started: boolean): number;
18
+ /** Whether a key press is the reader scrolling the page themselves. Space scrolls only outside a
19
+ * field that takes text. */
20
+ export declare function isUserScrollKey(key: string, target: EventTarget | null): boolean;
18
21
  export declare function ArtifactReader({ src, words, rootId, label, accent, ground, }: {
19
22
  src: string;
20
23
  words: WordTiming[];
@@ -1,10 +1,13 @@
1
1
  'use client';
2
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } 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
- // when the two disagree. Click a word to seek; the bar at the bottom plays and paces.
7
+ // when the two disagree. Tap a word while it plays to jump there; tap one while it is paused and a
8
+ // small bar offers to play from it, so reading and tapping never starts the audio by surprise. While
9
+ // it plays the page follows the voice, until the reader scrolls on their own: then it leaves them
10
+ // where they are and shows a button that brings them back to the voice.
8
11
  import { useEffect, useRef, useState } from 'react';
9
12
  import { nextRate, PlayerBar } from './player-bar.js';
10
13
  const SKIP = '[data-nospeak], [data-artifact-links], pre, [data-footnotes], sup, img, style, script';
@@ -130,6 +133,17 @@ function fmt(t) {
130
133
  const s = Math.floor(t % 60);
131
134
  return `${m}:${s.toString().padStart(2, '0')}`;
132
135
  }
136
+ const SCROLL_KEYS = new Set(['ArrowDown', 'ArrowUp', 'PageDown', 'PageUp', 'Home', 'End', ' ']);
137
+ /** Whether a key press is the reader scrolling the page themselves. Space scrolls only outside a
138
+ * field that takes text. */
139
+ export function isUserScrollKey(key, target) {
140
+ if (!SCROLL_KEYS.has(key))
141
+ return false;
142
+ const el = target;
143
+ if (el && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName ?? '')))
144
+ return false;
145
+ return true;
146
+ }
133
147
  export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
134
148
  const narrator = label;
135
149
  const audioRef = useRef(null);
@@ -141,6 +155,15 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
141
155
  const [t, setT] = useState(0);
142
156
  const [dur, setDur] = useState(0);
143
157
  const [rate, setRate] = useState(1);
158
+ // Whether the page follows the voice. A reader scrolling on their own turns it off; the button
159
+ // that appears, a word tapped while playing, or pressing play turns it back on.
160
+ const [following, setFollowing] = useState(true);
161
+ const followingRef = useRef(true);
162
+ const playingRef = useRef(false);
163
+ const follow = (on) => { followingRef.current = on; setFollowing(on); };
164
+ // The Play from here bar, beside the word a paused reader tapped, in page coordinates so it
165
+ // stays with the word when they scroll.
166
+ const [offer, setOffer] = useState(null);
144
167
  useEffect(() => {
145
168
  const root = document.getElementById(rootId);
146
169
  if (!root)
@@ -154,12 +177,50 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
154
177
  const idx = mapRef.current.indexOf(el);
155
178
  if (idx === -1 || !audioRef.current)
156
179
  return;
157
- audioRef.current.currentTime = words[idx].s;
158
- void audioRef.current.play();
180
+ if (playingRef.current) {
181
+ audioRef.current.currentTime = words[idx].s;
182
+ follow(true);
183
+ setOffer(null);
184
+ return;
185
+ }
186
+ const r = el.getBoundingClientRect();
187
+ setOffer({ idx, top: r.bottom + window.scrollY + 6, left: Math.max(8, r.left + window.scrollX) });
159
188
  };
160
189
  root.addEventListener('click', onClick);
161
190
  return () => root.removeEventListener('click', onClick);
162
191
  }, [rootId, words]);
192
+ // The reader scrolling on their own, while it plays, stops the page following the voice.
193
+ useEffect(() => {
194
+ const mine = () => { if (playingRef.current && followingRef.current)
195
+ follow(false); };
196
+ const onKey = (e) => { if (isUserScrollKey(e.key, e.target))
197
+ mine(); };
198
+ const onEsc = (e) => { if (e.key === 'Escape')
199
+ setOffer(null); };
200
+ window.addEventListener('wheel', mine, { passive: true });
201
+ window.addEventListener('touchmove', mine, { passive: true });
202
+ window.addEventListener('keydown', onKey);
203
+ window.addEventListener('keydown', onEsc);
204
+ return () => {
205
+ window.removeEventListener('wheel', mine);
206
+ window.removeEventListener('touchmove', mine);
207
+ window.removeEventListener('keydown', onKey);
208
+ window.removeEventListener('keydown', onEsc);
209
+ };
210
+ }, []);
211
+ const playFrom = (idx) => {
212
+ const a = audioRef.current;
213
+ if (!a)
214
+ return;
215
+ a.currentTime = words[idx].s;
216
+ follow(true);
217
+ setOffer(null);
218
+ void a.play();
219
+ };
220
+ const backToVoice = () => {
221
+ follow(true);
222
+ litRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
223
+ };
163
224
  useEffect(() => {
164
225
  const a = audioRef.current;
165
226
  if (!a)
@@ -174,7 +235,7 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
174
235
  litRef.current?.classList.remove('artifact-word-lit');
175
236
  el?.classList.add('artifact-word-lit');
176
237
  litRef.current = el;
177
- if (el && playing) {
238
+ if (el && playing && followingRef.current) {
178
239
  const r = el.getBoundingClientRect();
179
240
  if (r.top < 80 || r.bottom > window.innerHeight - 140) {
180
241
  el.scrollIntoView({ block: 'center', behavior: 'smooth' });
@@ -197,10 +258,13 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
197
258
  };
198
259
  return (_jsxs(_Fragment, { children: [_jsx("audio", { ref: audioRef, src: src, preload: "metadata", onPlay: () => {
199
260
  startedRef.current = true;
261
+ playingRef.current = true;
200
262
  setPlaying(true);
263
+ setOffer(null);
264
+ follow(true);
201
265
  }, onSeeking: () => {
202
266
  startedRef.current = 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) => {
267
+ }, onPause: () => { playingRef.current = false; setPlaying(false); }, onLoadedMetadata: (e) => setDur(e.target.duration), onRateChange: (e) => setRate(e.target.playbackRate) }), offer ? (_jsxs("div", { "data-nospeak": true, className: "absolute z-50 flex items-center gap-1 rounded-full border px-1 py-1 text-sm shadow-lg", style: { top: offer.top, left: offer.left, background: ground, borderColor: accent }, children: [_jsx("button", { type: "button", "data-play-from-here": true, onClick: () => playFrom(offer.idx), className: "rounded-full px-3 py-1 font-medium", style: { background: accent, color: ground }, children: "\u25B6 Play from here" }), _jsx("button", { type: "button", "aria-label": "Close", onClick: () => setOffer(null), className: "px-2 opacity-70", children: "\u00D7" })] })) : null, playing && !following ? (_jsx("button", { type: "button", "data-follow-voice": true, "data-nospeak": true, onClick: backToVoice, className: "fixed left-1/2 z-50 -translate-x-1/2 rounded-full px-4 py-2 text-sm font-medium shadow-lg", style: { bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)', background: accent, color: ground }, children: "Back to the voice" })) : null, _jsx(PlayerBar, { playing: playing, onToggle: toggle, label: narrator, accent: accent, ground: ground, value: t, max: dur, step: 0.1, onSeek: (v) => {
204
268
  if (audioRef.current)
205
269
  audioRef.current.currentTime = v;
206
270
  }, progress: `${fmt(t)} / ${fmt(dur)}`, rate: rate, onRate: () => {
@@ -27,6 +27,10 @@ export type ArtifactRoutesConfig = {
27
27
  packFor?: (artifact: ArtifactRecord) => BrandPack | undefined;
28
28
  /** The host that serves a 200, e.g. https://artifacts.example.com. No trailing slash. */
29
29
  siteUrl: string;
30
+ /** The embed page of this host's Supersuit Session demo (an https URL, e.g.
31
+ * https://example.com/simulator/embed), for ```simulator blocks. Without it a block shows its
32
+ * caption only. No default: this package points at nobody's service. */
33
+ simulatorEmbed?: string;
30
34
  /** Read at request time, so a rotated key needs no rebuild. */
31
35
  publishKey: () => string | undefined;
32
36
  /** Verifies publisher passes (`p1`, see artifacts/publisher.ts): a signed, hour-long stand-in
@@ -303,7 +303,7 @@ export function createArtifactRoutes(config) {
303
303
  const title = v.title ?? a.title;
304
304
  const summary = v.summary ?? a.summary;
305
305
  const toc = showToc(v.markdown, a.toc) ? tocOf(v.markdown) : [];
306
- return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: pack.Cover ? (_jsx(pack.Cover, { title: title, subtitle: v.subtitle, summary: summary, to: v.to, markdown: v.markdown, kicker: _jsx(Kicker, { to: v.to, brand: brand }), meta: _jsx(VersionLine, { a: a, n: n, at: v.at, history: history, reader: reader }), version: n, updatedAt: v.at, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.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: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { a: a, n: n, at: v.at, history: history, reader: reader })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, definitions: a.definitions, checklist: { artifactId: a.id, accent: brand.accent }, Section: pack.Section, figure: pack.figure }) })] }));
306
+ return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: pack.Cover ? (_jsx(pack.Cover, { title: title, subtitle: v.subtitle, summary: summary, to: v.to, markdown: v.markdown, kicker: _jsx(Kicker, { to: v.to, brand: brand }), meta: _jsx(VersionLine, { a: a, n: n, at: v.at, history: history, reader: reader }), version: n, updatedAt: v.at, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.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: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { a: a, n: n, at: v.at, history: history, reader: reader })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, definitions: a.definitions, checklist: { artifactId: a.id, accent: brand.accent }, Section: pack.Section, figure: pack.figure }) })] }));
307
307
  }
308
308
  /** The page itself, shared by open and gated pages. */
309
309
  async function Body(a, { top, reader }) {
@@ -328,7 +328,7 @@ export function createArtifactRoutes(config) {
328
328
  const header = pack.Cover ? (_jsx(pack.Cover, { title: a.title, subtitle: a.subtitle, summary: a.summary, to: a.to, markdown: a.markdown, kicker: _jsx(Kicker, { to: a.to, brand: brand }), meta: meta, version: n, updatedAt: a.updatedAt, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx(Kicker, { to: a.to, brand: brand }), _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 }), meta] }));
329
329
  return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [header, 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] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined,
330
330
  // Ticks are kept in the reader's browser on any host; Send needs one that keeps answers.
331
- checklist: { artifactId: a.id, send: !!config.state && !!a.state && Object.hasOwn(a.state.slots, CHECKLIST_SLOT), owner, accent: brand.accent }, Section: pack.Section, figure: pack.figure }) }));
331
+ checklist: { artifactId: a.id, send: !!config.state && !!a.state && Object.hasOwn(a.state.slots, CHECKLIST_SLOT), owner, accent: brand.accent }, Section: pack.Section, figure: pack.figure, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined }) }));
332
332
  })() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: !!reader && !!ownerEmail && reader.email === ownerEmail, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), 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 })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
333
333
  2026-09-29: "NEVER PUT DEFAULT SAFARI VOICE DICTATION"); the audio is Kokoro or ElevenLabs,
334
334
  recorded at publish, or there is none. */] }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.24.0",
3
+ "version": "0.26.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",