@supersuit/artifacts 0.3.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,34 @@
3
3
  `@supersuit/artifacts`. One entry per version, newest first. Each entry says what changed, how a
4
4
  host can tell whether it is affected (DETECTOR), what a host does about it (REMEDY), and the tests.
5
5
 
6
+ ## 0.4.0 (2026-09-26)
7
+
8
+ **Light and dark modes, a table of contents, and an animated ground.**
9
+
10
+ - **Modes.** `BrandPack` gains `light` (a second palette), `mode` (`light`, `dark` or `system`,
11
+ default `dark`) and `backdrop`. A page may override the mode with `theme:` in its front matter.
12
+ Every colour the page draws is now a CSS variable set by `BrandGround` for the mode, so `system`
13
+ follows the reader's device. `freedomDefault` carries a light palette.
14
+ - **Table of contents.** Built from the page's `##` headings: a sticky rail in the left margin at
15
+ `xl` widths, highlighting the section being read, and a closed "Contents" list above the body
16
+ below that. Both are `data-nospeak`. It appears once a page has four sections; `toc: false`
17
+ removes it and `toc: true` draws it from one. Every heading now carries its slug as an `id`,
18
+ with or without the notes widget.
19
+ - **Bubbles.** `backdrop: { kind: 'bubbles' }` draws drifting colour behind the page in plain CSS,
20
+ held still under `prefers-reduced-motion`. A pack with its own `Wrapper` draws its own ground
21
+ and ignores `backdrop`.
22
+ - **Long words wrap.** The body is `break-words`, so a bare URL in prose no longer pushes a phone
23
+ sideways (a 390px screen scrolled 7px on the first page checked).
24
+ - **DETECTOR:** a page's root carries `data-artifact-theme`; an open page with four sections
25
+ carries `data-artifact-toc`. A host whose pages still show zinc-on-black text in a light mode is
26
+ building from a Tailwind `content` list that does not scan this package's `lib/`.
27
+ - **REMEDY:** none required. A pack with no `mode` keeps its dark look, and `theme`/`toc` are new
28
+ optional keys. A host whose publisher strips unknown front matter keys must forward `theme` and
29
+ `toc` for pages to set them.
30
+ - **Tests:** theme CSS per mode and the fallback light palette; the ground, the wrapper-pack path
31
+ and the backdrop; `theme` and `toc` parsed, refused, stored and cleared on republish; heading ids
32
+ without notes; the route's mode and both contents placements.
33
+
6
34
  ## 0.3.0 (2026-09-25)
7
35
 
8
36
  **Notes, the first widget.** A ```` ```notes ```` block in a page puts a small "note" control
package/README.md CHANGED
@@ -130,6 +130,8 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
130
130
  | `voice`, `narration`, `timings`, `narrationHash` | Read-aloud: the audio and word timings a publisher generated. Absent, no player |
131
131
  | `password` | Shuts the body behind a door (below) |
132
132
  | `access` | `freedom`, `invite` or `public`: a confidential page (below) |
133
+ | `theme` | `light`, `dark` or `system` (follow the reader's device). Overrides the brand pack's mode for this page |
134
+ | `toc` | The table of contents. Absent, it appears once the page has four `##` sections; `false` never; `true` from one |
133
135
  | `template` | `document`, the only one so far |
134
136
 
135
137
  An unknown key is refused, so a typo fails loudly instead of being ignored.
@@ -342,6 +344,20 @@ the narrator label, an optional full-page `Wrapper` and `Mark`, and an optional
342
344
  (a font loader and a backdrop) that draws the unfurl card. `freedomDefault` is the default look.
343
345
  Keep a pack carrying your own trademark in your own app, not in a pull request here.
344
346
 
