@supersuit/artifacts 0.4.1 → 0.4.3

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,35 @@
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.4.3 (2026-09-26)
7
+
8
+ **The bubble ground runs smooth on a phone, and the glow runs the whole spectrum.**
9
+
10
+ - The backdrop blurred the whole viewport (`filter: blur(40px)`) and hard-light blended every
11
+ bubble, so each frame re-rasterised the full screen and stuttered on a phone. Both are gone:
12
+ only `transform` animates, on its own layer (`will-change`, `translate3d`). The softness now
13
+ comes from the gradient itself (`closest-side`, fading to zero inside the bubble), which also
14
+ stops the gradients showing hard edges without the blur.
15
+ - `GLOW_PASTELS` grows from six washed pastels to nine saturated hues (blue, violet, magenta,
16
+ rose, coral, amber, lime, mint, cyan), one bubble each, spread over the page so neighbours
17
+ overlap at their edges instead of averaging to grey in the middle.
18
+ - **DETECTOR:** the animated ground stutters on a phone, or reads as grey-beige rather than colour.
19
+ - **REMEDY:** update the dependency. A pack that passes its own `backdrop.colors` keeps them.
20
+ - **Tests:** no full-screen blur or blend in the backdrop and `will-change:transform` present; the
21
+ default palette has at least nine colours and renders one bubble per colour.
22
+
23
+ ## 0.4.2 (2026-09-26)
24
+
25
+ **The read-along highlight waits for play.**
26
+
27
+ - The first word's timing starts at 0, so an untouched page sat with its first word lit before
28
+ anything played. `ArtifactReader` now lights nothing until the first play or seek. The word
29
+ choice is exported as `litWordIndex(words, now, started)`.
30
+ - **DETECTOR:** a narrated page shows its first word highlighted at 0:00 before play.
31
+ - **REMEDY:** update the dependency; no host change.
32
+ - **Tests:** nothing lit before start; the word under the playhead once started; nothing in a
33
+ gap after a word ends.
34
+
6
35
  ## 0.4.1 (2026-09-26)
7
36
 
8
37
  **Big files upload straight to the bucket.**
@@ -1,6 +1,6 @@
1
1
  import type { Backdrop } from './pack.js';
2
- /** The Freedom Glow's colours (a blue core and the spectrum it separates into), mixed most of
3
- * the way to cream so a light page still reads as light. */
2
+ /** The Freedom Glow: a blue core and the full spectrum it separates into, one bubble per colour.
3
+ * Saturated rather than pastel; the veil is what keeps a light page reading as light. */
4
4
  export declare const GLOW_PASTELS: string[];
