@supersuit/artifacts 0.20.0 → 0.22.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,54 @@
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.22.0
7
+
8
+ **A page can carry a checklist a reader ticks off and comes back to.** A per-person Mac setup
9
+ page that remembers its ticks could not be published: the renderer escapes raw HTML (rightly),
10
+ and a ```` ```checklist ```` fence was refused at publish as a widget not yet drawn, so each prep
11
+ page went out as its own one-off site instead of on the operator's host.
12
+
13
+ - New widget `checklist`: items with stable ids (`{#id}` or the slug of the text, numbered on a
14
+ repeat), indented descriptions drawn as markdown with raw HTML escaped, ticks kept in the
15
+ reader's `localStorage` under `artifact-checklist:<page>:<item>` (storage that throws costs only
16
+ the memory), "n of m done", and 44px rows. Several per page.
17
+ - Optional `send: signed-in | anyone` in the fence: a Send button writes `{ done: [ids] }` to the
18
+ page's own `checklist` slot (shape one, private), which the owner reads through `/responses`.
19
+ The server refuses ids that are not that checklist's. Drawn only on a host with a state store.
20
+ - `sms:` and `tel:` links now work on every page (react-markdown's default dropped them), and open
21
+ without `target="_blank"`. Everything else still goes through the default transform.
22
+ - `ArtifactMarkdown` takes `checklist={{ artifactId, send, owner, accent }}`; the route passes it.
23
+ New exports: `checklistsOf`, `checklistSendIds`, `mergeChecklistState`, `parseChecklistFence`,
24
+ `checkChecklistValue`, `checklistStorageKey`, `CHECKLIST_SLOT`, `artifactUrlTransform`.
25
+ - DETECTOR: publish a page with a ```` ```checklist ```` fence. "the checklist widget is not
26
+ available in this version" means the host is on 0.21.0 or earlier.
27
+ - REMEDY: bump. A page with no checklist renders byte for byte as before (the 0.15.0 golden still
28
+ passes unchanged).
29
+ - Tests: the fence grammar and every refusal with its file line; ids page-wide; the slot a Send
30
+ declares; the component's ids and count; raw HTML in an item and a description stays text; a
31
+ page with no checklist identical with and without the new prop; ticks saved by page and item id
32
+ and back after a remount; blocked storage; Send's body and its sign-in answer; the route taking
33
+ only that checklist's ids and `/responses` reading it; sms/tel links and `javascript:` still
34
+ dead; and the packed fixture drawing and posting one. Each was broken on purpose and went red.
35
+
36
+ ## 0.21.0 (2026-10-08)
37
+
38
+ **A page's past versions are for its admins.** Every reader who could open a page could also open
39
+ its History and read every earlier version at `/<id>/v/<n>`, so a passage edited out of a page
40
+ (a name taken out, a figure corrected) stayed readable one click away.
41
+
42
+ - New config `admins` (sign-in emails) and `history: 'admins' | 'everyone'`. The default,
43
+ `'admins'`, shows the History control and serves `/<id>/v/<n>` only to a signed-in reader whose
44
+ email is `ownerEmail` or in `admins`, compared without case. To everyone else the control is
45
+ absent and a past version is not found. "Version N · Updated <minute>" still shows to everyone.
46
+ - DETECTOR: after the bump, a reader who is not an admin no longer sees "History" under a page's
47
+ summary. A host that configures neither `ownerEmail` nor `admins` hides history from everyone.
48
+ - REMEDY: pass `admins` (and `ownerEmail`) for the people who should keep seeing history, or
49
+ `history: 'everyone'` to keep the 0.20.0 behaviour.
50
+ - Tests: signed out, a signed-in non-admin, the owner in another case and a listed admin, a past
51
+ version 404ing for non-admins, nobody configured, and `'everyone'` restoring the old page. The
52
+ earlier version and golden tests pin `history: 'everyone'`, so they still prove 0.20.0's page.
53
+
6
54
  ## 0.20.0 (2026-10-07)
7
55
 
8
56
  **A host can hear when a confidential page is read.** The reader record answered `GET /reads`
package/README.md CHANGED
@@ -27,6 +27,7 @@ Firestore), and optionally `@google-cloud/storage >= 7` for uploaded files.
27
27
  | `BrandGround`, `BrandMark` | The pack's page backdrop and mark, for your own pages (a home, a 404) |
28
28
  | `parseArtifactSource` | The front-matter contract, as a parser you can call before publishing (widget fences included) |
29
29
  | `headingsOf`, `scanWidgets`, `placeNotes` | The notes widget's pure parts: heading slugs, fence validation, where each note shows |
30
+ | `checklistsOf`, `parseChecklistFence`, `checklistStorageKey` | The checklist widget's pure parts: the fence grammar, every item's id, where a tick is kept |
30
31
  | `mintPass`, `verifyPass` | The reader pass, for the sign-in side (see below) |
31
32
  | `mintPublisherPass`, `verifyPublisherPass` | The publisher pass: an hour-long stand-in for the publish key, for one person on one host (see below) |
32
33
  | `ARTIFACT_PUBLIC_PREFIXES` (`/gate`) | Paths to leave open if you mount artifacts inside a gated site |
@@ -123,6 +124,17 @@ phone, a side panel on a desk), so opening it moves no text. Each past version i
123
124
  the same password or confidential door as the page itself. A version that does not exist is a
124
125
  404; on a shut page the door comes first, so it never says which versions exist.
125
126
 
127
+ **Past versions are for admins by default (0.21.0).** An earlier draft can hold what the current
128
+ one was edited to remove, so only the page's admins see the History control or open
129
+ `/<id>/v/<n>`: the reader signed in as `ownerEmail` or any address in `admins` (case does not
130
+ matter). Everyone else still sees "Version N · Updated <minute>", and a past version is a 404 to
131
+ them. With neither configured, nobody does. `history: 'everyone'` in the config restores the old
132
+ behaviour, where every reader who can open the page reads its history.
133
+
134
+ ```ts
135
+ createArtifactRoutes({ ..., ownerEmail: 'owner@example.com', admins: ['editor@example.com'] })
136
+ ```
137
+
126
138
  **Do not write a "Version history" section in the markdown.** Say what changed when you publish:
127
139
 
128
140
  - `POST /api/artifacts?id=<id>&note=<one line>`, or a `change:` line in the front matter. The query
@@ -395,9 +407,64 @@ the shell in the page's brand and kept through the state API above. Widgets need
395
407
  state store (`state:` in the config); on a host without one they draw nothing, and the publish
396
408
  response carries the no-answers warning.
397
409
 
398
- This version draws one widget, **notes**. Poll, form and checklist are coming: a fence named
399
- `poll`, `form` or `checklist` is refused at publish until then, so a page never ships a code
400
- block that turns into a live widget on a later update.
410
+ This version draws two widgets, **checklist** and **notes**. Poll and form are coming: a fence
411
+ named `poll` or `form` is refused at publish until then, so a page never ships a code block that
412
+ turns into a live widget on a later update. A checklist needs no state store unless it offers
413
+ Send.
414
+
415
+ ### Checklist
416
+
417
+ A list a reader ticks off and comes back to: a setup page, a pre-call prep list, a launch
418
+ checklist. It is a known component the shell draws, not markup the author writes, so raw HTML
419
+ stays escaped and no author script ever runs.
420
+
421
+ ````markdown
422
+ ## Before the call
423
+
424
+ ```checklist
425
+ send: anyone # optional: signed-in | anyone; adds a Send button, see below
426
+ - Update macOS {#update}
427
+ Apple menu > System Settings > General > Software Update.
428
+ Restart and check again until it says you're up to date.
429
+ - [ ] Install Chrome and sign in
430
+ Download it from [google.com/chrome](https://www.google.com/chrome/).
431
+ - Send us your GitHub username {#gh-send}
432
+ [Text Sam your username](sms:+15555550100&body=My%20GitHub%20username%20is%20)
433
+ ```
434
+ ````
435
+
436
+ - **An item is a line starting with `- `** (or `* `), optionally followed by `[ ]`. `[x]` is
437
+ accepted and ticks nothing: ticks belong to the reader.
438
+ - **Every item has a stable id.** `{#id}` at the end of the line sets it (lowercase letters,
439
+ digits, dashes, underscores; unique on the page). Without one the id is the slug of the item's
440
+ text, the way a heading's is (`Install Chrome and sign in` is `install-chrome-and-sign-in`), and
441
+ a repeat is numbered `-1`, `-2`. **Give an item an explicit id whenever its wording may change**:
442
+ a reworded item with a slugged id is a new item, and readers lose that tick.
443
+ - **Indented lines under an item are its description**, drawn as markdown: links, `code`, bold,
444
+ short lists. Raw HTML in an item or a description is shown as text. Images, headings, tables and
445
+ fences are not drawn inside a checklist.
446
+ - **Ticks are kept in the reader's browser**, in `localStorage` under
447
+ `artifact-checklist:<page id>:<item id>`, so they survive a reload, a republish, and a reworded
448
+ item with a kept id. Where storage is blocked (a private window, blocked site data) ticks last
449
+ for the visit. Nothing leaves the device unless the reader presses Send.
450
+ - **The list says how far along the reader is**, "3 of 8 done", and every row is a tap target at
451
+ least 44px tall, so it works one-handed on a phone.
452
+ - **Narration skips it**, like every widget: the fence is not prose and the component is
453
+ `data-nospeak`.
454
+ - **Several checklists on one page** are fine; ids are unique across all of them.
455
+ - **`sms:` and `tel:` links work on every page**, in a checklist or in prose, so "text us your
456
+ username" is one tap into the reader's own Messages.
457
+ - Refused at publish, naming the line in the file: an indented line before the first item, an
458
+ unindented line after one, an empty item, a bad or repeated `{#id}`, a name after `checklist`,
459
+ any setting but `send`, a checklist with no items, and a second checklist with `send`.
460
+
461
+ **Send** (`send: signed-in` or `send: anyone`) adds a "Send my progress to <owner>" button under
462
+ the list. It writes `{ done: [item ids] }` to the page's `checklist` slot, shape `one` and
463
+ `private`, so a reader's latest Send replaces their last and only the publisher reads them, through
464
+ `/responses` like any answer. `send:` names who may press it (`anyone` gets the anonymous cookie
465
+ and the rate limit, as `writers: anyone` does). The slot is the widget's own: a page may not
466
+ declare a `checklist` slot in `state:`. The server takes only ids of that checklist, each once. On a
467
+ host with no state store the button is not drawn; the ticks still work.
401
468
 
402
469
  ### Notes
403
470
 
@@ -0,0 +1,53 @@
1
+ export declare const CHECKLIST_SLOT = "checklist";
2
+ export declare const MAX_CHECKLIST_ITEMS = 200;
3
+ /** An item id: lowercase letters (any script), digits, dashes and underscores. The same alphabet
4
+ * a heading slug uses, so a slugged id is always a valid one. */
5
+ export declare const CHECKLIST_ID: RegExp;
6
+ export type SendWriters = 'signed-in' | 'anyone';
7
+ export type ChecklistItem = {
8
+ id: string;
9
+ text: string;
10
+ description: string;
11
+ line: number;
12
+ };
13
+ export type ChecklistBlock = {
14
+ line: number;
15
+ send?: SendWriters;
16
+ items: ChecklistItem[];
17
+ };
18
+ /** What a Send stores in the page's `checklist` slot: the ids the reader had ticked. */
19
+ export type ChecklistValue = {
20
+ done: string[];
21
+ };
22
+ /** A block as written, before ids are made unique across the page. */
23
+ export type RawChecklistItem = ChecklistItem & {
24
+ explicit: boolean;
25
+ };
26
+ export type RawChecklistBlock = Omit<ChecklistBlock, 'items'> & {
27
+ items: RawChecklistItem[];
28
+ };
29
+ /** The fence body as written, before ids are made unique across the page. `line` is the line the
30
+ * fence opens on in the author's file, so every error names a line they can find. */
31
+ export declare function parseChecklistFence(value: string, line: number, meta?: string | null): {
32
+ ok: true;
33
+ block: RawChecklistBlock;
34
+ } | {
35
+ ok: false;
36
+ error: string;
37
+ };
38
+ /** Gives every item on the page its final id. An explicit `{#id}` is kept as written and must be
39
+ * unique on the page; an item without one takes the slug of its text, numbered `-1`, `-2` when
40
+ * that slug is already used, the way repeated headings are. Ids are page-wide because ticks are
41
+ * saved by page and item id. */
42
+ export declare function assignChecklistIds(blocks: RawChecklistBlock[]): {
43
+ ok: true;
44
+ blocks: ChecklistBlock[];
45
+ } | {
46
+ ok: false;
47
+ error: string;
48
+ };
49
+ /** The server's check on a Send, on a page whose checklist offers one: every id is an item of
50
+ * that checklist, once. The browser sends exactly this; anything else is refused. */
51
+ export declare function checkChecklistValue(v: unknown, ids: readonly string[]): string | null;
52
+ /** Where a reader's tick on one item is kept in their browser. */
53
+ export declare const checklistStorageKey: (artifactId: string, itemId: string) => string;
@@ -0,0 +1,147 @@
1
+ // The checklist widget's pure parts: the fence's grammar, the ids every item keeps, and the
2
+ // server's check on what a Send hands the owner. Kept free of the markdown parser, like
3
+ // notes-place.ts, because the widget in the reader's browser imports the names from here.
4
+ //
5
+ // ```checklist
6
+ // send: anyone # optional: signed-in | anyone (who may press Send)
7
+ // - Update macOS {#update}
8
+ // Apple menu > System Settings > General > Software Update.
9
+ // - [ ] Sign in with your personal Apple Account
10
+ // ```
11
+ //
12
+ // An item is a line starting with `- ` (or `* `), optionally followed by `[ ]`. Its id is the
13
+ // `{#id}` at the end of the line when there is one, else a slug of its text. Indented lines under
14
+ // an item are its description, drawn as markdown with raw HTML escaped like the rest of the page.
15
+ // Ticks belong to the reader: `[x]` is accepted and ticks nothing.
16
+ export const CHECKLIST_SLOT = 'checklist';
17
+ export const MAX_CHECKLIST_ITEMS = 200;
18
+ const MAX_ITEM_CHARS = 500;
19
+ const MAX_ID_CHARS = 64;
20
+ /** An item id: lowercase letters (any script), digits, dashes and underscores. The same alphabet
21
+ * a heading slug uses, so a slugged id is always a valid one. */
22
+ export const CHECKLIST_ID = /^[\p{Ll}\p{Lo}\p{Lm}\p{N}_-]{1,64}$/u;
23
+ const ITEM = /^[-*]\s+(?:\[[ xX]\]\s+)?(.*)$/;
24
+ const EXPLICIT_ID = /\s*\{#([^}\s]*)\}\s*$/;
25
+ /** GitHub's heading rule (widgets.slugify), repeated here so this file stays parser-free. */
26
+ function slug(text) {
27
+ const s = text.trim().toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu, '').replace(/\s/g, '-');
28
+ return (s || 'item').slice(0, MAX_ID_CHARS).replace(/-+$/, '') || 'item';
29
+ }
30
+ /** The fence body as written, before ids are made unique across the page. `line` is the line the
31
+ * fence opens on in the author's file, so every error names a line they can find. */
32
+ export function parseChecklistFence(value, line, meta) {
33
+ if (meta?.trim())
34
+ return { ok: false, error: `line ${line}: the checklist block takes no name; put settings like "send: anyone" inside it` };
35
+ const rows = value ? value.split('\n') : [];
36
+ const items = [];
37
+ let send;
38
+ let desc = null;
39
+ const close = () => {
40
+ if (desc && items.length)
41
+ items[items.length - 1].description = desc.join('\n').replace(/^\n+|\n+$/g, '');
42
+ desc = null;
43
+ };
44
+ for (let i = 0; i < rows.length; i++) {
45
+ const row = rows[i];
46
+ const at = line + 1 + i;
47
+ if (!row.trim()) {
48
+ if (desc)
49
+ desc.push('');
50
+ continue;
51
+ }
52
+ if (/^(\s{2,}|\t)/.test(row)) {
53
+ if (!items.length)
54
+ return { ok: false, error: `line ${at}: an indented line belongs under an item; start the list with "- "` };
55
+ desc.push(row.replace(/^(\t| {2,4})/, ''));
56
+ continue;
57
+ }
58
+ const m = ITEM.exec(row);
59
+ if (m) {
60
+ close();
61
+ let text = m[1];
62
+ let id;
63
+ const e = EXPLICIT_ID.exec(text);
64
+ if (e) {
65
+ id = e[1];
66
+ text = text.slice(0, e.index);
67
+ if (!CHECKLIST_ID.test(id))
68
+ return { ok: false, error: `line ${at}: the id {#${id}} must be lowercase letters, digits, dashes or underscores, at most ${MAX_ID_CHARS}` };
69
+ }
70
+ text = text.trim();
71
+ if (!text)
72
+ return { ok: false, error: `line ${at}: a checklist item needs some text` };
73
+ if (text.length > MAX_ITEM_CHARS)
74
+ return { ok: false, error: `line ${at}: a checklist item is at most ${MAX_ITEM_CHARS} characters` };
75
+ if (items.length >= MAX_CHECKLIST_ITEMS)
76
+ return { ok: false, error: `line ${at}: a checklist takes at most ${MAX_CHECKLIST_ITEMS} items` };
77
+ items.push({ id: id ?? '', text, description: '', line: at, explicit: !!id });
78
+ desc = [];
79
+ continue;
80
+ }
81
+ if (items.length)
82
+ return { ok: false, error: `line ${at}: a checklist item starts with "- ", and its description under it is indented` };
83
+ const s = /^([A-Za-z_-]+)\s*:\s*([^#]*?)\s*(#.*)?$/.exec(row.trim());
84
+ if (!s)
85
+ return { ok: false, error: `line ${at}: the checklist block takes settings like "send: anyone", then items starting with "- "` };
86
+ if (s[1] !== 'send')
87
+ return { ok: false, error: `line ${at}: the checklist block has an unknown setting: ${s[1]}` };
88
+ if (s[2] !== 'signed-in' && s[2] !== 'anyone')
89
+ return { ok: false, error: `line ${at}: checklist send must be signed-in or anyone` };
90
+ send = s[2];
91
+ }
92
+ close();
93
+ if (!items.length)
94
+ return { ok: false, error: `line ${line}: a checklist needs at least one item starting with "- "` };
95
+ return { ok: true, block: { line, ...(send ? { send } : {}), items } };
96
+ }
97
+ /** Gives every item on the page its final id. An explicit `{#id}` is kept as written and must be
98
+ * unique on the page; an item without one takes the slug of its text, numbered `-1`, `-2` when
99
+ * that slug is already used, the way repeated headings are. Ids are page-wide because ticks are
100
+ * saved by page and item id. */
101
+ export function assignChecklistIds(blocks) {
102
+ const taken = new Map();
103
+ for (const b of blocks)
104
+ for (const it of b.items) {
105
+ if (!it.explicit)
106
+ continue;
107
+ if (taken.has(it.id))
108
+ return { ok: false, error: `line ${it.line}: the id {#${it.id}} is already used on line ${taken.get(it.id)}` };
109
+ taken.set(it.id, it.line);
110
+ }
111
+ const out = blocks.map((b) => ({
112
+ line: b.line,
113
+ ...(b.send ? { send: b.send } : {}),
114
+ items: b.items.map(({ explicit, ...it }) => {
115
+ if (explicit)
116
+ return it;
117
+ const base = slug(it.text);
118
+ let id = base;
119
+ for (let k = 1; taken.has(id); k++)
120
+ id = `${base}-${k}`;
121
+ taken.set(id, it.line);
122
+ return { ...it, id };
123
+ }),
124
+ }));
125
+ return { ok: true, blocks: out };
126
+ }
127
+ /** The server's check on a Send, on a page whose checklist offers one: every id is an item of
128
+ * that checklist, once. The browser sends exactly this; anything else is refused. */
129
+ export function checkChecklistValue(v, ids) {
130
+ if (!v || typeof v !== 'object' || Array.isArray(v) || Object.keys(v).some((k) => k !== 'done'))
131
+ return 'a checklist answer is { done: [item ids] }';
132
+ const done = v.done;
133
+ if (!Array.isArray(done))
134
+ return 'a checklist answer is { done: [item ids] }';
135
+ const known = new Set(ids);
136
+ const seen = new Set();
137
+ for (const id of done) {
138
+ if (typeof id !== 'string' || !known.has(id))
139
+ return 'a checklist answer names only items on this page';
140
+ if (seen.has(id))
141
+ return 'a checklist answer names each item once';
142
+ seen.add(id);
143
+ }
144
+ return null;
145
+ }
146
+ /** Where a reader's tick on one item is kept in their browser. */
147
+ export const checklistStorageKey = (artifactId, itemId) => `artifact-checklist:${artifactId}:${itemId}`;
@@ -3,7 +3,7 @@
3
3
  import matter from 'gray-matter';
4
4
  import { ACCESS_LEVELS } from './reader.js';
5
5
  import { parseStateConfig } from './state.js';
6
- import { mergeWidgetState, scanWidgets } from './widgets.js';
6
+ import { mergeChecklistState, mergeWidgetState, scanWidgets } from './widgets.js';
7
7
  import { COMMENTS_MODES, mergeCommentsState } from './comments.js';
8
8
  import { THEME_MODES } from '../brand/theme.js';
9
9
  import { parseDefinitions } from './definitions.js';
@@ -94,14 +94,17 @@ export function parseArtifactSource(text) {
94
94
  return { ok: false, error: 'comments_visible must be owner or readers' };
95
95
  meta.commentsVisible = d.comments_visible;
96
96
  }
97
- // Widgets declare their own slots, so a notes block is merged into state: here, before the
97
+ // Widgets declare their own slots, so a notes block (and a checklist with send) is merged into state: here, before the
98
98
  // page is stored, and the state API and the republish shape checks see it like any slot.
99
99
  // Errors name the line in the FILE: the body's lines are counted after the front matter's.
100
100
  const offset = text.endsWith(content) ? (text.slice(0, text.length - content.length).match(/\n/g) ?? []).length : 0;
101
101
  const w = scanWidgets(content, offset);
102
102
  if (!w.ok)
103
103
  return { ok: false, error: w.error };
104
- const merged = mergeWidgetState(meta.state, w.notes);
104
+ const noted = mergeWidgetState(meta.state, w.notes);
105
+ if (!noted.ok)
106
+ return { ok: false, error: noted.error };
107
+ const merged = mergeChecklistState(noted.state, w.checklists);
105
108
  if (!merged.ok)
106
109
  return { ok: false, error: merged.error };
107
110
  const withComments = mergeCommentsState(merged.state, meta.comments, meta.commentsVisible);
@@ -1,5 +1,5 @@
1
1
  export { parseArtifactSource, type ArtifactMeta } from './front-matter.js';
2
- export { ArtifactMarkdown } from './render.js';
2
+ export { ArtifactMarkdown, artifactUrlTransform } from './render.js';
3
3
  export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
4
4
  export { createArtifactStore, newArtifactId, cleanNote, MAX_CHANGE_NOTE_CHARS, type ArtifactStore, type ArtifactRecord, type SaveResult, type SaveInput, type VersionEntry, type VersionRecord } from './store.js';
5
5
  export { isPublishAuthed, isPublisherAuthed, type PublishAuth } from './auth.js';
@@ -12,7 +12,8 @@ export { readAlertsDue, readSecondsFor, wordCount, localDay, READ_ALERT_KINDS, W
12
12
  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';
13
13
  export { createStateStore, createMemoryStateStore, readerKeyFor, type StateStore, type StateEntry, type Writer } from './state-store.js';
14
14
  export { stateView, responsesOf, responsesCsv, type SlotView, type Tally, type SharedEntry, type Response as StateResponse } from './state-view.js';
15
- export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS, type Heading, type NotesWidget, type NoteValue, type PlacedNote } from './widgets.js';
15
+ export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, mergeChecklistState, checklistsOf, checklistSendIds, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS, type Heading, type NotesWidget, type NoteValue, type PlacedNote } from './widgets.js';
16
+ export { CHECKLIST_SLOT, checklistStorageKey, checkChecklistValue, parseChecklistFence, type ChecklistBlock, type ChecklistItem, type ChecklistValue } from './checklist.js';
16
17
  export { textAnchorFrom, findText, blockId, parseAnchor, CONTEXT_CHARS, MAX_QUOTE_CHARS, BLOCK_ID, type Anchor, type TextAnchor, type RegionAnchor } from './anchor.js';
17
18
  export { COMMENTS_SLOT, COMMENTS_MODES, COMMENT_TRANSCRIPTS, MAX_COMMENT_CHARS, mergeCommentsState, validateComment, type CommentsMode, type CommentsVisible, type CommentValue } from './comments.js';
18
19
  export { createPersonalStore, createMemoryPersonalStore, validatePersonal, personalAudioDir, MAX_PERSONAL_PER_READER, type PersonalStore, type PersonalNote, type PersonalNoteValue } from './personal-store.js';
@@ -1,5 +1,5 @@
1
1
  export { parseArtifactSource } from './front-matter.js';
2
- export { ArtifactMarkdown } from './render.js';
2
+ export { ArtifactMarkdown, artifactUrlTransform } from './render.js';
3
3
  export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
4
4
  export { createArtifactStore, newArtifactId, cleanNote, MAX_CHANGE_NOTE_CHARS } from './store.js';
5
5
  export { isPublishAuthed, isPublisherAuthed } from './auth.js';
@@ -12,7 +12,8 @@ export { readAlertsDue, readSecondsFor, wordCount, localDay, READ_ALERT_KINDS, W
12
12
  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';
13
13
  export { createStateStore, createMemoryStateStore, readerKeyFor } from './state-store.js';
14
14
  export { stateView, responsesOf, responsesCsv } from './state-view.js';
15
- export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS } from './widgets.js';
15
+ export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, mergeChecklistState, checklistsOf, checklistSendIds, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS } from './widgets.js';
16
+ export { CHECKLIST_SLOT, checklistStorageKey, checkChecklistValue, parseChecklistFence } from './checklist.js';
16
17
  export { textAnchorFrom, findText, blockId, parseAnchor, CONTEXT_CHARS, MAX_QUOTE_CHARS, BLOCK_ID } from './anchor.js';
17
18
  export { COMMENTS_SLOT, COMMENTS_MODES, COMMENT_TRANSCRIPTS, MAX_COMMENT_CHARS, mergeCommentsState, validateComment } from './comments.js';
18
19
  export { createPersonalStore, createMemoryPersonalStore, validatePersonal, personalAudioDir, MAX_PERSONAL_PER_READER } from './personal-store.js';
@@ -3,6 +3,7 @@ import type { FigureProps, SectionOutline, SectionProps } from '../brand/pack.js
3
3
  import { type Heading } from './widgets.js';
4
4
  import { type Definition } from './definitions.js';
5
5
  export declare function isReachableHref(href: unknown): href is string;
6
+ export declare function artifactUrlTransform(url: string): string;
6
7
  /** A pack's figure resolver (BrandPack.figure). */
7
8
  export type FigureFor = (src: string, alt: string) => ComponentType<FigureProps> | undefined;
8
9
  /** The page's top-level sections: every heading among the body's top-level blocks at the
@@ -10,8 +11,17 @@ export type FigureFor = (src: string, alt: string) => ComponentType<FigureProps>
10
11
  export declare function sectionOutline(markdown: string, headings?: Heading[]): SectionOutline[];
11
12
  /** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
12
13
  * page carries a ```notes block. */
13
- export declare function ArtifactMarkdown({ markdown, notes, definitions, Section, figure, }: {
14
+ export declare function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, }: {
14
15
  markdown: string;
16
+ /** The page a ```checklist keeps its ticks under, and whether its Send can reach the owner
17
+ * (a host that keeps answers, on a page that declares the slot). Without it a checklist still
18
+ * draws and ticks, for the visit only. */
19
+ checklist?: {
20
+ artifactId: string;
21
+ send?: boolean;
22
+ owner?: string;
23
+ accent?: string;
24
+ };
15
25
  /** The pack's section component (BrandPack.Section): each top-level section is drawn through it. */
16
26
  Section?: ComponentType<SectionProps>;
17
27
  /** The pack's figure resolver (BrandPack.figure). */
@@ -2,16 +2,20 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  // Markdown to the house look, for /a/<id>. Server-safe: no hooks, no client
3
3
  // directive. Raw HTML is escaped by react-markdown's default; never add
4
4
  // rehype-raw here, this renders strangers' links on a public route.
5
- // Two extensions past GFM, both small on purpose:
5
+ // Three extensions past GFM, all small on purpose:
6
6
  // ```links one "url | note" per line, rendered as link cards
7
+ // ```checklist items a reader ticks, kept in their browser (widgets/checklist.tsx)
7
8
  // > [!note] / > [!warning] a callout instead of a blockquote
9
+ // A checklist's item text and descriptions are markdown drawn by the same safe renderer: raw
10
+ // HTML escaped, links only where a reader can follow them, no images.
8
11
  import { Children, isValidElement } from 'react';
9
- import ReactMarkdown from 'react-markdown';
12
+ import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
10
13
  import remarkGfm from 'remark-gfm';
11
14
  import remarkParse from 'remark-parse';
12
15
  import { unified } from 'unified';
13
- import { blocksOf, hasNotesWidget, headingsOf } from './widgets.js';
16
+ import { blocksOf, checklistsOf, hasNotesWidget, headingsOf } from './widgets.js';
14
17
  import { HeadingNotes, NoteToggle, NotesEarlier, NotesProvider } from '../widgets/notes.js';
18
+ import { Checklist } from '../widgets/checklist.js';
15
19
  import { VideoAutoplay } from '../reader/video-autoplay.js';
16
20
  import { CodeCopy } from '../reader/code-copy.js';
17
21
  import { DefinitionLayer } from '../reader/definition-layer.js';
@@ -58,6 +62,13 @@ const REACHABLE = /^(?:[a-z][a-z0-9+.-]*:|\/|#)/i;
58
62
  export function isReachableHref(href) {
59
63
  return typeof href === 'string' && REACHABLE.test(href);
60
64
  }
65
+ /** `sms:` and `tel:` links open the reader's own Messages or Phone: a prep page's "text us your
66
+ * username" is one tap. react-markdown's default drops both schemes (it keeps http(s), mailto,
67
+ * xmpp and irc); everything else still goes through that default, so `javascript:` stays dead. */
68
+ const PHONE_LINK = /^(?:sms|tel):/i;
69
+ export function artifactUrlTransform(url) {
70
+ return PHONE_LINK.test(url) ? url : defaultUrlTransform(url);
71
+ }
61
72
  /** `![alt](x.mp4)` plays as video: an animation that must stay smooth on a phone. Animated WebP
62
73
  * is decoded frame by frame on the CPU and stuttered on an iPhone at 24fps (2026-09-27); video
63
74
  * is decoded in hardware. */
@@ -91,7 +102,27 @@ function soleImage(node) {
91
102
  const src = kids[0].properties?.src;
92
103
  return typeof src === 'string' ? { src, alt: String(kids[0].properties?.alt ?? '') } : null;
93
104
  }
94
- function baseComponents(block, figure) {
105
+ // Item text and descriptions: the page's own safe link, inline code and emphasis, nothing that
106
+ // would break out of a list row (no images, headings, tables, fences or rules).
107
+ const CHECKLIST_DISALLOWED = ['img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'table', 'pre', 'hr', 'blockquote', 'input'];
108
+ function checklistMarkdown(source, inline) {
109
+ const a = baseComponents(() => ({})).a;
110
+ return (_jsx(ReactMarkdown, { remarkPlugins: [remarkGfm], urlTransform: artifactUrlTransform, disallowedElements: CHECKLIST_DISALLOWED, unwrapDisallowed: true, components: {
111
+ a,
112
+ p: ({ children }) => (inline ? _jsx(_Fragment, { children: children }) : _jsx("p", { className: "my-1 leading-relaxed", children: children })),
113
+ ul: ({ children }) => _jsx("ul", { className: "my-1 list-disc pl-5", children: children }),
114
+ ol: ({ children }) => _jsx("ol", { className: "my-1 list-decimal pl-5", children: children }),
115
+ code: ({ children }) => _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 }),
116
+ }, children: source }));
117
+ }
118
+ function ChecklistFence({ line, ctx }) {
119
+ const c = ctx.byLine.get(line);
120
+ // Not a checklist this page validated: draw nothing rather than its source as code.
121
+ if (!c)
122
+ return null;
123
+ return (_jsx(Checklist, { items: c.items.map((it) => ({ id: it.id, title: checklistMarkdown(it.text, true), description: it.description ? checklistMarkdown(it.description, false) : null })), artifactId: ctx.artifactId, send: ctx.send && ctx.artifactId ? c.send : undefined, owner: ctx.owner, accent: ctx.accent }));
124
+ }
125
+ function baseComponents(block, figure, checklists) {
95
126
  return {
96
127
  p: ({ node, children }) => {
97
128
  // An image alone in its paragraph that the pack draws itself replaces the paragraph, so the
@@ -118,11 +149,15 @@ function baseComponents(block, figure) {
118
149
  // eslint-disable-next-line @next/next/no-img-element
119
150
  _jsx("img", { src: typeof src === 'string' ? src : undefined, alt: alt ?? '', className: "my-6 w-full rounded-lg border border-[color:var(--a-line)]" }));
120
151
  },
121
- 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] })),
122
- code: ({ className, children }) => {
152
+ a: ({ href, children }) => !isReachableHref(href) ? (_jsx("span", { children: children })) : (_jsxs("a", { href: href,
153
+ // A phone link opens an app, not a tab.
154
+ target: PHONE_LINK.test(href) ? undefined : '_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] })),
155
+ code: ({ node, className, children }) => {
123
156
  const lang = /language-(\w+)/.exec(className ?? '')?.[1];
124
157
  if (lang === 'links')
125
158
  return _jsx(LinkCards, { source: textOf(children) });
159
+ if (lang === 'checklist')
160
+ return checklists ? _jsx(ChecklistFence, { line: node?.position?.start.line ?? -1, ctx: checklists }) : null;
126
161
  // A notes fence is the widget's place on the page. Without notes enabled (the host keeps no
127
162
  // answers) it draws nothing: its settings are not prose and never shown as code.
128
163
  if (lang === 'notes')
@@ -136,7 +171,7 @@ function baseComponents(block, figure) {
136
171
  // A links fence renders its own block; do not wrap it in <pre>.
137
172
  const inner = Array.isArray(children) ? children[0] : children;
138
173
  const cls = inner?.props?.className ?? '';
139
- if (/language-(links|notes)/.test(cls))
174
+ if (/language-(links|notes|checklist)/.test(cls))
140
175
  return _jsx(_Fragment, { children: children });
141
176
  return (_jsx("pre", { ...block(node), "data-artifact-code": true,
142
177
  // whitespace-pre-wrap, because a fence here usually holds PROSE meant to be pasted
@@ -169,20 +204,20 @@ const HEADING_CLASS = {
169
204
  };
170
205
  /** Components for any page: every heading gets its slug as an id, the one headingsOf derived,
171
206
  * so a contents link and a note both land on it. */
172
- function pageComponents(headings, block, figure) {
207
+ function pageComponents(headings, block, figure, checklists) {
173
208
  const byLine = new Map(headings.map((h) => [h.line, h]));
174
209
  const heading = (depth) => function Heading({ node, children }) {
175
210
  const Tag = `h${depth}`;
176
211
  const h = byLine.get(node?.position?.start.line ?? -1);
177
212
  return _jsx(Tag, { id: h?.slug, ...block(node), className: HEADING_CLASS[depth], children: children });
178
213
  };
179
- return { ...baseComponents(block, figure), h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6) };
214
+ return { ...baseComponents(block, figure, checklists), h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6) };
180
215
  }
181
216
  /** Components for a page with a notes block: every heading gets its slug as an id, a note
182
217
  * control, and its notes after it. The heading is found by its source line, so the slug is the
183
218
  * one headingsOf derived, the same one a note stores. */
184
- function notesComponents(headings, block, figure) {
185
- const components = baseComponents(block, figure);
219
+ function notesComponents(headings, block, figure, checklists) {
220
+ const components = baseComponents(block, figure, checklists);
186
221
  const byLine = new Map(headings.map((h) => [h.line, h]));
187
222
  const heading = (depth) => function NotedHeading({ node, children }) {
188
223
  const Tag = `h${depth}`;
@@ -261,7 +296,7 @@ const DEFINED_TERM_CSS = '.artifact-defined-term{text-decoration-line:underline;
261
296
  'clip:rect(0,0,0,0);white-space:nowrap;border:0}';
262
297
  /** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
263
298
  * page carries a ```notes block. */
264
- export function ArtifactMarkdown({ markdown, notes, definitions, Section, figure, }) {
299
+ export function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, }) {
265
300
  const on = !!notes && hasNotesWidget(markdown);
266
301
  const headings = headingsOf(markdown);
267
302
  const blocks = blocksOf(markdown);
@@ -272,10 +307,14 @@ export function ArtifactMarkdown({ markdown, notes, definitions, Section, figure
272
307
  const defined = definitions?.length ? definitions : null;
273
308
  const outline = Section ? sectionOutline(markdown, headings) : [];
274
309
  const plugins = [remarkGfm, ...(defined ? [remarkDefinitions(defined)] : []), ...(outline.length ? [remarkSections(outline[0].depth)] : [])];
275
- const components = on ? notesComponents(headings, block, figure) : pageComponents(headings, block, figure);
310
+ const lists = /^ {0,3}(`{3,}|~{3,})\s*checklist\b/m.test(markdown) ? checklistsOf(markdown) : null;
311
+ const checklists = lists
312
+ ? { byLine: lists, artifactId: checklist?.artifactId, send: !!checklist?.send, owner: checklist?.owner, accent: checklist?.accent }
313
+ : undefined;
314
+ const components = on ? notesComponents(headings, block, figure, checklists) : pageComponents(headings, block, figure, checklists);
276
315
  if (outline.length)
277
316
  components.section = sectionComponent(Section, outline, block);
278
- const body = (_jsx(ReactMarkdown, { remarkPlugins: plugins, components: components, children: markdown }));
317
+ const body = (_jsx(ReactMarkdown, { remarkPlugins: plugins, components: components, urlTransform: artifactUrlTransform, children: markdown }));
279
318
  return (
280
319
  // break-words: a bare URL in prose is one unbreakable word and pushed phones 7px sideways (2026-09-26).
281
320
  _jsxs("div", { className: "break-words text-[17px] text-[color:var(--a-body)]", children: [on ? (_jsx(NotesProvider, { artifactId: notes.artifactId, headings: headings.map(({ slug, text }) => ({ slug, text })), accent: notes.accent, children: body })) : body, VIDEO_IN_MD.test(markdown) ? _jsx(VideoAutoplay, {}) : null, CODE_IN_MD.test(markdown) ? _jsx(CodeCopy, {}) : null, defined ? (_jsxs(_Fragment, { children: [_jsx("style", { children: DEFINED_TERM_CSS }), _jsx(DefinitionLayer, {})] })) : null] }));
@@ -1,5 +1,6 @@
1
1
  import type { StateConfig, Visibility } from './state.js';
2
2
  import { type Heading } from './notes-place.js';
3
+ import { type ChecklistBlock } from './checklist.js';
3
4
  export { MAX_NOTE_CHARS, NOTES_SLOT, placeNotes, type Heading, type NoteValue, type PlacedNote } from './notes-place.js';
4
5
  export type NotesWidget = {
5
6
  line: number;
@@ -17,6 +18,24 @@ export declare function headingsOf(markdown: string): Heading[];
17
18
  export declare function scanWidgets(body: string, offset: number): {
18
19
  ok: true;
19
20
  notes: NotesWidget | null;
21
+ checklists: ChecklistBlock[];
22
+ } | {
23
+ ok: false;
24
+ error: string;
25
+ };
26
+ /** Every checklist on a page, by the line its fence opens on in the body, with the ids the page
27
+ * draws. A page whose checklists do not parse (published before checklists existed, so never
28
+ * validated) draws none rather than half of them. */
29
+ export declare function checklistsOf(markdown: string): Map<number, ChecklistBlock>;
30
+ /** The item ids of the page's checklist that offers Send, or null when none does. The state
31
+ * route asks, to check what a Send may name. */
32
+ export declare function checklistSendIds(markdown: string): string[] | null;
33
+ /** A checklist with `send` declares its own slot, `checklist`, shape one, private, written by
34
+ * whoever `send:` names; the owner reads it through /responses. Like `comments:`, the slot is
35
+ * the widget's own, so state: may not declare one by that name. */
36
+ export declare function mergeChecklistState(state: StateConfig | undefined, checklists: ChecklistBlock[]): {
37
+ ok: true;
38
+ state: StateConfig | undefined;
20
39
  } | {
21
40
  ok: false;
22
41
  error: string;
@@ -38,5 +57,5 @@ export declare function hasNotesWidget(markdown: string): boolean;
38
57
  export declare function checkNoteValue(v: unknown): string | null;
39
58
  /** The stable id of every top-level block, by the offset it starts at in the markdown. The
40
59
  * renderer puts it on the block as `data-block`, and a region comment is pinned to it. Blocks
41
- * that draw no prose (a notes or links fence, a rule, raw html) get none. */
60
+ * that draw no prose (a notes, links or checklist fence, a rule, raw html) get none. */
42
61
  export declare function blocksOf(markdown: string): Map<number, string>;
@@ -1,8 +1,10 @@
1
1
  // Widgets: fenced blocks in a document's markdown that give readers a place to answer, drawn by
2
2
  // the shell in the page's brand and written through the state API (state.ts, state-routes.ts).
3
3
  //
4
- // This version draws ONE widget, `notes`: a small note control beside every heading, kept in the
5
- // `many` slot `notes`. A note stores the heading it was left under, both its slug and its text,
4
+ // This version draws two widgets. `checklist` (./checklist.ts) is a list of items a reader ticks,
5
+ // kept in their own browser, with an optional Send that hands their ticks to the page's owner
6
+ // through the `one` slot `checklist`. `notes` is a small note control beside every heading, kept
7
+ // in the `many` slot `notes`. A note stores the heading it was left under, both its slug and its text,
6
8
  // so a republish that renames or removes the heading keeps the note and shows it under "notes
7
9
  // on earlier versions" instead of losing it or attaching it to the wrong section.
8
10
  //
@@ -14,10 +16,11 @@ import remarkParse from 'remark-parse';
14
16
  import remarkGfm from 'remark-gfm';
15
17
  import { blockId } from './anchor.js';
16
18
  import { MAX_NOTE_CHARS, NOTES_SLOT } from './notes-place.js';
19
+ import { CHECKLIST_SLOT, assignChecklistIds, parseChecklistFence } from './checklist.js';
17
20
  export { MAX_NOTE_CHARS, NOTES_SLOT, placeNotes } from './notes-place.js';
18
21
  /** Widgets the design names and this version does not draw yet. A fence using one is refused at
19
22
  * publish, so a page never ships a code block that turns into a live widget on a later update. */
20
- const NOT_YET = ['poll', 'form', 'checklist'];
23
+ const NOT_YET = ['poll', 'form'];
21
24
  const MAX_HEADING_CHARS = 300;
22
25
  const parse = (markdown) => unified().use(remarkParse).use(remarkGfm).parse(markdown);
23
26
  function inline(nodes) {
@@ -97,7 +100,58 @@ export function scanWidgets(body, offset) {
97
100
  }
98
101
  notes = w;
99
102
  }
100
- return { ok: true, notes };
103
+ const lists = scanChecklists(fences, offset);
104
+ if (!lists.ok)
105
+ return lists;
106
+ return { ok: true, notes, checklists: lists.checklists };
107
+ }
108
+ function scanChecklists(fences, offset) {
109
+ const lists = [];
110
+ for (const f of fences) {
111
+ if (f.lang !== 'checklist')
112
+ continue;
113
+ const line = (f.position?.start.line ?? 1) + offset;
114
+ const c = parseChecklistFence(f.value, line, f.meta);
115
+ if (!c.ok)
116
+ return c;
117
+ if (c.block.send && lists.some((l) => l.send))
118
+ return { ok: false, error: `line ${line}: a page takes at most one checklist with send` };
119
+ lists.push(c.block);
120
+ }
121
+ const ids = assignChecklistIds(lists);
122
+ return ids.ok ? { ok: true, checklists: ids.blocks } : ids;
123
+ }
124
+ const fencesOf = (markdown) => {
125
+ const fences = [];
126
+ walk(parse(markdown).children, (n) => { if (n.type === 'code' && n.lang === 'checklist')
127
+ fences.push(n); });
128
+ return fences;
129
+ };
130
+ /** Every checklist on a page, by the line its fence opens on in the body, with the ids the page
131
+ * draws. A page whose checklists do not parse (published before checklists existed, so never
132
+ * validated) draws none rather than half of them. */
133
+ export function checklistsOf(markdown) {
134
+ const r = scanChecklists(fencesOf(markdown), 0);
135
+ return new Map(r.ok ? r.checklists.map((c) => [c.line, c]) : []);
136
+ }
137
+ /** The item ids of the page's checklist that offers Send, or null when none does. The state
138
+ * route asks, to check what a Send may name. */
139
+ export function checklistSendIds(markdown) {
140
+ const r = scanChecklists(fencesOf(markdown), 0);
141
+ const c = r.ok ? r.checklists.find((l) => l.send) : undefined;
142
+ return c ? c.items.map((i) => i.id) : null;
143
+ }
144
+ /** A checklist with `send` declares its own slot, `checklist`, shape one, private, written by
145
+ * whoever `send:` names; the owner reads it through /responses. Like `comments:`, the slot is
146
+ * the widget's own, so state: may not declare one by that name. */
147
+ export function mergeChecklistState(state, checklists) {
148
+ const c = checklists.find((l) => l.send);
149
+ if (!c)
150
+ return { ok: true, state };
151
+ if (state && Object.hasOwn(state.slots, CHECKLIST_SLOT))
152
+ return { ok: false, error: `line ${c.line}: the checklist's send writes to slot "${CHECKLIST_SLOT}", and state: declares a slot by that name; rename that slot` };
153
+ const base = state ?? { writers: 'signed-in', visibility: 'private', slots: {} };
154
+ return { ok: true, state: { ...base, slots: { ...base.slots, [CHECKLIST_SLOT]: { shape: 'one', visibility: 'private', writers: c.send } } } };
101
155
  }
102
156
  /** A widget declares its own slot. The notes block adds `notes: { shape: many }` to the page's
103
157
  * `state:` (creating `state:` with its defaults when the page had none), so the state API
@@ -157,7 +211,7 @@ function assetName(url) {
157
211
  }
158
212
  /** The stable id of every top-level block, by the offset it starts at in the markdown. The
159
213
  * renderer puts it on the block as `data-block`, and a region comment is pinned to it. Blocks
160
- * that draw no prose (a notes or links fence, a rule, raw html) get none. */
214
+ * that draw no prose (a notes, links or checklist fence, a rule, raw html) get none. */
161
215
  export function blocksOf(markdown) {
162
216
  const out = new Map();
163
217
  const seen = new Map();
@@ -195,7 +249,7 @@ export function blocksOf(markdown) {
195
249
  content = plain(n);
196
250
  }
197
251
  else if (n.type === 'code') {
198
- if (n.lang === 'links' || n.lang === NOTES_SLOT)
252
+ if (n.lang === 'links' || n.lang === NOTES_SLOT || n.lang === 'checklist')
199
253
  continue;
200
254
  kind = 'code';
201
255
  content = n.value;
@@ -64,6 +64,15 @@ export type ArtifactRoutesConfig = {
64
64
  * its writer's name, whatever `comments_visible:` says. Without it nobody is the owner, and
65
65
  * the publisher reads comments through /responses. */
66
66
  ownerEmail?: string;
67
+ /** Sign-in emails, besides `ownerEmail`, of the people who administer this host's pages. They
68
+ * see a page's version history; with `history: 'admins'` (the default) nobody else does. */
69
+ admins?: readonly string[];
70
+ /** Who sees a page's past versions: the History control under the summary and every
71
+ * `/<id>/v/<n>`. `'admins'` (default): only `ownerEmail` and `admins`, signed in; to everyone
72
+ * else the History control is absent and a past version is not found, because an earlier draft
73
+ * can hold what the current one was edited to remove. `'everyone'`: every reader who can open
74
+ * the page, which was the only behaviour before 0.21.0. */
75
+ history?: 'admins' | 'everyone';
67
76
  /** Turns a reader's voice memo into text on the server, so the service's key never reaches a
68
77
  * browser: `deepgramTranscriber(key)` or `openaiTranscriber(key)` from `./artifacts`, or your
69
78
  * own. Without it the transcribe route answers 404 and readers get the browser's live
@@ -6,6 +6,7 @@ import { isPublisherAuthed } from '../artifacts/auth.js';
6
6
  import { parseArtifactSource } from '../artifacts/front-matter.js';
7
7
  import { narrationText } from '../artifacts/narration.js';
8
8
  import { ArtifactMarkdown } from '../artifacts/render.js';
9
+ import { CHECKLIST_SLOT } from '../artifacts/checklist.js';
9
10
  import { ArtifactDoor } from '../artifacts/door.js';
10
11
  import { isUnlocked, keyHash, unlockCookieName } from '../artifacts/unlock.js';
11
12
  import { cleanNote } from '../artifacts/store.js';
@@ -60,6 +61,9 @@ export function createArtifactRoutes(config) {
60
61
  const signInOrigin = config.signInOrigin;
61
62
  const owner = config.owner ?? brand.name;
62
63
  const ownerEmail = config.ownerEmail?.trim().toLowerCase();
64
+ const admins = new Set([ownerEmail, ...(config.admins ?? []).map((e) => e.trim().toLowerCase())].filter((e) => !!e));
65
+ /** Whether this reader may see a page's past versions (config `history`). */
66
+ const seesHistory = (reader) => config.history === 'everyone' || (!!reader && admins.has(reader.email.trim().toLowerCase()));
63
67
  const readerSecret = () => config.readerSecret?.();
64
68
  const signOutUrl = (id) => `/api/reader/leave?to=${encodeURIComponent(pagePath(id))}`;
65
69
  // Signing out alone cannot change the account: the authority still holds the Google session
@@ -195,7 +199,7 @@ export function createArtifactRoutes(config) {
195
199
  if (!a)
196
200
  notFound();
197
201
  const { pack, brand } = lookOf(a);
198
- const render = (top, reader) => (n === null ? Body(a, { top, reader }) : VersionBody(a, n, { top }));
202
+ const render = (top, reader) => (n === null ? Body(a, { top, reader }) : VersionBody(a, n, { top, reader }));
199
203
  if (a.access)
200
204
  return GatedPage(a, id, render, n);
201
205
  // A password shuts the body, never the title: the header stays so the reader knows which
@@ -273,8 +277,8 @@ export function createArtifactRoutes(config) {
273
277
  return store.history(id).catch(() => null);
274
278
  }
275
279
  /** "Version N · Updated <minute> · History": the line under the summary. */
276
- function VersionLine({ a, n, at, history }) {
277
- return (_jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-60", children: [`Version ${n} · Updated `, _jsx(UpdatedTime, { iso: at }), history && history.length > 1 ? (_jsxs(_Fragment, { children: [' · ', _jsx(VersionHistory, { items: history, base: pagePath(a.id), viewing: n })] })) : null] }));
280
+ function VersionLine({ a, n, at, history, reader }) {
281
+ return (_jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-60", children: [`Version ${n} · Updated `, _jsx(UpdatedTime, { iso: at }), history && history.length > 1 && seesHistory(reader) ? (_jsxs(_Fragment, { children: [' · ', _jsx(VersionHistory, { items: history, base: pagePath(a.id), viewing: n })] })) : null] }));
278
282
  }
279
283
  /** The title block and the body, in the pack's Article when it has one. Without one this is
280
284
  * exactly the markup every page had before packs could frame it. */
@@ -284,8 +288,11 @@ export function createArtifactRoutes(config) {
284
288
  return (_jsxs(_Fragment, { children: [cover, _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc, children] })] }));
285
289
  }
286
290
  /** A past version: its own body and title, read-only (no narration, no answers), under a banner. */
287
- async function VersionBody(a, n, { top }) {
291
+ async function VersionBody(a, n, { top, reader }) {
288
292
  const { pack, brand } = lookOf(a);
293
+ // Past versions are for admins unless the host says otherwise: to anyone else none exists.
294
+ if (!seesHistory(reader))
295
+ notFound();
289
296
  const v = store.version ? await store.version(a.id, n) : null;
290
297
  if (!v)
291
298
  notFound();
@@ -296,7 +303,7 @@ export function createArtifactRoutes(config) {
296
303
  const title = v.title ?? a.title;
297
304
  const summary = v.summary ?? a.summary;
298
305
  const toc = showToc(v.markdown, a.toc) ? tocOf(v.markdown) : [];
299
- return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: pack.Cover ? (_jsx(pack.Cover, { title: title, subtitle: v.subtitle, summary: summary, to: v.to, markdown: v.markdown, kicker: _jsx(Kicker, { to: v.to, brand: brand }), meta: _jsx(VersionLine, { a: a, n: n, at: v.at, history: history }), version: n, updatedAt: v.at, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.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: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { a: a, n: n, at: v.at, history: history })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, definitions: a.definitions, Section: pack.Section, figure: pack.figure }) })] }));
306
+ return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: pack.Cover ? (_jsx(pack.Cover, { title: title, subtitle: v.subtitle, summary: summary, to: v.to, markdown: v.markdown, kicker: _jsx(Kicker, { to: v.to, brand: brand }), meta: _jsx(VersionLine, { a: a, n: n, at: v.at, history: history, reader: reader }), version: n, updatedAt: v.at, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.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: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { a: a, n: n, at: v.at, history: history, reader: reader })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, definitions: a.definitions, checklist: { artifactId: a.id, accent: brand.accent }, Section: pack.Section, figure: pack.figure }) })] }));
300
307
  }
301
308
  /** The page itself, shared by open and gated pages. */
302
309
  async function Body(a, { top, reader }) {
@@ -317,9 +324,11 @@ export function createArtifactRoutes(config) {
317
324
  const mode = config.state && a.state && a.comments && a.comments !== 'off' && a.state.slots[COMMENTS_SLOT] ? a.comments : 'off';
318
325
  return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { id: "artifact-narration-root", children: await (async () => {
319
326
  const n = a.version ?? (a.versions?.length ?? 0) + 1;
320
- const meta = (_jsxs(_Fragment, { children: [_jsx(VersionLine, { a: a, n: n, at: a.updatedAt, history: await historyOf(a.id) }), top && reader ? (_jsxs("p", { "data-nospeak": true, "data-reader-line": true, className: "mt-2 text-xs opacity-60", children: [`Signed in as ${reader.email} · `, _jsx("a", { href: switchUrl(a.id), className: "underline", children: "Use a different account" }), ' · ', _jsx("a", { href: signOutUrl(a.id), className: "underline", children: "Sign out" })] })) : null] }));
327
+ const meta = (_jsxs(_Fragment, { children: [_jsx(VersionLine, { a: a, n: n, at: a.updatedAt, history: await historyOf(a.id), reader: reader }), top && reader ? (_jsxs("p", { "data-nospeak": true, "data-reader-line": true, className: "mt-2 text-xs opacity-60", children: [`Signed in as ${reader.email} · `, _jsx("a", { href: switchUrl(a.id), className: "underline", children: "Use a different account" }), ' · ', _jsx("a", { href: signOutUrl(a.id), className: "underline", children: "Sign out" })] })) : null] }));
321
328
  const header = pack.Cover ? (_jsx(pack.Cover, { title: a.title, subtitle: a.subtitle, summary: a.summary, to: a.to, markdown: a.markdown, kicker: _jsx(Kicker, { to: a.to, brand: brand }), meta: meta, version: n, updatedAt: a.updatedAt, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx(Kicker, { to: a.to, brand: brand }), _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 }), meta] }));
322
- return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [header, 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] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined, Section: pack.Section, figure: pack.figure }) }));
329
+ return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [header, 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] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined,
330
+ // Ticks are kept in the reader's browser on any host; Send needs one that keeps answers.
331
+ checklist: { artifactId: a.id, send: !!config.state && !!a.state && Object.hasOwn(a.state.slots, CHECKLIST_SLOT), owner, accent: brand.accent }, Section: pack.Section, figure: pack.figure }) }));
323
332
  })() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: !!reader && !!ownerEmail && reader.email === ownerEmail, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), 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 /* No recorded narration: no player. Never the browser's own voice (the operator,
324
333
  2026-09-29: "NEVER PUT DEFAULT SAFARI VOICE DICTATION"); the audio is Kokoro or ElevenLabs,
325
334
  recorded at publish, or there is none. */] }));
