@supersuit/artifacts 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,73 @@
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
+
34
+ ## 0.3.0 (2026-09-25)
35
+
36
+ **Notes, the first widget.** A ```` ```notes ```` block in a page puts a small "note" control
37
+ beside every heading, so readers can leave notes on a section and, on a `shared` page, read each
38
+ other's. Poll, form and checklist follow in a later release.
39
+
40
+ - **Fence:** ```` ```notes ```` with an optional `visibility: private | shared` line. At most one
41
+ per page. It declares slot `notes` of shape `many` itself, merged into `state:` at parse time
42
+ (a page with no `state:` gets `writers: signed-in`, `visibility: private`), so the 0.2.0 state
43
+ API takes notes unchanged. Refused at publish with the file's line number: a second block, a
44
+ name, an unknown key, `tally`, a `notes` slot declared `one`, a visibility that disagrees with
45
+ `state:`. `poll`, `form` and `checklist` fences are refused until they ship.
46
+ - **A note is `{ slug, heading, note }`**, checked by the server on a page with a notes block.
47
+ Headings carry their GitHub-style slug as an `id`; the renderer finds each heading by its source
48
+ line, so the slug on the page and the slug a note stores come from one derivation.
49
+ - **A renamed or removed heading keeps its notes**, shown under "Notes on earlier versions" with
50
+ the heading they were left under, never attached to another section.
51
+ - **Narration skips the widget**: the fence is not spoken and everything drawn is `data-nospeak`.
52
+ - **Password pages with `state:`** now set the answer API's unlock cookie on every open, not only
53
+ on `?key=`. Readers who unlocked such a page before 0.2.0 are no longer refused (the item parked
54
+ in 0.2.0).
55
+ - **The replace-at-cap check** reads one document (`StateStore.hasOne`, optional, implemented by
56
+ both shipped stores) instead of every answer on the page.
57
+ - **DETECTOR:** a page with a notes block published to a host with no state store gets `warning:
58
+ this host keeps no answers` and draws no controls. A notes page whose readers see no "note"
59
+ button beside its headings is on 0.2.0 or earlier.
60
+ - **REMEDY (hosts):** take 0.3.0. No new routes: the notes widget uses the 0.2.0 state routes
61
+ (`app/api/artifacts/[id]/state/route.ts`), which a host must already mount. A host with its own
62
+ `StateStore` may add `hasOne`; without it the old full read is used.
63
+ - **Tests:** `widgets.test.ts` (slugs, fence parsing, one per page, reserved widgets, merge,
64
+ note shape, placement), `front-matter.test.ts` (line numbers in the file, the first real page's
65
+ front matter), `render.test.tsx` (controls beside headings, fence never drawn as code),
66
+ `widgets/notes.test.tsx` (jsdom: shared notes under their heading, earlier versions, posting
67
+ appends `{ slug, heading, note }`, sign-in offer, removal, no-store host), `narration.test.ts`,
68
+ `state.test.tsx` (note shape refused, shared read, `hasOne` path), `artifacts.test.tsx` (API
69
+ cookie on every open, page wiring, publish refusal), `state-store.test.ts` (`hasOne`), and the
70
+ packed fixture (a notes page draws its controls, a note posts and reads back, a malformed one is
71
+ refused, the compiled widget keeps `'use client'`).
72
+
6
73
  ## 0.2.0 (2026-09-24)
7
74
 
8
75
  **Reader answers.** A page can now take input from the people reading it. It declares named
package/README.md CHANGED
@@ -25,7 +25,8 @@ Firestore), and optionally `@google-cloud/storage >= 7` for uploaded files.
25
25
  | `createArtifactAssets(bucket, prefix)` | Uploaded images for a page, in a Cloud Storage bucket |
26
26
  | `freedomDefault`, `BrandPack` | The default look and the type any other look implements |
27
27
  | `BrandGround`, `BrandMark` | The pack's page backdrop and mark, for your own pages (a home, a 404) |
28
- | `parseArtifactSource` | The front-matter contract, as a parser you can call before publishing |
28
+ | `parseArtifactSource` | The front-matter contract, as a parser you can call before publishing (widget fences included) |
29
+ | `headingsOf`, `scanWidgets`, `placeNotes` | The notes widget's pure parts: heading slugs, fence validation, where each note shows |
29
30
  | `mintPass`, `verifyPass` | The reader pass, for the sign-in side (see below) |
30
31
  | `ARTIFACT_PUBLIC_PREFIXES` (`/gate`) | Paths to leave open if you mount artifacts inside a gated site |
31
32
 
