@supersuit/artifacts 0.3.0 → 0.4.1

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,57 @@
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.1 (2026-09-26)
7
+
8
+ **Big files upload straight to the bucket.**
9
+
10
+ - A route handler on Vercel refuses a body over 4.5 MB (`FUNCTION_PAYLOAD_TOO_LARGE`), about five
11
+ minutes of narration, so a long page's audio could never publish; `PUT_ASSET`'s own 16 MiB cap
12
+ was never reached. New route `UPLOAD` (`POST /api/artifacts/<id>/uploads/<name>`, publish key,
13
+ JSON `{ digest }`) returns a v4 signed URL and the headers to send; the publisher PUTs the bytes
14
+ there, then calls again with `{ digest, done: true }`, which makes the object readable and
15
+ returns its URL (409 if nothing arrived). The key is the same hashed name `put()` writes, with
16
+ the same `immutable` cache header. `ArtifactAssets` gains optional `signUpload` and
17
+ `finishUpload`; `createArtifactAssets` implements both. `storedName`, `hashedName` and
18
+ `ASSET_DIGEST` are exported.
19
+ - **DETECTOR:** publishing a page whose narration is longer than about five minutes fails with
20
+ `narration upload failed 413: ... FUNCTION_PAYLOAD_TOO_LARGE`.
21
+ - **REMEDY:** add `app/api/artifacts/[id]/uploads/[name]/route.ts` exporting `POST` that calls
22
+ the routes' `UPLOAD`, the same shape as the assets route. The bucket's service account needs no
23
+ new permission when the host signs with a service-account key. Until the route exists the
24
+ publisher gets a 404 and falls back to `PUT_ASSET`.
25
+ - **Tests:** signing targets put()'s key with its cache header; finishing refuses a missing
26
+ object; the route's refusals match `PUT_ASSET`'s, a bad digest is 400, and a host whose assets
27
+ cannot sign answers 501.
28
+
29
+ ## 0.4.0 (2026-09-26)
30
+
31
+ **Light and dark modes, a table of contents, and an animated ground.**
32
+
33
+ - **Modes.** `BrandPack` gains `light` (a second palette), `mode` (`light`, `dark` or `system`,
34
+ default `dark`) and `backdrop`. A page may override the mode with `theme:` in its front matter.
35
+ Every colour the page draws is now a CSS variable set by `BrandGround` for the mode, so `system`
36
+ follows the reader's device. `freedomDefault` carries a light palette.
37
+ - **Table of contents.** Built from the page's `##` headings: a sticky rail in the left margin at
38
+ `xl` widths, highlighting the section being read, and a closed "Contents" list above the body
39
+ below that. Both are `data-nospeak`. It appears once a page has four sections; `toc: false`
40
+ removes it and `toc: true` draws it from one. Every heading now carries its slug as an `id`,
41
+ with or without the notes widget.
42
+ - **Bubbles.** `backdrop: { kind: 'bubbles' }` draws drifting colour behind the page in plain CSS,
43
+ held still under `prefers-reduced-motion`. A pack with its own `Wrapper` draws its own ground
44
+ and ignores `backdrop`.
45
+ - **Long words wrap.** The body is `break-words`, so a bare URL in prose no longer pushes a phone
46
+ sideways (a 390px screen scrolled 7px on the first page checked).
47
+ - **DETECTOR:** a page's root carries `data-artifact-theme`; an open page with four sections
48
+ carries `data-artifact-toc`. A host whose pages still show zinc-on-black text in a light mode is
49
+ building from a Tailwind `content` list that does not scan this package's `lib/`.
50
+ - **REMEDY:** none required. A pack with no `mode` keeps its dark look, and `theme`/`toc` are new
51
+ optional keys. A host whose publisher strips unknown front matter keys must forward `theme` and
52
+ `toc` for pages to set them.
53
+ - **Tests:** theme CSS per mode and the fallback light palette; the ground, the wrapper-pack path
54
+ and the backdrop; `theme` and `toc` parsed, refused, stored and cleared on republish; heading ids
55
+ without notes; the route's mode and both contents placements.
56
+
6
57
  ## 0.3.0 (2026-09-25)
