@supersuit/artifacts 0.22.0 → 0.24.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,59 @@
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.24.0
7
+
8
+ **A reader is asked for their name when they comment, so the owner can credit the feedback.**
9
+ Comments from readers without an account arrived as "a reader", with nothing to thank or follow up
10
+ with.
11
+
12
+ - The comment card shows `Your name, so <owner> can credit your feedback` above the share switch
13
+ when the reader is not signed in and is sharing with the owner. It is optional; a comment
14
+ without it still saves, as "a reader". The name is remembered on the device
15
+ (`localStorage` key `artifacts:comment-name`), so the next comment is already signed.
16
+ - `CommentValue` gains `by?: string` (one line, at most 80 characters, trimmed; a blank one is
17
+ dropped). New export `MAX_COMMENT_NAME_CHARS`. A signed-in reader's account name always wins.
18
+ - The shared thread shows the declared name where it used to show "a reader", and the publisher's
19
+ `/responses` (JSON and CSV) carries it in `name` for an anonymous row, so feeds that already read
20
+ `name` (an owner's comment notifications) credit the reader with no change of their own.
21
+ - DETECTOR: open a page with `comments: anyone` while signed out and select text. No name field
22
+ in the card means the host is on 0.23.0 or earlier; a comment posted with `by` is refused as
23
+ "a comment is { anchor, body, version, parent? }" on those versions.
24
+ - REMEDY: update the dependency. No route or front-matter change.
25
+ - Tests: `comments.test.ts` (keeps, trims, drops blank, refuses long or multi-line names),
26
+ `state-view.test.ts` (declared name shown, account name wins, responses and CSV carry it, "a
27
+ reader" when none), `comment-card.test.tsx` (asked when signed out and sharing, remembered,
28
+ passed to save; not asked when signed in or for a personal note). `test:packed` green.
29
+
30
+ ## 0.23.0
31
+
32
+ **A checklist can keep its ticks in the cloud, shared by everyone on the page.** Two people
33
+ walking one list on a call each saw only their own browser's ticks: 0.22.0 kept ticks in
34
+ `localStorage`, and Send hands one reader's set to the owner rather than to the other reader.
35
+
36
+ - New fence setting `sync: anyone | signed-in` (who may tick). One block saying it makes every
37
+ checklist on the page one shared set, kept in the page's `checklist` slot declared `shared`
38
+ (shape one). Blocks that disagree on `sync`, and `send` on a page that syncs, are refused.
39
+ - The server checks a synced set against every checklist item on the page (the 0.22.0 check took
40
+ only the first block with `send`). New exports `checklistAnswerIds`, `checklistSyncs`.
41
+ - The widget holds one store per page, shared by all its blocks: reads the newest shared set on
42
+ load, posts the whole page-wide set 400ms after a tick, polls every 4s while the tab is visible,
43
+ and shows "Synced" / "Saving…" / the error after the count. Last write wins on the whole set.
44
+ A host with no state store (501), a page with no slot (404) or a failed first read falls back
45
+ to `localStorage` exactly as before.
46
+ - No route change: the route's existing `checklist.send` (host keeps answers, page declares the
47
+ slot) turns sync on for a page whose checklists sync.
48
+ - DETECTOR: publish a page whose checklist says `sync: anyone`. "the checklist block has an unknown
49
+ setting: sync" means the host is on 0.22.0 or earlier.
50
+ - REMEDY: bump. Pages without `sync` render and behave exactly as before.
51
+ - Tests: the grammar accepts and refuses `sync`; multi-block ids validated server-side (an id from
52
+ the second block accepted, an id no block draws refused); disagreeing blocks and send-with-sync
53
+ refused; the slot declared shared; an anonymous reader on an unlocked password page posts and a
54
+ second browser reads the set, then the reverse; the widget reads the newest shared set into
55
+ every block with one request, posts the page-wide set after a tick, takes another reader's
56
+ untick on the next poll, and falls back to storage on 501. Picking the newest set and checking
57
+ ids page-wide were each broken on purpose and went red.
58
+
6
59
  ## 0.22.0
7
60
 
8
61
  **A page can carry a checklist a reader ticks off and comes back to.** A per-person Mac setup
package/README.md CHANGED
@@ -27,7 +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
+ | `checklistsOf`, `parseChecklistFence`, `checklistStorageKey`, `checklistAnswerIds`, `checklistSyncs` | The checklist widget's pure parts: the fence grammar, every item's id, where a tick is kept |
31
31
  | `mintPass`, `verifyPass` | The reader pass, for the sign-in side (see below) |
32
32
  | `mintPublisherPass`, `verifyPublisherPass` | The publisher pass: an hour-long stand-in for the publish key, for one person on one host (see below) |
33
33
  | `ARTIFACT_PUBLIC_PREFIXES` (`/gate`) | Paths to leave open if you mount artifacts inside a gated site |
@@ -410,7 +410,7 @@ response carries the no-answers warning.
410
410
  This version draws two widgets, **checklist** and **notes**. Poll and form are coming: a fence
411
411
  named `poll` or `form` is refused at publish until then, so a page never ships a code block that
412
412
  turns into a live widget on a later update. A checklist needs no state store unless it offers
413
- Send.
413
+ Send or syncs.
414
414
 
415
415
  ### Checklist
416
416
 
@@ -423,6 +423,7 @@ stays escaped and no author script ever runs.
423
423
 
424
424
  ```checklist
425
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
426
427
  - Update macOS {#update}
427
428
  Apple menu > System Settings > General > Software Update.
428
429
  Restart and check again until it says you're up to date.
@@ -456,7 +457,8 @@ send: anyone # optional: signed-in | anyone; adds a Send button, see
456
457
  username" is one tap into the reader's own Messages.
457
458
  - Refused at publish, naming the line in the file: an indented line before the first item, an
458
459
  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
+ 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.
460
462
 
461
463
  **Send** (`send: signed-in` or `send: anyone`) adds a "Send my progress to <owner>" button under
462
464
  the list. It writes `{ done: [item ids] }` to the page's `checklist` slot, shape `one` and
@@ -466,6 +468,21 @@ and the rate limit, as `writers: anyone` does). The slot is the widget's own: a
466
468
  declare a `checklist` slot in `state:`. The server takes only ids of that checklist, each once. On a
467
469
  host with no state store the button is not drawn; the ticks still work.
468
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.
485
+
469
486
  ### Notes
470
487
 
471
488
  ````markdown
@@ -13,6 +13,7 @@ export type ChecklistItem = {
13
13
  export type ChecklistBlock = {
14
14
  line: number;
15
15
  send?: SendWriters;
16
+ sync?: SendWriters;
16
17
  items: ChecklistItem[];
17
18
  };
18
19
  /** What a Send stores in the page's `checklist` slot: the ids the reader had ticked. */
@@ -46,8 +47,8 @@ export declare function assignChecklistIds(blocks: RawChecklistBlock[]): {
46
47
  ok: false;
47
48
  error: string;
48
49
  };
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. */
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. */
51
52
  export declare function checkChecklistValue(v: unknown, ids: readonly string[]): string | null;
52
53
  /** Where a reader's tick on one item is kept in their browser. */
53
54
  export declare const checklistStorageKey: (artifactId: string, itemId: string) => string;
@@ -4,6 +4,7 @@
4
4
  //
5
5
  // ```checklist
6
6
  // send: anyone # optional: signed-in | anyone (who may press Send)
7
+ // sync: anyone # optional: signed-in | anyone (who may tick a shared set)
7
8
  // - Update macOS {#update}
8
9
  // Apple menu > System Settings > General > Software Update.
9
10
  // - [ ] Sign in with your personal Apple Account
@@ -13,6 +14,11 @@
13
14
  // `{#id}` at the end of the line when there is one, else a slug of its text. Indented lines under
14
15
  // an item are its description, drawn as markdown with raw HTML escaped like the rest of the page.
15
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.
16
22
  export const CHECKLIST_SLOT = 'checklist';
17
23
  export const MAX_CHECKLIST_ITEMS = 200;
18
24
  const MAX_ITEM_CHARS = 500;