@@ -129,6 +130,8 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
129
130
  | `voice`, `narration`, `timings`, `narrationHash` | Read-aloud: the audio and word timings a publisher generated. Absent, no player |
130
131
  | `password` | Shuts the body behind a door (below) |
131
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 |
132
135
  | `template` | `document`, the only one so far |
133
136
 
134
137
  An unknown key is refused, so a typo fails loudly instead of being ignored.
@@ -285,6 +288,55 @@ On a proxy other than Vercel's, pass `clientIp` to `createArtifactRoutes` (used
285
288
  rate limit): the default reads the first hop of `x-forwarded-for`, which Vercel overwrites with
286
289
  the real client IP but another proxy may only append to.
287
290
 
291
+ ## Widgets in a page
292
+
293
+ A widget is a fenced block in a page's markdown that gives readers a place to answer, drawn by
294
+ the shell in the page's brand and kept through the state API above. Widgets need a host with a
295
+ state store (`state:` in the config); on a host without one they draw nothing, and the publish
296
+ response carries the no-answers warning.
297
+
298
+ This version draws one widget, **notes**. Poll, form and checklist are coming: a fence named
299
+ `poll`, `form` or `checklist` is refused at publish until then, so a page never ships a code
300
+ block that turns into a live widget on a later update.
301
+
302
+ ### Notes
303
+
304
+ ````markdown
305
+ ## Sales
306
+
307
+ The weekly pipeline review.
308
+
309
+ ```notes
310
+ visibility: shared # optional: private | shared
311
+ ```
312
+ ````
313
+
314
+ - **A small "note" control sits beside every heading.** A reader opens it, writes, and saves. The
315
+ note appears under that heading; with `visibility: shared` every reader sees every note with
316
+ the writer's first name ("a reader" for a signed-out one), with `private` each reader sees only
317
+ their own and the publisher sees all through `/responses`.
318
+ - **At most one notes block per page.** Where it sits is where the page shows "Notes on earlier
319
+ versions" (below).
320
+ - **The block declares its own slot**: `notes`, shape `many`. A page needs no `state:` for it;
321
+ without one the page takes `writers: signed-in` and `visibility: private`. A page may declare
322
+ the slot itself (`slots: { notes: { shape: many } }`) and set `writers:` and `visibility:` as
323
+ usual. Refused at publish, naming the line in the file: a second notes block, a name after
324
+ `notes`, any key but `visibility`, `visibility: tally`, a `notes` slot declared `shape: one`,
325
+ and a block visibility that disagrees with one `state:` sets on the `notes` slot.
326
+ - **A note is `{ slug, heading, note }`**: the slug and the text of the heading it was left under,
327
+ and up to 4,000 characters of text. On a page with a notes block the server refuses any other
328
+ shape in the `notes` slot. Slugs follow GitHub's rule (lowercase, punctuation dropped, spaces
329
+ to dashes, a repeated heading `-1`, `-2`), and each heading carries its slug as its `id`, so
330
+ `#sales` links to it.
331
+ - **Notes survive a changed heading.** A note shows under the heading with its slug; failing
332
+ that, under a heading with exactly its text; otherwise under "Notes on earlier versions", with
333
+ the heading it was left under. It never attaches to a different section.
334
+ - **Narration skips it.** The fence is not prose, and everything the widget draws is marked
335
+ `data-nospeak`, so the narrator and the read-along highlighter read the page as before.
336
+ - On a gated page (`access:`) only signed-in readers the page is open to, after the agreement,
337
+ can leave or read notes; the banner, watermark and print refusal are unchanged. On a public
338
+ page with `writers: signed-in`, the control offers sign-in through `signInOrigin`.
339
+
288
340
  ## Brand packs
289
341
 
290
342
  A `BrandPack` is data plus at most two components: colours, type, the kicker line above a title,
@@ -292,6 +344,20 @@ the narrator label, an optional full-page `Wrapper` and `Mark`, and an optional
292
344
  (a font loader and a backdrop) that draws the unfurl card. `freedomDefault` is the default look.
293
345
  Keep a pack carrying your own trademark in your own app, not in a pull request here.
294
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
+
295
361
  ## Environment
296
362
 
297
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;
@@ -3,7 +3,9 @@
3
3
  import matter from 'gray-matter';
4
4
  import { ACCESS_LEVELS } from './reader.js';
5
5
  import { parseStateConfig } from './state.js';
