@supersuit/artifacts 0.12.0 → 0.13.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,37 @@
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.13.0 (2026-09-28)
7
+
8
+ **A publisher can hear about new comments, and every comment has a link to itself.**
9
+
10
+ - `COMMENTS_FEED`: `GET /api/comments?since=<ISO with zone>`, publish key only. Shared comments
11
+ across the tenant's pages, oldest first, at most 200, `at > since`: `{ now, comments: [{
12
+ artifactId, title, entryId, parent, name, quote, region, body, transcript, audioUrl, at, link }] }`.
13
+ `name` null means an anonymous reader. Personal notes never appear (the privacy test calls it).
14
+ Comments on deleted pages are left out. Callers keep the last row's `at` as their cursor and
15
+ use `now` only on a first run.
16
+ - Deep link: `/<id>#comment-<entryId>` scrolls to the comment and opens its thread, on load and
17
+ on `hashchange`; a reply opens its parent's thread; an earlier-versions comment is highlighted.
18
+ - New optional `StateStore.slotSince(slot, after, limit)`; a store without it answers 501.
19
+ - DETECTOR: a host with no `/api/comments` route.
20
+ - REMEDY: bump; add `app/api/comments/route.ts` delegating GET to `COMMENTS_FEED`; create the
21
+ Firestore composite index on `<base>State` (collection scope): `slot` ascending, `at` ascending.
22
+ - Tests: `comments-feed.test.tsx`, five deep-link tests, the privacy test, and the packed
23
+ fixture reading a comment back through the feed. Since-exclusivity, the slot filter, the
24
+ personal-note exclusion, reply-to-parent and the hashchange listener are mutation-checked.
25
+
26
+ ## 0.12.1 (2026-09-28)
27
+
28
+ **Tapping into a comment box no longer zooms the page on iPhone.** iOS Safari zooms the page
29
+ when a text field under 16px takes focus, and leaves it zoomed. The comment card, the thread
30
+ reply box and the notes widget are 16px now (`NO_ZOOM_FONT`). Pinch-zoom is untouched.
31
+
32
+ - DETECTOR: on an iPhone, tapping the comment box zooms the page in.
33
+ - REMEDY: bump.
34
+ - Tests: `no-zoom.test.ts` scans every text field the reader and widgets draw, so a new field
35
+ without the size fails the suite (mutation-checked).
36
+
6
37
  ## 0.12.0 (2026-09-28)
7
38
 
8
39
  **Say a comment instead of typing it.** The comment card has a microphone. While you speak, a
package/README.md CHANGED
@@ -80,6 +80,7 @@ The rest, each exporting the named handler(s) from `artifacts`:
80
80
  | `app/api/reader/leave/route.ts` | `GET` = `LEAVE` |
81
81
  | `app/api/reader/ack/route.ts` | `POST` = `ACK` |
82
82
  | `app/api/reader/track/route.ts` | `POST` = `TRACK` |
