@supersuit/artifacts 0.2.0 → 0.4.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.
@@ -0,0 +1,38 @@
1
+ import type { StateConfig, Visibility } from './state.js';
2
+ import { type Heading } from './notes-place.js';
3
+ export { MAX_NOTE_CHARS, NOTES_SLOT, placeNotes, type Heading, type NoteValue, type PlacedNote } from './notes-place.js';
4
+ export type NotesWidget = {
5
+ line: number;
6
+ visibility?: Exclude<Visibility, 'tally'>;
7
+ };
8
+ /** GitHub's rule: lowercase, drop everything but letters, digits, spaces, dashes and
9
+ * underscores, spaces to dashes. A heading of only punctuation is `section`. */
10
+ export declare function slugify(text: string): string;
11
+ /** Every heading in source order, with the line it starts on and a slug unique on the page
12
+ * (a repeated heading gets `-1`, `-2`). The renderer finds a heading here by its line, so the
13
+ * slug a note stores is exactly the slug the page draws. */
14
+ export declare function headingsOf(markdown: string): Heading[];
15
+ /** Finds the widget fences in a body. `offset` is how many lines of the file sit above the body
16
+ * (the front matter), so every error names the line the author sees in their editor. */
17
+ export declare function scanWidgets(body: string, offset: number): {
18
+ ok: true;
19
+ notes: NotesWidget | null;
20
+ } | {
21
+ ok: false;
22
+ error: string;
23
+ };
24
+ /** A widget declares its own slot. The notes block adds `notes: { shape: many }` to the page's
25
+ * `state:` (creating `state:` with its defaults when the page had none), so the state API
26
+ * takes notes with no change and a republish's shape checks cover the slot. */
27
+ export declare function mergeWidgetState(state: StateConfig | undefined, notes: NotesWidget | null): {
28
+ ok: true;
29
+ state: StateConfig | undefined;
30
+ } | {
31
+ ok: false;
32
+ error: string;
33
+ };
34
+ /** Does this page's body carry a notes block? The state route asks, to check a note's shape. */
35
+ export declare function hasNotesWidget(markdown: string): boolean;
36
+ /** The server's check on a note, on a page with a notes block. The browser checks too; this is
37
+ * the one that counts. */
38
+ export declare function checkNoteValue(v: unknown): string | null;
@@ -0,0 +1,141 @@
1
+ // Widgets: fenced blocks in a document's markdown that give readers a place to answer, drawn by
2
+ // the shell in the page's brand and written through the state API (state.ts, state-routes.ts).
3
+ //
4
+ // This version draws ONE widget, `notes`: a small note control beside every heading, kept in the
5
+ // `many` slot `notes`. A note stores the heading it was left under, both its slug and its text,
6
+ // so a republish that renames or removes the heading keeps the note and shows it under "notes
7
+ // on earlier versions" instead of losing it or attaching it to the wrong section.
8
+ //
9
+ // Everything here is pure and server-safe: parsing, publish-time validation, the heading slugs
10
+ // the renderer and the stored notes agree on, and where each note is placed. The drawing lives
11
+ // in ../widgets/notes.tsx.
12
+ import { unified } from 'unified';
13
+ import remarkParse from 'remark-parse';
14
+ import remarkGfm from 'remark-gfm';
15
+ import { MAX_NOTE_CHARS, NOTES_SLOT } from './notes-place.js';
16
+ export { MAX_NOTE_CHARS, NOTES_SLOT, placeNotes } from './notes-place.js';
17
+ /** Widgets the design names and this version does not draw yet. A fence using one is refused at
18
+ * publish, so a page never ships a code block that turns into a live widget on a later update. */
19
+ const NOT_YET = ['poll', 'form', 'checklist'];
20
+ const MAX_HEADING_CHARS = 300;
21
+ const parse = (markdown) => unified().use(remarkParse).use(remarkGfm).parse(markdown);
22
+ function inline(nodes) {
23
+ let out = '';
24
+ for (const n of nodes) {
25
+ if (n.type === 'text' || n.type === 'inlineCode')
26
+ out += n.value;
27
+ else if (n.type === 'break')
28
+ out += ' ';
29
+ else if ('children' in n)
30
+ out += inline(n.children);
31
+ }
32
+ return out;
33
+ }
34
+ function walk(nodes, visit) {
35
+ for (const n of nodes) {
36
+ visit(n);
37
+ if ('children' in n && n.type !== 'heading' && n.type !== 'paragraph')
38
+ walk(n.children, visit);
39
+ }
40
+ }
41
+ /** GitHub's rule: lowercase, drop everything but letters, digits, spaces, dashes and
42
+ * underscores, spaces to dashes. A heading of only punctuation is `section`. */
43
+ export function slugify(text) {
44
+ const s = text.trim().toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu, '').replace(/\s/g, '-');
45
+ return s || 'section';
46
+ }
47
+ /** Every heading in source order, with the line it starts on and a slug unique on the page
48
+ * (a repeated heading gets `-1`, `-2`). The renderer finds a heading here by its line, so the
49
+ * slug a note stores is exactly the slug the page draws. */
50
+ export function headingsOf(markdown) {
51
+ const out = [];
52
+ const seen = new Map();
53
+ walk(parse(markdown).children, (n) => {
54
+ if (n.type !== 'heading')
55
+ return;
56
+ const text = inline(n.children).replace(/\s+/g, ' ').trim();
57
+ const base = slugify(text);
58
+ const k = seen.get(base) ?? 0;
59
+ seen.set(base, k + 1);
60
+ out.push({ depth: n.depth, line: n.position?.start.line ?? 0, text, slug: k ? `${base}-${k}` : base });
61
+ });
62
+ return out;
63
+ }
64
+ /** Finds the widget fences in a body. `offset` is how many lines of the file sit above the body
65
+ * (the front matter), so every error names the line the author sees in their editor. */
66
+ export function scanWidgets(body, offset) {
67
+ const fences = [];
68
+ walk(parse(body).children, (n) => { if (n.type === 'code')
69
+ fences.push(n); });
70
+ let notes = null;
71
+ for (const f of fences) {
72
+ const line = (f.position?.start.line ?? 1) + offset;
73
+ if (f.lang && NOT_YET.includes(f.lang))
74
+ return { ok: false, error: `line ${line}: the ${f.lang} widget is not available in this version of the artifacts package` };
75
+ if (f.lang !== NOTES_SLOT)
76
+ continue;
77
+ if (notes)
78
+ return { ok: false, error: `line ${line}: a page takes at most one notes block` };
79
+ if (f.meta?.trim())
80
+ return { ok: false, error: `line ${line}: the notes block takes no name; it always writes to slot "notes"` };
81
+ const w = { line };
82
+ const rows = f.value ? f.value.split('\n') : [];
83
+ for (let i = 0; i < rows.length; i++) {
84
+ const raw = rows[i].replace(/#.*$/, '').trim();
85
+ if (!raw)
86
+ continue;
87
+ const at = line + 1 + i;
88
+ const m = /^([A-Za-z_-]+)\s*:\s*(.*)$/.exec(raw);
89
+ if (!m)
90
+ return { ok: false, error: `line ${at}: the notes block takes lines like "visibility: shared"` };
91
+ if (m[1] !== 'visibility')
92
+ return { ok: false, error: `line ${at}: the notes block has an unknown key: ${m[1]}` };
93
+ if (m[2] !== 'private' && m[2] !== 'shared')
94
+ return { ok: false, error: `line ${at}: notes visibility must be private or shared` };
95
+ w.visibility = m[2];
96
+ }
97
+ notes = w;
98
+ }
99
+ return { ok: true, notes };
100
+ }
101
+ /** A widget declares its own slot. The notes block adds `notes: { shape: many }` to the page's
102
+ * `state:` (creating `state:` with its defaults when the page had none), so the state API
103
+ * takes notes with no change and a republish's shape checks cover the slot. */
104
+ export function mergeWidgetState(state, notes) {
105
+ if (!notes)
106
+ return { ok: true, state };
107
+ const had = state?.slots[NOTES_SLOT];
108
+ if (had && had.shape !== 'many')
109
+ return { ok: false, error: `line ${notes.line}: the notes block writes to slot "notes" as shape many, and state: declares it shape ${had.shape}; rename that slot` };
110
+ if (had?.visibility && notes.visibility && had.visibility !== notes.visibility)
111
+ return { ok: false, error: `line ${notes.line}: the notes block says visibility ${notes.visibility} and state: says ${had.visibility} for slot "notes"; say it once` };
112
+ const slot = had
113
+ ? (notes.visibility && !had.visibility ? { ...had, visibility: notes.visibility } : had)
114
+ : { shape: 'many', ...(notes.visibility ? { visibility: notes.visibility } : {}) };
115
+ const base = state ?? { writers: 'signed-in', visibility: 'private', slots: {} };
116
+ if (slot === had)
117
+ return { ok: true, state: base };
118
+ return { ok: true, state: { ...base, slots: { ...base.slots, [NOTES_SLOT]: slot } } };
119
+ }
120
+ /** Does this page's body carry a notes block? The state route asks, to check a note's shape. */
121
+ export function hasNotesWidget(markdown) {
122
+ const r = scanWidgets(markdown, 0);
123
+ return r.ok && r.notes !== null;
124
+ }
125
+ const SLUG = /^[\p{Ll}\p{Lo}\p{Lm}\p{N}_-]{1,120}$/u;
126
+ const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
127
+ /** The server's check on a note, on a page with a notes block. The browser checks too; this is
128
+ * the one that counts. */
129
+ export function checkNoteValue(v) {
130
+ if (!isMap(v) || Object.keys(v).some((k) => !['slug', 'heading', 'note'].includes(k)))
131
+ return 'a note is { slug, heading, note }';
132
+ if (typeof v.slug !== 'string' || !SLUG.test(v.slug))
133
+ return 'a note needs the slug of the heading it is under';
134
+ if (typeof v.heading !== 'string' || v.heading.length > MAX_HEADING_CHARS)
135
+ return 'a note needs the heading it is under';
136
+ if (typeof v.note !== 'string' || !v.note.trim())
137
+ return 'a note needs some text';
138
+ if (v.note.length > MAX_NOTE_CHARS)
139
+ return `a note is at most ${MAX_NOTE_CHARS} characters`;
140
+ return null;
141
+ }
@@ -0,0 +1,7 @@
1
+ import type { Backdrop } from './pack.js';
2
+ /** The Freedom Glow's colours (a blue core and the spectrum it separates into), mixed most of
3
+ * the way to cream so a light page still reads as light. */
4
+ export declare const GLOW_PASTELS: string[];
5
+ export declare function Bubbles({ backdrop }: {
6
+ backdrop: Backdrop;
7
+ }): import("react").JSX.Element;
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /** The Freedom Glow's colours (a blue core and the spectrum it separates into), mixed most of
3
+ * the way to cream so a light page still reads as light. */
4
+ export const GLOW_PASTELS = ['150,190,255', '215,185,240', '160,225,220', '250,195,180', '250,215,160', '190,210,255'];
5
+ // [animation, duration s, transform-origin] per bubble, after the original's six.
6
+ const MOTION = [
7
+ ['a-bubble-v', 30, 'center center'],
8
+ ['a-bubble-c', 20, 'calc(50% - 400px) center'],
9
+ ['a-bubble-c', 40, 'calc(50% + 400px) center'],
10
+ ['a-bubble-h', 40, 'calc(50% - 200px) center'],
11
+ ['a-bubble-c', 20, 'calc(50% - 800px) calc(50% + 200px)'],
12
+ ['a-bubble-v', 35, 'calc(50% + 300px) calc(50% - 200px)'],
13
+ ];
14
+ const CSS = `
15
+ @keyframes a-bubble-v{0%,100%{transform:translateY(-50%)}50%{transform:translateY(50%)}}
16
+ @keyframes a-bubble-c{0%{transform:rotate(0deg)}50%{transform:rotate(180deg)}100%{transform:rotate(360deg)}}
17
+ @keyframes a-bubble-h{0%,100%{transform:translateX(-50%) translateY(-10%)}50%{transform:translateX(50%) translateY(10%)}}
18
+ [data-artifact-backdrop] .a-bubble{position:absolute;width:80%;height:80%;top:10%;left:10%;border-radius:9999px;mix-blend-mode:hard-light;animation-timing-function:ease;animation-iteration-count:infinite}
19
+ @media (prefers-reduced-motion: reduce){[data-artifact-backdrop] .a-bubble{animation:none!important}}
20
+ `;
21
+ export function Bubbles({ backdrop }) {
22
+ const colors = backdrop.colors?.length ? backdrop.colors : GLOW_PASTELS;
23
+ const veil = Math.round((backdrop.veil ?? 0.4) * 100);
24
+ return (_jsxs("div", { "data-artifact-backdrop": "bubbles", "aria-hidden": true, style: { position: 'fixed', inset: 0, zIndex: -1, overflow: 'hidden', background: 'var(--a-ground)', pointerEvents: 'none' }, children: [_jsx("style", { dangerouslySetInnerHTML: { __html: CSS } }), _jsx("div", { style: { position: 'absolute', inset: 0, filter: 'blur(40px)' }, children: colors.map((c, i) => {
25
+ const [name, secs, origin] = MOTION[i % MOTION.length];
26
+ return (_jsx("div", { className: "a-bubble", style: {
27
+ background: `radial-gradient(circle at center, rgba(${c},0.8) 0, rgba(${c},0) 50%)`,
28
+ animationName: name,
29
+ animationDuration: `${secs}s`,
30
+ animationDirection: i % 2 ? 'reverse' : 'normal',
31
+ transformOrigin: origin,
32
+ } }, i));
33
+ }) }), _jsx("div", { style: { position: 'absolute', inset: 0, background: `color-mix(in srgb, var(--a-ground) ${veil}%, transparent)` } })] }));
34
+ }
@@ -1,3 +1,5 @@
1
- export { freedomDefault, type BrandPack, type ShareCardAssets } from './pack.js';
1
+ export { freedomDefault, type BrandPack, type ShareCardAssets, type Palette, type Backdrop } from './pack.js';
2
2
  export { BrandGround, BrandMark } from './wrapper.js';
3
3
  export { renderShareCard } from './share-card.js';
4
+ export { THEME_MODES, type ThemeMode, themeCss, themedPack, resolveMode } from './theme.js';
5
+ export { GLOW_PASTELS } from './backdrop.js';
@@ -1,3 +1,5 @@
1
1
  export { freedomDefault } from './pack.js';
2
2
  export { BrandGround, BrandMark } from './wrapper.js';
3
3
  export { renderShareCard } from './share-card.js';
4
+ export { THEME_MODES, themeCss, themedPack, resolveMode } from './theme.js';
5
+ export { GLOW_PASTELS } from './backdrop.js';
@@ -1,4 +1,18 @@
1
1
  import type { ComponentType, ReactNode } from 'react';
2
+ import type { ThemeMode } from './theme.js';
3
+ /** One set of page colours. */
4
+ export type Palette = {
5
+ ground: string;
6
+ ink: string;
7
+ accent: string;
8
+ };
9
+ /** The animated ground behind a page. `bubbles` is soft colour drifting under a veil of the
10
+ * ground colour. Colours are `r,g,b` triplets. */
11
+ export type Backdrop = {
12
+ kind: 'bubbles';
13
+ colors?: string[];
14
+ veil?: number;
15
+ };
2
16
  /**
3
17
  * A brand pack is what makes an operator's pages theirs: data plus at most two
4
18
  * components. The package ships `freedomDefault`; an instance passes its own. A pack
@@ -7,10 +21,16 @@ import type { ComponentType, ReactNode } from 'react';
7
21
  export type BrandPack = {
8
22
  id: string;
9
23
  name: string;
10
- /** CSS colours. */
24
+ /** CSS colours: the DARK palette, which is every pack's original one. */
11
25
  ground: string;
12
26
  ink: string;
13
27
  accent: string;
28
+ /** The light palette. Absent, a warm paper palette with this pack's accent is used. */
29
+ light?: Palette;
30
+ /** The mode a page opens in unless its front matter says otherwise. Default `dark`. */
31
+ mode?: ThemeMode;
32
+ /** An animated ground drawn behind the page. Ignored when the pack has its own Wrapper. */
33
+ backdrop?: Backdrop;
14
34
  /** CSS font-family values for headings and body. */
15
35
  type: {
16
36
  display: string;
package/lib/brand/pack.js CHANGED
@@ -5,6 +5,7 @@ export const freedomDefault = {
5
5
  ground: '#0e0f13',
6
6
  ink: '#ece7dc',
7
7
  accent: '#c9a96e',
8
+ light: { ground: '#f6f1e7', ink: '#1c1a17', accent: '#8a6a2c' },
8
9
  type: {
9
10
  display: 'Georgia, "Iowan Old Style", "Times New Roman", serif',
10
11
  body: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
@@ -0,0 +1,9 @@
1
+ import type { BrandPack } from './pack.js';
2
+ export declare const THEME_MODES: readonly ["light", "dark", "system"];
3
+ export type ThemeMode = (typeof THEME_MODES)[number];
4
+ export declare function resolveMode(pack: BrandPack, page?: ThemeMode): ThemeMode;
5
+ /** The stylesheet for one pack: its two palettes, the tones derived from them, and the
6
+ * device switch for `system`. */
7
+ export declare function themeCss(pack: BrandPack): string;
8
+ /** The pack as the page renders it: every colour is the variable the mode sets. */
9
+ export declare function themedPack(pack: BrandPack): BrandPack;
@@ -0,0 +1,30 @@
1
+ export const THEME_MODES = ['light', 'dark', 'system'];
2
+ /** The light palette a pack gets when it declares none: warm paper, near-black ink, and its
3
+ * own accent, which every shipped accent is dark enough to read on. */
4
+ const FALLBACK_LIGHT = { ground: '#f7f3ea', ink: '#1d1b17' };
5
+ export function resolveMode(pack, page) {
6
+ return page ?? pack.mode ?? 'dark';
7
+ }
8
+ function vars(p, scheme) {
9
+ return `--a-ground:${p.ground};--a-ink:${p.ink};--a-accent:${p.accent};color-scheme:${scheme}`;
10
+ }
11
+ /** The stylesheet for one pack: its two palettes, the tones derived from them, and the
12
+ * device switch for `system`. */
13
+ export function themeCss(pack) {
14
+ const dark = { ground: pack.ground, ink: pack.ink, accent: pack.accent };
15
+ const light = pack.light ?? { ...FALLBACK_LIGHT, accent: pack.accent };
16
+ const mix = (n) => `color-mix(in srgb,var(--a-ink) ${n}%,var(--a-ground))`;
17
+ return [
18
+ `[data-artifact-theme]{--a-strong:var(--a-ink);--a-body:${mix(86)};--a-muted:${mix(55)};` +
19
+ `--a-line:${mix(12)};--a-line-strong:${mix(28)};--a-surface:${mix(4)};--a-surface-strong:${mix(9)};` +
20
+ `--a-code:${mix(6)};--a-on-accent:var(--a-ground)}`,
21
+ `[data-artifact-theme="dark"]{${vars(dark, 'dark')}}`,
22
+ `[data-artifact-theme="light"]{${vars(light, 'light')}}`,
23
+ `[data-artifact-theme="system"]{${vars(dark, 'dark')}}`,
24
+ `@media (prefers-color-scheme: light){[data-artifact-theme="system"]{${vars(light, 'light')}}}`,
25
+ ].join('\n');
26
+ }
27
+ /** The pack as the page renders it: every colour is the variable the mode sets. */
28
+ export function themedPack(pack) {
29
+ return { ...pack, ground: 'var(--a-ground)', ink: 'var(--a-ink)', accent: 'var(--a-accent)' };
30
+ }
@@ -1,8 +1,12 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { BrandPack } from './pack.js';
3
- /** The page backdrop: the pack's own Wrapper when it has one, else a flat ground. */
4
- export declare function BrandGround({ pack, children }: {
3
+ import { type ThemeMode } from './theme.js';
4
+ /** The page backdrop: the pack's own Wrapper when it has one, else its ground (and its animated
5
+ * backdrop, when it declares one). Either way the colour variables for the mode are set here,
6
+ * so everything inside paints with them. `mode` is the page's override of the pack's. */
7
+ export declare function BrandGround({ pack, mode, children }: {
5
8
  pack: BrandPack;
9
+ mode?: ThemeMode;
6
10
  children: ReactNode;
7
11
  }): import("react").JSX.Element;
8
12
  /** The mark above the kicker: the pack's own, else a small dove. */
@@ -1,9 +1,19 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- /** The page backdrop: the pack's own Wrapper when it has one, else a flat ground. */
3
- export function BrandGround({ pack, children }) {
4
- if (pack.Wrapper)
5
- return _jsx(pack.Wrapper, { children: children });
6
- return (_jsx("div", { "data-brand-ground": pack.id, className: "min-h-screen", style: { background: pack.ground, color: pack.ink, fontFamily: pack.type.body }, children: children }));
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Bubbles } from './backdrop.js';
3
+ import { resolveMode, themeCss } from './theme.js';
4
+ /** The page backdrop: the pack's own Wrapper when it has one, else its ground (and its animated
5
+ * backdrop, when it declares one). Either way the colour variables for the mode are set here,
6
+ * so everything inside paints with them. `mode` is the page's override of the pack's. */
7
+ export function BrandGround({ pack, mode, children }) {
8
+ const m = resolveMode(pack, mode);
9
+ const css = _jsx("style", { dangerouslySetInnerHTML: { __html: themeCss(pack) } });
10
+ if (pack.Wrapper) {
11
+ // display:contents keeps the wrapper's own layout; custom properties still inherit through it.
12
+ return (_jsxs("div", { "data-artifact-theme": m, style: { display: 'contents' }, children: [css, _jsx(pack.Wrapper, { children: children })] }));
13
+ }
14
+ return (_jsxs("div", { "data-brand-ground": pack.id, "data-artifact-theme": m, className: "min-h-screen",
15
+ // isolation puts the fixed backdrop (z-index -1) above this ground and below the page.
16
+ style: { background: 'var(--a-ground)', color: 'var(--a-ink)', fontFamily: pack.type.body, position: 'relative', isolation: 'isolate' }, children: [css, pack.backdrop?.kind === 'bubbles' ? _jsx(Bubbles, { backdrop: pack.backdrop }) : null, children] }));
7
17
  }
8
18
  /** The mark above the kicker: the pack's own, else a small dove. */
9
19
  export function BrandMark({ pack, className }) {
@@ -152,14 +152,14 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
152
152
  };
153
153
  return (_jsxs(_Fragment, { children: [_jsx("style", { children: `
154
154
  .artifact-word { border-radius: 3px; transition: background-color 120ms; }
155
- .artifact-word:hover { background: rgba(255,255,255,0.08); cursor: pointer; }
156
- .artifact-word-lit { background: ${accent}59; color: #fff; }
157
- ` }), _jsx("audio", { ref: audioRef, src: src, preload: "metadata", onPlay: () => setPlaying(true), 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-white/10 backdrop-blur", style: { background: `${ground}e6` }, 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) => {
155
+ .artifact-word:hover { background: var(--a-surface-strong, rgba(255,255,255,0.08)); cursor: pointer; }
156
+ .artifact-word-lit { background: color-mix(in srgb, ${accent} 35%, transparent); color: var(--a-strong, #fff); }
157
+ ` }), _jsx("audio", { ref: audioRef, src: src, preload: "metadata", onPlay: () => setPlaying(true), onPause: () => setPlaying(false), onLoadedMetadata: (e) => setDur(e.target.duration), onRateChange: (e) => setRate(e.target.playbackRate) }), _jsx("div", { className: "fixed inset-x-0 bottom-0 z-40 border-t border-[color:var(--a-line)] backdrop-blur", style: { background: `color-mix(in srgb, ${ground} 90%, transparent)` }, children: _jsxs("div", { className: "mx-auto flex max-w-2xl items-center gap-4 px-6 py-3", children: [_jsx("button", { type: "button", onClick: toggle, "aria-label": playing ? 'Pause narration' : 'Play narration', className: "flex h-11 w-11 shrink-0 items-center justify-center rounded-full", style: { background: accent, color: ground }, children: playing ? (_jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("rect", { x: "6", y: "5", width: "4", height: "14" }), _jsx("rect", { x: "14", y: "5", width: "4", height: "14" })] })) : (_jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M8 5v14l11-7z" }) })) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "text-[11px] uppercase tracking-[0.2em]", style: { color: accent }, children: narrator }), _jsx("input", { type: "range", min: 0, max: dur || 0, step: 0.1, value: Math.min(t, dur || 0), onChange: (e) => {
158
158
  if (audioRef.current)
159
159
  audioRef.current.currentTime = Number(e.target.value);
160
- }, "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-zinc-400", children: [fmt(t), " / ", fmt(dur)] }), _jsxs("button", { type: "button", onClick: () => {
160
+ }, "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: () => {
161
161
  const next = rate >= 1.5 ? 1 : rate + 0.25;
162
162
  if (audioRef.current)
163
163
  audioRef.current.playbackRate = next;
164
- }, className: "shrink-0 rounded border border-white/15 px-2 py-1 text-xs text-zinc-300", "aria-label": "Playback speed", children: [rate, "x"] })] }) }), _jsx("div", { className: "h-20", "aria-hidden": true })] }));
164
+ }, className: "shrink-0 rounded border border-[color:var(--a-line-strong)] px-2 py-1 text-xs text-[color:var(--a-body)]", "aria-label": "Playback speed", children: [rate, "x"] })] }) }), _jsx("div", { className: "h-20", "aria-hidden": true })] }));
165
165
  }
@@ -121,5 +121,5 @@ export function ReaderWatch({ artifactId, endpoint, accent }) {
121
121
  window.removeEventListener(ev, active);
122
122
  };
123
123
  }, [artifactId, endpoint]);
124
- return notice ? (_jsx("div", { role: "status", "data-nospeak": true, className: "fixed inset-x-0 bottom-6 z-50 mx-auto w-fit max-w-[90vw] rounded-lg px-4 py-3 text-sm shadow-lg", style: { background: '#111', color: '#fff', border: `1px solid ${accent}` }, children: notice })) : null;
124
+ return notice ? (_jsx("div", { role: "status", "data-nospeak": true, className: "fixed inset-x-0 bottom-6 z-50 mx-auto w-fit max-w-[90vw] rounded-lg px-4 py-3 text-sm shadow-lg", style: { background: 'var(--a-ground, #111)', color: 'var(--a-ink, #fff)', border: `1px solid ${accent}` }, children: notice })) : null;
125
125
  }
@@ -11,6 +11,8 @@ import { isUnlocked, keyHash, unlockCookieName } from '../artifacts/unlock.js';
11
11
  import { shapeChanges } from '../artifacts/state.js';
12
12
  import { ASSET_NAME, contentTypeFor } from '../artifacts/assets.js';
13
13
  import { BrandGround } from '../brand/wrapper.js';
14
+ import { themedPack } from '../brand/theme.js';
15
+ import { showToc, tocOf, TocInline, TocRail } from '../artifacts/toc.js';
14
16
  import { renderShareCard } from '../brand/share-card.js';
15
17
  import { ArtifactReader } from '../reader/artifact-reader.js';
16
18
  import { ReaderWatch } from '../reader/reader-watch.js';
@@ -26,7 +28,11 @@ async function defaultReadCookie(name) {
26
28
  return (await cookies()).get(name)?.value;
27
29
  }
28
30
  export function createArtifactRoutes(config) {
29
- const { store, brand, siteUrl } = config;
31
+ const { store, siteUrl } = config;
32
+ // The pack as configured draws the share card; the page paints with its theme variables, so a
33
+ // page follows its light, dark or system mode (brand/theme.ts).
34
+ const pack = config.brand;
35
+ const brand = themedPack(pack);
30
36
  const prefix = config.pagePrefix ?? '/';
31
37
  const pageUrl = (id) => `${siteUrl}${prefix}${id}`;
32
38
  const pagePath = (id) => `${prefix}${id}`;
@@ -88,26 +94,20 @@ export function createArtifactRoutes(config) {
88
94
  // The same cookie again on the page's state API: a cookie scoped to the page path is never
89
95
  // sent to /api/artifacts/<id>/state, so without it a password page could not take answers.
90
96
  const unlockLine = (path) => `${unlockCookieName(id)}=${keyHash(id, a.password)}; Path=${path}; Max-Age=31536000; SameSite=Lax; Secure`;
91
- const remember = open && a.password && key !== undefined && cookie !== keyHash(id, a.password)
92
- ? [pagePath(id), `/api/artifacts/${id}`].map((p) => `document.cookie=${JSON.stringify(unlockLine(p))}`).join(';')
93
- : null;
97
+ // The API copy is set on EVERY open of a page with state:, because the page cannot see it (a
98
+ // cookie scoped to /api/... never reaches the page path): a reader who unlocked the page
99
+ // before its API cookie existed would otherwise be refused every answer until they reopened
100
+ // the ?key= link.
101
+ const paths = !open || !a.password ? [] : [
102
+ ...(key !== undefined && cookie !== keyHash(id, a.password) ? [pagePath(id)] : []),
103
+ ...(a.state ? [`/api/artifacts/${id}`] : key !== undefined && cookie !== keyHash(id, a.password) ? [`/api/artifacts/${id}`] : []),
104
+ ];
105
+ const remember = paths.length ? paths.map((p) => `document.cookie=${JSON.stringify(unlockLine(p))}`).join(';') : null;
94
106
  if (!open) {
95
- return (_jsxs(BrandGround, { pack: brand, children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", 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 })] }), _jsx(ArtifactDoor, { brand: brand, wrongKey: key !== undefined })] }));
107
+ return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", 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 })] }), _jsx(ArtifactDoor, { brand: brand, wrongKey: key !== undefined })] }));
96
108
  }
97
109
  void store.bumpViews(id);
98
- let words = [];
99
- if (a.narration && a.timings) {
100
- try {
101
- const r = await fetch(a.timings, { next: { revalidate: 3600 } });
102
- if (r.ok)
103
- words = (await r.json()).words ?? [];
104
- }
105
- catch {
106
- words = [];
107
- }
108
- }
109
- const when = new Date(a.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
110
- return (_jsxs(BrandGround, { pack: brand, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, _jsxs("div", { id: "artifact-narration-root", children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", 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-white/10" }) })) : null, _jsx("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: _jsx(ArtifactMarkdown, { markdown: a.markdown }) })] }), 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] }));
110
+ return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, await Body(a, { top: true })] }));
111
111
  }
112
112
  /** A page with `access:`. The body is rendered only after the reader is known and allowed;
113
113
  * everyone else gets the title, the summary, and a door. */
@@ -120,17 +120,17 @@ export function createArtifactRoutes(config) {
120
120
  if (!d.open) {
121
121
  if (d.why === 'not-allowed' && config.readers)
122
122
  void config.readers.flag({ artifactId: id, reader: d.reader, kind: 'refused' }).catch(() => { });
123
- return (_jsxs(BrandGround, { pack: brand, children: [header, d.why === 'not-allowed'
123
+ return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [header, d.why === 'not-allowed'
124
124
  ? _jsx(NotAllowedDoor, { brand: brand, reader: d.reader, signOutUrl: signOutUrl(id) })
125
125
  : _jsx(SignInDoor, { brand: brand, href: signInOrigin ? signInUrl(signInOrigin, pageUrl(id)) : undefined })] }));
126
126
  }
127
127
  const r = reader;
128
128
  // The agreement comes before the body, every reader, once per page. No agreement, no body.
129
129
  if (config.readers && !(await config.readers.acknowledged(id, r.email))) {
130
- return (_jsxs(BrandGround, { pack: brand, children: [header, _jsx(AckDoor, { brand: brand, name: firstName(r, allow), email: r.email, owner: owner, pageId: id, signOutUrl: signOutUrl(id) })] }));
130
+ return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [header, _jsx(AckDoor, { brand: brand, name: firstName(r, allow), email: r.email, owner: owner, pageId: id, signOutUrl: signOutUrl(id) })] }));
131
131
  }
132
132
  void store.bumpViews(id);
133
- return (_jsxs(BrandGround, { pack: brand, children: [_jsx("style", { dangerouslySetInnerHTML: { __html: NO_PRINT_CSS } }), _jsx(Watermark, { email: r.email }), _jsx("div", { className: "px-6 pt-20 sm:pt-24", children: _jsx(ConfidentialBanner, { name: firstName(r, allow), email: r.email, reason: d.reason, owner: owner, brand: brand, signOutUrl: signOutUrl(id) }) }), await Body(a, { top: false }), _jsx(ReaderWatch, { artifactId: id, endpoint: "/api/reader/track", accent: brand.accent })] }));
133
+ return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsx("style", { dangerouslySetInnerHTML: { __html: NO_PRINT_CSS } }), _jsx(Watermark, { email: r.email }), _jsx("div", { className: "px-6 pt-20 sm:pt-24", children: _jsx(ConfidentialBanner, { name: firstName(r, allow), email: r.email, reason: d.reason, owner: owner, brand: brand, signOutUrl: signOutUrl(id) }) }), await Body(a, { top: false }), _jsx(ReaderWatch, { artifactId: id, endpoint: "/api/reader/track", accent: brand.accent })] }));
134
134
  }
135
135
  /** The page itself, shared by open and gated pages. */
136
136
  async function Body(a, { top }) {
@@ -146,7 +146,8 @@ export function createArtifactRoutes(config) {
146
146
  }
147
147
  }
148
148
  const when = new Date(a.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
149
- return (_jsxs(_Fragment, { children: [_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-white/10" }) })) : null, _jsx("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: _jsx(ArtifactMarkdown, { markdown: a.markdown }) })] }), 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] }));
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
151
  }
151
152
  const cookieLine = (value, maxAge) => `${GRANT_COOKIE}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
152
153
  /** GET /api/reader/enter?pass=<pass>&to=/<id>: where the sign-in authority sends a signed-in
@@ -366,7 +367,7 @@ export function createArtifactRoutes(config) {
366
367
  const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
367
368
  if (!a)
368
369
  return new NextResponse(`no artifact with id ${id}`, { status: 404 });
369
- return renderShareCard(brand, a.title);
370
+ return renderShareCard(pack, a.title);
370
371
  }
371
372
  async function DELETE(request, { params }) {
372
373
  if (!isPublishAuthed(request, config.publishKey()))
@@ -16,6 +16,7 @@ import { GRANT_COOKIE, decide, firstName, signInUrl, verifyGrant } from '../arti
16
16
  import { isUnlocked, unlockCookieName } from '../artifacts/unlock.js';
17
17
  import { ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, checkValue, effectiveWriters } from '../artifacts/state.js';
18
18
  import { readerKeyFor } from '../artifacts/state-store.js';
19
+ import { NOTES_SLOT, checkNoteValue, hasNotesWidget } from '../artifacts/widgets.js';
19
20
  import { responsesCsv, responsesOf, stateView } from '../artifacts/state-view.js';
20
21
  export const ANON_COOKIE = 'artifact_anon';
21
22
  const ANON_ID = /^[A-Za-z0-9]{24}$/;
@@ -168,13 +169,15 @@ export function createStateRoutes(ctx) {
168
169
  await ctx.state.remove({ artifactId: id, slot, readerKey: writer.key, entryId: typeof b.entry === 'string' ? b.entry : undefined });
169
170
  }
170
171
  else {
171
- const bad = checkValue(b.value);
172
+ const bad = checkValue(b.value) ?? (slot === NOTES_SLOT && hasNotesWidget(a.markdown) ? checkNoteValue(b.value) : null);
172
173
  if (bad)
173
174
  return json({ error: bad }, 400);
174
175
  // The page-wide cap per slot. Counted before the write and not atomically with it, so a
175
176
  // burst can overshoot by the writes in flight; it bounds the slot, it is not a quota.
176
177
  if ((await ctx.state.countSlot(id, slot)) >= MAX_ENTRIES_PER_SLOT) {
177
- const replacing = op === 'set' && (await ctx.state.entries(id)).some((e) => e.slot === slot && e.readerKey === writer.key);
178
+ const replacing = op === 'set' && (ctx.state.hasOne
179
+ ? await ctx.state.hasOne(id, slot, writer.key)
180
+ : (await ctx.state.entries(id)).some((e) => e.slot === slot && e.readerKey === writer.key));
178
181
  if (!replacing)
179
182
  return json({ error: 'this page is not taking more answers here' }, 409);
180
183
  }
@@ -0,0 +1,21 @@
1
+ import { type ReactNode } from 'react';
2
+ export declare function NotesProvider({ artifactId, headings, accent, children }: {
3
+ artifactId: string;
4
+ headings: {
5
+ slug: string;
6
+ text: string;
7
+ }[];
8
+ accent?: string;
9
+ children: ReactNode;
10
+ }): import("react").JSX.Element;
11
+ /** The small control inside a heading. */
12
+ export declare function NoteToggle({ slug }: {
13
+ slug: string;
14
+ }): import("react").JSX.Element | null;
15
+ /** After each heading: its notes, and the box when its control is open. */
16
+ export declare function HeadingNotes({ slug, text }: {
17
+ slug: string;
18
+ text: string;
19
+ }): import("react").JSX.Element | null;
20
+ /** Where the ```notes fence sits: a line saying notes are on, and the notes whose heading is gone. */
21
+ export declare function NotesEarlier(): import("react").JSX.Element | null;