6
- const KNOWN = new Set(['title', 'summary', 'subtitle', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state']);
6
+ import { mergeWidgetState, scanWidgets } from './widgets.js';
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']);
7
9
  export function parseArtifactSource(text) {
8
10
  const { data, content } = matter(text);
9
11
  const d = data;
@@ -34,11 +36,33 @@ export function parseArtifactSource(text) {
34
36
  return { ok: false, error: `access must be one of: public, ${ACCESS_LEVELS.join(', ')}` };
35
37
  meta.access = d.access;
36
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
+ }
37
49
  if (d.state !== undefined) {
38
50
  const s = parseStateConfig(d.state);
39
51
  if (!s.ok)
40
52
  return { ok: false, error: s.error };
41
53
  meta.state = s.state;
42
54
  }
55
+ // Widgets declare their own slots, so a notes block is merged into state: here, before the
56
+ // page is stored, and the state API and the republish shape checks see it like any slot.
57
+ // Errors name the line in the FILE: the body's lines are counted after the front matter's.
58
+ const offset = text.endsWith(content) ? (text.slice(0, text.length - content.length).match(/\n/g) ?? []).length : 0;
59
+ const w = scanWidgets(content, offset);
60
+ if (!w.ok)
61
+ return { ok: false, error: w.error };
62
+ const merged = mergeWidgetState(meta.state, w.notes);
63
+ if (!merged.ok)
64
+ return { ok: false, error: merged.error };
65
+ if (merged.state)
66
+ meta.state = merged.state;
43
67
  return { ok: true, meta, body: content };
44
68
  }
@@ -10,3 +10,4 @@ export { createReadersStore, summarize, FLAG_KINDS, type ReadersStore, type Flag
10
10
  export { parseStateConfig, effectiveWriters, slotVisibility, checkValue, shapeChanges, SLOT_NAME, MAX_VALUE_BYTES, MAX_MANY_PER_READER, ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, SHARED_LIMIT, type StateConfig, type SlotDef, type Shape, type Visibility, type Writers } from './state.js';
11
11
  export { createStateStore, createMemoryStateStore, readerKeyFor, type StateStore, type StateEntry, type Writer } from './state-store.js';
12
12
  export { stateView, responsesOf, responsesCsv, type SlotView, type Tally, type SharedEntry, type Response as StateResponse } from './state-view.js';
13
+ export { slugify, headingsOf, scanWidgets, mergeWidgetState, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS, type Heading, type NotesWidget, type NoteValue, type PlacedNote } from './widgets.js';
@@ -10,3 +10,4 @@ export { createReadersStore, summarize, FLAG_KINDS } from './readers-store.js';
10
10
  export { parseStateConfig, effectiveWriters, slotVisibility, checkValue, shapeChanges, SLOT_NAME, MAX_VALUE_BYTES, MAX_MANY_PER_READER, ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, SHARED_LIMIT } from './state.js';
11
11
  export { createStateStore, createMemoryStateStore, readerKeyFor } from './state-store.js';
12
12
  export { stateView, responsesOf, responsesCsv } from './state-view.js';
13
+ export { slugify, headingsOf, scanWidgets, mergeWidgetState, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS } from './widgets.js';
@@ -0,0 +1,27 @@
1
+ export declare const NOTES_SLOT = "notes";
2
+ export declare const MAX_NOTE_CHARS = 4000;
3
+ export type Heading = {
4
+ depth: number;
5
+ line: number;
6
+ text: string;
7
+ slug: string;
8
+ };
9
+ export type NoteValue = {
10
+ slug: string;
11
+ heading: string;
12
+ note: string;
13
+ };
14
+ /** A note as a reader's page holds it: the stored value plus who and when. */
15
+ export type PlacedNote = NoteValue & {
16
+ id: string;
17
+ name: string;
18
+ at: string;
19
+ mine: boolean;
20
+ };
21
+ /** Where each note shows: under the heading with its slug, else under a heading with its exact
22
+ * text (a repeated heading renumbered), else under "notes on earlier versions". Never under a
23
+ * heading that merely looks close: a note on the wrong section is worse than one set aside. */
24
+ export declare function placeNotes(headings: Pick<Heading, 'slug' | 'text'>[], notes: PlacedNote[]): {
25
+ bySlug: Record<string, PlacedNote[]>;
26
+ earlier: PlacedNote[];
27
+ };
@@ -0,0 +1,25 @@
1
+ // Where each note on a page shows, and the few names both sides share. Kept free of the
2
+ // markdown parser on purpose: the notes widget runs in the reader's browser and imports this,
3
+ // and the parser has no business in that bundle.
4
+ export const NOTES_SLOT = 'notes';
5
+ export const MAX_NOTE_CHARS = 4000;
6
+ /** Where each note shows: under the heading with its slug, else under a heading with its exact
7
+ * text (a repeated heading renumbered), else under "notes on earlier versions". Never under a
8
+ * heading that merely looks close: a note on the wrong section is worse than one set aside. */
9
+ export function placeNotes(headings, notes) {
10
+ const bySlug = {};
11
+ const earlier = [];
12
+ const slugs = new Set(headings.map((h) => h.slug));
13
+ const byText = new Map();
14
+ for (const h of headings)
15
+ if (!byText.has(h.text))
16
+ byText.set(h.text, h.slug);
17
+ for (const n of notes) {
18
+ const at = slugs.has(n.slug) ? n.slug : byText.get(n.heading);
19
+ if (at)
20
+ (bySlug[at] ??= []).push(n);
21
+ else
22
+ earlier.push(n);
23
+ }
24
+ return { bySlug, earlier };
25
+ }
@@ -1,4 +1,10 @@
1
1
  export declare function isReachableHref(href: unknown): href is string;
2
- export declare function ArtifactMarkdown({ markdown }: {
2
+ /** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
3
+ * page carries a ```notes block. */
4
+ export declare function ArtifactMarkdown({ markdown, notes }: {
3
5
  markdown: string;
6
+ notes?: {
7
+ artifactId: string;
8
+ accent?: string;
9
+ };
4
10
  }): import("react").JSX.Element;
@@ -1,7 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import ReactMarkdown from 'react-markdown';
3
3
  import remarkGfm from 'remark-gfm';
4
- const GOLD = '#C2A15C';
4
+ import { hasNotesWidget, headingsOf } from './widgets.js';
5
+ import { HeadingNotes, NoteToggle, NotesEarlier, NotesProvider } from '../widgets/notes.js';
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)';
5
8
  function hostOf(url) {
6
9
  try {
7
10
  return new URL(url).host.replace(/^www\./, '');
@@ -31,7 +34,7 @@ function LinkCards({ source }) {
31
34
  const [url, ...rest] = l.split('|');
32
35
  return { url: url.trim(), note: rest.join('|').trim() };
33
36
  });
34
- 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))) }));
35
38
  }