7
58
 
8
59
  **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 |
@@ -2,7 +2,21 @@ import type { Bucket } from '@google-cloud/storage';
2
2
  export interface ArtifactAssets {
3
3
  /** Store `bytes` for artifact `id` under `name`; return the public URL. */
4
4
  put(id: string, name: string, bytes: Buffer, contentType: string): Promise<string>;
5
+ /**
6
+ * A signed URL the publisher PUTs a big file to directly, skipping the route handler.
7
+ * `digest` is the first 8 hex of the bytes' sha256, so the key is the one put() would use.
8
+ * Optional: a host without it answers 501 and the publisher falls back to put().
9
+ */
10
+ signUpload?(id: string, name: string, digest: string, contentType: string): Promise<{
11
+ uploadUrl: string;
12
+ headers: Record<string, string>;
13
+ url: string;
14
+ }>;
15
+ /** After a signed upload: make it readable and return its URL, or null if nothing arrived. */
16
+ finishUpload?(id: string, name: string, digest: string): Promise<string | null>;
5
17
  }
18
+ /** The digest half of a stored name: the first 8 hex of the bytes' sha256. */
19
+ export declare const ASSET_DIGEST: RegExp;
6
20
  /** Names are one path segment: letters, digits, dot, dash, underscore. */
7
21
  export declare const ASSET_NAME: RegExp;
8
22
  export declare const ASSET_TYPES: Record<string, string>;
@@ -24,4 +38,6 @@ export declare function contentTypeFor(name: string): string | undefined;
24
38
  * URL and stay cached, which is the whole benefit.
25
39
  */
26
40
  export declare function hashedName(name: string, bytes: Buffer): string;
41
+ /** `name` with `digest` spliced in before the extension, the way hashedName stores it. */
42
+ export declare function storedName(name: string, digest: string): string;
27
43
  export declare function createArtifactAssets(bucket: Bucket, prefix: string): ArtifactAssets;
@@ -3,6 +3,9 @@
3
3
  // publisher shelled out to gsutil against the maintainer's bucket, which no other operator
4
4
  // could do, so every operator gets the same door: PUT bytes with their key, get a URL back.
5
5
  import { createHash } from 'node:crypto';
6
+ /** The digest half of a stored name: the first 8 hex of the bytes' sha256. */
7
+ export const ASSET_DIGEST = /^[0-9a-f]{8}$/;
8
+ const CACHE_CONTROL = 'public, max-age=31536000, immutable';
6
9
  /** Names are one path segment: letters, digits, dot, dash, underscore. */
7
10
  export const ASSET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;
