@supersuit/artifacts 0.30.0 → 0.30.2

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,33 @@
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.30.2
7
+
8
+ **Play from here is kept inside the screen on a host that insets the page.** 0.30.1 clamped the
9
+ bar in page coordinates, but it is placed in its container's, so on the company host (whose card is
10
+ inset from the screen edge) a word near the right edge still pushed the pill past it.
11
+
12
+ - After it renders, the bar measures where it actually landed and moves back inside the screen, once
13
+ per tap.
14
+ - DETECTOR: at iPhone width on a host with an inset card, pause and tap a word at the right edge.
15
+ The pill running off the screen means 0.30.1.
16
+ - REMEDY: update the dependency.
17
+ - Tests: `artifact-reader-controls.test.tsx` (a bar landing 30px right of its placement is moved
18
+ inside a 390px screen). Checked on the live page at iPhone size with Playwright.
19
+
20
+ ## 0.30.1
21
+
22
+ **Play from here is one compact pill that stays on screen.** A word tapped near the right edge put
23
+ the bar half off the screen, so its label wrapped to three lines inside a pill inside a pill, with
24
+ an emoji play glyph that iOS draws as a picture.
25
+
26
+ - One pill in the page's accent color, the UI font, 13px, never wrapping, a drawn play triangle, and
27
+ the close button inside the same pill.
28
+ - Placed under the tapped word and clamped inside the screen.
29
+ - DETECTOR: on a phone, pause and tap a word near the right edge. A three-line bubble means 0.30.0.
30
+ - REMEDY: update the dependency.
31
+ - Tests: `artifact-reader-controls.test.tsx` (no emoji, no wrapping, kept inside a 390px screen).
32
+
6
33
  ## 0.30.0
7
34
 
8
35
  **Every page has a page menu: one small "⋯" button, top right, whose items depend on who you are
@@ -8,7 +8,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
8
8
  // small bar offers to play from it, so reading and tapping never starts the audio by surprise. While
9
9
  // it plays the page follows the voice, until the reader scrolls on their own: then it leaves them
10
10
  // where they are and shows a button that brings them back to the voice.
11
- import { useEffect, useRef, useState } from 'react';
11
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
12
12
  import { nextRate, PlayerBar } from './player-bar.js';
13
13
  const SKIP = '[data-nospeak], [data-artifact-links], pre, [data-footnotes], sup, img, style, script';
14
14
  /** The elements that hold a run of text; a word whose block differs from the previous word's
@@ -144,6 +144,8 @@ export function isUserScrollKey(key, target) {
144
144
  return false;
145
145
  return true;
146
146
  }
147
+ /** The Play from here bar's width, so it can be kept inside the screen. */
148
+ const OFFER_WIDTH = 168;
147
149
  export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
148
150
  const narrator = label;
149
151
  const audioRef = useRef(null);
@@ -164,6 +166,20 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
164
166
  // The Play from here bar, beside the word a paused reader tapped, in page coordinates so it
165
167
  // stays with the word when they scroll.
166
168
  const [offer, setOffer] = useState(null);
169
+ const offerRef = useRef(null);
170
+ // The bar is placed in its container's coordinates, which on a host that insets the page are not
171
+ // the screen's; measure where it actually landed and nudge it back inside the screen.
172
+ useLayoutEffect(() => {
173
+ const el = offerRef.current;
174
+ if (!offer || !el || offer.nudged)
175
+ return;
176
+ const vw = document.documentElement.clientWidth || window.innerWidth;
177
+ const r = el.getBoundingClientRect();
178
+ // No layout to measure (a server render, a test DOM): leave the placement as computed.
179
+ const shift = r.width === 0 ? 0 : r.right > vw - 8 ? vw - 8 - r.right : r.left < 8 ? 8 - r.left : 0;
180
+ // Once per tap: measured again, an element with no layout would move forever.
181
+ setOffer({ ...offer, left: offer.left + (Math.abs(shift) >= 1 ? shift : 0), nudged: true });
182
+ }, [offer]);
167
183
  useEffect(() => {
168
184
  const root = document.getElementById(rootId);
169
185
  if (!root)
@@ -184,7 +200,9 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
184
200
  return;
185
201
  }
186
202
  const r = el.getBoundingClientRect();
187
- setOffer({ idx, top: r.bottom + window.scrollY + 6, left: Math.max(8, r.left + window.scrollX) });
203
+ // Kept on screen: a word near the right edge would push the bar off it and wrap its label.
204
+ const vw = window.innerWidth || document.documentElement.clientWidth || 1024;
205
+ setOffer({ idx, top: r.bottom + window.scrollY + 8, left: Math.max(8, Math.min(r.left, vw - OFFER_WIDTH - 8)) + window.scrollX });
188
206
  };
189
207
  root.addEventListener('click', onClick);
190
208
  return () => root.removeEventListener('click', onClick);
@@ -264,7 +282,7 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
264
282
  follow(true);
265
283
  }, onSeeking: () => {
266
284
  startedRef.current = true;
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) => {
285
+ }, onPause: () => { playingRef.current = false; setPlaying(false); }, onLoadedMetadata: (e) => setDur(e.target.duration), onRateChange: (e) => setRate(e.target.playbackRate) }), offer ? (_jsxs("div", { ref: offerRef, "data-nospeak": true, "data-play-offer": true, className: "absolute z-50 flex items-center rounded-full font-sans text-[13px] leading-none shadow-lg", style: { top: offer.top, left: offer.left, width: OFFER_WIDTH, whiteSpace: 'nowrap', background: accent, color: ground }, children: [_jsxs("button", { type: "button", "data-play-from-here": true, onClick: () => playFrom(offer.idx), className: "flex flex-1 items-center gap-1.5 rounded-l-full py-2 pl-3 pr-1 font-medium", children: [_jsx("svg", { "aria-hidden": "true", width: "10", height: "11", viewBox: "0 0 10 11", children: _jsx("path", { d: "M1 1l8 4.5L1 10z", fill: "currentColor" }) }), "Play from here"] }), _jsx("button", { type: "button", "aria-label": "Close", onClick: () => setOffer(null), className: "rounded-r-full py-2 pl-1 pr-3 text-base leading-none 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) => {
268
286
  if (audioRef.current)
269
287
  audioRef.current.currentTime = v;
270
288
  }, 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.30.0",
3
+ "version": "0.30.2",
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",