347
+ **Light, dark or system.** A pack's `ground`, `ink` and `accent` are its dark palette; `light` is
348
+ the other one (absent, warm paper with the pack's accent), and `mode` is the one a page opens in
349
+ (`dark` by default; a page's `theme:` overrides it). Pages paint with CSS variables
350
+ (`--a-ground`, `--a-ink`, `--a-accent` and tones derived from them), which is what lets `system`
351
+ follow the device.
352
+
353
+ **An animated ground.** `backdrop: { kind: 'bubbles', colors?, veil? }` draws soft colour drifting
354
+ behind the page under a veil of the ground, in plain CSS, still for readers who ask for reduced
355
+ motion. `colors` are `r,g,b` triplets (default `GLOW_PASTELS`); `veil` is 0 to 1 (default 0.4).
356
+
357
+ ```ts
358
+ const pack: BrandPack = { ...freedomDefault, mode: 'light', backdrop: { kind: 'bubbles' } }
359
+ ```
360
+
345
361
  ## Environment
346
362
 
347
363
  | Variable | Used for |
@@ -1,5 +1,6 @@
1
1
  import { type Access } from './reader.js';
2
2
  import { type StateConfig } from './state.js';
3
+ import { type ThemeMode } from '../brand/theme.js';
3
4
  export type ArtifactMeta = {
4
5
  title: string;
5
6
  summary: string;
@@ -27,6 +28,10 @@ export type ArtifactMeta = {
27
28
  /** What readers may put into the page. Content, like the body: a republish without it removes
28
29
  * the slots from the page and KEEPS the answers already given. */
29
30
  state?: StateConfig;
31
+ /** Light, dark, or the reader's device, overriding the brand pack's mode for this page. */
32
+ theme?: ThemeMode;
33
+ /** The table of contents: absent draws it once the page has four sections, false never. */
34
+ toc?: boolean;
30
35
  };
31
36
  export declare function parseArtifactSource(text: string): {
32
37
  ok: true;
@@ -4,7 +4,8 @@ import matter from 'gray-matter';
4
4
  import { ACCESS_LEVELS } from './reader.js';
5
5
  import { parseStateConfig } from './state.js';
6
6
  import { mergeWidgetState, scanWidgets } from './widgets.js';
7
- const KNOWN = new Set(['title', 'summary', 'subtitle', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state']);
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
9
  export function parseArtifactSource(text) {
9
10
  const { data, content } = matter(text);
10
11
  const d = data;
@@ -35,6 +36,16 @@ export function parseArtifactSource(text) {
35
36
  return { ok: false, error: `access must be one of: public, ${ACCESS_LEVELS.join(', ')}` };
36
37
  meta.access = d.access;
37
38
  }
39
+ if (d.theme !== undefined) {
40
+ if (!THEME_MODES.includes(d.theme))
41
+ return { ok: false, error: `theme must be one of: ${THEME_MODES.join(', ')}` };
42
+ meta.theme = d.theme;
43
+ }
44
+ if (d.toc !== undefined) {
45
+ if (typeof d.toc !== 'boolean')
46
+ return { ok: false, error: 'toc must be true or false' };
47
+ meta.toc = d.toc;
48
+ }
38
49
  if (d.state !== undefined) {
39
50
  const s = parseStateConfig(d.state);
40
51
  if (!s.ok)
@@ -3,7 +3,8 @@ 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
- const GOLD = '#C2A15C';
6
+ // Every colour is a theme variable (brand/theme.ts), so a page follows its light, dark or system mode.
7
+ const GOLD = 'var(--a-accent)';
7
8
  function hostOf(url) {
8
9
  try {
9
10
  return new URL(url).host.replace(/^www\./, '');
@@ -33,7 +34,7 @@ function LinkCards({ source }) {
33
34
  const [url, ...rest] = l.split('|');
34
35
  return { url: url.trim(), note: rest.join('|').trim() };
35
36
  });
36
- return (_jsx("ul", { "data-artifact-links": true, className: "my-6 grid list-none gap-3 p-0", children: rows.map((r) => (_jsx("li", { className: "m-0 p-0", children: _jsxs("a", { href: r.url, target: "_blank", rel: "noopener noreferrer", className: "block rounded-lg border border-white/10 bg-white/[0.03] px-4 py-3 no-underline transition-colors hover:border-white/25", children: [_jsx("span", { className: "block text-[11px] uppercase tracking-[0.2em]", style: { color: GOLD }, children: hostOf(r.url) }), _jsx("span", { className: "block text-zinc-100", children: r.note || r.url })] }) }, r.url))) }));
37
+ return (_jsx("ul", { "data-artifact-links": true, className: "my-6 grid list-none gap-3 p-0", children: rows.map((r) => (_jsx("li", { className: "m-0 p-0", children: _jsxs("a", { href: r.url, target: "_blank", rel: "noopener noreferrer", className: "block rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-4 py-3 no-underline transition-colors hover:border-[color:var(--a-line-strong)]", children: [_jsx("span", { className: "block text-[11px] uppercase tracking-[0.2em]", style: { color: GOLD }, children: hostOf(r.url) }), _jsx("span", { className: "block text-[color:var(--a-strong)]", children: r.note || r.url })] }) }, r.url))) }));
37
38
  }
38
39
  const CALLOUT = /^\[!(note|warning)\]\s*/i;
39
40
  // A link the reader can actually follow: absolute, a scheme like mailto:, a
@@ -45,20 +46,17 @@ export function isReachableHref(href) {
45
46
  return typeof href === 'string' && REACHABLE.test(href);
46
47
  }
47
48
  const components = {
48
- h1: ({ children }) => _jsx("h1", { className: "mt-10 mb-4 font-serif text-3xl text-zinc-50", children: children }),
49
- h2: ({ children }) => _jsx("h2", { className: "mt-10 mb-3 font-serif text-2xl text-zinc-50", children: children }),
50
- h3: ({ children }) => _jsx("h3", { className: "mt-8 mb-2 text-lg font-semibold text-zinc-100", children: children }),
51
49
  p: ({ children }) => _jsx("p", { className: "my-4 leading-relaxed", children: children }),
52
50
  ul: ({ children }) => _jsx("ul", { className: "my-4 list-disc space-y-1 pl-6", children: children }),
53
51
  ol: ({ children }) => _jsx("ol", { className: "my-4 list-decimal space-y-1 pl-6", children: children }),
54
- hr: () => _jsx("hr", { className: "my-10 border-white/10" }),
52
+ hr: () => _jsx("hr", { className: "my-10 border-[color:var(--a-line)]" }),
55
53
  table: ({ children }) => (_jsx("div", { className: "my-6 overflow-x-auto", children: _jsx("table", { className: "w-full border-collapse text-sm", children: children }) })),
56
- th: ({ children }) => (_jsx("th", { className: "border-b border-white/20 px-3 py-2 text-left font-semibold text-zinc-100", children: children })),
57
- td: ({ children }) => _jsx("td", { className: "border-b border-white/10 px-3 py-2 align-top", children: children }),
54
+ th: ({ children }) => (_jsx("th", { className: "border-b border-[color:var(--a-line-strong)] px-3 py-2 text-left font-semibold text-[color:var(--a-strong)]", children: children })),
55
+ td: ({ children }) => _jsx("td", { className: "border-b border-[color:var(--a-line)] px-3 py-2 align-top", children: children }),
58
56
  img: ({ src, alt }) => (
59
57
  // eslint-disable-next-line @next/next/no-img-element
60
- _jsx("img", { src: typeof src === 'string' ? src : undefined, alt: alt ?? '', className: "my-6 w-full rounded-lg border border-white/10" })),
61
- a: ({ href, children }) => !isReachableHref(href) ? (_jsx("span", { children: children })) : (_jsxs("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: "text-zinc-50 underline decoration-white/30 underline-offset-4 hover:decoration-white", children: [children, href && hostOf(href) ? (_jsxs("span", { "data-nospeak": true, className: "ml-1 text-xs text-zinc-500", children: ["(", hostOf(href), ")"] })) : null] })),
58
+ _jsx("img", { src: typeof src === 'string' ? src : undefined, alt: alt ?? '', className: "my-6 w-full rounded-lg border border-[color:var(--a-line)]" })),
59
+ a: ({ href, children }) => !isReachableHref(href) ? (_jsx("span", { children: children })) : (_jsxs("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: "text-[color:var(--a-strong)] underline decoration-[color:var(--a-line-strong)] underline-offset-4 hover:decoration-[color:var(--a-strong)]", children: [children, href && hostOf(href) ? (_jsxs("span", { "data-nospeak": true, className: "ml-1 text-xs text-[color:var(--a-muted)]", children: ["(", hostOf(href), ")"] })) : null] })),
62
60
  code: ({ className, children }) => {
63
61
  const lang = /language-(\w+)/.exec(className ?? '')?.[1];
64
62
  if (lang === 'links')
@@ -68,7 +66,7 @@ const components = {
68
66
  if (lang === 'notes')
69
67
  return null;
70
68
  if (!className) {
71
- return _jsx("code", { className: "rounded bg-white/10 px-1.5 py-0.5 text-[0.9em] text-zinc-100", children: children });
69
+ return _jsx("code", { className: "rounded bg-[color:var(--a-surface-strong)] px-1.5 py-0.5 text-[0.9em] text-[color:var(--a-strong)]", children: children });
72
70
  }
73
71
  return _jsx("code", { className: className, children: children });
74
72
  },
@@ -78,27 +76,39 @@ const components = {
78
76
  const cls = inner?.props?.className ?? '';
79
77
  if (/language-(links|notes)/.test(cls))
80
78
  return _jsx(_Fragment, { children: children });
81
- return (_jsx("pre", { "data-artifact-code": true, className: "my-6 overflow-x-auto rounded-lg border border-white/10 bg-black/40 p-4 text-sm text-zinc-100", children: children }));
79
+ return (_jsx("pre", { "data-artifact-code": true, className: "my-6 overflow-x-auto rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-code)] p-4 text-sm text-[color:var(--a-strong)]", children: children }));
82
80
  },
83
81
  blockquote: ({ children }) => {
84
82
  const t = textOf(children).trim();
85
83
  const m = CALLOUT.exec(t);
86
84
  if (!m) {
87
- return (_jsx("blockquote", { className: "my-6 border-l-2 pl-4 italic text-zinc-300", style: { borderColor: GOLD }, children: children }));
85
+ return (_jsx("blockquote", { className: "my-6 border-l-2 pl-4 italic text-[color:var(--a-body)]", style: { borderColor: GOLD }, children: children }));
88
86
  }
89
87
  const kind = m[1].toLowerCase();
90
88
  const body = t.replace(CALLOUT, '');
91
- return (_jsxs("aside", { "data-callout": kind, className: "my-6 rounded-lg border px-4 py-3", style: { borderColor: kind === 'warning' ? '#d97706' : GOLD, background: 'rgba(255,255,255,0.03)' }, children: [_jsx("span", { "data-nospeak": true, className: "block text-[11px] uppercase tracking-[0.2em]", style: { color: GOLD }, children: kind }), _jsx("p", { className: "mt-1 text-zinc-100", children: body })] }));
89
+ return (_jsxs("aside", { "data-callout": kind, className: "my-6 rounded-lg border px-4 py-3", style: { borderColor: kind === 'warning' ? '#d97706' : GOLD, background: 'var(--a-surface)' }, children: [_jsx("span", { "data-nospeak": true, className: "block text-[11px] uppercase tracking-[0.2em]", style: { color: GOLD }, children: kind }), _jsx("p", { className: "mt-1 text-[color:var(--a-strong)]", children: body })] }));
92
90
  },
93
91
  };
92
+ // scroll-mt keeps a heading clear of the top of the screen when a contents link jumps to it.
94
93
  const HEADING_CLASS = {
95
- 1: 'mt-10 mb-4 font-serif text-3xl text-zinc-50',
96
- 2: 'mt-10 mb-3 font-serif text-2xl text-zinc-50',
97
- 3: 'mt-8 mb-2 text-lg font-semibold text-zinc-100',
98
- 4: 'mt-6 mb-2 font-semibold text-zinc-100',
99
- 5: 'mt-6 mb-2 font-semibold text-zinc-100',
100
- 6: 'mt-6 mb-2 font-semibold text-zinc-100',
94
+ 1: 'mt-10 mb-4 scroll-mt-24 font-serif text-3xl text-[color:var(--a-strong)]',
95
+ 2: 'mt-10 mb-3 scroll-mt-24 font-serif text-2xl text-[color:var(--a-strong)]',
96
+ 3: 'mt-8 mb-2 scroll-mt-24 text-lg font-semibold text-[color:var(--a-strong)]',
97
+ 4: 'mt-6 mb-2 scroll-mt-24 font-semibold text-[color:var(--a-strong)]',
98
+ 5: 'mt-6 mb-2 scroll-mt-24 font-semibold text-[color:var(--a-strong)]',
99
+ 6: 'mt-6 mb-2 scroll-mt-24 font-semibold text-[color:var(--a-strong)]',
101
100
  };
101
+ /** Components for any page: every heading gets its slug as an id, the one headingsOf derived,
102
+ * so a contents link and a note both land on it. */
103
+ function pageComponents(headings) {
104
+ const byLine = new Map(headings.map((h) => [h.line, h]));
105
+ const heading = (depth) => function Heading({ node, children }) {
106
+ const Tag = `h${depth}`;
107
+ const h = byLine.get(node?.position?.start.line ?? -1);
108
+ return _jsx(Tag, { id: h?.slug, className: HEADING_CLASS[depth], children: children });
109
+ };
110
+ return { ...components, h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6) };
111
+ }
102
112
  /** Components for a page with a notes block: every heading gets its slug as an id, a note
103
113
  * control, and its notes after it. The heading is found by its source line, so the slug is the
104
114
  * one headingsOf derived, the same one a note stores. */
@@ -122,7 +132,9 @@ function notesComponents(headings) {
122
132
  * page carries a ```notes block. */
123
133
  export function ArtifactMarkdown({ markdown, notes }) {
124
134
  const on = !!notes && hasNotesWidget(markdown);
125
- const headings = on ? headingsOf(markdown) : [];
126
- const body = (_jsx(ReactMarkdown, { remarkPlugins: [remarkGfm], components: on ? notesComponents(headings) : components, children: markdown }));
127
- return (_jsx("div", { className: "text-[17px] text-zinc-200", children: on ? (_jsx(NotesProvider, { artifactId: notes.artifactId, headings: headings.map(({ slug, text }) => ({ slug, text })), accent: notes.accent, children: body })) : body }));
135
+ const headings = headingsOf(markdown);
136
+ const body = (_jsx(ReactMarkdown, { remarkPlugins: [remarkGfm], components: on ? notesComponents(headings) : pageComponents(headings), children: markdown }));
137
+ return (
138
+ // break-words: a bare URL in prose is one unbreakable word and pushed phones 7px sideways (2026-09-26).
139
+ _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 }));
128
140
  }
@@ -2,6 +2,7 @@ import { type Firestore } from 'firebase-admin/firestore';
2
2
  import type { ArtifactMeta } from './front-matter.js';
3
3
  import type { Access } from './reader.js';
4
4
  import type { StateConfig } from './state.js';
5
+ import type { ThemeMode } from '../brand/theme.js';
5
6
  export type ArtifactRecord = {
6
7
  id: string;
7
8
  title: string;
@@ -17,6 +18,8 @@ export type ArtifactRecord = {
17
18
  password?: string;
18
19
  access?: Access;
19
20
  state?: StateConfig;
21
+ theme?: ThemeMode;
22
+ toc?: boolean;
20
23
  markdown: string;
21
24
  createdAt: string;
22
25
  updatedAt: string;
@@ -57,6 +57,8 @@ async function saveArtifact(col, input) {
57
57
  ...(input.meta.password ? { password: input.meta.password } : {}),
58
58
  ...(input.meta.access && input.meta.access !== 'public' ? { access: input.meta.access } : {}),
59
59
  ...(input.meta.state ? { state: input.meta.state } : {}),
60
+ ...(input.meta.theme ? { theme: input.meta.theme } : {}),
61
+ ...(input.meta.toc !== undefined ? { toc: input.meta.toc } : {}),
60
62
  };
61
63
  if (input.id) {
62
64
  const existing = await getArtifact(col, input.id);
@@ -82,8 +84,13 @@ async function saveArtifact(col, input) {
82
84
  const access = input.meta.access === 'public' ? { access: FieldValue.delete() } : {};
83
85
  // Content, like the body: a republish that no longer declares state removes the slots.
84
86
  const state = input.meta.state ? {} : { state: FieldValue.delete() };
87
+ // How the page looks is content too: a line the file no longer carries goes back to the default.
88
+ const look = {
89
+ ...(input.meta.theme ? {} : { theme: FieldValue.delete() }),
90
+ ...(input.meta.toc !== undefined ? {} : { toc: FieldValue.delete() }),
91
+ };
85
92
  await ref.update({
86
- ...fields, ...password, ...subtitle, ...access, ...state, markdown: input.markdown, updatedAt: now, version: next,
93
+ ...fields, ...password, ...subtitle, ...access, ...state, ...look, markdown: input.markdown, updatedAt: now, version: next,
87
94
  ...(existing.versions ? { versions: FieldValue.delete() } : {}),
88
95
  });
89
96
  return { id: input.id, version: next, created: false };
@@ -0,0 +1,3 @@
1
+ export declare function TocSpy({ slugs }: {
2
+ slugs: string[];
3
+ }): null;
@@ -0,0 +1,40 @@
1
+ 'use client';
2
+ // Marks the contents entry for the section being read. Renders nothing: it only sets
3
+ // aria-current on the links the server drew, which is also what styles them.
4
+ import { useEffect } from 'react';
5
+ export function TocSpy({ slugs }) {
6
+ const key = slugs.join('|');
7
+ useEffect(() => {
8
+ const ids = key.split('|');
9
+ let frame = 0;
10
+ const mark = () => {
11
+ frame = 0;
12
+ let current = ids[0];
13
+ for (const id of ids) {
14
+ const el = document.getElementById(id);
15
+ if (el && el.getBoundingClientRect().top <= 140)
16
+ current = id;
17
+ }
18
+ document.querySelectorAll('a[data-toc-slug]').forEach((a) => {
19
+ if (a.dataset.tocSlug === current)
20
+ a.setAttribute('aria-current', 'location');
21
+ else
22
+ a.removeAttribute('aria-current');
23
+ });
24
+ };
25
+ const onScroll = () => {
26
+ if (!frame)
27
+ frame = requestAnimationFrame(mark);
28
+ };
29
+ mark();
30
+ window.addEventListener('scroll', onScroll, { passive: true });
31
+ window.addEventListener('resize', onScroll);
32
+ return () => {
33
+ window.removeEventListener('scroll', onScroll);
34
+ window.removeEventListener('resize', onScroll);
35
+ if (frame)
36
+ cancelAnimationFrame(frame);
37
+ };
38
+ }, [key]);
39
+ return null;
40
+ }
@@ -0,0 +1,15 @@
1
+ export type TocItem = {
2
+ slug: string;
3
+ text: string;
4
+ };
5
+ export declare function tocOf(markdown: string): TocItem[];
6
+ /** `flag` is the page's `toc:`. Absent, the list appears once there are enough sections. */
7
+ export declare function showToc(markdown: string, flag?: boolean): boolean;
8
+ /** Wide screens: fixed in the left margin, so the reading column stays centred. */
9
+ export declare function TocRail({ items }: {
10
+ items: TocItem[];
11
+ }): import("react").JSX.Element;
12
+ /** Narrow screens: a closed list above the body. */
13
+ export declare function TocInline({ items }: {
14
+ items: TocItem[];
15
+ }): import("react").JSX.Element;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The table of contents: the page's sections, as a sticky rail on a wide screen and a
3
+ // collapsible list above the body on a narrow one. Both are marked data-nospeak so the
4
+ // read-along never narrates them, and both link to the ids the renderer puts on headings,
5
+ // which come from the same headingsOf the notes widget uses.
6
+ import { headingsOf } from './widgets.js';
7
+ import { TocSpy } from './toc-spy.js';
8
+ /** Sections below this count read fine without a contents list. */
9
+ const AUTO_MIN = 4;
10
+ export function tocOf(markdown) {
11
+ return headingsOf(markdown).filter((h) => h.depth === 2).map(({ slug, text }) => ({ slug, text }));
12
+ }
13
+ /** `flag` is the page's `toc:`. Absent, the list appears once there are enough sections. */
14
+ export function showToc(markdown, flag) {
15
+ if (flag === false)
16
+ return false;
17
+ const n = tocOf(markdown).length;
18
+ return flag === true ? n > 0 : n >= AUTO_MIN;
19
+ }
20
+ const LINK = 'block border-l-2 border-transparent py-1 pl-3 no-underline transition-colors text-[color:var(--a-muted)] hover:text-[color:var(--a-strong)] aria-[current=location]:border-[color:var(--a-accent)] aria-[current=location]:text-[color:var(--a-strong)]';
21
+ function List({ items }) {
22
+ return (_jsx("ol", { className: "m-0 list-none space-y-0.5 p-0 text-sm leading-snug", children: items.map((i) => (_jsx("li", { className: "m-0 p-0", children: _jsx("a", { href: `#${i.slug}`, "data-toc-slug": i.slug, className: LINK, children: i.text }) }, i.slug))) }));
23
+ }
24
+ /** Wide screens: fixed in the left margin, so the reading column stays centred. */
25
+ export function TocRail({ items }) {
26
+ return (_jsxs("nav", { "data-nospeak": true, "data-artifact-toc": "rail", "aria-label": "Contents", className: "fixed top-28 left-6 z-10 hidden max-h-[calc(100vh-10rem)] w-56 overflow-y-auto xl:block 2xl:left-12 2xl:w-64", children: [_jsx("p", { className: "mb-3 pl-3 text-[11px] font-medium uppercase tracking-[0.25em] text-[color:var(--a-accent)]", children: "Contents" }), _jsx(List, { items: items }), _jsx(TocSpy, { slugs: items.map((i) => i.slug) })] }));
27
+ }
28
+ /** Narrow screens: a closed list above the body. */
29
+ export function TocInline({ items }) {
30
+ return (_jsxs("details", { "data-nospeak": true, "data-artifact-toc": "inline", className: "mb-8 rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-4 py-3 xl:hidden", children: [_jsx("summary", { className: "cursor-pointer text-[11px] font-medium uppercase tracking-[0.25em] text-[color:var(--a-accent)]", children: "Contents" }), _jsx("div", { className: "mt-3", children: _jsx(List, { items: items }) })] }));
31
+ }
@@ -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}`;
@@ -98,22 +104,10 @@ export function createArtifactRoutes(config) {
98
104
  ];
99
105
  const remember = paths.length ? paths.map((p) => `document.cookie=${JSON.stringify(unlockLine(p))}`).join(';') : null;
100
106
  if (!open) {
101
- 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 })] }));
102
108
  }
103
109
  void store.bumpViews(id);
104
- let words = [];
105
- if (a.narration && a.timings) {
106
- try {
107
- const r = await fetch(a.timings, { next: { revalidate: 3600 } });
108
- if (r.ok)
109
- words = (await r.json()).words ?? [];
110
- }
111
- catch {
112
- words = [];
113
- }
114
- }
115
- const when = new Date(a.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
116
- 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, 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] }));
110
+ return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, await Body(a, { top: true })] }));
117
111
  }
118
112
  /** A page with `access:`. The body is rendered only after the reader is known and allowed;
119
113
  * everyone else gets the title, the summary, and a door. */
@@ -126,17 +120,17 @@ export function createArtifactRoutes(config) {
126
120
  if (!d.open) {
127
121
  if (d.why === 'not-allowed' && config.readers)
128
122
  void config.readers.flag({ artifactId: id, reader: d.reader, kind: 'refused' }).catch(() => { });
129
- 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'
130
124
  ? _jsx(NotAllowedDoor, { brand: brand, reader: d.reader, signOutUrl: signOutUrl(id) })
131
125
  : _jsx(SignInDoor, { brand: brand, href: signInOrigin ? signInUrl(signInOrigin, pageUrl(id)) : undefined })] }));
132
126
  }
133
127
  const r = reader;
134
128
  // The agreement comes before the body, every reader, once per page. No agreement, no body.
135
129
  if (config.readers && !(await config.readers.acknowledged(id, r.email))) {
136
- 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) })] }));
137
131
  }
138
132
  void store.bumpViews(id);
139
- 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 })] }));
140
134
  }
141
135
  /** The page itself, shared by open and gated pages. */
142
136
  async function Body(a, { top }) {
@@ -152,7 +146,8 @@ export function createArtifactRoutes(config) {
152
146
  }
153
147
  }
154
148
  const when = new Date(a.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
155
- 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, 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] }));
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] }));
156
151
  }
157
152
  const cookieLine = (value, maxAge) => `${GRANT_COOKIE}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
158
153
  /** GET /api/reader/enter?pass=<pass>&to=/<id>: where the sign-in authority sends a signed-in
@@ -372,7 +367,7 @@ export function createArtifactRoutes(config) {
372
367
  const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
373
368
  if (!a)
374
369
  return new NextResponse(`no artifact with id ${id}`, { status: 404 });
375
- return renderShareCard(brand, a.title);
370
+ return renderShareCard(pack, a.title);
376
371
  }
377
372
  async function DELETE(request, { params }) {
378
373
  if (!isPublishAuthed(request, config.publishKey()))
@@ -97,7 +97,7 @@ export function NoteToggle({ slug }) {
97
97
  return (_jsx("button", { type: "button", "data-nospeak": true, "data-note-toggle": slug, "aria-expanded": c.open === slug, onClick: () => c.setOpen(c.open === slug ? null : slug), className: "ml-3 inline-block rounded-full border px-2 py-0.5 align-middle font-sans text-[11px] font-medium uppercase tracking-[0.15em] opacity-70 transition-opacity hover:opacity-100", style: { borderColor: c.accent, color: c.accent }, children: n ? `note · ${n}` : 'note' }));
98
98
  }
99
99
  function NoteItem({ n, c, under }) {
100
- return (_jsxs("li", { className: "m-0 border-l-2 py-1 pl-3", style: { borderColor: c.accent }, children: [_jsx("span", { className: "block whitespace-pre-wrap text-[15px] text-zinc-100", children: n.note }), _jsxs("span", { className: "block text-xs opacity-60", children: [n.name, under ? _jsxs(_Fragment, { children: [" \u00B7 under \u201C", n.heading, "\u201D"] }) : null, n.mine && n.id ? (_jsx("button", { type: "button", "data-note-remove": n.id, onClick: () => void c.remove(n.id), className: "ml-2 underline opacity-80 hover:opacity-100", children: "remove" })) : null] })] }));
100
+ return (_jsxs("li", { className: "m-0 border-l-2 py-1 pl-3", style: { borderColor: c.accent }, children: [_jsx("span", { className: "block whitespace-pre-wrap text-[15px] text-[color:var(--a-strong)]", children: n.note }), _jsxs("span", { className: "block text-xs opacity-60", children: [n.name, under ? _jsxs(_Fragment, { children: [" \u00B7 under \u201C", n.heading, "\u201D"] }) : null, n.mine && n.id ? (_jsx("button", { type: "button", "data-note-remove": n.id, onClick: () => void c.remove(n.id), className: "ml-2 underline opacity-80 hover:opacity-100", children: "remove" })) : null] })] }));
101
101
  }
102
102
  function NoteForm({ slug, heading, c }) {
103
103
  const [text, setText] = useState('');
@@ -112,7 +112,7 @@ function NoteForm({ slug, heading, c }) {
112
112
  if (!c.canWrite) {
113
113
  return c.signIn ? (_jsx("p", { className: "my-2 text-sm", children: _jsx("a", { href: c.signIn, className: "underline", style: { color: c.accent }, children: "Sign in to leave a note" }) })) : (_jsx("p", { className: "my-2 text-sm opacity-70", children: "Notes here are open to signed-in readers." }));
114
114
  }
115
- return (_jsxs("form", { onSubmit: submit, className: "my-2", children: [_jsx("textarea", { value: text, onChange: (e) => setText(e.target.value), maxLength: MAX_NOTE_CHARS, rows: 3, "aria-label": `A note on ${heading}`, placeholder: `A note on “${heading}”`, className: "w-full rounded-lg border border-white/15 bg-white/[0.04] p-3 text-[15px] text-zinc-100" }), c.error ? _jsx("p", { className: "mt-1 text-xs text-amber-500", children: c.error }) : null, _jsxs("div", { className: "mt-2 flex gap-3", children: [_jsx("button", { type: "submit", disabled: c.busy || !text.trim(), className: "rounded-full px-4 py-1 text-sm font-medium disabled:opacity-40", style: { background: c.accent, color: '#111' }, children: "Save note" }), _jsx("button", { type: "button", onClick: () => c.setOpen(null), className: "text-sm opacity-70 hover:opacity-100", children: "Cancel" })] })] }));
115
+ return (_jsxs("form", { onSubmit: submit, className: "my-2", children: [_jsx("textarea", { value: text, onChange: (e) => setText(e.target.value), maxLength: MAX_NOTE_CHARS, rows: 3, "aria-label": `A note on ${heading}`, placeholder: `A note on “${heading}”`, className: "w-full rounded-lg border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-[15px] text-[color:var(--a-strong)]" }), c.error ? _jsx("p", { className: "mt-1 text-xs text-amber-500", children: c.error }) : null, _jsxs("div", { className: "mt-2 flex gap-3", children: [_jsx("button", { type: "submit", disabled: c.busy || !text.trim(), className: "rounded-full px-4 py-1 text-sm font-medium disabled:opacity-40", style: { background: c.accent, color: 'var(--a-on-accent, #111)' }, children: "Save note" }), _jsx("button", { type: "button", onClick: () => c.setOpen(null), className: "text-sm opacity-70 hover:opacity-100", children: "Cancel" })] })] }));
116
116
  }
117
117
  /** After each heading: its notes, and the box when its control is open. */
118
118
  export function HeadingNotes({ slug, text }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",