8
11
  export const ASSET_TYPES = {
@@ -35,7 +38,10 @@ export function contentTypeFor(name) {
35
38
  * URL and stay cached, which is the whole benefit.
36
39
  */
37
40
  export function hashedName(name, bytes) {
38
- const digest = createHash('sha256').update(bytes).digest('hex').slice(0, 8);
41
+ return storedName(name, createHash('sha256').update(bytes).digest('hex').slice(0, 8));
42
+ }
43
+ /** `name` with `digest` spliced in before the extension, the way hashedName stores it. */
44
+ export function storedName(name, digest) {
39
45
  const dot = name.lastIndexOf('.');
40
46
  // A name with no extension is legal here, so append rather than assume there is a dot.
41
47
  return dot <= 0 ? `${name}.${digest}` : `${name.slice(0, dot)}.${digest}${name.slice(dot)}`;
@@ -49,7 +55,7 @@ export function createArtifactAssets(bucket, prefix) {
49
55
  await file.save(bytes, {
50
56
  contentType,
51
57
  resumable: false,
52
- metadata: { cacheControl: 'public, max-age=31536000, immutable' },
58
+ metadata: { cacheControl: CACHE_CONTROL },
53
59
  });
54
60
  try {
55
61
  await file.makePublic();
@@ -59,5 +65,35 @@ export function createArtifactAssets(bucket, prefix) {
59
65
  }
60
66
  return `https://storage.googleapis.com/${bucket.name}/${key}`;
61
67
  },
68
+ // A route handler on Vercel refuses a body over 4.5 MB (FUNCTION_PAYLOAD_TOO_LARGE), which is
69
+ // about five minutes of narration. An hour-long paper's audio (~55 MB) could never publish.
70
+ // So a big file goes to the bucket directly, on a URL signed with the host's own service
71
+ // account, to the same hashed key and with the same cache header put() writes.
72
+ async signUpload(id, name, digest, contentType) {
73
+ const key = `${clean}/${id}/${storedName(name, digest)}`;
74
+ const headers = { 'content-type': contentType, 'cache-control': CACHE_CONTROL };
75
+ const [uploadUrl] = await bucket.file(key).getSignedUrl({
76
+ version: 'v4',
77
+ action: 'write',
78
+ expires: Date.now() + 15 * 60 * 1000,
79
+ contentType,
80
+ extensionHeaders: { 'cache-control': CACHE_CONTROL },
81
+ });
82
+ return { uploadUrl, headers, url: `https://storage.googleapis.com/${bucket.name}/${key}` };
83
+ },
84
+ async finishUpload(id, name, digest) {
85
+ const key = `${clean}/${id}/${storedName(name, digest)}`;
86
+ const file = bucket.file(key);
87
+ const [there] = await file.exists();
88
+ if (!there)
89
+ return null;
90
+ try {
91
+ await file.makePublic();
92
+ }
93
+ catch {
94
+ // uniform bucket access: already public
95
+ }
96
+ return `https://storage.googleapis.com/${bucket.name}/${key}`;
97
+ },
62
98
  };
63
99
  }
@@ -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,7 @@ export { ArtifactMarkdown } from './render.js';
3
3
  export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
4
4
  export { createArtifactStore, newArtifactId, type ArtifactStore, type ArtifactRecord, type SaveResult } from './store.js';
5
5
  export { isPublishAuthed } from './auth.js';
6
- export { createArtifactAssets, contentTypeFor, ASSET_NAME, type ArtifactAssets } from './assets.js';
6
+ export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, type ArtifactAssets } from './assets.js';
7
7
  export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
8
8
  export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS, type Access, type Reader, type AllowEntry, type Decision } from './reader.js';
9
9
  export { createReadersStore, summarize, FLAG_KINDS, type ReadersStore, type FlagKind, type SessionDoc, type FlagDoc } from './readers-store.js';
@@ -3,7 +3,7 @@ export { ArtifactMarkdown } from './render.js';
3
3
  export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
4
4
  export { createArtifactStore, newArtifactId } from './store.js';
5
5
  export { isPublishAuthed } from './auth.js';
6
- export { createArtifactAssets, contentTypeFor, ASSET_NAME } from './assets.js';
6
+ export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST } from './assets.js';
7
7
  export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
8
8
  export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS } from './reader.js';
9
9
  export { createReadersStore, summarize, FLAG_KINDS } from './readers-store.js';
@@ -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
  }
@@ -102,6 +102,18 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
102
102
  name: string;
103
103
  url: string;
104
104
  }>>;
105
+ UPLOAD: (request: NextRequest, { params }: {
106
+ params: Promise<{
107
+ id: string;
108
+ name: string;
109
+ }>;
110
+ }) => Promise<NextResponse<{
111
+ error: string;
112
+ }> | NextResponse<{
113
+ id: string;
114
+ name: string;
115
+ url: string;
116
+ }>>;
105
117
  SHARE_IMAGE: (_request: NextRequest, { params }: Params) => Promise<Response>;
106
118
  ENTER: (request: NextRequest) => Promise<NextResponse<unknown>>;
107
119
  LEAVE: (request: NextRequest) => Promise<NextResponse<unknown>>;
