@supersuit/artifacts 0.21.0 → 0.23.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,65 @@
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.23.0
7
+
8
+ **A checklist can keep its ticks in the cloud, shared by everyone on the page.** Two people
9
+ walking one list on a call each saw only their own browser's ticks: 0.22.0 kept ticks in
10
+ `localStorage`, and Send hands one reader's set to the owner rather than to the other reader.
11
+
12
+ - New fence setting `sync: anyone | signed-in` (who may tick). One block saying it makes every
13
+ checklist on the page one shared set, kept in the page's `checklist` slot declared `shared`
14
+ (shape one). Blocks that disagree on `sync`, and `send` on a page that syncs, are refused.
15
+ - The server checks a synced set against every checklist item on the page (the 0.22.0 check took
16
+ only the first block with `send`). New exports `checklistAnswerIds`, `checklistSyncs`.
17
+ - The widget holds one store per page, shared by all its blocks: reads the newest shared set on
18
+ load, posts the whole page-wide set 400ms after a tick, polls every 4s while the tab is visible,
19
+ and shows "Synced" / "Saving…" / the error after the count. Last write wins on the whole set.
20
+ A host with no state store (501), a page with no slot (404) or a failed first read falls back
21
+ to `localStorage` exactly as before.
22
+ - No route change: the route's existing `checklist.send` (host keeps answers, page declares the
23
+ slot) turns sync on for a page whose checklists sync.
24
+ - DETECTOR: publish a page whose checklist says `sync: anyone`. "the checklist block has an unknown
25
+ setting: sync" means the host is on 0.22.0 or earlier.
26
+ - REMEDY: bump. Pages without `sync` render and behave exactly as before.
27
+ - Tests: the grammar accepts and refuses `sync`; multi-block ids validated server-side (an id from
28
+ the second block accepted, an id no block draws refused); disagreeing blocks and send-with-sync
29
+ refused; the slot declared shared; an anonymous reader on an unlocked password page posts and a
30
+ second browser reads the set, then the reverse; the widget reads the newest shared set into
31
+ every block with one request, posts the page-wide set after a tick, takes another reader's
32
+ untick on the next poll, and falls back to storage on 501. Picking the newest set and checking
33
+ ids page-wide were each broken on purpose and went red.
34
+
35
+ ## 0.22.0
36
+
37
+ **A page can carry a checklist a reader ticks off and comes back to.** A per-person Mac setup
38
+ page that remembers its ticks could not be published: the renderer escapes raw HTML (rightly),
39
+ and a ```` ```checklist ```` fence was refused at publish as a widget not yet drawn, so each prep
40
+ page went out as its own one-off site instead of on the operator's host.
41
+
42
+ - New widget `checklist`: items with stable ids (`{#id}` or the slug of the text, numbered on a
43
+ repeat), indented descriptions drawn as markdown with raw HTML escaped, ticks kept in the
44
+ reader's `localStorage` under `artifact-checklist:<page>:<item>` (storage that throws costs only
45
+ the memory), "n of m done", and 44px rows. Several per page.
46
+ - Optional `send: signed-in | anyone` in the fence: a Send button writes `{ done: [ids] }` to the
47
+ page's own `checklist` slot (shape one, private), which the owner reads through `/responses`.
48
+ The server refuses ids that are not that checklist's. Drawn only on a host with a state store.
49
+ - `sms:` and `tel:` links now work on every page (react-markdown's default dropped them), and open
50
+ without `target="_blank"`. Everything else still goes through the default transform.
51
+ - `ArtifactMarkdown` takes `checklist={{ artifactId, send, owner, accent }}`; the route passes it.
52
+ New exports: `checklistsOf`, `checklistSendIds`, `mergeChecklistState`, `parseChecklistFence`,
53
+ `checkChecklistValue`, `checklistStorageKey`, `CHECKLIST_SLOT`, `artifactUrlTransform`.
54
+ - DETECTOR: publish a page with a ```` ```checklist ```` fence. "the checklist widget is not
55
+ available in this version" means the host is on 0.21.0 or earlier.
56
+ - REMEDY: bump. A page with no checklist renders byte for byte as before (the 0.15.0 golden still
57
+ passes unchanged).
58
+ - Tests: the fence grammar and every refusal with its file line; ids page-wide; the slot a Send
59
+ declares; the component's ids and count; raw HTML in an item and a description stays text; a
60
+ page with no checklist identical with and without the new prop; ticks saved by page and item id
61
+ and back after a remount; blocked storage; Send's body and its sign-in answer; the route taking
62
+ only that checklist's ids and `/responses` reading it; sms/tel links and `javascript:` still
63
+ dead; and the packed fixture drawing and posting one. Each was broken on purpose and went red.
64
+
6
65
  ## 0.21.0 (2026-10-08)
7
66
 
8
67
  **A page's past versions are for its admins.** Every reader who could open a page could also open
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`, `checklistAnswerIds`, `checklistSyncs` | 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 |
@@ -406,9 +407,81 @@ the shell in the page's brand and kept through the state API above. Widgets need
406
407
  state store (`state:` in the config); on a host without one they draw nothing, and the publish
407
408
  response carries the no-answers warning.
408
409
 
409
- This version draws one widget, **notes**. Poll, form and checklist are coming: a fence named
410
- `poll`, `form` or `checklist` is refused at publish until then, so a page never ships a code
411
- 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 or syncs.
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
+ # or sync: signed-in | anyone; one shared set for every reader, see below
427
+ - Update macOS {#update}
428
+ Apple menu > System Settings > General > Software Update.
429
+ Restart and check again until it says you're up to date.
430
+ - [ ] Install Chrome and sign in
431
+ Download it from [google.com/chrome](https://www.google.com/chrome/).
432
+ - Send us your GitHub username {#gh-send}
433
+ [Text Sam your username](sms:+15555550100&body=My%20GitHub%20username%20is%20)
434
+ ```
435
+ ````
436
+
437
+ - **An item is a line starting with `- `** (or `* `), optionally followed by `[ ]`. `[x]` is
438
+ accepted and ticks nothing: ticks belong to the reader.
439
+ - **Every item has a stable id.** `{#id}` at the end of the line sets it (lowercase letters,
440
+ digits, dashes, underscores; unique on the page). Without one the id is the slug of the item's
441
+ text, the way a heading's is (`Install Chrome and sign in` is `install-chrome-and-sign-in`), and
442
+ a repeat is numbered `-1`, `-2`. **Give an item an explicit id whenever its wording may change**:
443
+ a reworded item with a slugged id is a new item, and readers lose that tick.
444
+ - **Indented lines under an item are its description**, drawn as markdown: links, `code`, bold,
445
+ short lists. Raw HTML in an item or a description is shown as text. Images, headings, tables and
446
+ fences are not drawn inside a checklist.
447
+ - **Ticks are kept in the reader's browser**, in `localStorage` under
448
+ `artifact-checklist:<page id>:<item id>`, so they survive a reload, a republish, and a reworded
449
+ item with a kept id. Where storage is blocked (a private window, blocked site data) ticks last
450
+ for the visit. Nothing leaves the device unless the reader presses Send.
451
+ - **The list says how far along the reader is**, "3 of 8 done", and every row is a tap target at
452
+ least 44px tall, so it works one-handed on a phone.
453
+ - **Narration skips it**, like every widget: the fence is not prose and the component is
454
+ `data-nospeak`.
455
+ - **Several checklists on one page** are fine; ids are unique across all of them.
456
+ - **`sms:` and `tel:` links work on every page**, in a checklist or in prose, so "text us your
457
+ username" is one tap into the reader's own Messages.
458
+ - Refused at publish, naming the line in the file: an indented line before the first item, an
459
+ unindented line after one, an empty item, a bad or repeated `{#id}`, a name after `checklist`,
460
+ any setting but `send` or `sync`, a checklist with no items, a second checklist with `send`,
461
+ `send` and `sync` on one page, and two checklists that say `sync` differently.
462
+
463
+ **Send** (`send: signed-in` or `send: anyone`) adds a "Send my progress to <owner>" button under
464
+ the list. It writes `{ done: [item ids] }` to the page's `checklist` slot, shape `one` and
465
+ `private`, so a reader's latest Send replaces their last and only the publisher reads them, through
466
+ `/responses` like any answer. `send:` names who may press it (`anyone` gets the anonymous cookie
467
+ and the rate limit, as `writers: anyone` does). The slot is the widget's own: a page may not
468
+ declare a `checklist` slot in `state:`. The server takes only ids of that checklist, each once. On a
469
+ host with no state store the button is not drawn; the ticks still work.
470
+
471
+ **Sync** (`sync: anyone` or `sync: signed-in`) makes the ticks the PAGE's: one set every reader
472
+ sees and, as `sync:` names, may change. Two people on a call ticking the same list see each
473
+ other's ticks within a few seconds. One block saying `sync` is enough: every checklist on the page
474
+ then shares the one set, because item ids are page-wide, and two blocks that both say it must say
475
+ the same thing. The page declares its `checklist` slot `shared` (shape `one`), each tick updates
476
+ the screen at once and, after a short pause, posts the whole page-wide `{ done: [ids] }`, and the
477
+ newest set by server time is the page's. The widget reads it on load and every four seconds while
478
+ the tab is visible, and says where it stands after the count: "Synced", "Saving…", or what went
479
+ wrong. The server takes only ids of checklists on the page. `anyone` works for anonymous readers
480
+ (the anonymous cookie and rate limit, as `writers: anyone`), on a password page once it is
481
+ unlocked. **Concurrency is last-write-wins on the whole set**: two readers ticking different items
482
+ inside the same couple of seconds can lose one tick. A page cannot both sync and send. On a host
483
+ with no state store, or where the first read fails, the list falls back to the reader's own
484
+ `localStorage` exactly as an unsynced one.
412
485
 
413
486
  ### Notes
414
487
 
@@ -0,0 +1,54 @@
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
+ sync?: SendWriters;
17
+ items: ChecklistItem[];
18
+ };
19
+ /** What a Send stores in the page's `checklist` slot: the ids the reader had ticked. */
20
+ export type ChecklistValue = {
21
+ done: string[];
22
+ };
23
+ /** A block as written, before ids are made unique across the page. */
24
+ export type RawChecklistItem = ChecklistItem & {
25
+ explicit: boolean;
26
+ };
27
+ export type RawChecklistBlock = Omit<ChecklistBlock, 'items'> & {
28
+ items: RawChecklistItem[];
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 declare function parseChecklistFence(value: string, line: number, meta?: string | null): {
33
+ ok: true;
34
+ block: RawChecklistBlock;
35
+ } | {
36
+ ok: false;
37
+ error: string;
38
+ };
39
+ /** Gives every item on the page its final id. An explicit `{#id}` is kept as written and must be
40
+ * unique on the page; an item without one takes the slug of its text, numbered `-1`, `-2` when
41
+ * that slug is already used, the way repeated headings are. Ids are page-wide because ticks are
42
+ * saved by page and item id. */
43
+ export declare function assignChecklistIds(blocks: RawChecklistBlock[]): {
44
+ ok: true;
45
+ blocks: ChecklistBlock[];
46
+ } | {
47
+ ok: false;
48
+ error: string;
49
+ };
50
+ /** The server's check on a Send or a synced set: every id is an item of that checklist (of any
51
+ * checklist on the page, when it syncs), once. The browser sends exactly this; anything else is refused. */
52
+ export declare function checkChecklistValue(v: unknown, ids: readonly string[]): string | null;
53
+ /** Where a reader's tick on one item is kept in their browser. */
54
+ export declare const checklistStorageKey: (artifactId: string, itemId: string) => string;
@@ -0,0 +1,160 @@
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
+ // sync: anyone # optional: signed-in | anyone (who may tick a shared set)
8
+ // - Update macOS {#update}
9
+ // Apple menu > System Settings > General > Software Update.
10
+ // - [ ] Sign in with your personal Apple Account
11
+ // ```
12
+ //
13
+ // An item is a line starting with `- ` (or `* `), optionally followed by `[ ]`. Its id is the
14
+ // `{#id}` at the end of the line when there is one, else a slug of its text. Indented lines under
15
+ // an item are its description, drawn as markdown with raw HTML escaped like the rest of the page.
16
+ // Ticks belong to the reader: `[x]` is accepted and ticks nothing.
17
+ //
18
+ // `sync:` makes the ticks the PAGE's instead of the reader's: one page-wide set, kept by the host in
19
+ // the shared `checklist` slot, that every reader sees and (as `sync:` names) may change. One block
20
+ // saying it is enough; every checklist on the page then shares the one set, because item ids are
21
+ // page-wide. A page cannot both sync and send: a synced set already reaches the owner.
22
+ export const CHECKLIST_SLOT = 'checklist';
23
+ export const MAX_CHECKLIST_ITEMS = 200;
24
+ const MAX_ITEM_CHARS = 500;
25
+ const MAX_ID_CHARS = 64;
26
+ /** An item id: lowercase letters (any script), digits, dashes and underscores. The same alphabet
27
+ * a heading slug uses, so a slugged id is always a valid one. */
28
+ export const CHECKLIST_ID = /^[\p{Ll}\p{Lo}\p{Lm}\p{N}_-]{1,64}$/u;
29
+ const ITEM = /^[-*]\s+(?:\[[ xX]\]\s+)?(.*)$/;
30
+ const EXPLICIT_ID = /\s*\{#([^}\s]*)\}\s*$/;
31
+ /** GitHub's heading rule (widgets.slugify), repeated here so this file stays parser-free. */
32
+ function slug(text) {
33
+ const s = text.trim().toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu, '').replace(/\s/g, '-');
34
+ return (s || 'item').slice(0, MAX_ID_CHARS).replace(/-+$/, '') || 'item';
35
+ }
36
+ /** The fence body as written, before ids are made unique across the page. `line` is the line the
37
+ * fence opens on in the author's file, so every error names a line they can find. */
38
+ export function parseChecklistFence(value, line, meta) {
39
+ if (meta?.trim())
40
+ return { ok: false, error: `line ${line}: the checklist block takes no name; put settings like "send: anyone" inside it` };
41
+ const rows = value ? value.split('\n') : [];
42
+ const items = [];
43
+ let send;
44
+ let sync;
45
+ let desc = null;
46
+ const close = () => {
47
+ if (desc && items.length)
48
+ items[items.length - 1].description = desc.join('\n').replace(/^\n+|\n+$/g, '');
49
+ desc = null;
50
+ };
51
+ for (let i = 0; i < rows.length; i++) {
52
+ const row = rows[i];
53
+ const at = line + 1 + i;
54
+ if (!row.trim()) {
55
+ if (desc)
56
+ desc.push('');
57
+ continue;
58
+ }
59
+ if (/^(\s{2,}|\t)/.test(row)) {
60
+ if (!items.length)
61
+ return { ok: false, error: `line ${at}: an indented line belongs under an item; start the list with "- "` };
62
+ desc.push(row.replace(/^(\t| {2,4})/, ''));
63
+ continue;
64
+ }
65
+ const m = ITEM.exec(row);
66
+ if (m) {
67
+ close();
68
+ let text = m[1];
69
+ let id;
70
+ const e = EXPLICIT_ID.exec(text);
71
+ if (e) {
72
+ id = e[1];
73
+ text = text.slice(0, e.index);
74
+ if (!CHECKLIST_ID.test(id))
75
+ return { ok: false, error: `line ${at}: the id {#${id}} must be lowercase letters, digits, dashes or underscores, at most ${MAX_ID_CHARS}` };
76
+ }
77
+ text = text.trim();
78
+ if (!text)
79
+ return { ok: false, error: `line ${at}: a checklist item needs some text` };
80
+ if (text.length > MAX_ITEM_CHARS)
81
+ return { ok: false, error: `line ${at}: a checklist item is at most ${MAX_ITEM_CHARS} characters` };
82
+ if (items.length >= MAX_CHECKLIST_ITEMS)
83
+ return { ok: false, error: `line ${at}: a checklist takes at most ${MAX_CHECKLIST_ITEMS} items` };
84
+ items.push({ id: id ?? '', text, description: '', line: at, explicit: !!id });
85
+ desc = [];
86
+ continue;
87
+ }
88
+ if (items.length)
89
+ return { ok: false, error: `line ${at}: a checklist item starts with "- ", and its description under it is indented` };
90
+ const s = /^([A-Za-z_-]+)\s*:\s*([^#]*?)\s*(#.*)?$/.exec(row.trim());
91
+ if (!s)
92
+ return { ok: false, error: `line ${at}: the checklist block takes settings like "send: anyone", then items starting with "- "` };
93
+ if (s[1] !== 'send' && s[1] !== 'sync')
94
+ return { ok: false, error: `line ${at}: the checklist block has an unknown setting: ${s[1]}` };
95
+ if (s[2] !== 'signed-in' && s[2] !== 'anyone')
96
+ return { ok: false, error: `line ${at}: checklist ${s[1]} must be signed-in or anyone` };
97
+ if (s[1] === 'send')
98
+ send = s[2];
99
+ else
100
+ sync = s[2];
101
+ }
102
+ if (send && sync)
103
+ return { ok: false, error: `line ${line}: a checklist takes send or sync, not both; a synced list already reaches the owner` };
104
+ close();
105
+ if (!items.length)
106
+ return { ok: false, error: `line ${line}: a checklist needs at least one item starting with "- "` };
107
+ return { ok: true, block: { line, ...(send ? { send } : {}), ...(sync ? { sync } : {}), items } };
108
+ }
109
+ /** Gives every item on the page its final id. An explicit `{#id}` is kept as written and must be
110
+ * unique on the page; an item without one takes the slug of its text, numbered `-1`, `-2` when
111
+ * that slug is already used, the way repeated headings are. Ids are page-wide because ticks are
112
+ * saved by page and item id. */
113
+ export function assignChecklistIds(blocks) {
114
+ const taken = new Map();
115
+ for (const b of blocks)
116
+ for (const it of b.items) {
117
+ if (!it.explicit)
118
+ continue;
119
+ if (taken.has(it.id))
120
+ return { ok: false, error: `line ${it.line}: the id {#${it.id}} is already used on line ${taken.get(it.id)}` };
121
+ taken.set(it.id, it.line);
122
+ }
123
+ const out = blocks.map((b) => ({
124
+ line: b.line,
125
+ ...(b.send ? { send: b.send } : {}),
126
+ ...(b.sync ? { sync: b.sync } : {}),
127
+ items: b.items.map(({ explicit, ...it }) => {
128
+ if (explicit)
129
+ return it;
130
+ const base = slug(it.text);
131
+ let id = base;
132
+ for (let k = 1; taken.has(id); k++)
133
+ id = `${base}-${k}`;
134
+ taken.set(id, it.line);
135
+ return { ...it, id };
136
+ }),
137
+ }));
138
+ return { ok: true, blocks: out };
139
+ }
140
+ /** The server's check on a Send or a synced set: every id is an item of that checklist (of any
141
+ * checklist on the page, when it syncs), once. The browser sends exactly this; anything else is refused. */
142
+ export function checkChecklistValue(v, ids) {
143
+ if (!v || typeof v !== 'object' || Array.isArray(v) || Object.keys(v).some((k) => k !== 'done'))
144
+ return 'a checklist answer is { done: [item ids] }';
145
+ const done = v.done;
146
+ if (!Array.isArray(done))
147
+ return 'a checklist answer is { done: [item ids] }';
148
+ const known = new Set(ids);
149
+ const seen = new Set();
150
+ for (const id of done) {
151
+ if (typeof id !== 'string' || !known.has(id))
152
+ return 'a checklist answer names only items on this page';
153
+ if (seen.has(id))
154
+ return 'a checklist answer names each item once';
155
+ seen.add(id);
156
+ }
157
+ return null;
158
+ }
159
+ /** Where a reader's tick on one item is kept in their browser. */
160
+ 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, checklistAnswerIds, checklistSyncs, 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, checklistAnswerIds, checklistSyncs, 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,18 @@ 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). On a page whose checklists
18
+ * `sync`, the same `send` turns on the shared, page-wide set. Without it a checklist still
19
+ * draws and ticks, for the visit only. */
20
+ checklist?: {
21
+ artifactId: string;
22
+ send?: boolean;
23
+ owner?: string;
24
+ accent?: string;
25
+ };
15
26
  /** The pack's section component (BrandPack.Section): each top-level section is drawn through it. */
16
27
  Section?: ComponentType<SectionProps>;
17
28
  /** 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, checklistSyncs, 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 && !ctx.sync ? c.send : undefined, sync: ctx.sync && !!ctx.artifactId, 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,16 @@ 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
+ // `send` from the host means it keeps answers and the page declared the `checklist` slot; on a
313
+ // page whose checklists sync, that same slot is the shared set, so it turns sync on instead.
314
+ ? { byLine: lists, artifactId: checklist?.artifactId, send: !!checklist?.send, sync: !!checklist?.send && checklistSyncs(lists.values()), owner: checklist?.owner, accent: checklist?.accent }
315
+ : undefined;
316
+ const components = on ? notesComponents(headings, block, figure, checklists) : pageComponents(headings, block, figure, checklists);
276
317
  if (outline.length)
277
318
  components.section = sectionComponent(Section, outline, block);
278
- const body = (_jsx(ReactMarkdown, { remarkPlugins: plugins, components: components, children: markdown }));
319
+ const body = (_jsx(ReactMarkdown, { remarkPlugins: plugins, components: components, urlTransform: artifactUrlTransform, children: markdown }));
279
320
  return (
280
321
  // break-words: a bare URL in prose is one unbreakable word and pushed phones 7px sideways (2026-09-26).
281
322
  _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] }));