83
+ | `app/api/comments/route.ts` | `GET` = `COMMENTS_FEED` (see [the comments feed](#the-comments-feed)) |
83
84
 
84
85
  Pages live at `/<id>` by default. Mounting inside a larger site, pass `pagePrefix: '/a/'` and put
85
86
  the page at `app/a/[id]/`. One deployment can serve many sites: build one set of routes per
@@ -486,6 +487,43 @@ The bucket also needs CORS allowing `PUT` from the site's origin with the `conte
486
487
  `cache-control` and `x-goog-content-length-range` headers, since the reader's browser uploads
487
488
  directly.
488
489
 
490
+ ### The comments feed
491
+
492
+ So the publisher hears about a shared comment without opening every page, one route lists every
493
+ shared comment on any of the host's pages after a time. Publish key only; personal notes never
494
+ appear (the privacy test covers this handler like every other).
495
+
496
+ ```
497
+ GET /api/comments?since=2026-09-28T12:00:00Z
498
+ Authorization: Bearer <publish key>
499
+
500
+ 200 { now, comments: [ { artifactId, title, entryId, parent, name, quote, region, body,
501
+ transcript, audioUrl, at, link } ] }
502
+ ```
503
+
504
+ Oldest first, at most 200, strictly after `since` (an ISO 8601 time with a zone; anything else is
505
+ a 400). `name` is null for an anonymous reader; `quote` is the text the comment is pinned to, or
506
+ null for a box (`region: true`); `parent` is the comment a reply answers; `audioUrl` is a signed
507
+ URL to the recording, when there is one and the host can sign it. `link` is the page with
508
+ `#comment-<entryId>`: opening it scrolls to the pin and opens its thread (a reply opens its
509
+ parent's; a comment on an earlier version is highlighted in that list). A caller keeps a cursor
510
+ at the last row's `at` and leaves it where it was when nothing came back; `now` is where a first
511
+ run starts, so it does not replay history.
512
+
513
+ The path sits outside `/api/artifacts/` because `comments` is itself a legal page id there.
514
+
515
+ ```ts
516
+ // app/api/comments/route.ts
517
+ import type { NextRequest } from 'next/server'
518
+ import { artifacts } from '@/lib/artifacts'
519
+ export function GET(request: NextRequest) { return artifacts.COMMENTS_FEED(request) }
520
+ ```
521
+
522
+ On Firestore (`createStateStore`) the query is one range scan over `<base>State` and needs a
523
+ composite index: **`slot` ascending, `at` ascending**, collection scope. Without it Firestore
524
+ refuses the query and the error names a link that creates the index. A host with its own
525
+ `StateStore` implements `slotSince(slot, after, limit)`; without it the feed answers 501.
526
+
489
527
  ## Brand packs
490
528
 
491
529
  A `BrandPack` is data plus at most two components: colours, type, the kicker line above a title,
@@ -62,6 +62,11 @@ export interface StateStore {
62
62
  entryId: string;
63
63
  value: unknown;
64
64
  }): Promise<StateEntry | null>;
65
+ /** Every entry in one slot across ALL of this tenant's pages written strictly after `after` (an
66
+ * ISO time, compared as the stored `at` string), oldest first, at most `limit`. The comments
67
+ * feed reads it. Optional so a host's own store keeps working; without it the feed answers 501.
68
+ * The Firestore query needs a composite index on <base>State: slot ASC, at ASC. */
69
+ slotSince?(slot: string, after: string, limit: number): Promise<StateEntry[]>;
65
70
  }
66
71
  export declare const readerKeyFor: {
67
72
  signedIn: (uid: string) => string;
@@ -101,6 +101,10 @@ export function createStateStore(db, base) {
101
101
  await ref.update({ json: JSON.stringify(value) });
102
102
  return { ...e, value };
103
103
  },
104
+ // One indexed range query over the tenant's whole collection: equality on slot, range and
105
+ // order on at. Needs the composite index (slot ASC, at ASC) on <base>State, or Firestore
106
+ // refuses the query with FAILED_PRECONDITION and a link that creates it.
107
+ slotSince: (slot, after, limit) => load(col().where('slot', '==', slot).where('at', '>', after).orderBy('at').limit(limit)),
104
108
  };
105
109
  return store;
106
110
  }
@@ -168,5 +172,10 @@ export function createMemoryStateStore() {
168
172
  docs.set(entryId, next);
169
173
  return structuredClone(next);
170
174
  },
175
+ slotSince: async (slot, after, limit) => [...docs.values()]
176
+ .filter((e) => e.slot === slot && e.at > after)
177
+ .sort((x, y) => x.at.localeCompare(y.at) || x.id.localeCompare(y.id))
178
+ .slice(0, limit)
179
+ .map((e) => structuredClone(e)),
171
180
  };
172
181
  }
@@ -10,6 +10,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
10
10
  // you speak, then the host's transcript when the recording stops, unless you have edited the box
11
11
  // since, in which case your words win. With neither, the memo is kept and the box stays empty.
12
12
  import { useRef, useState } from 'react';
13
+ import { NO_ZOOM_FONT } from './no-zoom.js';
13
14
  import { MAX_COMMENT_CHARS } from '../artifacts/comments.js';
14
15
  import { VoiceRecorder } from './voice-recorder.js';
15
16
  /** The warning, word for word. `They` is fixed copy: the card never guesses a pronoun. */
