@supersuit/artifacts 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,127 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // The checklist widget in the reader's browser. Each tick is kept in localStorage under the page
4
+ // and the item id (checklist.ts, checklistStorageKey), so it survives a reload, a republish and a
5
+ // reworded item, and never leaves the device unless the reader presses Send. Storage that throws
6
+ // (a private window, blocked site data) costs the reader only the memory: ticks still work for
7
+ // the visit.
8
+ //
9
+ // The item text and description arrive already drawn on the server, as markdown with raw HTML
10
+ // escaped, so this file draws no author content itself. Everything here is data-nospeak: the
11
+ // narrator reads the prose, never the checklist.
12
+ //
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';
17
+ import { CHECKLIST_SLOT, checklistStorageKey } from '../artifacts/checklist.js';
18
+ import { SERVER_SYNC_SNAPSHOT, syncStoreFor } from './checklist-sync.js';
19
+ function readTick(key) {
20
+ if (!key)
21
+ return false;
22
+ try {
23
+ return globalThis.localStorage?.getItem(key) === '1';
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ function writeTick(key, on) {
30
+ if (!key)
31
+ return;
32
+ try {
33
+ if (on)
34
+ globalThis.localStorage?.setItem(key, '1');
35
+ else
36
+ globalThis.localStorage?.removeItem(key);
37
+ }
38
+ catch {
39
+ // Storage blocked: the tick lives for this visit only.
40
+ }
41
+ }
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, }) {
54
+ const key = useCallback((id) => (artifactId ? checklistStorageKey(artifactId, id) : null), [artifactId]);
55
+ const [local, setDone] = useState({});
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);
59
+ // Read after mount, never during render: the server has no storage, and reading it while
60
+ // hydrating would draw a page that disagrees with the server's.
61
+ const ids = items.map((it) => it.id).join('\n');
62
+ useEffect(() => {
63
+ if (store)
64
+ return;
65
+ const next = {};
66
+ for (const id of ids.split('\n'))
67
+ if (readTick(key(id)))
68
+ next[id] = true;
69
+ setDone(next);
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;
81
+ const toggle = (id) => {
82
+ if (store)
83
+ return store.toggle(id);
84
+ setDone((d) => {
85
+ const on = !d[id];
86
+ writeTick(key(id), on);
87
+ const next = { ...d };
88
+ if (on)
89
+ next[id] = true;
90
+ else
91
+ delete next[id];
92
+ return next;
93
+ });
94
+ setSent((s) => (s.status === 'sent' ? { status: 'idle' } : s));
95
+ };
96
+ const count = items.filter((it) => done[it.id]).length;
97
+ const color = accent ?? 'var(--a-accent)';
98
+ const doSend = async () => {
99
+ if (!artifactId)
100
+ return;
101
+ setSent({ status: 'busy' });
102
+ try {
103
+ const r = await fetch(`/api/artifacts/${artifactId}/state`, {
104
+ method: 'POST',
105
+ credentials: 'same-origin',
106
+ cache: 'no-store',
107
+ headers: { 'content-type': 'application/json' },
108
+ body: JSON.stringify({ slot: CHECKLIST_SLOT, op: 'set', value: { done: items.filter((it) => done[it.id]).map((it) => it.id) } }),
109
+ });
110
+ const body = (await r.json().catch(() => ({})));
111
+ if (r.ok)
112
+ setSent({ status: 'sent' });
113
+ else if (r.status === 401)
114
+ setSent({ status: 'sign-in', signIn: typeof body.signIn === 'string' ? body.signIn : null });
115
+ else
116
+ setSent({ status: 'error', message: typeof body.error === 'string' ? body.error : 'that did not send; try again' });
117
+ }
118
+ catch {
119
+ setSent({ status: 'error', message: 'that did not reach the page; try again' });
120
+ }
121
+ };
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) => {
123
+ const on = !!done[it.id];
124
+ const inputId = `checklist-${artifactId ?? 'page'}-${it.id}`;
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));
126
+ }) }), send && artifactId ? (_jsxs("div", { className: "border-t border-[color:var(--a-line)] px-4 py-3", children: [_jsx("button", { type: "button", "data-checklist-send": true, disabled: sent.status === 'busy', onClick: () => void doSend(), className: "rounded-full px-5 text-sm font-medium disabled:opacity-40", style: { background: color, color: 'var(--a-on-accent, #111)', minHeight: 44 }, children: owner ? `Send my progress to ${owner}` : 'Send my progress' }), sent.status === 'sent' ? _jsx("p", { "data-checklist-sent": true, className: "mt-2 text-sm opacity-80", children: `Sent: ${count} of ${items.length} done.` }) : null, sent.status === 'error' ? _jsx("p", { className: "mt-2 text-sm text-amber-500", children: sent.message }) : null, sent.status === 'sign-in' ? (sent.signIn ? (_jsx("p", { className: "mt-2 text-sm", children: _jsx("a", { href: sent.signIn, className: "underline", style: { color }, children: "Sign in to send" }) })) : (_jsx("p", { className: "mt-2 text-sm opacity-70", children: "Sending is open to signed-in readers." }))) : null] })) : null] }));
127
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.21.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",