36
39
  const CALLOUT = /^\[!(note|warning)\]\s*/i;
37
40
  // A link the reader can actually follow: absolute, a scheme like mailto:, a
@@ -43,26 +46,27 @@ export function isReachableHref(href) {
43
46
  return typeof href === 'string' && REACHABLE.test(href);
44
47
  }
45
48
  const components = {
46
- h1: ({ children }) => _jsx("h1", { className: "mt-10 mb-4 font-serif text-3xl text-zinc-50", children: children }),
47
- h2: ({ children }) => _jsx("h2", { className: "mt-10 mb-3 font-serif text-2xl text-zinc-50", children: children }),
48
- h3: ({ children }) => _jsx("h3", { className: "mt-8 mb-2 text-lg font-semibold text-zinc-100", children: children }),
49
49
  p: ({ children }) => _jsx("p", { className: "my-4 leading-relaxed", children: children }),
50
50
  ul: ({ children }) => _jsx("ul", { className: "my-4 list-disc space-y-1 pl-6", children: children }),
51
51
  ol: ({ children }) => _jsx("ol", { className: "my-4 list-decimal space-y-1 pl-6", children: children }),
52
- hr: () => _jsx("hr", { className: "my-10 border-white/10" }),
52
+ hr: () => _jsx("hr", { className: "my-10 border-[color:var(--a-line)]" }),
53
53
  table: ({ children }) => (_jsx("div", { className: "my-6 overflow-x-auto", children: _jsx("table", { className: "w-full border-collapse text-sm", children: children }) })),
54
- th: ({ children }) => (_jsx("th", { className: "border-b border-white/20 px-3 py-2 text-left font-semibold text-zinc-100", children: children })),
55
- 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 }),
56
56
  img: ({ src, alt }) => (
57
57
  // eslint-disable-next-line @next/next/no-img-element
58
- _jsx("img", { src: typeof src === 'string' ? src : undefined, alt: alt ?? '', className: "my-6 w-full rounded-lg border border-white/10" })),
59
- 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] })),
60
60
  code: ({ className, children }) => {
61
61
  const lang = /language-(\w+)/.exec(className ?? '')?.[1];
62
62
  if (lang === 'links')
63
63
  return _jsx(LinkCards, { source: textOf(children) });
64
+ // A notes fence is the widget's place on the page. Without notes enabled (the host keeps no
65
+ // answers) it draws nothing: its settings are not prose and never shown as code.
66
+ if (lang === 'notes')
67
+ return null;
64
68
  if (!className) {
65
- 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 });
66
70
  }
