@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 +29 -0
- package/README.md +20 -3
- package/lib/artifacts/checklist.d.ts +3 -2
- package/lib/artifacts/checklist.js +19 -6
- package/lib/artifacts/index.d.ts +1 -1
- package/lib/artifacts/index.js +1 -1
- package/lib/artifacts/render.d.ts +2 -1
- package/lib/artifacts/render.js +5 -3
- package/lib/artifacts/widgets.d.ts +12 -4
- package/lib/artifacts/widgets.js +37 -7
- package/lib/routes/state-routes.js +2 -2
- package/lib/widgets/checklist-sync.d.ts +54 -0
- package/lib/widgets/checklist-sync.js +255 -0
- package/lib/widgets/checklist.d.ts +3 -1
- package/lib/widgets/checklist.js +37 -5
- package/package.json +1 -1
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,
|
|
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
|
|
50
|
-
*
|
|
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
|
|
90
|
-
|
|
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
|
|
128
|
-
*
|
|
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] }';
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -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';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -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).
|
|
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;
|
package/lib/artifacts/render.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
35
|
-
* the
|
|
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;
|
package/lib/artifacts/widgets.js
CHANGED
|
@@ -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.
|
|
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.
|
|
146
|
-
* the
|
|
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
|
|
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
|
-
|
|
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,
|
|
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 ?
|
|
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;
|
package/lib/widgets/checklist.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 [
|
|
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: [
|
|
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.
|
|
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",
|