@supersuit/artifacts 0.22.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,35 @@
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
+
6
35
  ## 0.22.0
7
36
 
8
37
  **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] }';
@@ -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)
@@ -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
@@ -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.23.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",