@supersuit/artifacts 0.4.5 → 0.4.7
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.7 (2026-09-27)
|
|
7
|
+
|
|
8
|
+
**A page of videos opens as fast as a page of text.**
|
|
9
|
+
|
|
10
|
+
- 0.4.6 marked every inline video `autoplay`, so all of them downloaded the moment the page opened
|
|
11
|
+
(21 diagrams, 6.8 MB, competing with the text and narration on a phone). The markup now carries
|
|
12
|
+
`preload="metadata"` and `data-artifact-video` instead, and a small client component,
|
|
13
|
+
`VideoAutoplay`, mounted only on pages that embed an `.mp4`, loads and plays each video as it
|
|
14
|
+
comes within a screen of view and pauses it when it leaves.
|
|
15
|
+
- **DETECTOR:** a page with many inline videos is slow to become readable on cellular.
|
|
16
|
+
- **REMEDY:** update the dependency; no host change.
|
|
17
|
+
- **Tests:** the markup no longer autoplays and preloads only metadata; `VideoAutoplay` plays a
|
|
18
|
+
video only once it intersects, raises its preload, and pauses it when it leaves.
|
|
19
|
+
|
|
20
|
+
## 0.4.6 (2026-09-27)
|
|
21
|
+
|
|
22
|
+
**Pages play video: `` is a silent, looping, autoplaying inline video.**
|
|
23
|
+
|
|
24
|
+
- Animated WebP is decoded frame by frame on the CPU and stuttered on an iPhone at 24fps; video
|
|
25
|
+
is decoded in hardware. Image syntax pointing at an `.mp4` now renders
|
|
26
|
+
`<video autoplay muted loop playsinline>` with the alt text as its `aria-label`, in the same
|
|
27
|
+
rounded frame an image gets. It is written as markup because React leaves `muted` out of
|
|
28
|
+
server HTML, and iOS will not autoplay a video whose markup is not muted.
|
|
29
|
+
- `mp4` (`video/mp4`) joins the allowed asset types for `PUT_ASSET` and `UPLOAD`.
|
|
30
|
+
- **DETECTOR:** a published `.mp4` renders as a broken image, or its upload answers 415.
|
|
31
|
+
- **REMEDY:** update the dependency; no host change.
|
|
32
|
+
- **Tests:** an `.mp4` renders a muted, looping, inline, autoplaying video labelled by its alt
|
|
33
|
+
text and a `.png` still renders an image; `d.mp4` maps to `video/mp4`.
|
|
34
|
+
|
|
6
35
|
## 0.4.5 (2026-09-27)
|
|
7
36
|
|
|
8
37
|
**The bubble ground looks as it did in 0.4.2, and stays smooth.**
|
package/lib/artifacts/assets.js
CHANGED
package/lib/artifacts/render.js
CHANGED
|
@@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown';
|
|
|
3
3
|
import remarkGfm from 'remark-gfm';
|
|
4
4
|
import { hasNotesWidget, headingsOf } from './widgets.js';
|
|
5
5
|
import { HeadingNotes, NoteToggle, NotesEarlier, NotesProvider } from '../widgets/notes.js';
|
|
6
|
+
import { VideoAutoplay } from '../reader/video-autoplay.js';
|
|
6
7
|
// Every colour is a theme variable (brand/theme.ts), so a page follows its light, dark or system mode.
|
|
7
8
|
const GOLD = 'var(--a-accent)';
|
|
8
9
|
function hostOf(url) {
|
|
@@ -45,6 +46,20 @@ const REACHABLE = /^(?:[a-z][a-z0-9+.-]*:|\/|#)/i;
|
|
|
45
46
|
export function isReachableHref(href) {
|
|
46
47
|
return typeof href === 'string' && REACHABLE.test(href);
|
|
47
48
|
}
|
|
49
|
+
/** `` plays as video: an animation that must stay smooth on a phone. Animated WebP
|
|
50
|
+
* is decoded frame by frame on the CPU and stuttered on an iPhone at 24fps (2026-09-27); video
|
|
51
|
+
* is decoded in hardware. */
|
|
52
|
+
const VIDEO = /\.mp4(?:[?#].*)?$/i;
|
|
53
|
+
const VIDEO_IN_MD = /\]\([^)\s]+\.mp4(?:[?#][^)]*)?\)/i;
|
|
54
|
+
const escapeAttr = (s) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
55
|
+
// Written as markup, not JSX: React renders `muted` as a property and leaves it out of server
|
|
56
|
+
// HTML, and iOS refuses to play a video unprompted whose markup is not muted. `playsinline` keeps
|
|
57
|
+
// iPhone from going full screen. No `autoplay`: VideoAutoplay starts each one as it nears the
|
|
58
|
+
// screen, so a page of videos does not download them all on open.
|
|
59
|
+
function InlineVideo({ src, label }) {
|
|
60
|
+
const html = `<video src="${escapeAttr(src)}" aria-label="${escapeAttr(label)}" data-artifact-video muted loop playsinline preload="metadata" disablepictureinpicture class="block w-full"></video>`;
|
|
61
|
+
return _jsx("span", { className: "my-6 block overflow-hidden rounded-lg border border-[color:var(--a-line)]", dangerouslySetInnerHTML: { __html: html } });
|
|
62
|
+
}
|
|
48
63
|
const components = {
|
|
49
64
|
p: ({ children }) => _jsx("p", { className: "my-4 leading-relaxed", children: children }),
|
|
50
65
|
ul: ({ children }) => _jsx("ul", { className: "my-4 list-disc space-y-1 pl-6", children: children }),
|
|
@@ -53,7 +68,7 @@ const components = {
|
|
|
53
68
|
table: ({ children }) => (_jsx("div", { className: "my-6 overflow-x-auto", children: _jsx("table", { className: "w-full border-collapse text-sm", children: children }) })),
|
|
54
69
|
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
70
|
td: ({ children }) => _jsx("td", { className: "border-b border-[color:var(--a-line)] px-3 py-2 align-top", children: children }),
|
|
56
|
-
img: ({ src, alt }) => (
|
|
71
|
+
img: ({ src, alt }) => typeof src === 'string' && VIDEO.test(src) ? (_jsx(InlineVideo, { src: src, label: alt ?? '' })) : (
|
|
57
72
|
// eslint-disable-next-line @next/next/no-img-element
|
|
58
73
|
_jsx("img", { src: typeof src === 'string' ? src : undefined, alt: alt ?? '', className: "my-6 w-full rounded-lg border border-[color:var(--a-line)]" })),
|
|
59
74
|
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] })),
|
|
@@ -136,5 +151,5 @@ export function ArtifactMarkdown({ markdown, notes }) {
|
|
|
136
151
|
const body = (_jsx(ReactMarkdown, { remarkPlugins: [remarkGfm], components: on ? notesComponents(headings) : pageComponents(headings), children: markdown }));
|
|
137
152
|
return (
|
|
138
153
|
// break-words: a bare URL in prose is one unbreakable word and pushed phones 7px sideways (2026-09-26).
|
|
139
|
-
|
|
154
|
+
_jsxs("div", { className: "break-words text-[17px] text-[color:var(--a-body)]", children: [on ? (_jsx(NotesProvider, { artifactId: notes.artifactId, headings: headings.map(({ slug, text }) => ({ slug, text })), accent: notes.accent, children: body })) : body, VIDEO_IN_MD.test(markdown) ? _jsx(VideoAutoplay, {}) : null] }));
|
|
140
155
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function VideoAutoplay(): null;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
// Plays each inline video only while it is near the screen. With `autoplay` in the markup every
|
|
3
|
+
// video on the page downloads the moment it opens (the Freedom whitepaper: 21 diagrams, 6.8 MB,
|
|
4
|
+
// competing with the text and the narration on a phone). Here a video loads and plays as it
|
|
5
|
+
// comes within a screen of view and pauses when it leaves, so opening the page costs the text.
|
|
6
|
+
import { useEffect } from 'react';
|
|
7
|
+
export function VideoAutoplay() {
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const videos = Array.from(document.querySelectorAll('video[data-artifact-video]'));
|
|
10
|
+
if (!videos.length || typeof IntersectionObserver === 'undefined') {
|
|
11
|
+
for (const v of videos)
|
|
12
|
+
void v.play?.().catch(() => { });
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const io = new IntersectionObserver((entries) => {
|
|
16
|
+
for (const e of entries) {
|
|
17
|
+
const v = e.target;
|
|
18
|
+
if (e.isIntersecting) {
|
|
19
|
+
v.preload = 'auto';
|
|
20
|
+
void v.play().catch(() => { });
|
|
21
|
+
}
|
|
22
|
+
else
|
|
23
|
+
v.pause();
|
|
24
|
+
}
|
|
25
|
+
}, { rootMargin: '100% 0px' });
|
|
26
|
+
for (const v of videos)
|
|
27
|
+
io.observe(v);
|
|
28
|
+
return () => io.disconnect();
|
|
29
|
+
}, []);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
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.7",
|
|
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",
|