@supersuit/artifacts 0.4.4 → 0.4.6
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/artifacts/assets.js +1 -0
- package/lib/artifacts/render.js +13 -1
- package/lib/brand/backdrop.d.ts +2 -3
- package/lib/brand/backdrop.js +11 -31
- package/lib/routes/artifacts.js +2 -2
- 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.6 (2026-09-27)
|
|
7
|
+
|
|
8
|
+
**Pages play video: `` is a silent, looping, autoplaying inline video.**
|
|
9
|
+
|
|
10
|
+
- Animated WebP is decoded frame by frame on the CPU and stuttered on an iPhone at 24fps; video
|
|
11
|
+
is decoded in hardware. Image syntax pointing at an `.mp4` now renders
|
|
12
|
+
`<video autoplay muted loop playsinline>` with the alt text as its `aria-label`, in the same
|
|
13
|
+
rounded frame an image gets. It is written as markup because React leaves `muted` out of
|
|
14
|
+
server HTML, and iOS will not autoplay a video whose markup is not muted.
|
|
15
|
+
- `mp4` (`video/mp4`) joins the allowed asset types for `PUT_ASSET` and `UPLOAD`.
|
|
16
|
+
- **DETECTOR:** a published `.mp4` renders as a broken image, or its upload answers 415.
|
|
17
|
+
- **REMEDY:** update the dependency; no host change.
|
|
18
|
+
- **Tests:** an `.mp4` renders a muted, looping, inline, autoplaying video labelled by its alt
|
|
19
|
+
text and a `.png` still renders an image; `d.mp4` maps to `video/mp4`.
|
|
20
|
+
|
|
21
|
+
## 0.4.5 (2026-09-27)
|
|
22
|
+
|
|
23
|
+
**The bubble ground looks as it did in 0.4.2, and stays smooth.**
|
|
24
|
+
|
|
25
|
+
- The palette and layout changes of 0.4.3 and 0.4.4 are reverted: the six original pastels, the
|
|
26
|
+
original stacking and gradient. What stays is the performance fix, done differently: each bubble
|
|
27
|
+
carries its own `filter: blur(40px)` on its own layer (`will-change: transform`), so it is
|
|
28
|
+
rasterised once and slid, instead of one blur over the whole screen re-rasterised every frame.
|
|
29
|
+
The hard-light blend stays off; side by side the render is indistinguishable.
|
|
30
|
+
- **DETECTOR:** the ground shows saturated magenta, teal or pink bubbles (0.4.3/0.4.4).
|
|
31
|
+
- **REMEDY:** update the dependency.
|
|
32
|
+
- **Tests:** the blur sits on the bubble, never on a full-screen wrapper; no blend; one bubble per
|
|
33
|
+
default colour.
|
|
34
|
+
|
|
6
35
|
## 0.4.4 (2026-09-26)
|
|
7
36
|
|
|
8
37
|
**The glow stays on the cool-to-magenta arc.**
|
package/lib/artifacts/assets.js
CHANGED
package/lib/artifacts/render.js
CHANGED
|
@@ -45,6 +45,18 @@ const REACHABLE = /^(?:[a-z][a-z0-9+.-]*:|\/|#)/i;
|
|
|
45
45
|
export function isReachableHref(href) {
|
|
46
46
|
return typeof href === 'string' && REACHABLE.test(href);
|
|
47
47
|
}
|
|
48
|
+
/** `` plays as video: an animation that must stay smooth on a phone. Animated WebP
|
|
49
|
+
* is decoded frame by frame on the CPU and stuttered on an iPhone at 24fps (2026-09-27); video
|
|
50
|
+
* is decoded in hardware. */
|
|
51
|
+
const VIDEO = /\.mp4(?:[?#].*)?$/i;
|
|
52
|
+
const escapeAttr = (s) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
53
|
+
// Written as markup, not JSX: React renders `muted` as a property and leaves it out of server
|
|
54
|
+
// HTML, and iOS refuses to autoplay a video whose markup is not muted. `playsinline` keeps
|
|
55
|
+
// iPhone from going full screen.
|
|
56
|
+
function InlineVideo({ src, label }) {
|
|
57
|
+
const html = `<video src="${escapeAttr(src)}" aria-label="${escapeAttr(label)}" autoplay muted loop playsinline preload="auto" disablepictureinpicture class="block w-full"></video>`;
|
|
58
|
+
return _jsx("span", { className: "my-6 block overflow-hidden rounded-lg border border-[color:var(--a-line)]", dangerouslySetInnerHTML: { __html: html } });
|
|
59
|
+
}
|
|
48
60
|
const components = {
|
|
49
61
|
p: ({ children }) => _jsx("p", { className: "my-4 leading-relaxed", children: children }),
|
|
50
62
|
ul: ({ children }) => _jsx("ul", { className: "my-4 list-disc space-y-1 pl-6", children: children }),
|
|
@@ -53,7 +65,7 @@ const components = {
|
|
|
53
65
|
table: ({ children }) => (_jsx("div", { className: "my-6 overflow-x-auto", children: _jsx("table", { className: "w-full border-collapse text-sm", children: children }) })),
|
|
54
66
|
th: ({ children }) => (_jsx("th", { className: "border-b border-[color:var(--a-line-strong)] px-3 py-2 text-left font-semibold text-[color:var(--a-strong)]", children: children })),
|
|
55
67
|
td: ({ children }) => _jsx("td", { className: "border-b border-[color:var(--a-line)] px-3 py-2 align-top", children: children }),
|
|
56
|
-
img: ({ src, alt }) => (
|
|
68
|
+
img: ({ src, alt }) => typeof src === 'string' && VIDEO.test(src) ? (_jsx(InlineVideo, { src: src, label: alt ?? '' })) : (
|
|
57
69
|
// eslint-disable-next-line @next/next/no-img-element
|
|
58
70
|
_jsx("img", { src: typeof src === 'string' ? src : undefined, alt: alt ?? '', className: "my-6 w-full rounded-lg border border-[color:var(--a-line)]" })),
|
|
59
71
|
a: ({ href, children }) => !isReachableHref(href) ? (_jsx("span", { children: children })) : (_jsxs("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: "text-[color:var(--a-strong)] underline decoration-[color:var(--a-line-strong)] underline-offset-4 hover:decoration-[color:var(--a-strong)]", children: [children, href && hostOf(href) ? (_jsxs("span", { "data-nospeak": true, className: "ml-1 text-xs text-[color:var(--a-muted)]", children: ["(", hostOf(href), ")"] })) : null] })),
|
package/lib/brand/backdrop.d.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { Backdrop } from './pack.js';
|
|
2
|
-
/** The Freedom Glow
|
|
3
|
-
*
|
|
4
|
-
* Saturated rather than pastel; the veil is what keeps a light page reading as light. */
|
|
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. */
|
|
5
4
|
export declare const GLOW_PASTELS: string[];
|
|
6
5
|
export declare function Bubbles({ backdrop }: {
|
|
7
6
|
backdrop: Backdrop;
|
package/lib/brand/backdrop.js
CHANGED
|
@@ -1,19 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
/** The Freedom Glow
|
|
3
|
-
*
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
'31,107,255', // brand blue
|
|
7
|
-
'224,82,196', // magenta
|
|
8
|
-
'25,181,201', // teal
|
|
9
|
-
'156,92,240', // purple
|
|
10
|
-
'255,111,174', // pink
|
|
11
|
-
'34,195,230', // cyan
|
|
12
|
-
'123,107,230', // violet
|
|
13
|
-
'90,162,255', // sky
|
|
14
|
-
'199,125,255', // lavender
|
|
15
|
-
];
|
|
16
|
-
// [animation, duration s, transform-origin] per bubble, after animate-ui's six plus three more.
|
|
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.
|
|
17
6
|
const MOTION = [
|
|
18
7
|
['a-bubble-v', 30, 'center center'],
|
|
19
8
|
['a-bubble-c', 20, 'calc(50% - 400px) center'],
|
|
@@ -21,24 +10,17 @@ const MOTION = [
|
|
|
21
10
|
['a-bubble-h', 40, 'calc(50% - 200px) center'],
|
|
22
11
|
['a-bubble-c', 20, 'calc(50% - 800px) calc(50% + 200px)'],
|
|
23
12
|
['a-bubble-v', 35, 'calc(50% + 300px) calc(50% - 200px)'],
|
|
24
|
-
['a-bubble-h', 45, 'calc(50% + 200px) calc(50% + 300px)'],
|
|
25
|
-
['a-bubble-c', 30, 'calc(50% + 600px) calc(50% - 300px)'],
|
|
26
|
-
['a-bubble-v', 25, 'calc(50% - 300px) calc(50% + 400px)'],
|
|
27
|
-
];
|
|
28
|
-
// [top %, left %] per bubble: spread over the page so neighbours overlap at their edges only.
|
|
29
|
-
// Stacked in the middle, nine translucent hues average to grey.
|
|
30
|
-
const HOME = [
|
|
31
|
-
[-10, -15], [-15, 45], [15, 20], [30, -20], [35, 50], [55, 10], [65, 45], [80, -15], [85, 35],
|
|
32
13
|
];
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
// bubble, which re-rasterised the
|
|
36
|
-
//
|
|
14
|
+
// Each bubble is blurred on its OWN layer and only `transform` animates, so the browser rasterises
|
|
15
|
+
// it once and slides the bitmap. The first version blurred a full-screen parent and hard-light
|
|
16
|
+
// blended every bubble, which re-rasterised the whole viewport each frame and stuttered on a
|
|
17
|
+
// phone (2026-09-26). The colours and layout are unchanged from that version on purpose: an
|
|
18
|
+
// attempt to also widen the palette was reverted the same night ("really not a fan").
|
|
37
19
|
const CSS = `
|
|
38
20
|
@keyframes a-bubble-v{0%,100%{transform:translate3d(0,-50%,0)}50%{transform:translate3d(0,50%,0)}}
|
|
39
21
|
@keyframes a-bubble-c{0%{transform:rotate(0deg)}50%{transform:rotate(180deg)}100%{transform:rotate(360deg)}}
|
|
40
22
|
@keyframes a-bubble-h{0%,100%{transform:translate3d(-50%,-10%,0)}50%{transform:translate3d(50%,10%,0)}}
|
|
41
|
-
[data-artifact-backdrop] .a-bubble{position:absolute;width:
|
|
23
|
+
[data-artifact-backdrop] .a-bubble{position:absolute;width:80%;height:80%;top:10%;left:10%;border-radius:9999px;filter:blur(40px);will-change:transform;backface-visibility:hidden;animation-timing-function:ease;animation-iteration-count:infinite}
|
|
42
24
|
@media (prefers-reduced-motion: reduce){[data-artifact-backdrop] .a-bubble{animation:none!important}}
|
|
43
25
|
`;
|
|
44
26
|
export function Bubbles({ backdrop }) {
|
|
@@ -47,13 +29,11 @@ export function Bubbles({ backdrop }) {
|
|
|
47
29
|
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) => {
|
|
48
30
|
const [name, secs, origin] = MOTION[i % MOTION.length];
|
|
49
31
|
return (_jsx("div", { className: "a-bubble", style: {
|
|
50
|
-
background: `radial-gradient(
|
|
32
|
+
background: `radial-gradient(circle at center, rgba(${c},0.8) 0, rgba(${c},0) 50%)`,
|
|
51
33
|
animationName: name,
|
|
52
34
|
animationDuration: `${secs}s`,
|
|
53
35
|
animationDirection: i % 2 ? 'reverse' : 'normal',
|
|
54
36
|
transformOrigin: origin,
|
|
55
|
-
top: `${HOME[i % HOME.length][0]}%`,
|
|
56
|
-
left: `${HOME[i % HOME.length][1]}%`,
|
|
57
37
|
} }, i));
|
|
58
38
|
}) }), _jsx("div", { style: { position: 'absolute', inset: 0, background: `color-mix(in srgb, var(--a-ground) ${veil}%, transparent)` } })] }));
|
|
59
39
|
}
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -349,7 +349,7 @@ export function createArtifactRoutes(config) {
|
|
|
349
349
|
return NextResponse.json({ error: 'asset name must be one path segment: letters, digits, dot, dash, underscore' }, { status: 400 });
|
|
350
350
|
const type = contentTypeFor(name);
|
|
351
351
|
if (!type)
|
|
352
|
-
return NextResponse.json({ error: 'asset type not allowed; use webp, png, jpg, gif, mp3 or json' }, { status: 415 });
|
|
352
|
+
return NextResponse.json({ error: 'asset type not allowed; use webp, png, jpg, gif, mp3, mp4 or json' }, { status: 415 });
|
|
353
353
|
const bytes = Buffer.from(await request.arrayBuffer());
|
|
354
354
|
if (bytes.length === 0)
|
|
355
355
|
return NextResponse.json({ error: 'empty body' }, { status: 400 });
|
|
@@ -379,7 +379,7 @@ export function createArtifactRoutes(config) {
|
|
|
379
379
|
return NextResponse.json({ error: 'asset name must be one path segment: letters, digits, dot, dash, underscore' }, { status: 400 });
|
|
380
380
|
const type = contentTypeFor(name);
|
|
381
381
|
if (!type)
|
|
382
|
-
return NextResponse.json({ error: 'asset type not allowed; use webp, png, jpg, gif, mp3 or json' }, { status: 415 });
|
|
382
|
+
return NextResponse.json({ error: 'asset type not allowed; use webp, png, jpg, gif, mp3, mp4 or json' }, { status: 415 });
|
|
383
383
|
const body = (await request.json().catch(() => ({})));
|
|
384
384
|
const digest = typeof body.digest === 'string' ? body.digest : '';
|
|
385
385
|
if (!ASSET_DIGEST.test(digest))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
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",
|