@@ -9,8 +9,10 @@ import { ArtifactMarkdown } from '../artifacts/render.js';
9
9
  import { ArtifactDoor } from '../artifacts/door.js';
10
10
  import { isUnlocked, keyHash, unlockCookieName } from '../artifacts/unlock.js';
11
11
  import { shapeChanges } from '../artifacts/state.js';
12
- import { ASSET_NAME, contentTypeFor } from '../artifacts/assets.js';
12
+ import { ASSET_DIGEST, 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
@@ -363,6 +358,40 @@ export function createArtifactRoutes(config) {
363
358
  const url = await config.assets.put(id, name, bytes, type);
364
359
  return NextResponse.json({ id, name, url }, { status: 201 });
365
360
  }
361
+ /**
362
+ * POST /api/artifacts/<id>/uploads/<name> with `{ digest }`: a signed URL to PUT a big file to
363
+ * directly, because a route handler cannot take one (Vercel caps a body at 4.5 MB). Then the
364
+ * same call with `{ digest, done: true }` makes it readable and returns its URL; 409 when the
365
+ * bytes never arrived. 501 on a host whose assets cannot sign, so the publisher falls back.
366
+ */
367
+ async function UPLOAD(request, { params }) {
368
+ if (!isPublishAuthed(request, config.publishKey()))
369
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
370
+ const assets = config.assets;
371
+ if (!assets?.signUpload || !assets.finishUpload)
372
+ return NextResponse.json({ error: 'this host does not take direct uploads' }, { status: 501 });
373
+ const { id, name } = await params;
374
+ if (!ARTIFACT_ID.test(id))
375
+ return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
376
+ if (!(await store.get(id)))
377
+ return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
378
+ if (!ASSET_NAME.test(name))
379
+ return NextResponse.json({ error: 'asset name must be one path segment: letters, digits, dot, dash, underscore' }, { status: 400 });
380
+ const type = contentTypeFor(name);
381
+ if (!type)
382
+ return NextResponse.json({ error: 'asset type not allowed; use webp, png, jpg, gif, mp3 or json' }, { status: 415 });
383
+ const body = (await request.json().catch(() => ({})));
384
+ const digest = typeof body.digest === 'string' ? body.digest : '';
385
+ if (!ASSET_DIGEST.test(digest))
386
+ return NextResponse.json({ error: 'digest must be the first 8 hex of the sha256 of the bytes' }, { status: 400 });
387
+ if (body.done === true) {
388
+ const url = await assets.finishUpload(id, name, digest);
389
+ if (!url)
390
+ return NextResponse.json({ error: 'nothing was uploaded to that url' }, { status: 409 });
391
+ return NextResponse.json({ id, name, url }, { status: 201 });
392
+ }
393
+ return NextResponse.json({ id, name, ...(await assets.signUpload(id, name, digest, type)) }, { status: 201 });
394
+ }
366
395
  /** GET /<id>/share.png: the page's title card. 404 when the pack draws none, so a
367
396
  * tenant on the static default never serves a half-styled card. */
368
397
  async function SHARE_IMAGE(_request, { params }) {
@@ -372,7 +401,7 @@ export function createArtifactRoutes(config) {
372
401
  const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
373
402
  if (!a)
374
403
  return new NextResponse(`no artifact with id ${id}`, { status: 404 });
375
- return renderShareCard(brand, a.title);
404
+ return renderShareCard(pack, a.title);
376
405
  }
377
406
  async function DELETE(request, { params }) {
378
407
  if (!isPublishAuthed(request, config.publishKey()))
@@ -384,5 +413,5 @@ export function createArtifactRoutes(config) {
384
413
  revalidatePath(pagePath(id));
385
414
  return NextResponse.json({ deleted: true });
386
415
  }
387
- return { Page, generateMetadata, POST, GET, DELETE, PUT_ASSET, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, ...stateRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
416
+ return { Page, generateMetadata, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, ...stateRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
388
417
  }
@@ -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.1",
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",