@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 +29 -0
- package/lib/brand/backdrop.d.ts +2 -2
- package/lib/brand/backdrop.js +33 -9
- package/lib/reader/artifact-reader.d.ts +3 -0
- package/lib/reader/artifact-reader.js +29 -15
- package/package.json +1 -1
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.**
|
package/lib/brand/backdrop.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Backdrop } from './pack.js';
|
|
2
|
-
/** The Freedom Glow
|
|
3
|
-
* the
|
|
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;
|
package/lib/brand/backdrop.js
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
/** The Freedom Glow
|
|
3
|
-
* the
|
|
4
|
-
export const GLOW_PASTELS = [
|
|
5
|
-
|
|
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:
|
|
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:
|
|
18
|
-
[data-artifact-backdrop] .a-bubble{position:absolute;width:
|
|
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
|
|
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(
|
|
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
|
-
|
|
115
|
-
|
|
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: () =>
|
|
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.
|
|
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",
|