@supersuit/artifacts 0.25.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,27 @@
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
+
6
27
  ## 0.25.0
7
28
 
8
29
  **A page can play a Supersuit Session demo inline: the ```simulator block.** A book or guide that
@@ -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: () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.25.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",