67
71
  return _jsx("code", { className: className, children: children });
68
72
  },
@@ -70,21 +74,67 @@ const components = {
70
74
  // A links fence renders its own block; do not wrap it in <pre>.
71
75
  const inner = Array.isArray(children) ? children[0] : children;
72
76
  const cls = inner?.props?.className ?? '';
73
- if (/language-links/.test(cls))
77
+ if (/language-(links|notes)/.test(cls))
74
78
  return _jsx(_Fragment, { children: children });
75
- 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 }));
76
80
  },
77
81
  blockquote: ({ children }) => {
78
82
  const t = textOf(children).trim();
79
83
  const m = CALLOUT.exec(t);
80
84
  if (!m) {
81
- 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 }));
82
86
  }
83
87
  const kind = m[1].toLowerCase();
84
88
  const body = t.replace(CALLOUT, '');
85
- 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 })] }));
86
90
  },
87
91
  };
88
- export function ArtifactMarkdown({ markdown }) {
89
- return (_jsx("div", { className: "text-[17px] text-zinc-200", children: _jsx(ReactMarkdown, { remarkPlugins: [remarkGfm], components: components, children: markdown }) }));
92
+ // scroll-mt keeps a heading clear of the top of the screen when a contents link jumps to it.
93
+ const HEADING_CLASS = {
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)]',
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
+ }
112
+ /** Components for a page with a notes block: every heading gets its slug as an id, a note
113
+ * control, and its notes after it. The heading is found by its source line, so the slug is the
114
+ * one headingsOf derived, the same one a note stores. */
115
+ function notesComponents(headings) {
116
+ const byLine = new Map(headings.map((h) => [h.line, h]));
117
+ const heading = (depth) => function NotedHeading({ node, children }) {
118
+ const Tag = `h${depth}`;
119
+ const h = byLine.get(node?.position?.start.line ?? -1);
120
+ if (!h)
121
+ return _jsx(Tag, { className: HEADING_CLASS[depth], children: children });
122
+ return (_jsxs(_Fragment, { children: [_jsxs(Tag, { id: h.slug, className: HEADING_CLASS[depth], children: [children, _jsx(NoteToggle, { slug: h.slug })] }), _jsx(HeadingNotes, { slug: h.slug, text: h.text })] }));
123
+ };
124
+ const Code = components.code;
125
+ return {
126
+ ...components,
127
+ h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6),
128
+ code: (props) => (/language-notes/.test(props.className ?? '') ? _jsx(NotesEarlier, {}) : _jsx(Code, { ...props })),
129
+ };
130
+ }
131
+ /** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
132
+ * page carries a ```notes block. */
133
+ export function ArtifactMarkdown({ markdown, notes }) {
134
+ const on = !!notes && hasNotesWidget(markdown);
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 }));
90
140
  }
@@ -49,6 +49,9 @@ export interface StateStore {
49
49
  countAnonWrite(artifactId: string, ipHash: string, minute: number): Promise<number>;
50
50
  /** Every reader's entries in one slot on one page, for MAX_ENTRIES_PER_SLOT. */
51
51
  countSlot(artifactId: string, slot: string): Promise<number>;
52
+ /** Does this reader already hold a `one` answer in this slot? One document read. Optional so a
53
+ * host's own store keeps working; without it the route falls back to loading the page. */
54
+ hasOne?(artifactId: string, slot: string, readerKey: string): Promise<boolean>;
52
55
  }
53
56
  export declare const readerKeyFor: {
54
57
  signedIn: (uid: string) => string;
@@ -89,6 +89,7 @@ export function createStateStore(db, base) {
89
89
  async countSlot(artifactId, slot) {
90
90
  return (await col().where('artifactId', '==', artifactId).where('slot', '==', slot).count().get()).data().count;
91
91
  },
92
+ hasOne: async (artifactId, slot, readerKey) => (await col().doc(oneId(artifactId, slot, readerKey)).get()).exists,
92
93
  };
93
94
  return store;
94
95
  }
@@ -147,5 +148,6 @@ export function createMemoryStateStore() {
147
148
  return n;
148
149
  },
149
150
  countSlot: async (artifactId, slot) => of(artifactId).filter((e) => e.slot === slot).length,
151
+ hasOne: async (artifactId, slot, readerKey) => docs.has(oneId(artifactId, slot, readerKey)),
150
152
  };
151
153
  }
@@ -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
+ }