@@ -66,6 +67,6 @@ export function CommentCard({ ownerName, mode, canShare, signedIn, quote, initia
66
67
  setBusy(false);
67
68
  setError(err);
68
69
  };
69
- return (_jsxs("form", { "data-comment-card": true, "data-comment-ui": true, "data-nospeak": true, onSubmit: submit, className: "w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-left font-sans text-sm text-[color:var(--a-strong)] shadow-xl", children: [!canShare ? (_jsx("p", { "data-comment-warning": true, role: "note", className: "mb-2 rounded-lg border px-3 py-2 text-[13px] leading-snug", style: { borderColor: accent }, children: warningLine(ownerName, mode) })) : null, quote ? _jsx("p", { className: "mb-2 line-clamp-3 border-l-2 pl-2 text-[13px] italic opacity-75", style: { borderColor: accent }, children: quote }) : null, _jsx("textarea", { value: text, autoFocus: true, onChange: (e) => { setText(e.target.value); edited.current = true; setTranscript('typed'); }, onKeyDown: (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey))
70
+ return (_jsxs("form", { "data-comment-card": true, "data-comment-ui": true, "data-nospeak": true, onSubmit: submit, className: "w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-left font-sans text-sm text-[color:var(--a-strong)] shadow-xl", children: [!canShare ? (_jsx("p", { "data-comment-warning": true, role: "note", className: "mb-2 rounded-lg border px-3 py-2 text-[13px] leading-snug", style: { borderColor: accent }, children: warningLine(ownerName, mode) })) : null, quote ? _jsx("p", { className: "mb-2 line-clamp-3 border-l-2 pl-2 text-[13px] italic opacity-75", style: { borderColor: accent }, children: quote }) : null, _jsx("textarea", { value: text, autoFocus: true, style: NO_ZOOM_FONT, onChange: (e) => { setText(e.target.value); edited.current = true; setTranscript('typed'); }, onKeyDown: (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey))
70
71
  void submit(); }, maxLength: MAX_COMMENT_CHARS, rows: 3, "aria-label": personal ? 'A personal note' : `A comment for ${ownerName}`, placeholder: personal ? 'A note for yourself' : `A comment for ${ownerName}`, className: "w-full rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-ground,transparent)] p-2 text-[15px]" }), _jsx(VoiceRecorder, { accent: accent, onStart: () => { base.current = text.trim(); setMemo(null); setTranscript(undefined); setError(null); }, onLive: (heard) => { setText(base.current ? `${base.current} ${heard}` : heard); setTranscript('browser'); }, onStop: (m) => void recorded(m), onError: setError }), memo ? (_jsxs("p", { "data-voice-kept": true, className: "m-0 mt-1 text-xs opacity-70", children: [status ?? 'Recording kept with this comment.', _jsx("button", { type: "button", "data-voice-discard": true, className: "ml-2 underline", onClick: () => { setMemo(null); uploaded.current = null; }, children: "Discard recording" })] })) : null, canShare ? (_jsxs("fieldset", { className: "mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[13px]", children: [_jsx("legend", { className: "sr-only", children: "Who sees this" }), _jsxs("label", { className: "flex items-center gap-1.5", children: [_jsx("input", { type: "radio", name: "comment-share", "data-comment-share": "share", checked: share, onChange: () => setShare(true) }), `Share with ${ownerName}`] }), _jsxs("label", { className: "flex items-center gap-1.5", children: [_jsx("input", { type: "radio", name: "comment-share", "data-comment-share": "mine", checked: !share, onChange: () => setShare(false) }), "Just for me"] })] })) : null, personal && !signedIn ? (_jsx("p", { "data-comment-device": true, className: "mt-2 text-xs opacity-70", children: signIn ? _jsxs(_Fragment, { children: ["Saved on this device. ", _jsx("a", { href: signIn, className: "underline", children: "Sign in to keep it everywhere." })] }) : DEVICE_LINE })) : null, error ? _jsx("p", { role: "alert", className: "mt-2 text-xs text-amber-500", children: error }) : null, _jsxs("div", { className: "mt-3 flex items-center gap-3", children: [_jsx("button", { type: "submit", "data-comment-save": true, disabled: busy || (!text.trim() && !memo), className: "rounded-full px-4 py-1 text-sm font-medium disabled:opacity-40", style: { background: accent, color: 'var(--a-on-accent, #111)' }, children: personal ? 'Save note' : 'Comment' }), _jsx("button", { type: "button", onClick: onCancel, className: "text-sm opacity-70 hover:opacity-100", children: "Cancel" })] })] }));
71
72
  }
@@ -16,6 +16,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
16
16
  // deep and only on shared comments. Everything drawn here is data-nospeak and data-comment-ui, so
17
17
  // the read-aloud never reads it and the text index never counts it.
18
18
  //
19
+ // A link to `#comment-<entryId>` (what the publisher's notification carries) lands on the comment:
20
+ // on load and on every hashchange the pin is scrolled into view and its thread opened; a reply's
21
+ // id opens its parent's thread; a comment on an earlier version is scrolled to and highlighted.
22
+ //
19
23
  // Rendered empty on the server and on the first client render so the markup always hydrates; the
20
24
  // overlay is portalled onto document.body so its coordinates are page coordinates.
21
25
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -27,10 +31,14 @@ import { AudioPlay, hostVoice, newMemoId } from './voice-recorder.js';
27
31
  import { deviceAudio as defaultDeviceAudio, isDeviceAudio } from './device-audio.js';
28
32
  import { audioExtFor } from '../artifacts/audio.js';
29
33
  import { themeVarsAround } from './theme-vars.js';
34
+ import { NO_ZOOM_FONT } from './no-zoom.js';
30
35
  import { addDeviceNote, deviceNotes, removeDeviceNote, replaceDeviceNote } from './device-notes.js';
31
36
  import { boxOf, headingAt, indexText, offsetAt, placeAnchor, regionFrom } from './comment-place.js';
32
37
  const isValue = (v) => !!v && typeof v === 'object' && typeof v.body === 'string' && !!v.anchor && typeof v.anchor === 'object';
33
38
  const LONG_PRESS_MS = 400;
39
+ const HASH = /^#comment-([A-Za-z0-9_-]{1,128})$/;
40
+ /** The entry id a `#comment-<id>` hash names, or null. */
41
+ const commentHashId = (hash) => HASH.exec(hash)?.[1] ?? null;
34
42
  const DRAG_PX = 8;
35
43
  /** Where a drag never starts a box: controls, and anything that is somebody's words. */
36
44
  const NOT_FOR_BOXES = 'a, button, input, textarea, select, audio, video[controls], summary, label, [data-comment-ui], [data-note-toggle], [data-heading-notes], [data-artifact-notes], .artifact-defined-term, .artifact-word';
@@ -84,6 +92,11 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
84
92
  const [drag, setDrag] = useState(null);
85
93
  const [tick, setTick] = useState(0);
86
94
  const [theme, setTheme] = useState({});
95
+ /** The comment the address names, until it has been found and shown. */
96
+ const [target, setTarget] = useState(null);
97
+ const [highlight, setHighlight] = useState(null);
98
+ /** A selector to scroll into view once the render that draws it has committed. */
99
+ const [scrollTo, setScrollTo] = useState(null);
87
100
  const call = useCallback(async (url, init) => {
88
101
  try {
89
102
  const r = await fetch(url, { credentials: 'same-origin', cache: 'no-store', ...init });
@@ -281,6 +294,14 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
281
294
  root.removeEventListener('dragstart', noImageDrag);
282
295
  };
283
296
  }, [rootId]);
297
+ // The address names a comment: on load, and every time the hash changes while the page is open.
298
+ useEffect(() => {
299
+ const read = () => { const id = commentHashId(window.location.hash); if (id)
300
+ setTarget(id); };
301
+ read();
302
+ window.addEventListener('hashchange', read);
303
+ return () => window.removeEventListener('hashchange', read);
304
+ }, []);
284
305
  const items = useMemo(() => [...shared, ...serverNotes, ...device], [shared, serverNotes, device]);
285
306
  const tops = useMemo(() => items.filter((i) => !i.value.parent || !items.some((p) => p.id === i.value.parent)), [items]);
286
307
  // Where each top-level comment sits on this version, or that it is on an earlier one.
@@ -317,6 +338,37 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
317
338
  return { placed, earlier };
318
339
  // `tick` re-measures after a resize.
319
340
  }, [mounted, rootId, tops, tick]); // eslint-disable-line react-hooks/exhaustive-deps
341
+ // Show the comment the address names once it has loaded: a reply is shown in its parent's thread.
342
+ // Held until the comments arrive; an id that never arrives (deleted, or not this reader's to see)
343
+ // simply does nothing.
344
+ useEffect(() => {
345
+ if (!target || !mounted)
346
+ return;
347
+ const item = items.find((i) => i.id === target);
348
+ if (!item)
349
+ return;
350
+ const parent = item.value.parent ? items.find((i) => i.id === item.value.parent) : undefined;
351
+ const id = (parent ?? item).id;
352
+ setTarget(null);
353
+ if (layout.placed.some((p) => p.item.id === id)) {
354
+ setShowPins(true);
355
+ setHighlight(null);
356
+ setThread(id);
357
+ setScrollTo(`[data-comment-pin][data-comment-id="${id}"]`);
358
+ }
359
+ else if (layout.earlier.some((i) => i.id === id)) {
360
+ setThread(null);
361
+ setHighlight(id);
362
+ setScrollTo(`[data-comment-earlier-id="${id}"]`);
363
+ }
364
+ }, [target, mounted, items, layout]);
365
+ // Effects run after the DOM is committed, so the pin or list item drawn for the target is there.
366
+ useEffect(() => {
367
+ if (!scrollTo)
368
+ return;
369
+ document.querySelector(scrollTo)?.scrollIntoView?.({ block: 'center', behavior: 'smooth' });
370
+ setScrollTo(null);
371
+ }, [scrollTo]);
320
372
  /** The recording's path for where it is being saved: reused when the card already uploaded it
321
373
  * there, uploaded now when the reader switched between Share and Just for me after recording. */
322
374
  const memoPath = async (m, scope) => m.uploaded?.scope === scope ? m.uploaded.path : host.upload(m.memo, scope);
@@ -413,7 +465,7 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
413
465
  const openItem = open?.item ?? layout.earlier.find((i) => i.id === thread);
414
466
  const count = tops.length;
415
467
  const overlay = mounted ? (_jsxs("div", { "data-comment-ui": true, "data-nospeak": true, className: "font-sans", style: theme, children: [showPins ? layout.placed.map(({ item, left, top, box }) => (_jsxs("div", { children: [box ? (_jsx("div", { "data-comment-box": item.id, "aria-hidden": true, className: "pointer-events-none absolute z-30 rounded-md border-2", style: { left: box.left, top: box.top, width: box.width, height: box.height, borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid', opacity: thread === item.id ? 1 : 0.55 } })) : null, _jsx(Pin, { item: item, left: left, top: top, accent: accent, ground: ground, onOpen: () => setThread(thread === item.id ? null : item.id) })] }, item.id))) : null, chip && !draft ? (_jsx("button", { type: "button", "data-comment-chip": true, className: "absolute z-50 -translate-x-full rounded-full px-3 py-1 text-xs font-medium shadow-lg", style: { left: chip.x, top: chip.y, background: accent, color: 'var(--a-on-accent, #111)' }, onMouseDown: (e) => e.preventDefault(), onClick: () => { setDraft(chip); setChip(null); }, children: "Comment" })) : null, draft ? (_jsx(Floating, { x: draft.x, y: draft.y, children: _jsx(CommentCard, { ownerName: ownerName, mode: comments, canShare: canShare, signedIn: signedIn && onServer, quote: draft.quote, accent: accent, signIn: signIn, voice: host, onSave: (body, share, memo) => saveNew(body, share, draft, memo), onCancel: () => setDraft(null) }) })) : null, openItem && open ? (_jsx(Floating, { x: open.left + 28, y: open.top, children: _jsx(Thread, { item: openItem, replies: items.filter((i) => i.value.parent === openItem.id), canReply: openItem.kind === 'shared' && canShare, accent: accent, audioDb: audioDb, onClose: () => setThread(null), onRemove: remove, onEdit: edit, onReply: reply }) })) : null, drag ? (_jsx("div", { "aria-hidden": true, className: "pointer-events-none fixed z-40 rounded-md border-2 border-dashed", style: { left: Math.min(drag.x1, drag.x2), top: Math.min(drag.y1, drag.y2), width: Math.abs(drag.x2 - drag.x1), height: Math.abs(drag.y2 - drag.y1), borderColor: accent, background: `color-mix(in srgb, ${accent} 12%, transparent)` } })) : null, _jsxs("div", { className: "fixed right-4 z-40 flex flex-col items-end gap-2", style: { bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)' }, children: [panel ? (_jsxs("div", { "data-comment-panel": true, className: "w-64 rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-xs text-[color:var(--a-strong)] shadow-xl", children: [_jsx("p", { className: "opacity-80", children: "Select any text, or hold and drag a box over anything, to leave a comment." }), isOwner ? _jsx("p", { "data-comment-owner": true, className: "mt-1 opacity-80", children: "This is your page: you see every comment, with the name of who left it." }) : null, _jsx("button", { type: "button", "data-comment-pins-toggle": true, className: "mt-2 underline", onClick: () => setShowPins((v) => !v), children: showPins ? 'Hide comments' : 'Show comments' })] })) : null, _jsx("button", { type: "button", "data-comment-button": true, "aria-expanded": panel, onClick: () => setPanel((v) => !v), className: "rounded-full border px-3 py-1.5 text-xs font-medium shadow-lg backdrop-blur", style: { borderColor: accent, color: accent, background: `color-mix(in srgb, ${ground} 85%, transparent)` }, children: count ? `Comments · ${count}` : 'Comment' })] })] })) : null;
416
- return (_jsxs(_Fragment, { children: [_jsx("span", { hidden: true, "data-comment-layer": comments, "data-comment-can-share": canShare ? 'yes' : 'no' }), mounted && layout.earlier.length ? (_jsxs("section", { "data-comments-earlier": true, "data-comment-ui": true, "data-nospeak": true, className: "mx-auto max-w-2xl px-6 pb-16 text-sm", children: [_jsx("p", { className: "text-[11px] uppercase tracking-[0.2em]", style: { color: accent }, children: "Comments on earlier versions" }), _jsx("ul", { className: "m-0 mt-2 grid list-none gap-3 p-0", children: layout.earlier.map((item) => (_jsxs("li", { className: "m-0 border-l-2 py-1 pl-3", style: { borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid' }, children: [item.value.anchor.kind === 'text' ? _jsxs("span", { className: "block text-[13px] italic opacity-70", children: ["\u201C", item.value.anchor.quote, "\u201D"] }) : _jsx("span", { className: "block text-[13px] italic opacity-70", children: "A box on part of the page that has changed" }), _jsx("span", { className: "block whitespace-pre-wrap text-[15px] text-[color:var(--a-strong)]", children: item.value.body }), _jsxs("span", { className: "block text-xs opacity-60", children: [item.kind === 'personal' ? 'only you' : item.name, ` · version ${item.value.version}`, item.mine ? _jsx("button", { type: "button", onClick: () => void remove(item), className: "ml-2 underline", children: "delete" }) : null] })] }, item.id))) })] })) : null, overlay ? createPortal(overlay, document.body) : null] }));
468
+ return (_jsxs(_Fragment, { children: [_jsx("span", { hidden: true, "data-comment-layer": comments, "data-comment-can-share": canShare ? 'yes' : 'no' }), mounted && layout.earlier.length ? (_jsxs("section", { "data-comments-earlier": true, "data-comment-ui": true, "data-nospeak": true, className: "mx-auto max-w-2xl px-6 pb-16 text-sm", children: [_jsx("p", { className: "text-[11px] uppercase tracking-[0.2em]", style: { color: accent }, children: "Comments on earlier versions" }), _jsx("ul", { className: "m-0 mt-2 grid list-none gap-3 p-0", children: layout.earlier.map((item) => (_jsxs("li", { "data-comment-earlier-id": item.id, ...(highlight === item.id ? { 'data-comment-highlight': '' } : {}), className: "m-0 border-l-2 py-1 pl-3", style: { borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid', ...(highlight === item.id ? { background: `color-mix(in srgb, ${accent} 14%, transparent)` } : {}) }, children: [item.value.anchor.kind === 'text' ? _jsxs("span", { className: "block text-[13px] italic opacity-70", children: ["\u201C", item.value.anchor.quote, "\u201D"] }) : _jsx("span", { className: "block text-[13px] italic opacity-70", children: "A box on part of the page that has changed" }), _jsx("span", { className: "block whitespace-pre-wrap text-[15px] text-[color:var(--a-strong)]", children: item.value.body }), _jsxs("span", { className: "block text-xs opacity-60", children: [item.kind === 'personal' ? 'only you' : item.name, ` · version ${item.value.version}`, item.mine ? _jsx("button", { type: "button", onClick: () => void remove(item), className: "ml-2 underline", children: "delete" }) : null] })] }, item.id))) })] })) : null, overlay ? createPortal(overlay, document.body) : null] }));
417
469
  }
418
470
  function Pin({ item, left, top, accent, ground, onOpen }) {
419
471
  const personal = item.kind === 'personal';
@@ -462,5 +514,5 @@ function InlineForm({ label, initial = '', accent = 'currentColor', onSubmit, on
462
514
  const [text, setText] = useState(initial);
463
515
  const [error, setError] = useState(null);
464
516
  return (_jsxs("form", { className: "mt-2", onSubmit: async (e) => { e.preventDefault(); if (text.trim())
465
- setError(await onSubmit(text.trim())); }, children: [_jsx("textarea", { value: text, autoFocus: true, rows: 2, onChange: (e) => setText(e.target.value), "aria-label": label, className: "w-full rounded-lg border border-[color:var(--a-line)] bg-transparent p-2 text-[15px]" }), error ? _jsx("p", { role: "alert", className: "text-xs text-amber-500", children: error }) : null, _jsxs("div", { className: "mt-1 flex gap-3 text-xs", children: [_jsx("button", { type: "submit", disabled: !text.trim(), className: "rounded-full px-3 py-0.5 font-medium disabled:opacity-40", style: { background: accent, color: 'var(--a-on-accent, #111)' }, children: label }), _jsx("button", { type: "button", onClick: onCancel, className: "opacity-70", children: "Cancel" })] })] }));
517
+ setError(await onSubmit(text.trim())); }, children: [_jsx("textarea", { value: text, autoFocus: true, rows: 2, onChange: (e) => setText(e.target.value), "aria-label": label, className: "w-full rounded-lg border border-[color:var(--a-line)] bg-transparent p-2", style: NO_ZOOM_FONT }), error ? _jsx("p", { role: "alert", className: "text-xs text-amber-500", children: error }) : null, _jsxs("div", { className: "mt-1 flex gap-3 text-xs", children: [_jsx("button", { type: "submit", disabled: !text.trim(), className: "rounded-full px-3 py-0.5 font-medium disabled:opacity-40", style: { background: accent, color: 'var(--a-on-accent, #111)' }, children: label }), _jsx("button", { type: "button", onClick: onCancel, className: "opacity-70", children: "Cancel" })] })] }));
466
518
  }
@@ -0,0 +1,3 @@
1
+ export declare const NO_ZOOM_FONT: {
2
+ readonly fontSize: 16;
3
+ };
@@ -0,0 +1,6 @@
1
+ // iOS Safari zooms the whole page when a text field under 16px takes focus, and does not zoom
2
+ // back out, so a reader who taps into a comment box loses the page they were commenting on
3
+ // (reported from an iPhone on 2026-09-28). Every text field the reader draws uses this size.
4
+ // A viewport `maximum-scale` would also stop the zoom, but it takes pinch-zoom away from readers
5
+ // who need it, so the size is the fix.
6
+ export const NO_ZOOM_FONT = { fontSize: 16 };
@@ -112,6 +112,7 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
112
112
  id: string;
113
113
  }>;
114
114
  }) => Promise<NextResponse<unknown>>;
115
+ COMMENTS_FEED: (req: NextRequest) => Promise<NextResponse<unknown>>;
115
116
  Page: ({ params, searchParams }: PageProps) => Promise<import("react").JSX.Element>;
116
117
  generateMetadata: ({ params }: Params) => Promise<Metadata>;
117
118
  VersionPage: ({ params, searchParams }: VersionProps) => Promise<import("react").JSX.Element>;
@@ -3,6 +3,12 @@ import { type StateStore } from '../artifacts/state-store.js';
3
3
  import type { ArtifactStore } from '../artifacts/store.js';
4
4
  import type { ReadersStore } from '../artifacts/readers-store.js';
5
5
  export declare const ANON_COOKIE = "artifact_anon";
6
+ /** The most comments one feed answer carries. A caller that gets this many moves its cursor to the
7
+ * last row's `at` and asks again. */
8
+ export declare const FEED_MAX = 200;
9
+ /** `since` as the stored `at` form (UTC, milliseconds), or null. A `+` offset left unencoded in a
10
+ * query string arrives as a space, so that one spelling is read back as the `+` it was. */
11
+ export declare function feedSince(raw: string | null): string | null;
6
12
  /** The id an anonymous writer's rate counter is stored under. An HMAC over the site and the IP,
7
13
  * keyed by the reader secret when the host has one (so the stored value cannot be reversed by
8
14
  * hashing the IPv4 space), else by the site URL. The site is in the message either way, so two
@@ -37,5 +43,6 @@ export declare function createStateRoutes(ctx: StateRoutesContext): {
37
43
  STATE_GET: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown> | undefined>;
38
44
  STATE_POST: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown> | undefined>;
39
45
  RESPONSES: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
46
+ COMMENTS_FEED: (req: NextRequest) => Promise<NextResponse<unknown>>;
40
47
  };
41
48
  export {};
@@ -4,6 +4,7 @@
4
4
  // POST /api/artifacts/<id>/state { slot, op: set|append|remove|replace, value?, entry? }
5
5
  // GET /api/artifacts/<id>/responses publish key; every answer with who (?format=csv)
6
6
  // DELETE /api/artifacts/<id>/responses?reader=<key> publish key; one reader's answers
7
+ // GET /api/comments?since=<ISO> publish key; every shared comment on any page after a time
7
8
  //
8
9
  // Who is writing: the grant a gated page already uses, or on a page with `writers: anyone`, an
9
10
  // anonymous id in an HttpOnly cookie. When both are present the anonymous answers move to the
@@ -23,6 +24,21 @@ import { responsesCsv, responsesOf, stateView } from '../artifacts/state-view.js
23
24
  export const ANON_COOKIE = 'artifact_anon';
24
25
  const ANON_ID = /^[A-Za-z0-9]{24}$/;
25
26
  const MAX_BODY = 16 * 1024;
27
+ /** The most comments one feed answer carries. A caller that gets this many moves its cursor to the
28
+ * last row's `at` and asks again. */
29
+ export const FEED_MAX = 200;
30
+ // An ISO 8601 time with a zone: 2026-09-28T12:00Z, 2026-09-28T12:00:00.123+02:00. A date alone, or
31
+ // a time with no zone, is refused rather than read in the server's own zone.
32
+ const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:?\d{2})$/;
33
+ /** `since` as the stored `at` form (UTC, milliseconds), or null. A `+` offset left unencoded in a
34
+ * query string arrives as a space, so that one spelling is read back as the `+` it was. */
35
+ export function feedSince(raw) {
36
+ const v = (raw ?? '').trim().replace(/ (\d{2}:?\d{2})$/, '+$1');
37
+ if (!ISO_TIME.test(v))
38
+ return null;
39
+ const d = new Date(v);
40
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
41
+ }
26
42
  /** The id an anonymous writer's rate counter is stored under. An HMAC over the site and the IP,
27
43
  * keyed by the reader secret when the host has one (so the stored value cannot be reversed by
28
44
  * hashing the IPv4 space), else by the site URL. The site is in the message either way, so two
@@ -295,5 +311,42 @@ export function createStateRoutes(ctx) {
295
311
  await withAudioUrls(id, rows.filter((r) => r.slot === COMMENTS_SLOT));
296
312
  return json({ id, title: a.title, responses: rows });
297
313
  }
298
- return { STATE_GET, STATE_POST, RESPONSES };
314
+ /** GET /api/comments?since=<ISO>: every SHARED comment on any of this host's pages written after
315
+ * `since` (exclusive), oldest first, at most FEED_MAX, each with a link that opens it on the
316
+ * page. It reads the `comments` slot of the state core only; personal notes live in a store no
317
+ * publisher route reads, so they cannot appear here. The publisher's own notifier polls it. */
318
+ async function COMMENTS_FEED(req) {
319
+ if (!isPublishAuthed(req, ctx.publishKey()))
320
+ return json({ error: 'Unauthorized' }, 401);
321
+ const since = feedSince(req.nextUrl.searchParams.get('since'));
322
+ if (!since)
323
+ return json({ error: 'since must be an ISO 8601 time with a zone, like 2026-09-28T12:00:00Z' }, 400);
324
+ if (!ctx.state)
325
+ return json({ error: 'this host keeps no answers' }, 501);
326
+ if (!ctx.state.slotSince)
327
+ return json({ error: 'this host cannot list comments by time' }, 501);
328
+ const now = new Date().toISOString();
329
+ const entries = await ctx.state.slotSince(COMMENTS_SLOT, since, FEED_MAX);
330
+ const pages = new Map();
331
+ const pageOf = (id) => { if (!pages.has(id))
332
+ pages.set(id, ctx.store.get(id)); return pages.get(id); };
333
+ const comments = [];
334
+ for (const e of entries) {
335
+ const a = await pageOf(e.artifactId);
336
+ // A page deleted since the comment was left has nowhere for the link to land.
337
+ if (!a)
338
+ continue;
339
+ const v = e.value;
340
+ const audio = isCommentAudioPath(v.audio) && ctx.audioUrl ? await ctx.audioUrl(e.artifactId, v.audio) : null;
341
+ comments.push({
342
+ artifactId: e.artifactId, title: a.title, entryId: e.id, parent: v.parent ?? null,
343
+ name: e.writer.anonymous ? null : e.writer.name?.trim() || 'a signed-in reader',
344
+ quote: v.anchor?.kind === 'text' ? v.anchor.quote : null, region: v.anchor?.kind === 'region',
345
+ body: v.body, transcript: v.transcript ?? null, audioUrl: audio, at: e.at,
346
+ link: `${ctx.pageUrl(e.artifactId)}#comment-${e.id}`,
347
+ });
348
+ }
349
+ return json({ now, comments });
350
+ }
351
+ return { STATE_GET, STATE_POST, RESPONSES, COMMENTS_FEED };
299
352
  }
@@ -8,6 +8,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
8
8
  // Everything drawn here carries data-nospeak: narration reads the prose, never the notes, and
9
9
  // the read-along highlighter skips the same nodes. Notes are shown as text, never as markup.
10
10
  import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
11
+ import { NO_ZOOM_FONT } from '../reader/no-zoom.js';
11
12
  import { MAX_NOTE_CHARS, NOTES_SLOT, placeNotes } from '../artifacts/notes-place.js';
12
13
  const NotesContext = createContext(null);
13
14
  const isNote = (v) => !!v && typeof v === 'object' && typeof v.slug === 'string' &&
@@ -112,7 +113,7 @@ function NoteForm({ slug, heading, c }) {
112
113
  if (!c.canWrite) {
113
114
  return c.signIn ? (_jsx("p", { className: "my-2 text-sm", children: _jsx("a", { href: c.signIn, className: "underline", style: { color: c.accent }, children: "Sign in to leave a note" }) })) : (_jsx("p", { className: "my-2 text-sm opacity-70", children: "Notes here are open to signed-in readers." }));
114
115
  }
115
- return (_jsxs("form", { onSubmit: submit, className: "my-2", children: [_jsx("textarea", { value: text, onChange: (e) => setText(e.target.value), maxLength: MAX_NOTE_CHARS, rows: 3, "aria-label": `A note on ${heading}`, placeholder: `A note on “${heading}”`, className: "w-full rounded-lg border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-[15px] text-[color:var(--a-strong)]" }), c.error ? _jsx("p", { className: "mt-1 text-xs text-amber-500", children: c.error }) : null, _jsxs("div", { className: "mt-2 flex gap-3", children: [_jsx("button", { type: "submit", disabled: c.busy || !text.trim(), className: "rounded-full px-4 py-1 text-sm font-medium disabled:opacity-40", style: { background: c.accent, color: 'var(--a-on-accent, #111)' }, children: "Save note" }), _jsx("button", { type: "button", onClick: () => c.setOpen(null), className: "text-sm opacity-70 hover:opacity-100", children: "Cancel" })] })] }));
116
+ return (_jsxs("form", { onSubmit: submit, className: "my-2", children: [_jsx("textarea", { value: text, onChange: (e) => setText(e.target.value), style: NO_ZOOM_FONT, maxLength: MAX_NOTE_CHARS, rows: 3, "aria-label": `A note on ${heading}`, placeholder: `A note on “${heading}”`, className: "w-full rounded-lg border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-[15px] text-[color:var(--a-strong)]" }), c.error ? _jsx("p", { className: "mt-1 text-xs text-amber-500", children: c.error }) : null, _jsxs("div", { className: "mt-2 flex gap-3", children: [_jsx("button", { type: "submit", disabled: c.busy || !text.trim(), className: "rounded-full px-4 py-1 text-sm font-medium disabled:opacity-40", style: { background: c.accent, color: 'var(--a-on-accent, #111)' }, children: "Save note" }), _jsx("button", { type: "button", onClick: () => c.setOpen(null), className: "text-sm opacity-70 hover:opacity-100", children: "Cancel" })] })] }));
116
117
  }
117
118
  /** After each heading: its notes, and the box when its control is open. */
118
119
  export function HeadingNotes({ slug, text }) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.12.0",
3
+ "version": "0.13.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",