@supersuit/artifacts 0.4.6 → 0.5.0

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,45 @@
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.5.0 (2026-09-27)
7
+
8
+ **Inline definitions: a page can define its jargon, and a reader sees it in line.**
9
+
10
+ - New front matter key `definitions:`, a list of `Term | definition | optional link` (or a map).
11
+ The first occurrence of each term in the prose renders with a dotted underline; hover (desk) or
12
+ tap (phone) opens the definition, drawn by a new client component, `DefinitionLayer`, through a
13
+ portal on `document.body`, so nothing in the paragraph opens or closes and nothing reflows. The
14
+ store keeps the terms and a republish without the key removes them.
15
+ - The read-along reader skips `<style>` and `<script>` text (it used to wrap any text node, which
16
+ would have turned an inline stylesheet into word spans) and a click on a defined term no longer
17
+ seeks. Its bar carries `data-artifact-player` so an overlay can sit above it.
18
+ - Punctuation that continues a word across an element edge (`Edge` in a term, then `, which`;
19
+ `**bold**,`) is wrapped as a tail of that word rather than a word of its own, so the
20
+ highlighter's word sequence matches the narration's tokens. Found in WebKit, where a comma after
21
+ a defined term had become an extra, clickable word.
22
+ - **DETECTOR:** a page with `definitions:` is refused as an unknown key before 0.5.0.
23
+ - **REMEDY:** update the dependency; no host change. A publisher that filters keys must pass
24
+ `definitions` through.
25
+ - **Tests:** parsing both shapes and refusals; first occurrence only, whole words, case rules and
26
+ every skipped context; the karaoke word spans are identical in number and order with and without
27
+ definitions and equal the narrated words; a tap on a defined word is not a seek; the definition
28
+ opens on `document.body` with the paragraph's markup unchanged, and closes on a second tap,
29
+ Escape or a tap elsewhere; the store round-trips and clears the key.
30
+
31
+ ## 0.4.7 (2026-09-27)
32
+
33
+ **A page of videos opens as fast as a page of text.**
34
+
35
+ - 0.4.6 marked every inline video `autoplay`, so all of them downloaded the moment the page opened
36
+ (21 diagrams, 6.8 MB, competing with the text and narration on a phone). The markup now carries
37
+ `preload="metadata"` and `data-artifact-video` instead, and a small client component,
38
+ `VideoAutoplay`, mounted only on pages that embed an `.mp4`, loads and plays each video as it
39
+ comes within a screen of view and pauses it when it leaves.
40
+ - **DETECTOR:** a page with many inline videos is slow to become readable on cellular.
41
+ - **REMEDY:** update the dependency; no host change.
42
+ - **Tests:** the markup no longer autoplays and preloads only metadata; `VideoAutoplay` plays a
43
+ video only once it intersects, raises its preload, and pauses it when it leaves.
44
+
6
45
  ## 0.4.6 (2026-09-27)
7
46
 
8
47
  **Pages play video: `![alt](x.mp4)` is a silent, looping, autoplaying inline video.**
package/README.md CHANGED
@@ -132,10 +132,32 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
132
132
  | `access` | `freedom`, `invite` or `public`: a confidential page (below) |