5
5
  export declare function Bubbles({ backdrop }: {
6
6
  backdrop: Backdrop;
@@ -1,8 +1,18 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- /** The Freedom Glow's colours (a blue core and the spectrum it separates into), mixed most of
3
- * the way to cream so a light page still reads as light. */
4
- export const GLOW_PASTELS = ['150,190,255', '215,185,240', '160,225,220', '250,195,180', '250,215,160', '190,210,255'];
5
- // [animation, duration s, transform-origin] per bubble, after the original's six.
2
+ /** The Freedom Glow: a blue core and the full spectrum it separates into, one bubble per colour.
3
+ * Saturated rather than pastel; the veil is what keeps a light page reading as light. */
4
+ export const GLOW_PASTELS = [
5
+ '90,150,255', // blue
6
+ '165,120,245', // violet
7
+ '235,110,210', // magenta
8
+ '255,120,150', // rose
9
+ '255,150,110', // coral
10
+ '255,195,90', // amber
11
+ '170,225,100', // lime
12
+ '90,215,170', // mint
13
+ '80,200,240', // cyan
14
+ ];
15
+ // [animation, duration s, transform-origin] per bubble, after animate-ui's six plus three more.
6
16
  const MOTION = [
7
17
  ['a-bubble-v', 30, 'center center'],
8
18
  ['a-bubble-c', 20, 'calc(50% - 400px) center'],
@@ -10,25 +20,39 @@ const MOTION = [
10
20
  ['a-bubble-h', 40, 'calc(50% - 200px) center'],
11
21
  ['a-bubble-c', 20, 'calc(50% - 800px) calc(50% + 200px)'],
12
22
  ['a-bubble-v', 35, 'calc(50% + 300px) calc(50% - 200px)'],
23
+ ['a-bubble-h', 45, 'calc(50% + 200px) calc(50% + 300px)'],
24
+ ['a-bubble-c', 30, 'calc(50% + 600px) calc(50% - 300px)'],
25
+ ['a-bubble-v', 25, 'calc(50% - 300px) calc(50% + 400px)'],
26
+ ];
27
+ // [top %, left %] per bubble: spread over the page so neighbours overlap at their edges only.
28
+ // Stacked in the middle, nine translucent hues average to grey.
29
+ const HOME = [
30
+ [-10, -15], [-15, 45], [15, 20], [30, -20], [35, 50], [55, 10], [65, 45], [80, -15], [85, 35],
13
31
  ];
32
+ // Only `transform` animates, on its own layer, so the browser moves finished bitmaps and never
33
+ // repaints. The first version put a 40px blur over the whole viewport and hard-light blended every
34
+ // bubble, which re-rasterised the full screen each frame and stuttered on a phone; the radial
35
+ // gradients already fade to nothing, so neither was buying softness.
14
36
  const CSS = `
15
- @keyframes a-bubble-v{0%,100%{transform:translateY(-50%)}50%{transform:translateY(50%)}}
37
+ @keyframes a-bubble-v{0%,100%{transform:translate3d(0,-50%,0)}50%{transform:translate3d(0,50%,0)}}
16
38
  @keyframes a-bubble-c{0%{transform:rotate(0deg)}50%{transform:rotate(180deg)}100%{transform:rotate(360deg)}}
17
- @keyframes a-bubble-h{0%,100%{transform:translateX(-50%) translateY(-10%)}50%{transform:translateX(50%) translateY(10%)}}
18
- [data-artifact-backdrop] .a-bubble{position:absolute;width:80%;height:80%;top:10%;left:10%;border-radius:9999px;mix-blend-mode:hard-light;animation-timing-function:ease;animation-iteration-count:infinite}
39
+ @keyframes a-bubble-h{0%,100%{transform:translate3d(-50%,-10%,0)}50%{transform:translate3d(50%,10%,0)}}
40
+ [data-artifact-backdrop] .a-bubble{position:absolute;width:70%;height:60%;border-radius:9999px;will-change:transform;backface-visibility:hidden;animation-timing-function:ease-in-out;animation-iteration-count:infinite}
19
41
  @media (prefers-reduced-motion: reduce){[data-artifact-backdrop] .a-bubble{animation:none!important}}
20
42
  `;
21
43
  export function Bubbles({ backdrop }) {
22
44
  const colors = backdrop.colors?.length ? backdrop.colors : GLOW_PASTELS;
23
45
  const veil = Math.round((backdrop.veil ?? 0.4) * 100);
24
- return (_jsxs("div", { "data-artifact-backdrop": "bubbles", "aria-hidden": true, style: { position: 'fixed', inset: 0, zIndex: -1, overflow: 'hidden', background: 'var(--a-ground)', pointerEvents: 'none' }, children: [_jsx("style", { dangerouslySetInnerHTML: { __html: CSS } }), _jsx("div", { style: { position: 'absolute', inset: 0, filter: 'blur(40px)' }, children: colors.map((c, i) => {
46
+ return (_jsxs("div", { "data-artifact-backdrop": "bubbles", "aria-hidden": true, style: { position: 'fixed', inset: 0, zIndex: -1, overflow: 'hidden', background: 'var(--a-ground)', pointerEvents: 'none' }, children: [_jsx("style", { dangerouslySetInnerHTML: { __html: CSS } }), _jsx("div", { style: { position: 'absolute', inset: 0 }, children: colors.map((c, i) => {
25
47
  const [name, secs, origin] = MOTION[i % MOTION.length];
26
48
  return (_jsx("div", { className: "a-bubble", style: {
27
- background: `radial-gradient(circle at center, rgba(${c},0.8) 0, rgba(${c},0) 50%)`,
49
+ background: `radial-gradient(closest-side, rgba(${c},0.9) 0%, rgba(${c},0.6) 35%, rgba(${c},0.2) 70%, rgba(${c},0) 100%)`,
28
50
  animationName: name,
29
51
  animationDuration: `${secs}s`,
30
52
  animationDirection: i % 2 ? 'reverse' : 'normal',
31
53
  transformOrigin: origin,
54
+ top: `${HOME[i % HOME.length][0]}%`,
55
+ left: `${HOME[i % HOME.length][1]}%`,
32
56
  } }, i));
33
57
  }) }), _jsx("div", { style: { position: 'absolute', inset: 0, background: `color-mix(in srgb, var(--a-ground) ${veil}%, transparent)` } })] }));
34
58
  }
@@ -3,6 +3,9 @@ export type WordTiming = {
3
3
  s: number;
4
4
  e: number;
5
5
  };
6
+ /** The word to light at `now`, or -1. Nothing lights until narration has started: the first
7
+ * word begins at 0, so an untouched page would otherwise sit with its first word highlighted. */
8
+ export declare function litWordIndex(words: WordTiming[], now: number, started: boolean): number;
6
9
  export declare function ArtifactReader({ src, words, rootId, label, accent, ground, }: {
7
10
  src: string;
8
11
  words: WordTiming[];
@@ -70,6 +70,25 @@ function align(spans, words) {
70
70
  }
71
71
  return out;
72
72
  }
73
+ /** The word to light at `now`, or -1. Nothing lights until narration has started: the first
74
+ * word begins at 0, so an untouched page would otherwise sit with its first word highlighted. */
75
+ export function litWordIndex(words, now, started) {
76
+ if (!started)
77
+ return -1;
78
+ let lo = 0;
79
+ let hi = words.length - 1;
80
+ let idx = -1;
81
+ while (lo <= hi) {
82
+ const mid = (lo + hi) >> 1;
83
+ if (words[mid].s <= now) {
84
+ idx = mid;
85
+ lo = mid + 1;
86
+ }
87
+ else
88
+ hi = mid - 1;
89
+ }
90
+ return idx >= 0 && now <= words[idx].e + 0.25 ? idx : -1;
91
+ }
73
92
  function fmt(t) {
74
93
  const m = Math.floor(t / 60);
75
94
  const s = Math.floor(t % 60);
@@ -80,6 +99,8 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
80
99
  const audioRef = useRef(null);
81
100
  const mapRef = useRef([]);
82
101
  const litRef = useRef(null);
102
+ // Set on the first play or seek; the highlight stays off until then.
103
+ const startedRef = useRef(false);
83
104
  const [playing, setPlaying] = useState(false);
84
105
  const [t, setT] = useState(0);
85
106
  const [dur, setDur] = useState(0);
@@ -111,20 +132,8 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
111
132
  const tick = () => {
112
133
  const now = a.currentTime;
113
134
  setT(now);
114
- // Binary search the word at `now`.
115
- let lo = 0;
116
- let hi = words.length - 1;
117
- let idx = -1;
118
- while (lo <= hi) {
119
- const mid = (lo + hi) >> 1;
120
- if (words[mid].s <= now) {
121
- idx = mid;
122
- lo = mid + 1;
123
- }
124
- else
125
- hi = mid - 1;
126
- }
127
- const el = idx >= 0 && now <= words[idx].e + 0.25 ? mapRef.current[idx] : null;
135
+ const idx = litWordIndex(words, now, startedRef.current);
136
+ const el = idx >= 0 ? mapRef.current[idx] : null;
128
137
  if (el !== litRef.current) {
129
138
  litRef.current?.classList.remove('artifact-word-lit');
130
139
  el?.classList.add('artifact-word-lit');
@@ -154,7 +163,12 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
154
163
  .artifact-word { border-radius: 3px; transition: background-color 120ms; }
155
164
  .artifact-word:hover { background: var(--a-surface-strong, rgba(255,255,255,0.08)); cursor: pointer; }
156
165
  .artifact-word-lit { background: color-mix(in srgb, ${accent} 35%, transparent); color: var(--a-strong, #fff); }
157
- ` }), _jsx("audio", { ref: audioRef, src: src, preload: "metadata", onPlay: () => setPlaying(true), onPause: () => setPlaying(false), onLoadedMetadata: (e) => setDur(e.target.duration), onRateChange: (e) => setRate(e.target.playbackRate) }), _jsx("div", { 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: toggle, "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: narrator }), _jsx("input", { type: "range", min: 0, max: dur || 0, step: 0.1, value: Math.min(t, dur || 0), onChange: (e) => {
166
+ ` }), _jsx("audio", { ref: audioRef, src: src, preload: "metadata", onPlay: () => {
167
+ startedRef.current = true;
168
+ setPlaying(true);
169
+ }, onSeeking: () => {
170
+ startedRef.current = true;
171
+ }, onPause: () => setPlaying(false), onLoadedMetadata: (e) => setDur(e.target.duration), onRateChange: (e) => setRate(e.target.playbackRate) }), _jsx("div", { 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: toggle, "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: narrator }), _jsx("input", { type: "range", min: 0, max: dur || 0, step: 0.1, value: Math.min(t, dur || 0), onChange: (e) => {
158
172
  if (audioRef.current)
159
173
  audioRef.current.currentTime = Number(e.target.value);
160
174
  }, "aria-label": "Seek", className: "mt-1 w-full", style: { accentColor: accent } })] }), _jsxs("div", { className: "w-16 shrink-0 text-right text-xs tabular-nums text-[color:var(--a-muted)]", children: [fmt(t), " / ", fmt(dur)] }), _jsxs("button", { type: "button", onClick: () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
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",