@@ -19,7 +19,8 @@ import { ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, checkValue, effectiveWrit
19
19
  import { COMMENTS_SLOT, validateComment } from '../artifacts/comments.js';
20
20
  import { isCommentAudioPath } from '../artifacts/audio.js';
21
21
  import { readerKeyFor } from '../artifacts/state-store.js';
22
- import { NOTES_SLOT, checkNoteValue, hasNotesWidget } from '../artifacts/widgets.js';
22
+ import { NOTES_SLOT, checkNoteValue, checklistSendIds, hasNotesWidget } from '../artifacts/widgets.js';
23
+ import { CHECKLIST_SLOT, checkChecklistValue } from '../artifacts/checklist.js';
23
24
  import { responsesCsv, responsesOf, stateView } from '../artifacts/state-view.js';
24
25
  export const ANON_COOKIE = 'artifact_anon';
25
26
  const ANON_ID = /^[A-Za-z0-9]{24}$/;
@@ -242,7 +243,10 @@ export function createStateRoutes(ctx) {
242
243
  return json({ error: 'no comment of yours with that id' }, 404);
243
244
  }
244
245
  else {
245
- const bad = checkValue(b.value) ?? (slot === NOTES_SLOT && hasNotesWidget(a.markdown) ? checkNoteValue(b.value) : null);
246
+ const sendIds = slot === CHECKLIST_SLOT ? checklistSendIds(a.markdown) : null;
247
+ const bad = checkValue(b.value)
248
+ ?? (slot === NOTES_SLOT && hasNotesWidget(a.markdown) ? checkNoteValue(b.value) : null)
249
+ ?? (sendIds ? checkChecklistValue(b.value, sendIds) : null);
246
250
  if (bad)
247
251
  return json({ error: bad }, 400);
248
252
  let value = b.value;
@@ -0,0 +1,16 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type SendWriters } from '../artifacts/checklist.js';
3
+ export type ChecklistViewItem = {
4
+ id: string;
5
+ title: ReactNode;
6
+ description: ReactNode | null;
7
+ };
8
+ export declare function Checklist({ items, artifactId, send, owner, accent, }: {
9
+ items: ChecklistViewItem[];
10
+ /** The page, for the storage key. Without one (a past version) ticks last for the visit. */
11
+ artifactId?: string;
12
+ /** Who may press Send, when the page offers it and the host keeps answers. */
13
+ send?: SendWriters;
14
+ owner?: string;
15
+ accent?: string;
16
+ }): import("react").JSX.Element;
@@ -0,0 +1,95 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // The checklist widget in the reader's browser. Each tick is kept in localStorage under the page
4
+ // and the item id (checklist.ts, checklistStorageKey), so it survives a reload, a republish and a
5
+ // reworded item, and never leaves the device unless the reader presses Send. Storage that throws
6
+ // (a private window, blocked site data) costs the reader only the memory: ticks still work for
7
+ // the visit.
8
+ //
9
+ // The item text and description arrive already drawn on the server, as markdown with raw HTML
10
+ // escaped, so this file draws no author content itself. Everything here is data-nospeak: the
11
+ // narrator reads the prose, never the checklist.
12
+ import { useCallback, useEffect, useState } from 'react';
13
+ import { CHECKLIST_SLOT, checklistStorageKey } from '../artifacts/checklist.js';
14
+ function readTick(key) {
15
+ if (!key)
16
+ return false;
17
+ try {
18
+ return globalThis.localStorage?.getItem(key) === '1';
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ function writeTick(key, on) {
25
+ if (!key)
26
+ return;
27
+ try {
28
+ if (on)
29
+ globalThis.localStorage?.setItem(key, '1');
30
+ else
31
+ globalThis.localStorage?.removeItem(key);
32
+ }
33
+ catch {
34
+ // Storage blocked: the tick lives for this visit only.
35
+ }
36
+ }
37
+ export function Checklist({ items, artifactId, send, owner, accent, }) {
38
+ const key = useCallback((id) => (artifactId ? checklistStorageKey(artifactId, id) : null), [artifactId]);
39
+ const [done, setDone] = useState({});
40
+ const [sent, setSent] = useState({ status: 'idle' });
41
+ // Read after mount, never during render: the server has no storage, and reading it while
42
+ // hydrating would draw a page that disagrees with the server's.
43
+ const ids = items.map((it) => it.id).join('\n');
44
+ useEffect(() => {
45
+ const next = {};
46
+ for (const id of ids.split('\n'))
47
+ if (readTick(key(id)))
48
+ next[id] = true;
49
+ setDone(next);
50
+ }, [ids, key]);
51
+ const toggle = (id) => {
52
+ setDone((d) => {
53
+ const on = !d[id];
54
+ writeTick(key(id), on);
55
+ const next = { ...d };
56
+ if (on)
57
+ next[id] = true;
58
+ else
59
+ delete next[id];
60
+ return next;
61
+ });
62
+ setSent((s) => (s.status === 'sent' ? { status: 'idle' } : s));
63
+ };
64
+ const count = items.filter((it) => done[it.id]).length;
65
+ const color = accent ?? 'var(--a-accent)';
66
+ const doSend = async () => {
67
+ if (!artifactId)
68
+ return;
69
+ setSent({ status: 'busy' });
70
+ try {
71
+ const r = await fetch(`/api/artifacts/${artifactId}/state`, {
72
+ method: 'POST',
73
+ credentials: 'same-origin',
74
+ cache: 'no-store',
75
+ headers: { 'content-type': 'application/json' },
76
+ body: JSON.stringify({ slot: CHECKLIST_SLOT, op: 'set', value: { done: items.filter((it) => done[it.id]).map((it) => it.id) } }),
77
+ });
78
+ const body = (await r.json().catch(() => ({})));
79
+ if (r.ok)
80
+ setSent({ status: 'sent' });
81
+ else if (r.status === 401)
82
+ setSent({ status: 'sign-in', signIn: typeof body.signIn === 'string' ? body.signIn : null });
83
+ else
84
+ setSent({ status: 'error', message: typeof body.error === 'string' ? body.error : 'that did not send; try again' });
85
+ }
86
+ catch {
87
+ setSent({ status: 'error', message: 'that did not reach the page; try again' });
88
+ }
89
+ };
90
+ return (_jsxs("div", { "data-nospeak": true, "data-artifact-checklist": true, className: "my-6 rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-surface)]", children: [_jsx("p", { "data-checklist-count": true, "aria-live": "polite", className: "m-0 border-b border-[color:var(--a-line)] px-4 py-2 text-[11px] uppercase tracking-[0.2em]", style: { color }, children: `${count} of ${items.length} done` }), _jsx("ul", { className: "m-0 list-none p-0", children: items.map((it) => {
91
+ const on = !!done[it.id];
92
+ const inputId = `checklist-${artifactId ?? 'page'}-${it.id}`;
93
+ return (_jsxs("li", { "data-checklist-item": it.id, "data-done": on ? 'yes' : 'no', className: "m-0 border-b border-[color:var(--a-line)] px-4 py-1 last:border-b-0", children: [_jsxs("label", { htmlFor: inputId, className: "flex cursor-pointer items-start gap-3 py-2", style: { minHeight: 44 }, children: [_jsx("input", { id: inputId, type: "checkbox", checked: on, onChange: () => toggle(it.id), className: "mt-0.5 shrink-0 cursor-pointer", style: { accentColor: color, width: 24, height: 24 } }), _jsx("span", { className: on ? 'text-[color:var(--a-muted)] line-through' : 'text-[color:var(--a-strong)]', children: it.title })] }), it.description ? _jsx("div", { className: "pb-2 pl-9 text-[15px] text-[color:var(--a-body)]", children: it.description }) : null] }, it.id));
94
+ }) }), send && artifactId ? (_jsxs("div", { className: "border-t border-[color:var(--a-line)] px-4 py-3", children: [_jsx("button", { type: "button", "data-checklist-send": true, disabled: sent.status === 'busy', onClick: () => void doSend(), className: "rounded-full px-5 text-sm font-medium disabled:opacity-40", style: { background: color, color: 'var(--a-on-accent, #111)', minHeight: 44 }, children: owner ? `Send my progress to ${owner}` : 'Send my progress' }), sent.status === 'sent' ? _jsx("p", { "data-checklist-sent": true, className: "mt-2 text-sm opacity-80", children: `Sent: ${count} of ${items.length} done.` }) : null, sent.status === 'error' ? _jsx("p", { className: "mt-2 text-sm text-amber-500", children: sent.message }) : null, sent.status === 'sign-in' ? (sent.signIn ? (_jsx("p", { className: "mt-2 text-sm", children: _jsx("a", { href: sent.signIn, className: "underline", style: { color }, children: "Sign in to send" }) })) : (_jsx("p", { className: "mt-2 text-sm opacity-70", children: "Sending is open to signed-in readers." }))) : null] })) : null] }));
95
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Publish markdown as branded, read-aloud pages from a Next.js app: the artifacts store, the route factory, the read-along reader, brand packs, password and confidential pages, and share cards.",
5
5
  "license": "MIT",
6
6
  "type": "module",