133
133
  | `theme` | `light`, `dark` or `system` (follow the reader's device). Overrides the brand pack's mode for this page |
134
134
  | `toc` | The table of contents. Absent, it appears once the page has four `##` sections; `false` never; `true` from one |
135
+ | `definitions` | Terms defined inline (below). A republish without it removes them |
135
136
  | `template` | `document`, the only one so far |
136
137
 
137
138
  An unknown key is refused, so a typo fails loudly instead of being ignored.
138
139
 
140
+ ## Inline definitions (`definitions:`)
141
+
142
+ ```yaml
143
+ definitions:
144
+ - "Agentic Edge | The compounded context only you could have produced. | https://example.com/edge"
145
+ - "harness | The agent loop that reads, writes and runs on your behalf."
146
+ ```
147
+
148
+ Each entry is `Term | definition | optional https link`; a map (`Term: definition`, or
149
+ `Term: { text, href }`) is accepted too. The FIRST time each term appears in the prose it gets a
150
+ dotted underline: hovering on a desk or tapping on a phone opens the definition, Escape or a tap
151
+ elsewhere closes it. Matching is conservative: whole words, a capitalised term only as written, an
152
+ all-lowercase term also at a sentence start, longest term first, and never inside a heading, a
153
+ link, code, an image or a callout.
154
+
155
+ The definition is drawn on `document.body`, outside the text, so opening it never reflows the
156
+ page: a sheet at the bottom of a phone (above the narration bar), a box under the word on a desk.
157
+ Screen readers get it as the term's accessible description. Read-aloud is unaffected: the spoken
158
+ text comes from the markdown, the term's words stay ordinary words to the highlighter, and a tap
159
+ on a defined word opens its definition instead of seeking.
160
+
139
161
  ## A page with a password
140
162
 
141
163
  `password: <string>` shuts the body behind a door. The title, summary and unfurl stay, so the
@@ -0,0 +1,19 @@
1
+ import type { Root } from 'mdast';
2
+ export type Definition = {
3
+ term: string;
4
+ text: string;
5
+ href?: string;
6
+ };
7
+ /** `definitions:` as front matter carries it: a list of `Term | definition | optional link`
8
+ * (the shape a Freedom workspace can write), or a map of term to definition, or to
9
+ * `{ text, href }` (the shape any gray-matter publisher can write). */
10
+ export declare function parseDefinitions(v: unknown): {
11
+ ok: true;
12
+ definitions: Definition[];
13
+ } | {
14
+ ok: false;
15
+ error: string;
16
+ };
17
+ export declare function termId(term: string, i: number): string;
18
+ /** The remark plugin: marks the first occurrence of each defined term. */
19
+ export declare function remarkDefinitions(definitions: Definition[]): () => (tree: Root) => void;
@@ -0,0 +1,132 @@
1
+ const LINK = /^https?:\/\//i;
2
+ function entry(term, text, href) {
3
+ const t = typeof term === 'string' ? term.trim() : '';
4
+ const d = typeof text === 'string' ? text.trim() : '';
5
+ if (!t || !d)
6
+ return `definitions: "${t || String(term)}" needs "Term | definition"`;
7
+ const out = { term: t, text: d };
8
+ if (href !== undefined && href !== null && String(href).trim()) {
9
+ const h = String(href).trim();
10
+ if (!LINK.test(h))
11
+ return `definitions: the link for "${t}" must start with http:// or https://`;
12
+ out.href = h;
13
+ }
14
+ return out;
15
+ }
16
+ /** `definitions:` as front matter carries it: a list of `Term | definition | optional link`
17
+ * (the shape a Freedom workspace can write), or a map of term to definition, or to
18
+ * `{ text, href }` (the shape any gray-matter publisher can write). */
19
+ export function parseDefinitions(v) {
20
+ const out = [];
21
+ const push = (e) => {
22
+ if (typeof e === 'string')
23
+ return e;
24
+ if (out.some((o) => o.term === e.term))
25
+ return `definitions: "${e.term}" is defined twice`;
26
+ out.push(e);
27
+ return null;
28
+ };
29
+ if (Array.isArray(v)) {
30
+ for (const item of v) {
31
+ if (typeof item !== 'string')
32
+ return { ok: false, error: 'definitions: each entry is a "Term | definition | optional link" string' };
33
+ const [term, text, href] = item.split('|');
34
+ const err = push(entry(term, text, href));
35
+ if (err)
36
+ return { ok: false, error: err };
37
+ }
38
+ return { ok: true, definitions: out };
39
+ }
40
+ if (v && typeof v === 'object') {
41
+ for (const [term, val] of Object.entries(v)) {
42
+ const e = val && typeof val === 'object' ? entry(term, val.text, val.href) : entry(term, val, undefined);
43
+ const err = push(e);
44
+ if (err)
45
+ return { ok: false, error: err };
46
+ }
47
+ return { ok: true, definitions: out };
48
+ }
49
+ return { ok: false, error: 'definitions: a list of "Term | definition | optional link" strings, or a map of term to definition' };
50
+ }
51
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
52
+ /** A term's matcher: whole word, case-aware. A term with any capital is a name and matches as
53
+ * written; an all-lowercase term also matches with a capital first letter, at a sentence start. */
54
+ function matcher(term) {
55
+ const body = escapeRe(term).replace(/\s+/g, '\\s+');
56
+ const lower = term === term.toLowerCase();
57
+ const first = term[0];
58
+ const head = lower && first.toUpperCase() !== first ? `[${escapeRe(first)}${escapeRe(first.toUpperCase())}]` + body.slice(escapeRe(first).length) : body;
59
+ return new RegExp(`(?<![\\p{L}\\p{N}])${head}(?![\\p{L}\\p{N}])`, 'u');
60
+ }
61
+ export function termId(term, i) {
62
+ return `artifact-def-${i}-${term.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '')}`;
63
+ }
64
+ const SKIP = new Set(['heading', 'link', 'linkReference', 'inlineCode', 'code', 'image', 'imageReference', 'footnoteDefinition', 'html', 'definition']);
65
+ const CALLOUT = /^\s*\[!(note|warning)\]/i;
66
+ function firstText(n) {
67
+ if (n.type === 'text')
68
+ return n.value;
69
+ if ('children' in n && n.children.length)
70
+ return firstText(n.children[0]);
71
+ return '';
72
+ }
73
+ /** The remark plugin: marks the first occurrence of each defined term. */
74
+ export function remarkDefinitions(definitions) {
75
+ return () => (tree) => {
76
+ const pending = definitions
77
+ .map((d, i) => ({ d, i, re: matcher(d.term) }))
78
+ .sort((a, b) => b.d.term.length - a.d.term.length);
79
+ const walk = (parent) => {
80
+ for (let k = 0; k < parent.children.length && pending.length; k++) {
81
+ const n = parent.children[k];
82
+ if (SKIP.has(n.type))
83
+ continue;
84
+ if (n.type === 'blockquote' && CALLOUT.test(firstText(n)))
85
+ continue;
86
+ if (n.type === 'text') {
87
+ const hit = earliest(n.value);
88
+ if (!hit)
89
+ continue;
90
+ const { at, len, p } = hit;
91
+ pending.splice(pending.indexOf(p), 1);
92
+ const id = termId(p.d.term, p.i);
93
+ const before = { type: 'text', value: n.value.slice(0, at) };
94
+ const after = { type: 'text', value: n.value.slice(at + len) };
95
+ const mark = {
96
+ type: 'definedTerm',
97
+ data: {
98
+ hName: 'span',
99
+ hProperties: { dataDefinedTerm: p.d.term, role: 'button', tabIndex: 0, ariaDescribedBy: id, ariaExpanded: 'false', className: ['artifact-defined-term'] },
100
+ },
101
+ children: [{ type: 'text', value: n.value.slice(at, at + len) }],
102
+ };
103
+ const note = {
104
+ type: 'definedTermNote',
105
+ data: {
106
+ hName: 'span',
107
+ hProperties: { id, dataNospeak: '', dataDefinition: p.d.text, ...(p.d.href ? { dataHref: p.d.href } : {}), className: ['artifact-defined-note'] },
108
+ },
109
+ children: [{ type: 'text', value: p.d.text }],
110
+ };
111
+ const parts = [before, mark, note, after].filter((x) => x.type !== 'text' || x.value);
112
+ parent.children.splice(k, 1, ...parts);
113
+ // Continue AFTER the note, on the remaining text: another term may follow in it.
114
+ k += parts.indexOf(note);
115
+ continue;
116
+ }
117
+ if ('children' in n)
118
+ walk(n);
119
+ }
120
+ };
121
+ const earliest = (s) => {
122
+ let best = null;
123
+ for (const p of pending) {
124
+ const m = p.re.exec(s);
125
+ if (m && (!best || m.index < best.at))
126
+ best = { at: m.index, len: m[0].length, p };
127
+ }
128
+ return best;
129
+ };
130
+ walk(tree);
131
+ };
132
+ }
@@ -1,6 +1,7 @@
1
1
  import { type Access } from './reader.js';
2
2
  import { type StateConfig } from './state.js';
3
3
  import { type ThemeMode } from '../brand/theme.js';
4
+ import { type Definition } from './definitions.js';
4
5
  export type ArtifactMeta = {
5
6
  title: string;
6
7
  summary: string;
@@ -32,6 +33,9 @@ export type ArtifactMeta = {
32
33
  theme?: ThemeMode;
33
34
  /** The table of contents: absent draws it once the page has four sections, false never. */
34
35
  toc?: boolean;
36
+ /** Terms this page defines inline: the first occurrence of each is underlined and opens its
37
+ * definition. Content, like the body: a republish without it removes them. */
38
+ definitions?: Definition[];
35
39
  };
36
40
  export declare function parseArtifactSource(text: string): {
37
41
  ok: true;
@@ -5,7 +5,8 @@ import { ACCESS_LEVELS } from './reader.js';
5
5
  import { parseStateConfig } from './state.js';
6
6
  import { mergeWidgetState, scanWidgets } from './widgets.js';
7
7
  import { THEME_MODES } from '../brand/theme.js';
8
- const KNOWN = new Set(['title', 'summary', 'subtitle', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'toc']);
8
+ import { parseDefinitions } from './definitions.js';
9
+ const KNOWN = new Set(['title', 'summary', 'subtitle', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'toc', 'definitions']);
9
10
  export function parseArtifactSource(text) {
10
11
  const { data, content } = matter(text);
11
12
  const d = data;
@@ -46,6 +47,13 @@ export function parseArtifactSource(text) {
46
47
  return { ok: false, error: 'toc must be true or false' };
47
48
  meta.toc = d.toc;
48
49
  }
50
+ if (d.definitions !== undefined) {
51
+ const r = parseDefinitions(d.definitions);
52
+ if (!r.ok)
53
+ return { ok: false, error: r.error };
54
+ if (r.definitions.length)
55
+ meta.definitions = r.definitions;
56
+ }
49
57
  if (d.state !== undefined) {
50
58
  const s = parseStateConfig(d.state);
51
59
  if (!s.ok)
@@ -1,10 +1,13 @@
1
+ import { type Definition } from './definitions.js';
1
2
  export declare function isReachableHref(href: unknown): href is string;
2
3
  /** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
3
4
  * page carries a ```notes block. */
4
- export declare function ArtifactMarkdown({ markdown, notes }: {
5
+ export declare function ArtifactMarkdown({ markdown, notes, definitions, }: {
5
6
  markdown: string;
6
7
  notes?: {
7
8
  artifactId: string;
8
9
  accent?: string;
9
10
  };
11
+ /** Terms to define inline (front matter `definitions:`): the first occurrence of each is underlined. */
12
+ definitions?: Definition[];
10
13
  }): import("react").JSX.Element;
@@ -3,6 +3,9 @@ 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';
7
+ import { DefinitionLayer } from '../reader/definition-layer.js';
8
+ import { remarkDefinitions } from './definitions.js';
6
9
  // Every colour is a theme variable (brand/theme.ts), so a page follows its light, dark or system mode.
7
10
  const GOLD = 'var(--a-accent)';
8
11
  function hostOf(url) {
@@ -49,12 +52,14 @@ export function isReachableHref(href) {
49
52
  * is decoded frame by frame on the CPU and stuttered on an iPhone at 24fps (2026-09-27); video
50
53
  * is decoded in hardware. */
51
54
  const VIDEO = /\.mp4(?:[?#].*)?$/i;
55
+ const VIDEO_IN_MD = /\]\([^)\s]+\.mp4(?:[?#][^)]*)?\)/i;
52
56
  const escapeAttr = (s) => s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
53
57
  // 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.
58
+ // HTML, and iOS refuses to play a video unprompted whose markup is not muted. `playsinline` keeps
59
+ // iPhone from going full screen. No `autoplay`: VideoAutoplay starts each one as it nears the
60
+ // screen, so a page of videos does not download them all on open.
56
61
  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>`;
62
+ const html = `<video src="${escapeAttr(src)}" aria-label="${escapeAttr(label)}" data-artifact-video muted loop playsinline preload="metadata" disablepictureinpicture class="block w-full"></video>`;
58
63
  return _jsx("span", { className: "my-6 block overflow-hidden rounded-lg border border-[color:var(--a-line)]", dangerouslySetInnerHTML: { __html: html } });
59
64
  }
60
65
  const components = {
@@ -140,13 +145,26 @@ function notesComponents(headings) {
140
145
  code: (props) => (/language-notes/.test(props.className ?? '') ? _jsx(NotesEarlier, {}) : _jsx(Code, { ...props })),
141
146
  };
142
147
  }
148
+ // The term is text with a dotted underline; the description copy is visually hidden and out of
149
+ // flow (absolute), so it takes no room in the line. Neither ever changes size: the definition
150
+ // that opens is drawn by DefinitionLayer on document.body.
151
+ const DEFINED_TERM_CSS = '.artifact-defined-term{text-decoration-line:underline;text-decoration-style:dotted;' +
152
+ 'text-decoration-color:color-mix(in srgb,currentColor 55%,transparent);text-decoration-thickness:0.08em;' +
153
+ 'text-underline-offset:0.2em;text-decoration-skip-ink:none;cursor:help;-webkit-tap-highlight-color:transparent}' +
154
+ '.artifact-defined-term:hover,.artifact-defined-term[aria-expanded="true"]{text-decoration-color:currentColor}' +
155
+ '.artifact-defined-term:focus-visible{outline:2px solid var(--a-accent);outline-offset:2px;border-radius:2px}' +
156
+ '.artifact-defined-term .artifact-word{cursor:inherit}' +
157
+ '@media (hover:none){.artifact-defined-term{cursor:pointer}}' +
158
+ '.artifact-defined-note{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;' +
159
+ 'clip:rect(0,0,0,0);white-space:nowrap;border:0}';
143
160
  /** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
144
161
  * page carries a ```notes block. */
145
- export function ArtifactMarkdown({ markdown, notes }) {
162
+ export function ArtifactMarkdown({ markdown, notes, definitions, }) {
146
163
  const on = !!notes && hasNotesWidget(markdown);
147
164
  const headings = headingsOf(markdown);
148
- const body = (_jsx(ReactMarkdown, { remarkPlugins: [remarkGfm], components: on ? notesComponents(headings) : pageComponents(headings), children: markdown }));
165
+ const defined = definitions?.length ? definitions : null;
166
+ const body = (_jsx(ReactMarkdown, { remarkPlugins: defined ? [remarkGfm, remarkDefinitions(defined)] : [remarkGfm], components: on ? notesComponents(headings) : pageComponents(headings), children: markdown }));
149
167
  return (
150
168
  // break-words: a bare URL in prose is one unbreakable word and pushed phones 7px sideways (2026-09-26).
151
- _jsx("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 }));
169
+ _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, defined ? (_jsxs(_Fragment, { children: [_jsx("style", { children: DEFINED_TERM_CSS }), _jsx(DefinitionLayer, {})] })) : null] }));
152
170
  }
@@ -3,6 +3,7 @@ import type { ArtifactMeta } from './front-matter.js';
3
3
  import type { Access } from './reader.js';
4
4
  import type { StateConfig } from './state.js';
5
5
  import type { ThemeMode } from '../brand/theme.js';
6
+ import type { Definition } from './definitions.js';
6
7
  export type ArtifactRecord = {
7
8
  id: string;
8
9
  title: string;
@@ -20,6 +21,7 @@ export type ArtifactRecord = {
20
21
  state?: StateConfig;
21
22
  theme?: ThemeMode;
22
23
  toc?: boolean;
24
+ definitions?: Definition[];
23
25
  markdown: string;
24
26
  createdAt: string;
25
27
  updatedAt: string;
@@ -59,6 +59,7 @@ async function saveArtifact(col, input) {
59
59
  ...(input.meta.state ? { state: input.meta.state } : {}),
60
60
  ...(input.meta.theme ? { theme: input.meta.theme } : {}),
61
61
  ...(input.meta.toc !== undefined ? { toc: input.meta.toc } : {}),
62
+ ...(input.meta.definitions?.length ? { definitions: input.meta.definitions } : {}),
62
63
  };
63
64
  if (input.id) {
64
65
  const existing = await getArtifact(col, input.id);
@@ -88,6 +89,7 @@ async function saveArtifact(col, input) {
88
89
  const look = {
89
90
  ...(input.meta.theme ? {} : { theme: FieldValue.delete() }),
90
91
  ...(input.meta.toc !== undefined ? {} : { toc: FieldValue.delete() }),
92
+ ...(input.meta.definitions?.length ? {} : { definitions: FieldValue.delete() }),
91
93
  };
92
94
  await ref.update({
93
95
  ...fields, ...password, ...subtitle, ...access, ...state, ...look, markdown: input.markdown, updatedAt: now, version: next,
@@ -3,6 +3,11 @@ export type WordTiming = {
3
3
  s: number;
4
4
  e: number;
5
5
  };
6
+ /** Wrap every word inside `root` (except skipped subtrees) in a span; return them in order. */
7
+ export declare function wrapWords(root: HTMLElement): HTMLSpanElement[];
8
+ /** The karaoke word a click lands on, or null. A defined term is a control that opens its
9
+ * definition, so a tap on it must never seek or start the narration. */
10
+ export declare function seekableWord(target: Element | null): HTMLSpanElement | null;
6
11
  /** The word to light at `now`, or -1. Nothing lights until narration has started: the first
7
12
  * word begins at 0, so an untouched page would otherwise sit with its first word highlighted. */
8
13
  export declare function litWordIndex(words: WordTiming[], now: number, started: boolean): number;
@@ -6,12 +6,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
6
6
  // sequence to the word timings by normalized token, resyncing within a short window
7
7
  // when the two disagree. Click a word to seek; the bar at the bottom plays and paces.
8
8
  import { useEffect, useRef, useState } from 'react';
9
- const SKIP = '[data-nospeak], [data-artifact-links], pre, [data-footnotes], sup, img';
9
+ const SKIP = '[data-nospeak], [data-artifact-links], pre, [data-footnotes], sup, img, style, script';
10
+ const BLOCK = 'p, li, td, th, h1, h2, h3, h4, h5, h6, blockquote, aside, dt, dd, figcaption, div';
10
11
  function normalize(w) {
11
12
  return w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
12
13
  }
13
14
  /** Wrap every word inside `root` (except skipped subtrees) in a span; return them in order. */
14
- function wrapWords(root) {
15
+ export function wrapWords(root) {
15
16
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
16
17
  acceptNode: (n) => {
17
18
  const el = n.parentElement;
@@ -24,12 +25,29 @@ function wrapWords(root) {
24
25
  for (let n = walker.nextNode(); n; n = walker.nextNode())
25
26
  nodes.push(n);
26
27
  const spans = [];
28
+ let prev = null;
27
29
  for (const node of nodes) {
28
30
  const parts = node.nodeValue.split(/(\s+)/);
29
31
  const frag = document.createDocumentFragment();
32
+ const block = node.parentElement.closest(BLOCK);
33
+ // Punctuation that continues the previous word across an element edge ("Edge" in a defined
34
+ // term, then ", which"; "**bold**,") is one spoken token with it. It is wrapped as a tail,
35
+ // not a word, so the word sequence matches the narration's whitespace tokens.
36
+ const glued = !!prev && prev.block === block && /\S$/.test(prev.text) && /^\S/.test(node.nodeValue);
37
+ prev = { text: node.nodeValue, block };
38
+ let first = true;
30
39
  for (const part of parts) {
31
40
  if (!part)
32
41
  continue;
42
+ const lead = first;
43
+ first = false;
44
+ if (lead && glued && !normalize(part)) {
45
+ const tail = document.createElement('span');
46
+ tail.textContent = part;
47
+ tail.className = 'artifact-word-tail';
48
+ frag.appendChild(tail);
49
+ continue;
50
+ }
33
51
  if (/^\s+$/.test(part)) {
34
52
  frag.appendChild(document.createTextNode(part));
35
53
  continue;
@@ -44,6 +62,13 @@ function wrapWords(root) {
44
62
  }
45
63
  return spans;
46
64
  }
65
+ /** The karaoke word a click lands on, or null. A defined term is a control that opens its
66
+ * definition, so a tap on it must never seek or start the narration. */
67
+ export function seekableWord(target) {
68
+ if (!target || target.closest('[data-defined-term]'))
69
+ return null;
70
+ return target.closest('.artifact-word');
71
+ }
47
72
  /** Map each timing index to a DOM span index, tolerating small mismatches. */
48
73
  function align(spans, words) {
49
74
  const out = [];
@@ -112,7 +137,7 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
112
137
  const spans = wrapWords(root);
113
138
  mapRef.current = align(spans, words);
114
139
  const onClick = (e) => {
115
- const el = e.target.closest('.artifact-word');
140
+ const el = seekableWord(e.target);
116
141
  if (!el)
117
142
  return;
118
143
  const idx = mapRef.current.indexOf(el);
@@ -168,7 +193,7 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
168
193
  setPlaying(true);
169
194
  }, onSeeking: () => {
170
195
  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) => {
196
+ }, onPause: () => setPlaying(false), onLoadedMetadata: (e) => setDur(e.target.duration), onRateChange: (e) => setRate(e.target.playbackRate) }), _jsx("div", { "data-artifact-player": true, 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) => {
172
197
  if (audioRef.current)
173
198
  audioRef.current.currentTime = Number(e.target.value);
174
199
  }, "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: () => {
@@ -0,0 +1 @@
1
+ export declare function DefinitionLayer(): import("react").ReactPortal | null;
@@ -0,0 +1,153 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // Opens a defined term's definition. ONE controller per page, listening on the document, so the
4
+ // terms themselves stay plain server-rendered spans: nothing in a paragraph is a React island that
5
+ // could re-render under the read-along's word spans, and nothing in a paragraph opens or closes.
6
+ //
7
+ // The definition renders through a portal on document.body, OUTSIDE the text flow, so opening it
8
+ // cannot reflow the page (earned 2026-09-27 on a sibling site: a definition toggled as
9
+ // a child of the word re-ran the paragraph's line breaking). On a phone it is a sheet pinned to
10
+ // the bottom, above the narration bar; on a desk it sits under the word, clamped in the window.
11
+ // Mouse hover shows it; a click, a tap, Enter or Space pins it; Escape or a tap elsewhere closes.
12
+ import { useCallback, useEffect, useRef, useState } from 'react';
13
+ import { createPortal } from 'react-dom';
14
+ const WIDTH = 320;
15
+ const GAP = 10;
16
+ const MARGIN = 16;
17
+ const PHONE = '(max-width: 639px)';
18
+ // The theme lives on a wrapper, not on body, so the portal carries the values it needs.
19
+ const VARS = ['--a-ground', '--a-ink', '--a-accent'];
20
+ function openFor(el, pinned) {
21
+ const note = document.getElementById(el.getAttribute('aria-describedby') ?? '');
22
+ const text = note?.dataset.definition;
23
+ if (!text)
24
+ return null;
25
+ return { el, term: el.dataset.definedTerm ?? el.textContent ?? '', text, href: note?.dataset.href, pinned };
26
+ }
27
+ export function DefinitionLayer() {
28
+ const [open, setOpen] = useState(null);
29
+ const [place, setPlace] = useState(null);
30
+ const [vars, setVars] = useState({});
31
+ const sheet = useRef(null);
32
+ const openRef = useRef(null);
33
+ openRef.current = open;
34
+ const show = useCallback((next) => {
35
+ const prev = openRef.current;
36
+ if (prev && prev.el !== next?.el)
37
+ prev.el.setAttribute('aria-expanded', 'false');
38
+ if (next)
39
+ next.el.setAttribute('aria-expanded', next.pinned ? 'true' : 'false');
40
+ openRef.current = next;
41
+ setOpen(next);
42
+ }, []);
43
+ const measure = useCallback(() => {
44
+ const o = openRef.current;
45
+ if (!o)
46
+ return setPlace(null);
47
+ const cs = getComputedStyle(o.el);
48
+ // The body font is not always the page's: the brand sets it on a wrapper, so carry it too.
49
+ setVars({ ...Object.fromEntries(VARS.map((v) => [v, cs.getPropertyValue(v).trim()]).filter(([, x]) => x)), fontFamily: cs.fontFamily });
50
+ if (window.matchMedia ? window.matchMedia(PHONE).matches : window.innerWidth < 640) {
51
+ const bar = document.querySelector('[data-artifact-player]')?.getBoundingClientRect();
52
+ const clear = bar && bar.top < window.innerHeight ? window.innerHeight - bar.top : 0;
53
+ return setPlace({ phone: true, bottom: clear + MARGIN });
54
+ }
55
+ const rects = o.el.getClientRects();
56
+ const r = rects.length ? rects[rects.length - 1] : o.el.getBoundingClientRect();
57
+ const width = Math.min(WIDTH, window.innerWidth - 2 * MARGIN);
58
+ const left = Math.min(Math.max(r.left + r.width / 2 - width / 2, MARGIN), window.innerWidth - width - MARGIN);
59
+ setPlace({ phone: false, top: r.bottom + window.scrollY + GAP, left: left + window.scrollX, width });
60
+ }, []);
61
+ useEffect(() => {
62
+ const termOf = (t) => (t instanceof Element ? t.closest('[data-defined-term]') : null);
63
+ const toggle = (el) => {
64
+ const cur = openRef.current;
65
+ show(cur && cur.el === el && cur.pinned ? null : openFor(el, true));
66
+ };
67
+ const onClick = (e) => {
68
+ const el = termOf(e.target);
69
+ if (el)
70
+ toggle(el);
71
+ };
72
+ const onDown = (e) => {
73
+ const cur = openRef.current;
74
+ if (!cur?.pinned)
75
+ return;
76
+ const t = e.target;
77
+ if (termOf(t) === cur.el || sheet.current?.contains(t))
78
+ return;
79
+ show(null);
80
+ };
81
+ const onOver = (e) => {
82
+ if (e.pointerType !== 'mouse' || openRef.current?.pinned)
83
+ return;
84
+ const el = termOf(e.target);
85
+ if (el && openRef.current?.el !== el)
86
+ show(openFor(el, false));
87
+ };
88
+ const onOut = (e) => {
89
+ const cur = openRef.current;
90
+ if (e.pointerType !== 'mouse' || !cur || cur.pinned)
91
+ return;
92
+ if (termOf(e.target) === cur.el && termOf(e.relatedTarget) !== cur.el)
93
+ show(null);
94
+ };
95
+ const onKey = (e) => {
96
+ if (e.key === 'Escape' && openRef.current) {
97
+ const el = openRef.current.el;
98
+ show(null);
99
+ el.focus?.();
100
+ return;
101
+ }
102
+ if (e.key !== 'Enter' && e.key !== ' ')
103
+ return;
104
+ const el = termOf(e.target);
105
+ if (!el)
106
+ return;
107
+ e.preventDefault();
108
+ toggle(el);
109
+ };
110
+ document.addEventListener('click', onClick);
111
+ document.addEventListener('pointerdown', onDown);
112
+ document.addEventListener('pointerover', onOver);
113
+ document.addEventListener('pointerout', onOut);
114
+ document.addEventListener('keydown', onKey);
115
+ return () => {
116
+ document.removeEventListener('click', onClick);
117
+ document.removeEventListener('pointerdown', onDown);
118
+ document.removeEventListener('pointerover', onOver);
119
+ document.removeEventListener('pointerout', onOut);
120
+ document.removeEventListener('keydown', onKey);
121
+ };
122
+ }, [show]);
123
+ useEffect(() => {
124
+ measure();
125
+ if (!open)
126
+ return;
127
+ window.addEventListener('resize', measure);
128
+ return () => window.removeEventListener('resize', measure);
129
+ }, [open, measure]);
130
+ if (!open || !place)
131
+ return null;
132
+ const style = {
133
+ ...vars,
134
+ zIndex: 60,
135
+ boxSizing: 'border-box',
136
+ borderRadius: 10,
137
+ padding: '12px 16px',
138
+ background: 'var(--a-ink, #111)',
139
+ color: 'var(--a-ground, #fff)',
140
+ fontSize: 15,
141
+ lineHeight: 1.45,
142
+ fontStyle: 'normal',
143
+ fontWeight: 400,
144
+ letterSpacing: 'normal',
145
+ textAlign: 'left',
146
+ boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
147
+ pointerEvents: open.pinned ? 'auto' : 'none',
148
+ ...(place.phone
149
+ ? { position: 'fixed', left: MARGIN, right: MARGIN, bottom: `calc(${place.bottom}px + env(safe-area-inset-bottom, 0px))` }
150
+ : { position: 'absolute', top: place.top, left: place.left, width: place.width }),
151
+ };
152
+ return createPortal(_jsxs("div", { ref: sheet, "data-definition-sheet": true, role: "note", "aria-hidden": open.href ? undefined : true, style: style, children: [_jsx("span", { style: { display: 'block', marginBottom: 4, fontSize: 11, fontWeight: 500, letterSpacing: '0.18em', textTransform: 'uppercase', opacity: 0.6 }, children: open.term }), open.text, open.href ? (_jsx("a", { href: open.href, target: "_blank", rel: "noopener noreferrer", style: { display: 'block', marginTop: 8, fontSize: 13, color: 'inherit', textDecoration: 'underline', textUnderlineOffset: '0.2em', opacity: 0.85 }, children: "Read more" })) : null] }), document.body);
153
+ }
@@ -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
+ }
@@ -147,7 +147,7 @@ export function createArtifactRoutes(config) {
147
147
  }
148
148
  const when = new Date(a.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
149
149
  const toc = showToc(a.markdown, a.toc) ? tocOf(a.markdown) : [];
150
- return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsxs("div", { id: "artifact-narration-root", children: [_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary }), _jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-50", children: ["Updated ", when] })] }), a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null, _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc.length ? _jsx(TocInline, { items: toc }) : null, _jsx(ArtifactMarkdown, { markdown: a.markdown, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined })] })] }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : null] }));
150
+ return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsxs("div", { id: "artifact-narration-root", children: [_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary }), _jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-50", children: ["Updated ", when] })] }), a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null, _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc.length ? _jsx(TocInline, { items: toc }) : null, _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined })] })] }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : null] }));
151
151
  }
152
152
  const cookieLine = (value, maxAge) => `${GRANT_COOKIE}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
153
153
  /** GET /api/reader/enter?pass=<pass>&to=/<id>: where the sign-in authority sends a signed-in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.4.6",
3
+ "version": "0.5.0",
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",