@@ -35,6 +41,7 @@ export function parseChecklistFence(value, line, meta) {
35
41
  const rows = value ? value.split('\n') : [];
36
42
  const items = [];
37
43
  let send;
44
+ let sync;
38
45
  let desc = null;
39
46
  const close = () => {
40
47
  if (desc && items.length)
@@ -83,16 +90,21 @@ export function parseChecklistFence(value, line, meta) {
83
90
  const s = /^([A-Za-z_-]+)\s*:\s*([^#]*?)\s*(#.*)?$/.exec(row.trim());
84
91
  if (!s)
85
92
  return { ok: false, error: `line ${at}: the checklist block takes settings like "send: anyone", then items starting with "- "` };
86
- if (s[1] !== 'send')
93
+ if (s[1] !== 'send' && s[1] !== 'sync')
87
94
  return { ok: false, error: `line ${at}: the checklist block has an unknown setting: ${s[1]}` };
88
95
  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];
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];
91
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` };
92
104
  close();
93
105
  if (!items.length)
94
106
  return { ok: false, error: `line ${line}: a checklist needs at least one item starting with "- "` };
95
- return { ok: true, block: { line, ...(send ? { send } : {}), items } };
107
+ return { ok: true, block: { line, ...(send ? { send } : {}), ...(sync ? { sync } : {}), items } };
96
108
  }
97
109
  /** Gives every item on the page its final id. An explicit `{#id}` is kept as written and must be
98
110
  * unique on the page; an item without one takes the slug of its text, numbered `-1`, `-2` when
@@ -111,6 +123,7 @@ export function assignChecklistIds(blocks) {
111
123
  const out = blocks.map((b) => ({
112
124
  line: b.line,
113
125
  ...(b.send ? { send: b.send } : {}),
126
+ ...(b.sync ? { sync: b.sync } : {}),
114
127
  items: b.items.map(({ explicit, ...it }) => {
115
128
  if (explicit)
116
129
  return it;
@@ -124,8 +137,8 @@ export function assignChecklistIds(blocks) {
124
137
  }));
125
138
  return { ok: true, blocks: out };
126
139
  }
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. */
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. */
129
142
  export function checkChecklistValue(v, ids) {
130
143
  if (!v || typeof v !== 'object' || Array.isArray(v) || Object.keys(v).some((k) => k !== 'done'))
131
144
  return 'a checklist answer is { done: [item ids] }';
@@ -15,6 +15,7 @@ export declare function mergeCommentsState(state: StateConfig | undefined, comme
15
15
  error: string;
16
16
  };
17
17
  export declare const MAX_COMMENT_CHARS = 4000;
18
+ export declare const MAX_COMMENT_NAME_CHARS = 80;
18
19
  export declare const COMMENT_TRANSCRIPTS: readonly ["browser", "host", "typed"];
19
20
  /** One shared comment or personal note. `parent` makes it a reply to a top-level comment (one
20
21
  * level only). `audio` and `transcript` are filled by voice memos; a memo may have an empty body. */
@@ -25,6 +26,8 @@ export type CommentValue = {
25
26
  parent?: string;
26
27
  audio?: string;
27
28
  transcript?: (typeof COMMENT_TRANSCRIPTS)[number];
29
+ /** The name a reader typed so the owner can credit them. A signed-in reader's account name wins. */
30
+ by?: string;
28
31
  };
29
32
  /** The server's check on a comment, called by the state POST for the `comments` slot. `existing`
30
33
  * returns a comment THIS WRITER can see by its entry id, so a reply can only name a thread its
@@ -14,6 +14,7 @@ export function mergeCommentsState(state, comments, visible) {
14
14
  return { ok: true, state: { ...base, slots: { ...base.slots, [COMMENTS_SLOT]: slot } } };
15
15
  }
16
16
  export const MAX_COMMENT_CHARS = 4000;
17
+ export const MAX_COMMENT_NAME_CHARS = 80;
17
18
  export const COMMENT_TRANSCRIPTS = ['browser', 'host', 'typed'];
18
19
  const ENTRY_ID = /^[A-Za-z0-9_-]{1,128}$/;
19
20
  // An uploaded asset path under the page (comments/<id>.webm), never a URL and never `..`.
@@ -23,8 +24,8 @@ const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
23
24
  * returns a comment THIS WRITER can see by its entry id, so a reply can only name a thread its
24
25
  * writer was shown. The browser checks too; this is the one that counts. */
25
26
  export function validateComment(v, existing) {
26
- if (!isMap(v) || Object.keys(v).some((k) => !['anchor', 'body', 'version', 'parent', 'audio', 'transcript'].includes(k)))
27
- return { ok: false, error: 'a comment is { anchor, body, version, parent? }' };
27
+ if (!isMap(v) || Object.keys(v).some((k) => !['anchor', 'body', 'version', 'parent', 'audio', 'transcript', 'by'].includes(k)))
28
+ return { ok: false, error: 'a comment is { anchor, body, version, parent?, by? }' };
28
29
  const anchor = parseAnchor(v.anchor);
29
30
  if (!anchor)
30
31
  return { ok: false, error: 'a comment needs the place on the page it is pinned to' };
@@ -53,5 +54,11 @@ export function validateComment(v, existing) {
53
54
  value.audio = v.audio;
54
55
  if (v.transcript !== undefined)
55
56
  value.transcript = v.transcript;
57
+ if (v.by !== undefined) {
58
+ if (typeof v.by !== 'string' || /[\r\n]/.test(v.by) || v.by.trim().length > MAX_COMMENT_NAME_CHARS)
59
+ return { ok: false, error: `a comment's name is one line of at most ${MAX_COMMENT_NAME_CHARS} characters` };
60
+ if (v.by.trim())
61
+ value.by = v.by.trim();
62
+ }
56
63
  return { ok: true, value };
57
64
  }
@@ -12,7 +12,7 @@ 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, mergeChecklistState, checklistsOf, checklistSendIds, 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
16
  export { CHECKLIST_SLOT, checklistStorageKey, checkChecklistValue, parseChecklistFence, type ChecklistBlock, type ChecklistItem, type ChecklistValue } from './checklist.js';
17
17
  export { textAnchorFrom, findText, blockId, parseAnchor, CONTEXT_CHARS, MAX_QUOTE_CHARS, BLOCK_ID, type Anchor, type TextAnchor, type RegionAnchor } from './anchor.js';
18
18
  export { COMMENTS_SLOT, COMMENTS_MODES, COMMENT_TRANSCRIPTS, MAX_COMMENT_CHARS, mergeCommentsState, validateComment, type CommentsMode, type CommentsVisible, type CommentValue } from './comments.js';
@@ -12,7 +12,7 @@ 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, mergeChecklistState, checklistsOf, checklistSendIds, 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
16
  export { CHECKLIST_SLOT, checklistStorageKey, checkChecklistValue, parseChecklistFence } from './checklist.js';
17
17
  export { textAnchorFrom, findText, blockId, parseAnchor, CONTEXT_CHARS, MAX_QUOTE_CHARS, BLOCK_ID } from './anchor.js';
18
18
  export { COMMENTS_SLOT, COMMENTS_MODES, COMMENT_TRANSCRIPTS, MAX_COMMENT_CHARS, mergeCommentsState, validateComment } from './comments.js';
@@ -14,7 +14,8 @@ export declare function sectionOutline(markdown: string, headings?: Heading[]):
14
14
  export declare function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, }: {
15
15
  markdown: string;
16
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
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
18
19
  * draws and ticks, for the visit only. */
19
20
  checklist?: {
20
21
  artifactId: string;
@@ -13,7 +13,7 @@ import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
13
13
  import remarkGfm from 'remark-gfm';
14
14
  import remarkParse from 'remark-parse';
15
15
  import { unified } from 'unified';
16
- import { blocksOf, checklistsOf, hasNotesWidget, headingsOf } from './widgets.js';
16
+ import { blocksOf, checklistSyncs, checklistsOf, hasNotesWidget, headingsOf } from './widgets.js';
17
17
  import { HeadingNotes, NoteToggle, NotesEarlier, NotesProvider } from '../widgets/notes.js';
18
18
  import { Checklist } from '../widgets/checklist.js';
19
19
  import { VideoAutoplay } from '../reader/video-autoplay.js';
@@ -120,7 +120,7 @@ function ChecklistFence({ line, ctx }) {
120
120
  // Not a checklist this page validated: draw nothing rather than its source as code.
121
121
  if (!c)
122
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 }));
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
124
  }
125
125
  function baseComponents(block, figure, checklists) {
126
126
  return {
@@ -309,7 +309,9 @@ export function ArtifactMarkdown({ markdown, notes, checklist, definitions, Sect
309
309
  const plugins = [remarkGfm, ...(defined ? [remarkDefinitions(defined)] : []), ...(outline.length ? [remarkSections(outline[0].depth)] : [])];
310
310
  const lists = /^ {0,3}(`{3,}|~{3,})\s*checklist\b/m.test(markdown) ? checklistsOf(markdown) : null;
311
311
  const checklists = lists
312
- ? { byLine: lists, artifactId: checklist?.artifactId, send: !!checklist?.send, owner: checklist?.owner, accent: checklist?.accent }
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 }
313
315
  : undefined;
314
316
  const components = on ? notesComponents(headings, block, figure, checklists) : pageComponents(headings, block, figure, checklists);
315
317
  if (outline.length)
@@ -4,6 +4,12 @@ import { createHash } from 'node:crypto';
4
4
  import { SHARED_LIMIT, slotVisibility } from './state.js';
5
5
  const byAt = (a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id);
6
6
  const firstWord = (name) => (name?.trim() ? name.trim().split(/\s+/)[0] : 'a reader');
7
+ // The name an anonymous commenter typed into the comment box, so the owner can credit them. An
8
+ // account name always wins, because the account is the stronger claim to who wrote it.
9
+ const declared = (value) => {
10
+ const by = value && typeof value === 'object' && !Array.isArray(value) ? value.by : undefined;
11
+ return typeof by === 'string' && by.trim() ? by.trim() : null;
12
+ };
7
13
  // A `one` entry's raw id is `<page>__<slot>__<readerKey>`, and the reader key inside it (`u:<uid>`
8
14
  // or `a:<anonId>`) is a credential: whoever reads it can write as that reader by minting the same
9
15
  // anonymous cookie. A `shared` slot handed that id straight to every reader, so it is hashed to
@@ -41,7 +47,7 @@ export function stateView(state, entries, readerKey) {
41
47
  if (visibility === 'shared')
42
48
  view.shared = here.slice(-SHARED_LIMIT).map((e) => ({
43
49
  id: def.shape === 'one' ? opaqueOneId(e.id) : e.id,
44
- name: firstWord(e.writer.name), value: e.value, at: e.at, mine: e.readerKey === readerKey,
50
+ name: e.writer.name?.trim() ? firstWord(e.writer.name) : (declared(e.value) ?? 'a reader'), value: e.value, at: e.at, mine: e.readerKey === readerKey,
45
51
  }));
46
52
  out[slot] = view;
47
53
  }
@@ -49,7 +55,7 @@ export function stateView(state, entries, readerKey) {
49
55
  }
50
56
  export function responsesOf(entries) {
51
57
  return entries.slice().sort(byAt).map((e) => ({
52
- slot: e.slot, id: e.id, value: e.value, at: e.at, email: e.writer.email ?? null, name: e.writer.name, anonymous: e.writer.anonymous, reader: e.readerKey,
58
+ slot: e.slot, id: e.id, value: e.value, at: e.at, email: e.writer.email ?? null, name: e.writer.name ?? declared(e.value), anonymous: e.writer.anonymous, reader: e.readerKey,
53
59
  }));
54
60
  }
55
61
  // A cell a spreadsheet reads as a formula (leading =, +, -, @, tab or CR) gets an escaping
@@ -27,12 +27,20 @@ export declare function scanWidgets(body: string, offset: number): {
27
27
  * draws. A page whose checklists do not parse (published before checklists existed, so never
28
28
  * validated) draws none rather than half of them. */
29
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. */
30
+ /** The item ids of the page's checklist that offers Send, or null when none does. */
32
31
  export declare function checklistSendIds(markdown: string): string[] | null;
32
+ /** Does any checklist on this page sync? Then every checklist on it shares one set of ticks. */
33
+ export declare function checklistSyncs(checklists: Iterable<ChecklistBlock>): boolean;
34
+ /** The ids an answer in the `checklist` slot may name, or null when the page takes none: on a page
35
+ * that syncs, every item of every checklist on it (ids are page-wide); else the items of the
36
+ * checklist that offers Send. The state route asks, so a synced set can never carry an id the
37
+ * page does not draw. */
38
+ export declare function checklistAnswerIds(markdown: string): string[] | null;
33
39
  /** 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. */
40
+ * whoever `send:` names; the owner reads it through /responses. A page whose checklists `sync`
41
+ * declares the same slot SHARED instead, so every reader is shown every reader's set and the
42
+ * widget takes the newest as the page's. Like `comments:`, the slot is the widget's own, so
43
+ * state: may not declare one by that name. */
36
44
  export declare function mergeChecklistState(state: StateConfig | undefined, checklists: ChecklistBlock[]): {
37
45
  ok: true;
38
46
  state: StateConfig | undefined;
@@ -116,6 +116,11 @@ function scanChecklists(fences, offset) {
116
116
  return c;
117
117
  if (c.block.send && lists.some((l) => l.send))
118
118
  return { ok: false, error: `line ${line}: a page takes at most one checklist with send` };
119
+ const synced = lists.find((l) => l.sync);
120
+ if (c.block.sync && synced && synced.sync !== c.block.sync)
121
+ return { ok: false, error: `line ${line}: this checklist says sync: ${c.block.sync} and the one on line ${synced.line} says sync: ${synced.sync}; a page's ticks are one set, so say it one way` };
122
+ if ((c.block.sync && lists.some((l) => l.send)) || (c.block.send && synced))
123
+ return { ok: false, error: `line ${line}: a page whose checklists sync takes no send; the synced ticks already reach the owner` };
119
124
  lists.push(c.block);
120
125
  }
121
126
  const ids = assignChecklistIds(lists);
@@ -134,24 +139,49 @@ export function checklistsOf(markdown) {
134
139
  const r = scanChecklists(fencesOf(markdown), 0);
135
140
  return new Map(r.ok ? r.checklists.map((c) => [c.line, c]) : []);
136
141
  }
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. */
142
+ /** The item ids of the page's checklist that offers Send, or null when none does. */
139
143
  export function checklistSendIds(markdown) {
140
144
  const r = scanChecklists(fencesOf(markdown), 0);
141
145
  const c = r.ok ? r.checklists.find((l) => l.send) : undefined;
142
146
  return c ? c.items.map((i) => i.id) : null;
143
147
  }
148
+ /** Does any checklist on this page sync? Then every checklist on it shares one set of ticks. */
149
+ export function checklistSyncs(checklists) {
150
+ for (const c of checklists)
151
+ if (c.sync)
152
+ return true;
153
+ return false;
154
+ }
155
+ /** The ids an answer in the `checklist` slot may name, or null when the page takes none: on a page
156
+ * that syncs, every item of every checklist on it (ids are page-wide); else the items of the
157
+ * checklist that offers Send. The state route asks, so a synced set can never carry an id the
158
+ * page does not draw. */
159
+ export function checklistAnswerIds(markdown) {
160
+ const r = scanChecklists(fencesOf(markdown), 0);
161
+ if (!r.ok)
162
+ return null;
163
+ if (checklistSyncs(r.checklists))
164
+ return r.checklists.flatMap((c) => c.items.map((i) => i.id));
165
+ const c = r.checklists.find((l) => l.send);
166
+ return c ? c.items.map((i) => i.id) : null;
167
+ }
144
168
  /** 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. */
169
+ * whoever `send:` names; the owner reads it through /responses. A page whose checklists `sync`
170
+ * declares the same slot SHARED instead, so every reader is shown every reader's set and the
171
+ * widget takes the newest as the page's. Like `comments:`, the slot is the widget's own, so
172
+ * state: may not declare one by that name. */
147
173
  export function mergeChecklistState(state, checklists) {
148
- const c = checklists.find((l) => l.send);
174
+ const c = checklists.find((l) => l.sync) ?? checklists.find((l) => l.send);
149
175
  if (!c)
150
176
  return { ok: true, state };
177
+ const what = c.sync ? 'sync' : 'send';
151
178
  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` };
179
+ return { ok: false, error: `line ${c.line}: the checklist's ${what} writes to slot "${CHECKLIST_SLOT}", and state: declares a slot by that name; rename that slot` };
153
180
  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 } } } };
181
+ const slot = c.sync
182
+ ? { shape: 'one', visibility: 'shared', writers: c.sync }
183
+ : { shape: 'one', visibility: 'private', writers: c.send };
184
+ return { ok: true, state: { ...base, slots: { ...base.slots, [CHECKLIST_SLOT]: slot } } };
155
185
  }
156
186
  /** A widget declares its own slot. The notes block adds `notes: { shape: many }` to the page's
157
187
  * `state:` (creating `state:` with its defaults when the page had none), so the state API
@@ -13,6 +13,8 @@ export type SavedMemo = {
13
13
  /** The warning, word for word. `They` is fixed copy: the card never guesses a pronoun. */
14
14
  export declare function warningLine(ownerName: string, mode: CommentsMode): string;
15
15
  export declare const DEVICE_LINE = "Saved on this device. Sign in to keep it everywhere.";
16
+ /** Where a reader's declared name is remembered, so their next comment is already signed. */
17
+ export declare const NAME_KEY = "artifacts:comment-name";
16
18
  export declare function CommentCard({ ownerName, mode, canShare, signedIn, quote, initial, accent, signIn, voice, onSave, onCancel, }: {
17
19
  ownerName: string;
18
20
  mode: CommentsMode;
@@ -26,6 +28,6 @@ export declare function CommentCard({ ownerName, mode, canShare, signedIn, quote
26
28
  /** The page's voice routes. Null: a memo is kept but never uploaded or transcribed here. */
27
29
  voice?: VoiceHost | null;
28
30
  /** Resolves to an error to show, or null when saved. */
29
- onSave: (body: string, share: boolean, memo?: SavedMemo) => Promise<string | null>;
31
+ onSave: (body: string, share: boolean, memo?: SavedMemo, by?: string) => Promise<string | null>;
30
32
  onCancel: () => void;
31
33
  }): import("react").JSX.Element;
@@ -11,7 +11,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
11
11
  // since, in which case your words win. With neither, the memo is kept and the box stays empty.
12
12
  import { useRef, useState } from 'react';
13
13
  import { NO_ZOOM_FONT } from './no-zoom.js';
14
- import { MAX_COMMENT_CHARS } from '../artifacts/comments.js';
14
+ import { MAX_COMMENT_CHARS, MAX_COMMENT_NAME_CHARS } from '../artifacts/comments.js';
15
15
  import { VoiceRecorder } from './voice-recorder.js';
16
16
  /** The warning, word for word. `They` is fixed copy: the card never guesses a pronoun. */
17
17
  export function warningLine(ownerName, mode) {
@@ -21,6 +21,18 @@ export function warningLine(ownerName, mode) {
21
21
  return `Only you will see this. ${why}, so this is a personal note. They won't see it.`;
22
22
  }
23
23
  export const DEVICE_LINE = 'Saved on this device. Sign in to keep it everywhere.';
24
+ /** Where a reader's declared name is remembered, so their next comment is already signed. */
25
+ export const NAME_KEY = 'artifacts:comment-name';
26
+ const recall = () => { try {
27
+ return localStorage.getItem(NAME_KEY) ?? '';
28
+ }
29
+ catch {
30
+ return '';
31
+ } };
32
+ const remember = (name) => { try {
33
+ localStorage.setItem(NAME_KEY, name);
34
+ }
35
+ catch { /* a browser that keeps nothing still comments */ } };
24
36
  export function CommentCard({ ownerName, mode, canShare, signedIn, quote, initial = '', accent = 'currentColor', signIn, voice = null, onSave, onCancel, }) {
25
37
  const [text, setText] = useState(initial);
26
38
  const [share, setShare] = useState(canShare);
@@ -29,11 +41,15 @@ export function CommentCard({ ownerName, mode, canShare, signedIn, quote, initia
29
41
  const [memo, setMemo] = useState(null);
30
42
  const [transcript, setTranscript] = useState(undefined);
31
43
  const [status, setStatus] = useState(null);
44
+ const [name, setName] = useState(recall);
32
45
  // Refs, because the host's transcript arrives after an await and must see the box as it is then.
33
46
  const base = useRef('');
34
47
  const edited = useRef(false);
35
48
  const uploaded = useRef(null);
36
49
  const personal = !canShare || !share;
50
+ // A reader with no account is asked for a name, so the owner can credit the feedback. Optional:
51
+ // a comment without one still goes through, signed "a reader".
52
+ const askName = !personal && !signedIn;
37
53
  // Where a memo goes: a shared comment, the reader's account, or (neither) this device only.
38
54
  const scope = () => (canShare && share ? 'comment' : signedIn ? 'personal' : null);
39
55
  const recorded = async (m) => {
@@ -61,12 +77,14 @@ export function CommentCard({ ownerName, mode, canShare, signedIn, quote, initia
61
77
  if ((!body && !memo) || busy)
62
78
  return;
63
79
  setBusy(true);
64
- const err = memo
65
- ? await onSave(body, canShare && share, { memo, uploaded: uploaded.current, ...(body && transcript ? { transcript } : {}) })
66
- : await onSave(body, canShare && share);
80
+ const by = askName && name.trim() ? name.trim() : undefined;
81
+ if (by)
82
+ remember(by);
83
+ const saved = memo ? { memo, uploaded: uploaded.current, ...(body && transcript ? { transcript } : {}) } : undefined;
84
+ const err = saved || by ? await onSave(body, canShare && share, saved, by) : await onSave(body, canShare && share);
67
85
  setBusy(false);
68
86
  setError(err);
69
87
  };
70
88
  return (_jsxs("form", { "data-comment-card": true, "data-comment-ui": true, "data-nospeak": true, onSubmit: submit, className: "w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-left font-sans text-sm text-[color:var(--a-strong)] shadow-xl", children: [!canShare ? (_jsx("p", { "data-comment-warning": true, role: "note", className: "mb-2 rounded-lg border px-3 py-2 text-[13px] leading-snug", style: { borderColor: accent }, children: warningLine(ownerName, mode) })) : null, quote ? _jsx("p", { className: "mb-2 line-clamp-3 border-l-2 pl-2 text-[13px] italic opacity-75", style: { borderColor: accent }, children: quote }) : null, _jsx("textarea", { value: text, autoFocus: true, style: NO_ZOOM_FONT, onChange: (e) => { setText(e.target.value); edited.current = true; setTranscript('typed'); }, onKeyDown: (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey))
71
- void submit(); }, maxLength: MAX_COMMENT_CHARS, rows: 3, "aria-label": personal ? 'A personal note' : `A comment for ${ownerName}`, placeholder: personal ? 'A note for yourself' : `A comment for ${ownerName}`, className: "w-full rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-ground,transparent)] p-2 text-[15px]" }), _jsx(VoiceRecorder, { accent: accent, onStart: () => { base.current = text.trim(); setMemo(null); setTranscript(undefined); setError(null); }, onLive: (heard) => { setText(base.current ? `${base.current} ${heard}` : heard); setTranscript('browser'); }, onStop: (m) => void recorded(m), onError: setError }), memo ? (_jsxs("p", { "data-voice-kept": true, className: "m-0 mt-1 text-xs opacity-70", children: [status ?? 'Recording kept with this comment.', _jsx("button", { type: "button", "data-voice-discard": true, className: "ml-2 underline", onClick: () => { setMemo(null); uploaded.current = null; }, children: "Discard recording" })] })) : null, canShare ? (_jsxs("fieldset", { className: "mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[13px]", children: [_jsx("legend", { className: "sr-only", children: "Who sees this" }), _jsxs("label", { className: "flex items-center gap-1.5", children: [_jsx("input", { type: "radio", name: "comment-share", "data-comment-share": "share", checked: share, onChange: () => setShare(true) }), `Share with ${ownerName}`] }), _jsxs("label", { className: "flex items-center gap-1.5", children: [_jsx("input", { type: "radio", name: "comment-share", "data-comment-share": "mine", checked: !share, onChange: () => setShare(false) }), "Just for me"] })] })) : null, personal && !signedIn ? (_jsx("p", { "data-comment-device": true, className: "mt-2 text-xs opacity-70", children: signIn ? _jsxs(_Fragment, { children: ["Saved on this device. ", _jsx("a", { href: signIn, className: "underline", children: "Sign in to keep it everywhere." })] }) : DEVICE_LINE })) : null, error ? _jsx("p", { role: "alert", className: "mt-2 text-xs text-amber-500", children: error }) : null, _jsxs("div", { className: "mt-3 flex items-center gap-3", children: [_jsx("button", { type: "submit", "data-comment-save": true, disabled: busy || (!text.trim() && !memo), className: "rounded-full px-4 py-1 text-sm font-medium disabled:opacity-40", style: { background: accent, color: 'var(--a-on-accent, #111)' }, children: personal ? 'Save note' : 'Comment' }), _jsx("button", { type: "button", onClick: onCancel, className: "text-sm opacity-70 hover:opacity-100", children: "Cancel" })] })] }));
89
+ void submit(); }, maxLength: MAX_COMMENT_CHARS, rows: 3, "aria-label": personal ? 'A personal note' : `A comment for ${ownerName}`, placeholder: personal ? 'A note for yourself' : `A comment for ${ownerName}`, className: "w-full rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-ground,transparent)] p-2 text-[15px]" }), _jsx(VoiceRecorder, { accent: accent, onStart: () => { base.current = text.trim(); setMemo(null); setTranscript(undefined); setError(null); }, onLive: (heard) => { setText(base.current ? `${base.current} ${heard}` : heard); setTranscript('browser'); }, onStop: (m) => void recorded(m), onError: setError }), memo ? (_jsxs("p", { "data-voice-kept": true, className: "m-0 mt-1 text-xs opacity-70", children: [status ?? 'Recording kept with this comment.', _jsx("button", { type: "button", "data-voice-discard": true, className: "ml-2 underline", onClick: () => { setMemo(null); uploaded.current = null; }, children: "Discard recording" })] })) : null, askName ? (_jsxs("label", { "data-comment-name": true, className: "mt-2 block text-[13px]", children: [_jsx("span", { className: "block opacity-80", children: `Your name, so ${ownerName} can credit your feedback` }), _jsx("input", { value: name, onChange: (e) => setName(e.target.value), maxLength: MAX_COMMENT_NAME_CHARS, autoComplete: "name", placeholder: "Your name", style: NO_ZOOM_FONT, className: "mt-1 w-full rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-ground,transparent)] px-2 py-1 text-[15px]" })] })) : null, canShare ? (_jsxs("fieldset", { className: "mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[13px]", children: [_jsx("legend", { className: "sr-only", children: "Who sees this" }), _jsxs("label", { className: "flex items-center gap-1.5", children: [_jsx("input", { type: "radio", name: "comment-share", "data-comment-share": "share", checked: share, onChange: () => setShare(true) }), `Share with ${ownerName}`] }), _jsxs("label", { className: "flex items-center gap-1.5", children: [_jsx("input", { type: "radio", name: "comment-share", "data-comment-share": "mine", checked: !share, onChange: () => setShare(false) }), "Just for me"] })] })) : null, personal && !signedIn ? (_jsx("p", { "data-comment-device": true, className: "mt-2 text-xs opacity-70", children: signIn ? _jsxs(_Fragment, { children: ["Saved on this device. ", _jsx("a", { href: signIn, className: "underline", children: "Sign in to keep it everywhere." })] }) : DEVICE_LINE })) : null, error ? _jsx("p", { role: "alert", className: "mt-2 text-xs text-amber-500", children: error }) : null, _jsxs("div", { className: "mt-3 flex items-center gap-3", children: [_jsx("button", { type: "submit", "data-comment-save": true, disabled: busy || (!text.trim() && !memo), className: "rounded-full px-4 py-1 text-sm font-medium disabled:opacity-40", style: { background: accent, color: 'var(--a-on-accent, #111)' }, children: personal ? 'Save note' : 'Comment' }), _jsx("button", { type: "button", onClick: onCancel, className: "text-sm opacity-70 hover:opacity-100", children: "Cancel" })] })] }));
72
90
  }
@@ -377,8 +377,8 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
377
377
  * there, uploaded now when the reader switched between Share and Just for me after recording. */
378
378
  const memoPath = async (m, scope) => m.uploaded?.scope === scope ? m.uploaded.path : host.upload(m.memo, scope);
379
379
  const LOST = 'The recording did not upload. Try again, or discard it and save the text.';
380
- const saveNew = async (body, share, d, memo) => {
381
- const value = { anchor: d.anchor, body, version, ...(memo?.transcript ? { transcript: memo.transcript } : {}) };
380
+ const saveNew = async (body, share, d, memo, by) => {
381
+ const value = { anchor: d.anchor, body, version, ...(memo?.transcript ? { transcript: memo.transcript } : {}), ...(share && by ? { by } : {}) };
382
382
  const toDevice = !share && !(signedIn && onServer);
383
383
  if (memo && !toDevice) {
384
384
  const path = await memoPath(memo, share ? 'comment' : 'personal');
@@ -472,7 +472,7 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
472
472
  // On a phone the button sits at the TOP of the screen, never beside the selection: that is
473
473
  // where iOS draws its own Copy / Look Up menu, and the two fought for the same tap (Gary,
474
474
  // 2026-09-29: "I keep trying to fight with the Apple pop-up"). A mouse keeps it by the text.
475
- _jsx("button", { type: "button", "data-comment-chip": true, "data-comment-chip-at": onTouch() ? 'top' : 'selection', className: onTouch() ? 'fixed left-1/2 z-50 -translate-x-1/2 rounded-full px-5 py-2 text-sm font-medium shadow-lg' : 'absolute z-50 -translate-x-full rounded-full px-3 py-1 text-xs font-medium shadow-lg', style: onTouch() ? { top: 'calc(env(safe-area-inset-top, 0px) + 12px)', background: accent, color: 'var(--a-on-accent, #111)' } : { left: chip.x, top: chip.y, background: accent, color: 'var(--a-on-accent, #111)' }, onMouseDown: (e) => e.preventDefault(), onClick: () => { setDraft(chip); setChip(null); }, children: "Comment" })) : null, draft ? (_jsx(Floating, { x: draft.x, y: draft.y, children: _jsx(CommentCard, { ownerName: ownerName, mode: comments, canShare: canShare, signedIn: signedIn && onServer, quote: draft.quote, accent: accent, signIn: signIn, voice: host, onSave: (body, share, memo) => saveNew(body, share, draft, memo), onCancel: () => setDraft(null) }) })) : null, openItem && open ? (_jsx(Floating, { x: open.left + 28, y: open.top, children: _jsx(Thread, { item: openItem, replies: items.filter((i) => i.value.parent === openItem.id), canReply: openItem.kind === 'shared' && canShare, accent: accent, audioDb: audioDb, onClose: () => setThread(null), onRemove: remove, onEdit: edit, onReply: reply }) })) : null, drag ? (_jsx("div", { "aria-hidden": true, className: "pointer-events-none fixed z-40 rounded-md border-2 border-dashed", style: { left: Math.min(drag.x1, drag.x2), top: Math.min(drag.y1, drag.y2), width: Math.abs(drag.x2 - drag.x1), height: Math.abs(drag.y2 - drag.y1), borderColor: accent, background: `color-mix(in srgb, ${accent} 12%, transparent)` } })) : null, _jsxs("div", { className: "fixed right-4 z-40 flex flex-col items-end gap-2", style: { bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)' }, children: [panel ? (_jsxs("div", { "data-comment-panel": true, className: "w-64 rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-xs text-[color:var(--a-strong)] shadow-xl", children: [_jsx("p", { className: "opacity-80", children: "Select any text, or hold and drag a box over anything, to leave a comment." }), isOwner ? _jsx("p", { "data-comment-owner": true, className: "mt-1 opacity-80", children: "This is your page: you see every comment, with the name of who left it." }) : null, _jsx("button", { type: "button", "data-comment-pins-toggle": true, className: "mt-2 underline", onClick: () => setShowPins((v) => !v), children: showPins ? 'Hide comments' : 'Show comments' })] })) : null, _jsx("button", { type: "button", "data-comment-button": true, "aria-expanded": panel, onClick: () => setPanel((v) => !v), className: "rounded-full border px-3 py-1.5 text-xs font-medium shadow-lg backdrop-blur", style: { borderColor: accent, color: accent, background: `color-mix(in srgb, ${ground} 85%, transparent)` }, children: count ? `Comments · ${count}` : 'Comment' })] })] })) : null;
475
+ _jsx("button", { type: "button", "data-comment-chip": true, "data-comment-chip-at": onTouch() ? 'top' : 'selection', className: onTouch() ? 'fixed left-1/2 z-50 -translate-x-1/2 rounded-full px-5 py-2 text-sm font-medium shadow-lg' : 'absolute z-50 -translate-x-full rounded-full px-3 py-1 text-xs font-medium shadow-lg', style: onTouch() ? { top: 'calc(env(safe-area-inset-top, 0px) + 12px)', background: accent, color: 'var(--a-on-accent, #111)' } : { left: chip.x, top: chip.y, background: accent, color: 'var(--a-on-accent, #111)' }, onMouseDown: (e) => e.preventDefault(), onClick: () => { setDraft(chip); setChip(null); }, children: "Comment" })) : null, draft ? (_jsx(Floating, { x: draft.x, y: draft.y, children: _jsx(CommentCard, { ownerName: ownerName, mode: comments, canShare: canShare, signedIn: signedIn && onServer, quote: draft.quote, accent: accent, signIn: signIn, voice: host, onSave: (body, share, memo, by) => saveNew(body, share, draft, memo, by), onCancel: () => setDraft(null) }) })) : null, openItem && open ? (_jsx(Floating, { x: open.left + 28, y: open.top, children: _jsx(Thread, { item: openItem, replies: items.filter((i) => i.value.parent === openItem.id), canReply: openItem.kind === 'shared' && canShare, accent: accent, audioDb: audioDb, onClose: () => setThread(null), onRemove: remove, onEdit: edit, onReply: reply }) })) : null, drag ? (_jsx("div", { "aria-hidden": true, className: "pointer-events-none fixed z-40 rounded-md border-2 border-dashed", style: { left: Math.min(drag.x1, drag.x2), top: Math.min(drag.y1, drag.y2), width: Math.abs(drag.x2 - drag.x1), height: Math.abs(drag.y2 - drag.y1), borderColor: accent, background: `color-mix(in srgb, ${accent} 12%, transparent)` } })) : null, _jsxs("div", { className: "fixed right-4 z-40 flex flex-col items-end gap-2", style: { bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)' }, children: [panel ? (_jsxs("div", { "data-comment-panel": true, className: "w-64 rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-xs text-[color:var(--a-strong)] shadow-xl", children: [_jsx("p", { className: "opacity-80", children: "Select any text, or hold and drag a box over anything, to leave a comment." }), isOwner ? _jsx("p", { "data-comment-owner": true, className: "mt-1 opacity-80", children: "This is your page: you see every comment, with the name of who left it." }) : null, _jsx("button", { type: "button", "data-comment-pins-toggle": true, className: "mt-2 underline", onClick: () => setShowPins((v) => !v), children: showPins ? 'Hide comments' : 'Show comments' })] })) : null, _jsx("button", { type: "button", "data-comment-button": true, "aria-expanded": panel, onClick: () => setPanel((v) => !v), className: "rounded-full border px-3 py-1.5 text-xs font-medium shadow-lg backdrop-blur", style: { borderColor: accent, color: accent, background: `color-mix(in srgb, ${ground} 85%, transparent)` }, children: count ? `Comments · ${count}` : 'Comment' })] })] })) : null;
476
476
  return (_jsxs(_Fragment, { children: [_jsx("span", { hidden: true, "data-comment-layer": comments, "data-comment-can-share": canShare ? 'yes' : 'no' }), mounted && layout.earlier.length ? (_jsxs("section", { "data-comments-earlier": true, "data-comment-ui": true, "data-nospeak": true, className: "mx-auto max-w-2xl px-6 pb-16 text-sm", children: [_jsx("p", { className: "text-[11px] uppercase tracking-[0.2em]", style: { color: accent }, children: "Comments on earlier versions" }), _jsx("ul", { className: "m-0 mt-2 grid list-none gap-3 p-0", children: layout.earlier.map((item) => (_jsxs("li", { "data-comment-earlier-id": item.id, ...(highlight === item.id ? { 'data-comment-highlight': '' } : {}), className: "m-0 border-l-2 py-1 pl-3", style: { borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid', ...(highlight === item.id ? { background: `color-mix(in srgb, ${accent} 14%, transparent)` } : {}) }, children: [item.value.anchor.kind === 'text' ? _jsxs("span", { className: "block text-[13px] italic opacity-70", children: ["\u201C", item.value.anchor.quote, "\u201D"] }) : _jsx("span", { className: "block text-[13px] italic opacity-70", children: "A box on part of the page that has changed" }), _jsx("span", { className: "block whitespace-pre-wrap text-[15px] text-[color:var(--a-strong)]", children: item.value.body }), _jsxs("span", { className: "block text-xs opacity-60", children: [item.kind === 'personal' ? 'only you' : item.name, ` · version ${item.value.version}`, item.mine ? _jsx("button", { type: "button", onClick: () => void remove(item), className: "ml-2 underline", children: "delete" }) : null] })] }, item.id))) })] })) : null, overlay ? createPortal(overlay, document.body) : null] }));
477
477
  }
478
478
  function Pin({ item, left, top, accent, ground, onOpen }) {
@@ -19,7 +19,7 @@ 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, checklistSendIds, hasNotesWidget } from '../artifacts/widgets.js';
22
+ import { NOTES_SLOT, checkNoteValue, checklistAnswerIds, hasNotesWidget } from '../artifacts/widgets.js';
23
23
  import { CHECKLIST_SLOT, checkChecklistValue } from '../artifacts/checklist.js';
24
24
  import { responsesCsv, responsesOf, stateView } from '../artifacts/state-view.js';
25
25
  export const ANON_COOKIE = 'artifact_anon';
@@ -243,7 +243,7 @@ export function createStateRoutes(ctx) {
243
243
  return json({ error: 'no comment of yours with that id' }, 404);
244
244
  }
245
245
  else {
246
- const sendIds = slot === CHECKLIST_SLOT ? checklistSendIds(a.markdown) : null;
246
+ const sendIds = slot === CHECKLIST_SLOT ? checklistAnswerIds(a.markdown) : null;
247
247
  const bad = checkValue(b.value)
248
248
  ?? (slot === NOTES_SLOT && hasNotesWidget(a.markdown) ? checkNoteValue(b.value) : null)
249
249
  ?? (sendIds ? checkChecklistValue(b.value, sendIds) : null);
@@ -0,0 +1,54 @@
1
+ export type SyncStatus = 'loading' | 'synced' | 'saving' | 'error' | 'sign-in' | 'local';
2
+ export type SyncSnapshot = {
3
+ done: ReadonlySet<string>;
4
+ status: SyncStatus;
5
+ message?: string;
6
+ signIn?: string | null;
7
+ };
8
+ export declare const SYNC_POLL_MS = 4000;
9
+ export declare const SYNC_SAVE_DELAY_MS = 400;
10
+ /** The page's set from a state answer: the newest shared entry's `done`, or null when the answer
11
+ * carries no checklist slot. An empty slot is an empty set. */
12
+ export declare function latestSharedDone(body: unknown): string[] | null;
13
+ declare class SyncStore {
14
+ private artifactId;
15
+ private blocks;
16
+ private done;
17
+ private status;
18
+ private message?;
19
+ private signIn?;
20
+ private snap;
21
+ private listeners;
22
+ private dirty;
23
+ private inflight;
24
+ private saveTimer;
25
+ private pollTimer;
26
+ private onVisible;
27
+ constructor(artifactId: string);
28
+ private make;
29
+ private emit;
30
+ private set;
31
+ private visible;
32
+ private url;
33
+ /** Every id a checklist on the page draws. A set from an earlier version can name an item the
34
+ * page no longer has, and the server refuses a set naming one, so only these are posted. */
35
+ private known;
36
+ snapshot: () => SyncSnapshot;
37
+ subscribe: (l: () => void) => () => void;
38
+ register(block: object, ids: readonly string[]): void;
39
+ unregister(block: object): void;
40
+ private start;
41
+ private stop;
42
+ private toLocal;
43
+ /** Read the page's set. Skipped while this reader has a change not yet saved, so a poll can
44
+ * never take back a tick the reader just made. */
45
+ pull(): Promise<void>;
46
+ toggle(id: string): void;
47
+ private schedule;
48
+ /** Post the whole page-wide set. Last write wins on the whole set (see the top of this file). */
49
+ push(): Promise<void>;
50
+ }
51
+ /** The one store for a page, shared by every checklist block on it. */
52
+ export declare function syncStoreFor(artifactId: string): SyncStore;
53
+ export declare const SERVER_SYNC_SNAPSHOT: SyncSnapshot;
54
+ export {};
@@ -0,0 +1,255 @@
1
+ 'use client';
2
+ // The shared set behind a page whose checklists `sync`: one store per page, held by every
3
+ // checklist block on it, because item ids are page-wide and the host keeps ONE set for the page
4
+ // (the shared `one` slot `checklist`, where each writer's latest set is an entry).
5
+ //
6
+ // The newest entry by its server time is the page's set. A tick updates the screen at once and,
7
+ // after a short pause, posts the WHOLE page-wide set; a poll every few seconds while the tab is
8
+ // visible brings in the other readers' ticks. Concurrency is last-write-wins on the whole set: two
9
+ // people ticking different items inside the same couple of seconds can lose one of the two ticks.
10
+ // That is accepted for a list two people walk through together; a per-item merge would need the
11
+ // host to keep per-item state, which this version does not.
12
+ //
13
+ // A host that keeps no answers (501), a page that takes none (404), or a first read that fails
14
+ // drops the store to `local`: ticks live in localStorage exactly as an unsynced checklist's do.
15
+ import { CHECKLIST_SLOT, checklistStorageKey } from '../artifacts/checklist.js';
16
+ export const SYNC_POLL_MS = 4000;
17
+ export const SYNC_SAVE_DELAY_MS = 400;
18
+ const RETRY_MS = 5000;
19
+ /** The page's set from a state answer: the newest shared entry's `done`, or null when the answer
20
+ * carries no checklist slot. An empty slot is an empty set. */
21
+ export function latestSharedDone(body) {
22
+ const slot = body?.slots?.[CHECKLIST_SLOT];
23
+ if (!slot)
24
+ return null;
25
+ let best = null;
26
+ for (const e of slot.shared ?? [])
27
+ if (typeof e?.at === 'string' && (!best || e.at > best.at))
28
+ best = e;
29
+ const done = best?.value?.done;
30
+ return Array.isArray(done) ? done.filter((d) => typeof d === 'string') : [];
31
+ }
32
+ function readLocal(artifactId, id) {
33
+ try {
34
+ return globalThis.localStorage?.getItem(checklistStorageKey(artifactId, id)) === '1';
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ function writeLocal(artifactId, id, on) {
41
+ try {
42
+ if (on)
43
+ globalThis.localStorage?.setItem(checklistStorageKey(artifactId, id), '1');
44
+ else
45
+ globalThis.localStorage?.removeItem(checklistStorageKey(artifactId, id));
46
+ }
47
+ catch {
48
+ // Storage blocked: the tick lives for this visit only.
49
+ }
50
+ }
51
+ class SyncStore {
52
+ artifactId;
53
+ blocks = new Map();
54
+ done = new Set();
55
+ status = 'loading';
56
+ message;
57
+ signIn;
58
+ snap;
59
+ listeners = new Set();
60
+ dirty = false;
61
+ inflight = false;
62
+ saveTimer = null;
63
+ pollTimer = null;
64
+ onVisible = () => { if (this.visible())
65
+ void this.pull(); };
66
+ constructor(artifactId) {
67
+ this.artifactId = artifactId;
68
+ this.snap = this.make();
69
+ }
70
+ make() {
71
+ return { done: new Set(this.done), status: this.status, ...(this.message ? { message: this.message } : {}), ...(this.signIn !== undefined ? { signIn: this.signIn } : {}) };
72
+ }
73
+ emit() {
74
+ this.snap = this.make();
75
+ for (const l of this.listeners)
76
+ l();
77
+ }
78
+ set(status, message, signIn) {
79
+ this.status = status;
80
+ this.message = message;
81
+ this.signIn = signIn;
82
+ }
83
+ visible() {
84
+ return typeof document === 'undefined' || document.visibilityState !== 'hidden';
85
+ }
86
+ url() {
87
+ return `/api/artifacts/${this.artifactId}/state`;
88
+ }
89
+ /** Every id a checklist on the page draws. A set from an earlier version can name an item the
90
+ * page no longer has, and the server refuses a set naming one, so only these are posted. */
91
+ known() {
92
+ const out = new Set();
93
+ for (const ids of this.blocks.values())
94
+ for (const id of ids)
95
+ out.add(id);
96
+ return out;
97
+ }
98
+ snapshot = () => this.snap;
99
+ subscribe = (l) => {
100
+ this.listeners.add(l);
101
+ return () => { this.listeners.delete(l); };
102
+ };
103
+ register(block, ids) {
104
+ // Back in the map after a stop (React's strict mode unmounts and remounts once).
105
+ if (!stores.has(this.artifactId))
106
+ stores.set(this.artifactId, this);
107
+ this.blocks.set(block, ids);
108
+ if (this.status === 'local') {
109
+ for (const id of ids)
110
+ if (readLocal(this.artifactId, id))
111
+ this.done.add(id);
112
+ this.emit();
113
+ }
114
+ if (this.blocks.size === 1)
115
+ this.start();
116
+ }
117
+ unregister(block) {
118
+ this.blocks.delete(block);
119
+ if (this.blocks.size === 0)
120
+ this.stop();
121
+ }
122
+ start() {
123
+ void this.pull();
124
+ if (typeof document !== 'undefined')
125
+ document.addEventListener('visibilitychange', this.onVisible);
126
+ this.pollTimer = setInterval(() => { if (this.visible())
127
+ void this.pull(); }, SYNC_POLL_MS);
128
+ }
129
+ stop() {
130
+ if (this.pollTimer)
131
+ clearInterval(this.pollTimer);
132
+ if (this.saveTimer)
133
+ clearTimeout(this.saveTimer);
134
+ this.pollTimer = this.saveTimer = null;
135
+ if (typeof document !== 'undefined')
136
+ document.removeEventListener('visibilitychange', this.onVisible);
137
+ stores.delete(this.artifactId);
138
+ }
139
+ toLocal() {
140
+ if (this.pollTimer)
141
+ clearInterval(this.pollTimer);
142
+ this.pollTimer = null;
143
+ this.done = new Set([...this.known()].filter((id) => readLocal(this.artifactId, id)));
144
+ this.set('local');
145
+ this.emit();
146
+ }
147
+ /** Read the page's set. Skipped while this reader has a change not yet saved, so a poll can
148
+ * never take back a tick the reader just made. */
149
+ async pull() {
150
+ if (this.status === 'local' || this.dirty || this.inflight)
151
+ return;
152
+ let r;
153
+ try {
154
+ r = await fetch(this.url(), { credentials: 'same-origin', cache: 'no-store' });
155
+ }
156
+ catch {
157
+ if (this.status === 'loading')
158
+ return this.toLocal();
159
+ this.set('error', 'offline; will retry');
160
+ return this.emit();
161
+ }
162
+ if (r.status === 501 || r.status === 404)
163
+ return this.toLocal();
164
+ const body = await r.json().catch(() => null);
165
+ if (!r.ok) {
166
+ if (this.status === 'loading')
167
+ return this.toLocal();
168
+ this.set('error', 'could not read the shared list; will retry');
169
+ return this.emit();
170
+ }
171
+ const done = latestSharedDone(body);
172
+ if (done === null)
173
+ return this.toLocal();
174
+ if (this.dirty || this.inflight)
175
+ return;
176
+ this.done = new Set(done);
177
+ if (this.status !== 'sign-in')
178
+ this.set('synced');
179
+ this.emit();
180
+ }
181
+ toggle(id) {
182
+ const on = !this.done.has(id);
183
+ if (on)
184
+ this.done.add(id);
185
+ else
186
+ this.done.delete(id);
187
+ writeLocal(this.artifactId, id, on);
188
+ if (this.status === 'local')
189
+ return this.emit();
190
+ this.dirty = true;
191
+ this.set('saving');
192
+ this.emit();
193
+ this.schedule(SYNC_SAVE_DELAY_MS);
194
+ }
195
+ schedule(ms) {
196
+ if (this.saveTimer)
197
+ clearTimeout(this.saveTimer);
198
+ this.saveTimer = setTimeout(() => { this.saveTimer = null; void this.push(); }, ms);
199
+ }
200
+ /** Post the whole page-wide set. Last write wins on the whole set (see the top of this file). */
201
+ async push() {
202
+ if (this.inflight || !this.dirty)
203
+ return;
204
+ this.inflight = true;
205
+ this.dirty = false;
206
+ const known = this.known();
207
+ const done = [...this.done].filter((id) => known.has(id));
208
+ let r = null;
209
+ let body = null;
210
+ try {
211
+ r = await fetch(this.url(), {
212
+ method: 'POST',
213
+ credentials: 'same-origin',
214
+ cache: 'no-store',
215
+ headers: { 'content-type': 'application/json' },
216
+ body: JSON.stringify({ slot: CHECKLIST_SLOT, op: 'set', value: { done } }),
217
+ });
218
+ body = (await r.json().catch(() => null));
219
+ }
220
+ catch {
221
+ r = null;
222
+ }
223
+ this.inflight = false;
224
+ if (r?.ok) {
225
+ if (this.dirty)
226
+ return this.schedule(SYNC_SAVE_DELAY_MS);
227
+ const latest = latestSharedDone(body);
228
+ if (latest)
229
+ this.done = new Set(latest);
230
+ this.set('synced');
231
+ }
232
+ else if (r?.status === 401) {
233
+ this.set('sign-in', undefined, typeof body?.signIn === 'string' ? body.signIn : null);
234
+ }
235
+ else if (r && r.status >= 400 && r.status < 500 && r.status !== 429) {
236
+ this.set('error', typeof body?.error === 'string' ? body.error : 'that tick did not save');
237
+ }
238
+ else {
239
+ // Offline, rate-limited or a server error: keep the tick and try again shortly.
240
+ this.dirty = true;
241
+ this.set('error', 'not saved yet; retrying');
242
+ this.schedule(RETRY_MS);
243
+ }
244
+ this.emit();
245
+ }
246
+ }
247
+ const stores = new Map();
248
+ /** The one store for a page, shared by every checklist block on it. */
249
+ export function syncStoreFor(artifactId) {
250
+ let s = stores.get(artifactId);
251
+ if (!s)
252
+ stores.set(artifactId, (s = new SyncStore(artifactId)));
253
+ return s;
254
+ }
255
+ export const SERVER_SYNC_SNAPSHOT = { done: new Set(), status: 'loading' };
@@ -5,12 +5,14 @@ export type ChecklistViewItem = {
5
5
  title: ReactNode;
6
6
  description: ReactNode | null;
7
7
  };
8
- export declare function Checklist({ items, artifactId, send, owner, accent, }: {
8
+ export declare function Checklist({ items, artifactId, send, sync, owner, accent, }: {
9
9
  items: ChecklistViewItem[];
10
10
  /** The page, for the storage key. Without one (a past version) ticks last for the visit. */
11
11
  artifactId?: string;
12
12
  /** Who may press Send, when the page offers it and the host keeps answers. */
13
13
  send?: SendWriters;
14
+ /** The page's ticks are one shared set the host keeps (a page whose checklists `sync`). */
15
+ sync?: boolean;
14
16
  owner?: string;
15
17
  accent?: string;
16
18
  }): import("react").JSX.Element;
@@ -9,8 +9,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
9
  // The item text and description arrive already drawn on the server, as markdown with raw HTML
10
10
  // escaped, so this file draws no author content itself. Everything here is data-nospeak: the
11
11
  // narrator reads the prose, never the checklist.
12
- import { useCallback, useEffect, useState } from 'react';
12
+ //
13
+ // With `sync`, the ticks are the page's, not the reader's: every checklist block on the page reads
14
+ // and writes one shared set through ./checklist-sync.ts, and falls back to the localStorage path
15
+ // below when the host keeps no answers.
16
+ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react';
13
17
  import { CHECKLIST_SLOT, checklistStorageKey } from '../artifacts/checklist.js';
18
+ import { SERVER_SYNC_SNAPSHOT, syncStoreFor } from './checklist-sync.js';
14
19
  function readTick(key) {
15
20
  if (!key)
16
21
  return false;
@@ -34,21 +39,48 @@ function writeTick(key, on) {
34
39
  // Storage blocked: the tick lives for this visit only.
35
40
  }
36
41
  }
37
- export function Checklist({ items, artifactId, send, owner, accent, }) {
42
+ const noopSubscribe = () => () => { };
43
+ const nullSnapshot = () => null;
44
+ const serverSnapshot = () => SERVER_SYNC_SNAPSHOT;
45
+ const SYNC_WORDS = { loading: 'Connecting…', synced: 'Synced', saving: 'Saving…' };
46
+ /** The quiet word after the count: where the shared set stands. */
47
+ function SyncLabel({ s, color }) {
48
+ const words = s.status === 'error' ? (s.message ?? 'not saved') : s.status === 'sign-in' ? null : SYNC_WORDS[s.status];
49
+ return (_jsxs("span", { "data-checklist-sync": s.status, className: "normal-case tracking-normal opacity-70", children: [' · ', s.status === 'sign-in'
50
+ ? (s.signIn ? _jsx("a", { href: s.signIn, className: "underline", style: { color }, children: "Sign in to tick" }) : 'Ticking is open to signed-in readers')
51
+ : words] }));
52
+ }
53
+ export function Checklist({ items, artifactId, send, sync, owner, accent, }) {
38
54
  const key = useCallback((id) => (artifactId ? checklistStorageKey(artifactId, id) : null), [artifactId]);
39
- const [done, setDone] = useState({});
55
+ const [local, setDone] = useState({});
40
56
  const [sent, setSent] = useState({ status: 'idle' });
57
+ const store = useMemo(() => (sync && artifactId ? syncStoreFor(artifactId) : null), [sync, artifactId]);
58
+ const shared = useSyncExternalStore(store ? store.subscribe : noopSubscribe, store ? store.snapshot : nullSnapshot, store ? serverSnapshot : nullSnapshot);
41
59
  // Read after mount, never during render: the server has no storage, and reading it while
42
60
  // hydrating would draw a page that disagrees with the server's.
43
61
  const ids = items.map((it) => it.id).join('\n');
44
62
  useEffect(() => {
63
+ if (store)
64
+ return;
45
65
  const next = {};
46
66
  for (const id of ids.split('\n'))
47
67
  if (readTick(key(id)))
48
68
  next[id] = true;
49
69
  setDone(next);
50
- }, [ids, key]);
70
+ }, [ids, key, store]);
71
+ // A synced block hands the page's store the ids it draws; the store reads and polls while any
72
+ // block on the page is mounted.
73
+ useEffect(() => {
74
+ if (!store)
75
+ return;
76
+ const block = {};
77
+ store.register(block, ids.split('\n'));
78
+ return () => store.unregister(block);
79
+ }, [store, ids]);
80
+ const done = shared ? Object.fromEntries([...shared.done].map((id) => [id, true])) : local;
51
81
  const toggle = (id) => {
82
+ if (store)
83
+ return store.toggle(id);
52
84
  setDone((d) => {
53
85
  const on = !d[id];
54
86
  writeTick(key(id), on);
@@ -87,7 +119,7 @@ export function Checklist({ items, artifactId, send, owner, accent, }) {
87
119
  setSent({ status: 'error', message: 'that did not reach the page; try again' });
88
120
  }
89
121
  };
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) => {
122
+ 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: [_jsxs("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`, shared && shared.status !== 'local' ? _jsx(SyncLabel, { s: shared, color: color }) : null] }), _jsx("ul", { className: "m-0 list-none p-0", children: items.map((it) => {
91
123
  const on = !!done[it.id];
92
124
  const inputId = `checklist-${artifactId ?? 'page'}-${it.id}`;
93
125
  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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.22.0",
3
+ "version": "0.24.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",