@supersuit/artifacts 0.4.7 → 0.6.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,46 @@
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.6.0 (2026-09-27)
7
+
8
+ **"Updated" names the exact minute, in the reader's own time zone.**
9
+
10
+ - The line under an artifact's summary read "Updated September 27, 2026", so a page republished
11
+ three times in one evening looked the same each time. It now reads "Updated September 27, 2026
12
+ at 8:07 AM CDT": the server renders UTC, and a small client component, `UpdatedTime`, swaps in
13
+ the viewer's zone once the page runs. The value is a `<time dateTime>` carrying the ISO stamp.
14
+ - New `formatUpdated(iso, timeZone?)` in `src/artifacts/updated-at.ts`; a bad value renders
15
+ nothing rather than an invented time.
16
+ - DETECTOR: an artifact page whose "Updated" line has no clock time.
17
+ - REMEDY: take 0.6.0; nothing to configure.
18
+ - Tests: `updated-at.test.ts` (exact minute and zone, UTC default, bad input), verified red with
19
+ the minute removed.
20
+
21
+ ## 0.5.0 (2026-09-27)
22
+
23
+ **Inline definitions: a page can define its jargon, and a reader sees it in line.**
24
+
25
+ - New front matter key `definitions:`, a list of `Term | definition | optional link` (or a map).
26
+ The first occurrence of each term in the prose renders with a dotted underline; hover (desk) or
27
+ tap (phone) opens the definition, drawn by a new client component, `DefinitionLayer`, through a
28
+ portal on `document.body`, so nothing in the paragraph opens or closes and nothing reflows. The
29
+ store keeps the terms and a republish without the key removes them.
30
+ - The read-along reader skips `<style>` and `<script>` text (it used to wrap any text node, which
31
+ would have turned an inline stylesheet into word spans) and a click on a defined term no longer
32
+ seeks. Its bar carries `data-artifact-player` so an overlay can sit above it.
33
+ - Punctuation that continues a word across an element edge (`Edge` in a term, then `, which`;
34
+ `**bold**,`) is wrapped as a tail of that word rather than a word of its own, so the
35
+ highlighter's word sequence matches the narration's tokens. Found in WebKit, where a comma after
36
+ a defined term had become an extra, clickable word.
37
+ - **DETECTOR:** a page with `definitions:` is refused as an unknown key before 0.5.0.
38
+ - **REMEDY:** update the dependency; no host change. A publisher that filters keys must pass
39
+ `definitions` through.
40
+ - **Tests:** parsing both shapes and refusals; first occurrence only, whole words, case rules and
41
+ every skipped context; the karaoke word spans are identical in number and order with and without
42
+ definitions and equal the narrated words; a tap on a defined word is not a seek; the definition
43
+ opens on `document.body` with the paragraph's markup unchanged, and closes on a second tap,
44
+ Escape or a tap elsewhere; the store round-trips and clears the key.
45
+
6
46
  ## 0.4.7 (2026-09-27)
7
47
 
8
48
  **A page of videos opens as fast as a page of text.**
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;
@@ -4,6 +4,8 @@ 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
6
  import { VideoAutoplay } from '../reader/video-autoplay.js';
7
+ import { DefinitionLayer } from '../reader/definition-layer.js';
8
+ import { remarkDefinitions } from './definitions.js';
7
9
  // Every colour is a theme variable (brand/theme.ts), so a page follows its light, dark or system mode.
8
10
  const GOLD = 'var(--a-accent)';
9
11
  function hostOf(url) {
@@ -143,13 +145,26 @@ function notesComponents(headings) {
143
145
  code: (props) => (/language-notes/.test(props.className ?? '') ? _jsx(NotesEarlier, {}) : _jsx(Code, { ...props })),
144
146
  };
145
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}';
146
160
  /** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
147
161
  * page carries a ```notes block. */
148
- export function ArtifactMarkdown({ markdown, notes }) {
162
+ export function ArtifactMarkdown({ markdown, notes, definitions, }) {
149
163
  const on = !!notes && hasNotesWidget(markdown);
150
164
  const headings = headingsOf(markdown);
151
- 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 }));
152
167
  return (
153
168
  // break-words: a bare URL in prose is one unbreakable word and pushed phones 7px sideways (2026-09-26).
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] }));
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] }));
155
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,
@@ -0,0 +1 @@
1
+ export declare function formatUpdated(iso: string, timeZone?: string): string;
@@ -0,0 +1,11 @@
1
+ // The "Updated" line under an artifact's summary, to the exact minute with its zone named.
2
+ // The server renders UTC; the reader swaps in the viewer's own zone once it runs, so the minute
3
+ // is always one a person can place (Gary, 2026-09-27).
4
+ export function formatUpdated(iso, timeZone = 'UTC') {
5
+ const d = new Date(iso);
6
+ if (Number.isNaN(d.getTime()))
7
+ return '';
8
+ const date = d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone });
9
+ const time = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', timeZone, timeZoneName: 'short' });
10
+ return `${date} at ${time}`;
11
+ }
@@ -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,4 @@
1
+ /** The exact minute a page was last updated, in the reader's own time zone once the page runs. */
2
+ export declare function UpdatedTime({ iso }: {
3
+ iso: string;
4
+ }): import("react").JSX.Element;
@@ -0,0 +1,12 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useEffect, useState } from 'react';
4
+ import { formatUpdated } from '../artifacts/updated-at.js';
5
+ /** The exact minute a page was last updated, in the reader's own time zone once the page runs. */
6
+ export function UpdatedTime({ iso }) {
7
+ const [text, setText] = useState(() => formatUpdated(iso));
8
+ useEffect(() => {
9
+ setText(formatUpdated(iso, Intl.DateTimeFormat().resolvedOptions().timeZone));
10
+ }, [iso]);
11
+ return (_jsx("time", { dateTime: iso, suppressHydrationWarning: true, children: text }));
12
+ }
@@ -16,6 +16,7 @@ import { showToc, tocOf, TocInline, TocRail } from '../artifacts/toc.js';
16
16
  import { renderShareCard } from '../brand/share-card.js';
17
17
  import { ArtifactReader } from '../reader/artifact-reader.js';
18
18
  import { ReaderWatch } from '../reader/reader-watch.js';
19
+ import { UpdatedTime } from '../reader/updated-time.js';
19
20
  import { ACCESS_LEVELS, GRANT_COOKIE, GRANT_TTL_SECONDS, decide, firstName, mintGrant, safeReturnPath, signInUrl, verifyGrant, verifyPass, } from '../artifacts/reader.js';
20
21
  import { FLAG_KINDS, summarize } from '../artifacts/readers-store.js';
21
22
  import { AckDoor, ConfidentialBanner, NO_PRINT_CSS, NotAllowedDoor, SignInDoor, Watermark, ackText } from '../artifacts/confidential.js';
@@ -145,9 +146,8 @@ export function createArtifactRoutes(config) {
145
146
  words = [];
146
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 ", _jsx(UpdatedTime, { iso: a.updatedAt })] })] }), 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.7",
3
+ "version": "0.6.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",