@supersuit/artifacts 0.32.0 → 0.33.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,44 @@
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.33.0
7
+
8
+ **A comment thread can be resolved** (Gary, 2026-10-12: "Are you going to resolve the comments at
9
+ some point?"). Until now a shared comment could only be replied to, so a page whose every comment
10
+ had been dealt with still read as a page full of open questions, and the publisher's notifier kept
11
+ listing them.
12
+
13
+ - `POST /api/artifacts/<id>/resolve` (`RESOLVE`), body `{ entry, resolved?, note?, by? }`: the
14
+ publish key or a publisher pass (an agent resolves with the credential it publishes with), or a
15
+ reader signed in as `ownerEmail` or one of `admins`, same-origin JSON. Nobody else: 401 signed
16
+ out, 403 signed in, before the page is looked up. `entry` may name a reply; the thread's top
17
+ comment is what is resolved. `resolved: false` reopens. `note` is one line of at most 280.
18
+ - Recorded on the top comment's value as `resolved: { at, by, note? }`, where `by` is a first name
19
+ or what the publisher calls itself, never an email. A reader cannot write the field (the
20
+ comment check refuses it), and an edit keeps it.
21
+ - On the page a resolved thread draws a muted check pin with no box, opens collapsed to
22
+ "Resolved: <note>" with the comment and replies a tap away, and leaves the Comments count. The
23
+ owner and admins get Resolve and Reopen in every shared thread.
24
+ - The comments feed carries `resolved` on every row (a reply carries its thread's), takes
25
+ `&open=1` to leave resolved threads out (a reply written after the resolution stays in), and
26
+ answers `cursor` and `more` so a filtered caller can page.
27
+ - `StateStore.setResolved` (optional), implemented by `createStateStore` and
28
+ `createMemoryStateStore`.
29
+ - DETECTOR: `POST /api/artifacts/<id>/resolve` with the publish key answers 404 from the host's
30
+ router (no route file), or the feed's rows have no `resolved` key: 0.32.0 or earlier.
31
+ - REMEDY: update the dependency and add `app/api/artifacts/[id]/resolve/route.ts` exporting
32
+ `POST` delegating to `RESOLVE`. A host with its own `StateStore` implements `setResolved`, or the
33
+ route answers 501. No index or data migration: an open thread is one with no `resolved`.
34
+ - Tests: `routes/resolve.test.tsx` (anonymous and signed-in non-admin refused, the comment's own
35
+ writer refused; owner, admin, publish key and publisher pass allowed; origin and JSON for the
36
+ cookie path; note length and lines; a reply resolves its thread; reopen; 501s; readers and the
37
+ writer shown the resolution; a forged `resolved` refused; an edit keeps it; the feed's field,
38
+ `open=1`, `cursor`), `artifacts/comments.test.ts` (note rules, `resolutionOf`),
39
+ `reader/comment-layer.test.tsx` (muted pin, no box, open count, collapsed line, expand, owner
40
+ Resolve and Reopen post the route, a reader gets neither, an edit never sends `resolved`),
41
+ `routes/publisher-pass.test.tsx` (RESOLVE takes a pass exactly as the key), and `check-packed`
42
+ (the tarball resolves through the fixture's route, the feed carries it, `open=1` drops it).
43
+
6
44
  ## 0.32.0
7
45
 
8
46
  **The page's owner and the hub's admins always get past a locked page's door** (Gary, 2026-10-11).
package/README.md CHANGED
@@ -83,6 +83,7 @@ The rest, each exporting the named handler(s) from `artifacts`:
83
83
  | `app/api/reader/leave/route.ts` | `GET` = `LEAVE` |
84
84
  | `app/api/reader/ack/route.ts` | `POST` = `ACK` |
85
85
  | `app/api/reader/track/route.ts` | `POST` = `TRACK` |
86
+ | `app/api/artifacts/[id]/resolve/route.ts` | `POST` = `RESOLVE` (see [resolving a thread](#resolving-a-thread)) |
86
87
  | `app/api/comments/route.ts` | `GET` = `COMMENTS_FEED` (see [the comments feed](#the-comments-feed)) |
87
88
 
88
89
  Pages live at `/<id>` by default. Mounting inside a larger site, pass `pagePrefix: '/a/'` and put
@@ -702,6 +703,48 @@ The bucket also needs CORS allowing `PUT` from the site's origin with the `conte
702
703
  `cache-control` and `x-goog-content-length-range` headers, since the reader's browser uploads
703
704
  directly.
704
705
 
706
+ ### Resolving a thread
707
+
708
+ When what a comment asked for has been done, the thread is resolved. On the page it then draws a
709
+ muted check pin with no box over the page, opens collapsed to one line, **"Resolved: <note>"** (or
710
+ **"Resolved"**), with the comment and its replies a tap away, and leaves the Comments count. The
711
+ reader who left the comment sees the note. The owner and the hub's admins get **Resolve** (with an
712
+ optional note) and **Reopen** in every shared thread.
713
+
714
+ ```
715
+ POST /api/artifacts/<id>/resolve
716
+ Authorization: Bearer <publish key or publisher pass>
717
+ Content-Type: application/json
718
+
719
+ { "entry": "<entryId>", "note": "Named the river in the second paragraph.", "by": "Pat's agent" }
720
+
721
+ 200 { id, entry, resolved: { at, by, note? } }
722
+ ```
723
+
724
+ - **Who:** the publish key or a publisher pass, so an agent resolves with the credential it
725
+ publishes with; or a reader signed in as `ownerEmail` or one of `admins`, from this host's own
726
+ pages, as JSON. Anyone else is refused (401 signed out, 403 signed in) before the page is looked
727
+ up. Leaving a comment does not make you able to resolve it.
728
+ - **`entry`** is a comment's entry id, as the feed and `/responses` give it. Naming a reply
729
+ resolves the thread it belongs to; the answer's `entry` is that thread's top comment, which is
730
+ where `resolved` is stored (in the comment's value, beside `body`).
731
+ - **`note`** is optional: one line, at most 280 characters, saying what changed. **`by`** names the
732
+ resolver and is the publisher's to set (one line, at most 80; default "the publisher"); a signed-in
733
+ owner or admin is named by their first name. Never an email: a readers-visible page shows it.
734
+ - **Reopen** with `{ "entry": "<entryId>", "resolved": false }` (no note), by the same people.
735
+ - A reader cannot set `resolved` by writing a comment (the server refuses the field), and an edit to
736
+ a resolved comment keeps its resolution.
737
+ - 404 for a page or comment that is not there; 501 on a host with no `state`, or whose own
738
+ `StateStore` has no `setResolved` (`createStateStore` and `createMemoryStateStore` have it).
739
+
740
+ ```ts
741
+ // app/api/artifacts/[id]/resolve/route.ts
742
+ import type { NextRequest } from 'next/server'
743
+ import { artifacts } from '@/lib/artifacts'
744
+ type Ctx = { params: Promise<{ id: string }> }
745
+ export function POST(request: NextRequest, ctx: Ctx) { return artifacts.RESOLVE(request, ctx) }
746
+ ```
747
+
705
748
  ### The comments feed
706
749
 
707
750
  So the publisher hears about a shared comment without opening every page, one route lists every
@@ -712,8 +755,8 @@ appear (the privacy test covers this handler like every other).
712
755
  GET /api/comments?since=2026-09-28T12:00:00Z
713
756
  Authorization: Bearer <publish key>
714
757
 
715
- 200 { now, comments: [ { artifactId, title, entryId, parent, name, quote, region, body,
716
- transcript, audioUrl, at, link } ] }
758
+ 200 { now, cursor, more, comments: [ { artifactId, title, entryId, parent, name, quote, region,
759
+ body, transcript, audioUrl, at, resolved, link } ] }
717
760
  ```
718
761
 
719
762
  Oldest first, at most 200, strictly after `since` (an ISO 8601 time with a zone; anything else is
@@ -725,6 +768,12 @@ parent's; a comment on an earlier version is highlighted in that list). A caller
725
768
  at the last row's `at` and leaves it where it was when nothing came back; `now` is where a first
726
769
  run starts, so it does not replay history.
727
770
 
771
+ `resolved` is the row's THREAD's resolution (`{ at, by, note? }`, a reply carries its parent's), or
772
+ null while the thread is open. **`&open=1` lists only open threads**: every row of a resolved thread
773
+ is left out, except a reply written after the thread was resolved, which is news about it. Filtered,
774
+ a page can come back short while more remains, so page with `cursor` (the `at` of the last comment
775
+ read, filtered or not) and `more` (a full page was read) rather than with the last row.
776
+
728
777
  The path sits outside `/api/artifacts/` because `comments` is itself a legal page id there.
729
778
 
730
779
  ```ts
@@ -28,7 +28,31 @@ export type CommentValue = {
28
28
  transcript?: (typeof COMMENT_TRANSCRIPTS)[number];
29
29
  /** The name a reader typed so the owner can credit them. A signed-in reader's account name wins. */
30
30
  by?: string;
31
+ /** On a thread's top-level comment only: the owner, an admin or the publisher marked the thread
32
+ * done (0.33.0). Written by the resolve route, never by a reader: `validateComment` refuses the
33
+ * key, and an edit keeps whatever the comment already carried. */
34
+ resolved?: CommentResolution;
31
35
  };
36
+ /** A resolved thread: when, by whom (a first name, or what the publisher called itself, never an
37
+ * email, because a readers-visible page shows it to every reader), and an optional one-line note
38
+ * saying what changed. */
39
+ export type CommentResolution = {
40
+ at: string;
41
+ by: string;
42
+ note?: string;
43
+ };
44
+ export declare const MAX_RESOLVE_NOTE_CHARS = 280;
45
+ /** The resolve route's check on a note: absent, null or blank means no note; otherwise one line of
46
+ * at most MAX_RESOLVE_NOTE_CHARS, trimmed. */
47
+ export declare function cleanResolveNote(v: unknown): {
48
+ ok: true;
49
+ note: string | null;
50
+ } | {
51
+ ok: false;
52
+ error: string;
53
+ };
54
+ /** The resolution a stored value carries, or null. Tolerant of anything, since it reads stored JSON. */
55
+ export declare function resolutionOf(v: unknown): CommentResolution | null;
32
56
  /** The server's check on a comment, called by the state POST for the `comments` slot. `existing`
33
57
  * returns a comment THIS WRITER can see by its entry id, so a reply can only name a thread its
34
58
  * writer was shown. The browser checks too; this is the one that counts. */
@@ -16,6 +16,29 @@ export function mergeCommentsState(state, comments, visible) {
16
16
  export const MAX_COMMENT_CHARS = 4000;
17
17
  export const MAX_COMMENT_NAME_CHARS = 80;
18
18
  export const COMMENT_TRANSCRIPTS = ['browser', 'host', 'typed'];
19
+ export const MAX_RESOLVE_NOTE_CHARS = 280;
20
+ /** The resolve route's check on a note: absent, null or blank means no note; otherwise one line of
21
+ * at most MAX_RESOLVE_NOTE_CHARS, trimmed. */
22
+ export function cleanResolveNote(v) {
23
+ if (v === undefined || v === null)
24
+ return { ok: true, note: null };
25
+ if (typeof v !== 'string')
26
+ return { ok: false, error: 'note is one line of text, or null' };
27
+ const note = v.trim();
28
+ if (/[\r\n]/.test(note))
29
+ return { ok: false, error: 'note is one line' };
30
+ if (note.length > MAX_RESOLVE_NOTE_CHARS)
31
+ return { ok: false, error: `note is at most ${MAX_RESOLVE_NOTE_CHARS} characters` };
32
+ return { ok: true, note: note || null };
33
+ }
34
+ /** The resolution a stored value carries, or null. Tolerant of anything, since it reads stored JSON. */
35
+ export function resolutionOf(v) {
36
+ const r = v && typeof v === 'object' ? v.resolved : undefined;
37
+ if (!r || typeof r !== 'object' || typeof r.at !== 'string')
38
+ return null;
39
+ const { at, by, note } = r;
40
+ return { at, by: typeof by === 'string' ? by : '', ...(typeof note === 'string' && note ? { note } : {}) };
41
+ }
19
42
  const ENTRY_ID = /^[A-Za-z0-9_-]{1,128}$/;
20
43
  // An uploaded asset path under the page (comments/<id>.webm), never a URL and never `..`.
21
44
  const AUDIO_PATH = /^(?!.*\.\.)[A-Za-z0-9_-][A-Za-z0-9._/-]{0,299}$/;
@@ -67,6 +67,16 @@ export interface StateStore {
67
67
  * feed reads it. Optional so a host's own store keeps working; without it the feed answers 501.
68
68
  * The Firestore query needs a composite index on <base>State: slot ASC, at ASC. */
69
69
  slotSince?(slot: string, after: string, limit: number): Promise<StateEntry[]>;
70
+ /** Set or clear `resolved` on one `many` entry's value, whoever wrote it (resolving a comment
71
+ * thread, 0.33.0). Keeps the entry's id, writer and time. Null when the entry is not on this page
72
+ * in this slot. Only the resolve route calls it, after its own check of who is asking. Optional
73
+ * so a host's own store keeps working; without it the resolve route answers 501. */
74
+ setResolved?(input: {
75
+ artifactId: string;
76
+ slot: string;
77
+ entryId: string;
78
+ resolved: unknown | null;
79
+ }): Promise<StateEntry | null>;
70
80
  }
71
81
  export declare const readerKeyFor: {
72
82
  signedIn: (uid: string) => string;
@@ -21,6 +21,15 @@ export const readerKeyFor = {
21
21
  const writerOf = (w) => ({
22
22
  ...(w.uid ? { uid: w.uid } : {}), ...(w.email ? { email: w.email } : {}), name: w.name, anonymous: w.anonymous,
23
23
  });
24
+ /** The value with `resolved` set to the given resolution, or removed when it is null. */
25
+ const withResolved = (value, resolved) => {
26
+ const base = value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {};
27
+ if (resolved === null)
28
+ delete base.resolved;
29
+ else
30
+ base.resolved = structuredClone(resolved);
31
+ return base;
32
+ };
24
33
  const oneId = (artifactId, slot, readerKey) => `${artifactId}__${slot}__${readerKey}`;
25
34
  const toEntry = (id, d) => {
26
35
  const { json, ...rest } = d;
@@ -105,6 +114,18 @@ export function createStateStore(db, base) {
105
114
  // order on at. Needs the composite index (slot ASC, at ASC) on <base>State, or Firestore
106
115
  // refuses the query with FAILED_PRECONDITION and a link that creates it.
107
116
  slotSince: (slot, after, limit) => load(col().where('slot', '==', slot).where('at', '>', after).orderBy('at').limit(limit)),
117
+ async setResolved({ artifactId, slot, entryId, resolved }) {
118
+ const ref = col().doc(entryId);
119
+ const snap = await ref.get();
120
+ if (!snap.exists)
121
+ return null;
122
+ const e = toEntry(snap.id, snap.data());
123
+ if (e.artifactId !== artifactId || e.slot !== slot || e.shape !== 'many')
124
+ return null;
125
+ const value = withResolved(e.value, resolved);
126
+ await ref.update({ json: JSON.stringify(value) });
127
+ return { ...e, value };
128
+ },
108
129
  };
109
130
  return store;
110
131
  }
@@ -172,6 +193,14 @@ export function createMemoryStateStore() {
172
193
  docs.set(entryId, next);
173
194
  return structuredClone(next);
174
195
  },
196
+ async setResolved({ artifactId, slot, entryId, resolved }) {
197
+ const e = docs.get(entryId);
198
+ if (!e || e.artifactId !== artifactId || e.slot !== slot || e.shape !== 'many')
199
+ return null;
200
+ const next = { ...e, value: withResolved(e.value, resolved) };
201
+ docs.set(entryId, next);
202
+ return structuredClone(next);
203
+ },
175
204
  slotSince: async (slot, after, limit) => [...docs.values()]
176
205
  .filter((e) => e.slot === slot && e.at > after)
177
206
  .sort((x, y) => x.at.localeCompare(y.at) || x.id.localeCompare(y.id))
@@ -1,6 +1,8 @@
1
- import { type CommentsMode } from '../artifacts/comments.js';
1
+ import { type CommentResolution, type CommentsMode } from '../artifacts/comments.js';
2
2
  import { type VoiceHost } from './voice-recorder.js';
3
3
  import { type DeviceAudio } from './device-audio.js';
4
+ /** The line a resolved thread collapses to. */
5
+ export declare const resolvedLine: (r: CommentResolution) => string;
4
6
  /** A finger rather than a mouse: the comment button then goes where the OS menu is not. */
5
7
  export declare function onTouch(): boolean;
6
8
  export declare function CommentLayer({ artifactId, rootId, ownerName, comments, canShare, signedIn, isOwner, version, accent, ground, signIn, voice, audioStore, }: {
@@ -20,13 +20,18 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
20
20
  // on load and on every hashchange the pin is scrolled into view and its thread opened; a reply's
21
21
  // id opens its parent's thread; a comment on an earlier version is scrolled to and highlighted.
22
22
  //
23
+ // A RESOLVED thread (0.33.0: the owner, an admin or the publisher marked it done) draws a muted
24
+ // check pin with no box, so the page reads clean, and opens collapsed to one line, "Resolved: <note>",
25
+ // with the comment and its replies a tap away. The Comments count is open threads only. The owner
26
+ // and admins get Resolve and Reopen in every shared thread.
27
+ //
23
28
  // Rendered empty on the server and on the first client render so the markup always hydrates; the
24
29
  // overlay is portalled onto document.body so its coordinates are page coordinates.
25
30
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
26
31
  import { createPortal } from 'react-dom';
27
32
  import { PINS_SET, announcePins, signInBack } from './page-bridge.js';
28
33
  import { MAX_QUOTE_CHARS, textAnchorFrom } from '../artifacts/anchor.js';
29
- import { COMMENTS_SLOT } from '../artifacts/comments.js';
34
+ import { COMMENTS_SLOT, MAX_RESOLVE_NOTE_CHARS, resolutionOf } from '../artifacts/comments.js';
30
35
  import { CommentCard } from './comment-card.js';
31
36
  import { AudioPlay, hostVoice, newMemoId } from './voice-recorder.js';
32
37
  import { deviceAudio as defaultDeviceAudio, isDeviceAudio } from './device-audio.js';
@@ -40,6 +45,9 @@ const LONG_PRESS_MS = 400;
40
45
  const HASH = /^#comment-([A-Za-z0-9_-]{1,128})$/;
41
46
  /** The entry id a `#comment-<id>` hash names, or null. */
42
47
  const commentHashId = (hash) => HASH.exec(hash)?.[1] ?? null;
48
+ /** The line a resolved thread collapses to. */
49
+ export const resolvedLine = (r) => (r.note ? `Resolved: ${r.note}` : 'Resolved');
50
+ const resolvedOf = (item) => (item.kind === 'shared' ? resolutionOf(item.value) : null);
43
51
  const DRAG_PX = 8;
44
52
  /** Where a drag never starts a box: controls, and anything that is somebody's words. */
45
53
  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';
@@ -83,6 +91,7 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
83
91
  const host = useMemo(() => voice ?? hostVoice(artifactId), [voice, artifactId]);
84
92
  const audioDb = useMemo(() => audioStore ?? defaultDeviceAudio, [audioStore]);
85
93
  const stateUrl = `/api/artifacts/${artifactId}/state`;
94
+ const resolveUrl = `/api/artifacts/${artifactId}/resolve`;
86
95
  const personalUrl = `/api/artifacts/${artifactId}/personal`;
87
96
  const [mounted, setMounted] = useState(false);
88
97
  const [shared, setShared] = useState([]);
@@ -105,6 +114,8 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
105
114
  const [highlight, setHighlight] = useState(null);
106
115
  /** A selector to scroll into view once the render that draws it has committed. */
107
116
  const [scrollTo, setScrollTo] = useState(null);
117
+ /** A resolved thread the address named, opened expanded rather than collapsed. */
118
+ const [expanded, setExpanded] = useState(null);
108
119
  const call = useCallback(async (url, init) => {
109
120
  try {
110
121
  const r = await fetch(url, { credentials: 'same-origin', cache: 'no-store', ...init });
@@ -379,6 +390,7 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
379
390
  setShowPins(true);
380
391
  setHighlight(null);
381
392
  setThread(id);
393
+ setExpanded(id);
382
394
  setScrollTo(`[data-comment-pin][data-comment-id="${id}"]`);
383
395
  }
384
396
  else if (layout.earlier.some((i) => i.id === id)) {
@@ -461,7 +473,9 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
461
473
  setThread(null);
462
474
  };
463
475
  const edit = async (item, body) => {
464
- const value = { ...item.value, body };
476
+ // `resolved` is the owner's to set, and the server keeps it through an edit; sending it back is refused.
477
+ const { resolved: _resolved, ...rest } = item.value;
478
+ const value = { ...rest, body };
465
479
  if (item.where === 'device') {
466
480
  replaceDeviceNote(artifactId, item.id, value);
467
481
  refreshDevice();
@@ -486,19 +500,29 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
486
500
  setShared(sharedFrom(r.body));
487
501
  return null;
488
502
  };
503
+ const resolveThread = async (item, resolved, note) => {
504
+ const r = await call(resolveUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ entry: item.id, resolved, ...(note ? { note } : {}) }) });
505
+ if (r.status !== 200)
506
+ return typeof r.body.error === 'string' ? r.body.error : 'that did not save; try again';
507
+ const s = await call(stateUrl);
508
+ if (s.status === 200)
509
+ setShared(sharedFrom(s.body));
510
+ return null;
511
+ };
489
512
  const open = layout.placed.find((p) => p.item.id === thread);
490
513
  const openItem = open?.item ?? layout.earlier.find((i) => i.id === thread);
491
- const count = tops.length;
492
- const overlay = mounted ? (_jsxs("div", { "data-comment-ui": true, "data-nospeak": true, className: "font-sans", style: theme, children: [missing ? (_jsxs("div", { "data-comment-missing": true, role: "status", className: "fixed inset-x-3 top-3 z-50 mx-auto max-w-sm rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-4 font-sans text-sm leading-snug text-[color:var(--a-strong)] shadow-xl", children: [_jsxs("div", { className: "flex items-start gap-3", children: [_jsx("p", { className: "m-0 flex-1", children: "That comment can't be shown to you. Comments on this page are visible to its owner and admins." }), _jsx("button", { type: "button", "aria-label": "Close", onClick: () => setMissing(false), className: "-mr-1 -mt-1 px-1 text-lg leading-none opacity-60", children: "\u00D7" })] }), !signedIn && signIn ? (_jsx("a", { "data-comment-missing-sign-in": true, href: signInBack(signIn), className: "mt-3 inline-block rounded-full px-4 py-1.5 font-medium no-underline", style: { background: accent, color: 'var(--a-on-accent, #111)' }, children: "Sign in" })) : null] })) : null, 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 ? (
514
+ const count = tops.filter((i) => !resolvedOf(i)).length;
515
+ const overlay = mounted ? (_jsxs("div", { "data-comment-ui": true, "data-nospeak": true, className: "font-sans", style: theme, children: [missing ? (_jsxs("div", { "data-comment-missing": true, role: "status", className: "fixed inset-x-3 top-3 z-50 mx-auto max-w-sm rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-4 font-sans text-sm leading-snug text-[color:var(--a-strong)] shadow-xl", children: [_jsxs("div", { className: "flex items-start gap-3", children: [_jsx("p", { className: "m-0 flex-1", children: "That comment can't be shown to you. Comments on this page are visible to its owner and admins." }), _jsx("button", { type: "button", "aria-label": "Close", onClick: () => setMissing(false), className: "-mr-1 -mt-1 px-1 text-lg leading-none opacity-60", children: "\u00D7" })] }), !signedIn && signIn ? (_jsx("a", { "data-comment-missing-sign-in": true, href: signInBack(signIn), className: "mt-3 inline-block rounded-full px-4 py-1.5 font-medium no-underline", style: { background: accent, color: 'var(--a-on-accent, #111)' }, children: "Sign in" })) : null] })) : null, showPins ? layout.placed.map(({ item, left, top, box }) => (_jsxs("div", { children: [box && !resolvedOf(item) ? (_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 ? (
493
516
  // On a phone the button sits at the TOP of the screen, never beside the selection: that is
494
517
  // where iOS draws its own Copy / Look Up menu, and the two fought for the same tap (Gary,
495
518
  // 2026-09-29: "I keep trying to fight with the Apple pop-up"). A mouse keeps it by the text.
496
- _jsx("button", { type: "button", "data-comment-chip": true, "data-comment-chip-at": onTouch() ? 'top' : 'selection', className: onTouch() ? 'fixed left-1/2 z-50 -translate-x-1/2 rounded-full px-5 py-2 text-sm font-medium shadow-lg' : 'absolute z-50 -translate-x-full rounded-full px-3 py-1 text-xs font-medium shadow-lg', style: onTouch() ? { top: 'calc(env(safe-area-inset-top, 0px) + 12px)', background: accent, color: 'var(--a-on-accent, #111)' } : { left: chip.x, top: chip.y, background: accent, color: 'var(--a-on-accent, #111)' }, onMouseDown: (e) => e.preventDefault(), onClick: () => { setDraft(chip); setChip(null); }, children: "Comment" })) : null, draft ? (_jsx(Floating, { x: draft.x, y: draft.y, children: _jsx(CommentCard, { ownerName: ownerName, mode: comments, canShare: canShare, signedIn: signedIn && onServer, quote: draft.quote, accent: accent, signIn: signIn, voice: host, onSave: (body, share, memo, by) => saveNew(body, share, draft, memo, by), onCancel: () => setDraft(null) }) })) : null, openItem && open ? (_jsx(Floating, { x: open.left + 28, y: open.top, children: _jsx(Thread, { item: openItem, replies: items.filter((i) => i.value.parent === openItem.id), canReply: openItem.kind === 'shared' && canShare, accent: accent, audioDb: audioDb, onClose: () => setThread(null), onRemove: remove, onEdit: edit, onReply: reply }) })) : null, drag ? (_jsx("div", { "aria-hidden": true, className: "pointer-events-none fixed z-40 rounded-md border-2 border-dashed", style: { left: Math.min(drag.x1, drag.x2), top: Math.min(drag.y1, drag.y2), width: Math.abs(drag.x2 - drag.x1), height: Math.abs(drag.y2 - drag.y1), borderColor: accent, background: `color-mix(in srgb, ${accent} 12%, transparent)` } })) : null, _jsxs("div", { className: "fixed right-4 z-40 flex flex-col items-end gap-2", style: { bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)' }, children: [panel ? (_jsxs("div", { "data-comment-panel": true, className: "w-64 rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-xs text-[color:var(--a-strong)] shadow-xl", children: [_jsx("p", { className: "opacity-80", children: "Select any text, or hold and drag a box over anything, to leave a comment." }), isOwner ? _jsx("p", { "data-comment-owner": true, className: "mt-1 opacity-80", children: "This is your page: you see every comment, with the name of who left it." }) : null, _jsx("button", { type: "button", "data-comment-pins-toggle": true, className: "mt-2 underline", onClick: () => setShowPins((v) => !v), children: showPins ? 'Hide comments' : 'Show comments' })] })) : null, _jsx("button", { type: "button", "data-comment-button": true, "aria-expanded": panel, onClick: () => setPanel((v) => !v), className: "rounded-full border px-3 py-1.5 text-xs font-medium shadow-lg backdrop-blur", style: { borderColor: accent, color: accent, background: `color-mix(in srgb, ${ground} 85%, transparent)` }, children: count ? `Comments · ${count}` : 'Comment' })] })] })) : null;
497
- 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] }));
519
+ _jsx("button", { type: "button", "data-comment-chip": true, "data-comment-chip-at": onTouch() ? 'top' : 'selection', className: onTouch() ? 'fixed left-1/2 z-50 -translate-x-1/2 rounded-full px-5 py-2 text-sm font-medium shadow-lg' : 'absolute z-50 -translate-x-full rounded-full px-3 py-1 text-xs font-medium shadow-lg', style: onTouch() ? { top: 'calc(env(safe-area-inset-top, 0px) + 12px)', background: accent, color: 'var(--a-on-accent, #111)' } : { left: chip.x, top: chip.y, background: accent, color: 'var(--a-on-accent, #111)' }, onMouseDown: (e) => e.preventDefault(), onClick: () => { setDraft(chip); setChip(null); }, children: "Comment" })) : null, draft ? (_jsx(Floating, { x: draft.x, y: draft.y, children: _jsx(CommentCard, { ownerName: ownerName, mode: comments, canShare: canShare, signedIn: signedIn && onServer, quote: draft.quote, accent: accent, signIn: signIn, voice: host, onSave: (body, share, memo, by) => saveNew(body, share, draft, memo, by), onCancel: () => setDraft(null) }) })) : null, openItem && open ? (_jsx(Floating, { x: open.left + 28, y: open.top, children: _jsx(Thread, { item: openItem, replies: items.filter((i) => i.value.parent === openItem.id), canReply: openItem.kind === 'shared' && canShare, canResolve: openItem.kind === 'shared' && isOwner, startExpanded: expanded === openItem.id, accent: accent, audioDb: audioDb, onClose: () => { setThread(null); setExpanded(null); }, onRemove: remove, onEdit: edit, onReply: reply, onResolve: resolveThread }, openItem.id) })) : 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;
520
+ 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: [resolvedOf(item) ? _jsxs("span", { "data-comment-resolved-line": true, className: "block text-[13px] font-medium", style: { color: accent }, children: ["\u2713 ", resolvedLine(resolvedOf(item))] }) : null, 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] }));
498
521
  }
499
522
  function Pin({ item, left, top, accent, ground, onOpen }) {
500
523
  const personal = item.kind === 'personal';
501
- return (_jsx("button", { type: "button", "data-comment-pin": item.kind, "data-comment-id": item.id, onClick: onOpen, "aria-label": personal ? 'Your personal note (only you)' : `Comment by ${item.name}`, className: "absolute z-30 flex h-6 min-w-6 items-center justify-center gap-1 rounded-full border-2 px-1.5 text-[10px] font-semibold shadow", style: { left, top, borderColor: accent, background: personal ? ground : accent, color: personal ? accent : 'var(--a-on-accent, #111)' }, children: personal ? _jsx("span", { children: "only you" }) : _jsx("span", { "aria-hidden": true, children: "\u25CF" }) }));
524
+ const resolved = resolvedOf(item);
525
+ return (_jsx("button", { type: "button", "data-comment-pin": item.kind, "data-comment-id": item.id, onClick: onOpen, ...(resolved ? { 'data-comment-resolved': '' } : {}), "aria-label": personal ? 'Your personal note (only you)' : resolved ? `${resolvedLine(resolved)}. Comment by ${item.name}` : `Comment by ${item.name}`, className: "absolute z-30 flex h-6 min-w-6 items-center justify-center gap-1 rounded-full border-2 px-1.5 text-[10px] font-semibold shadow", style: { left, top, borderColor: accent, background: personal || resolved ? ground : accent, color: personal || resolved ? accent : 'var(--a-on-accent, #111)', ...(resolved ? { opacity: 0.45 } : {}) }, children: personal ? _jsx("span", { children: "only you" }) : resolved ? _jsx("span", { "aria-hidden": true, children: "\u2713" }) : _jsx("span", { "aria-hidden": true, children: "\u25CF" }) }));
502
526
  }
503
527
  function Floating({ x, y, children }) {
504
528
  // Kept inside the screen on a phone, where the margin a pin sits in may be the screen's edge.
@@ -506,12 +530,25 @@ function Floating({ x, y, children }) {
506
530
  const left = typeof window === 'undefined' ? x : Math.max(window.scrollX + 8, Math.min(x, window.scrollX + window.innerWidth - width - 8));
507
531
  return _jsx("div", { className: "absolute z-50", style: { left, top: y }, children: children });
508
532
  }
509
- function Thread({ item, replies, canReply, accent, audioDb, onClose, onRemove, onEdit, onReply }) {
533
+ function Thread({ item, replies, canReply, canResolve = false, startExpanded = false, accent, audioDb, onClose, onRemove, onEdit, onReply, onResolve }) {
510
534
  const [replying, setReplying] = useState(false);
511
- return (_jsxs("div", { "data-comment-thread": true, "data-comment-ui": true, "data-nospeak": true, className: "w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-sm text-[color:var(--a-strong)] shadow-xl", children: [_jsxs("div", { className: "mb-2 flex items-center justify-between text-[11px] uppercase tracking-[0.15em]", style: { color: accent }, children: [_jsx("span", { children: item.kind === 'personal' ? 'Only you' : 'Comment' }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close", className: "opacity-70 hover:opacity-100", children: "\u00D7" })] }), item.value.anchor.kind === 'text' ? _jsx("p", { className: "mb-2 line-clamp-2 border-l-2 pl-2 text-[13px] italic opacity-70", style: { borderColor: accent }, children: item.value.anchor.quote }) : null, _jsx(Entry, { item: item, accent: accent, audioDb: audioDb, onRemove: onRemove, onEdit: onEdit }), replies.length ? _jsx("ul", { className: "m-0 mt-2 grid list-none gap-2 border-l pl-3 p-0", children: replies.map((r) => _jsx("li", { className: "m-0", children: _jsx(Entry, { item: r, accent: accent, audioDb: audioDb, onRemove: onRemove, onEdit: onEdit }) }, r.id)) }) : null, canReply ? (replying
535
+ const [resolving, setResolving] = useState(false);
536
+ const [error, setError] = useState(null);
537
+ const [showResolved, setShowResolved] = useState(startExpanded);
538
+ const resolved = resolvedOf(item);
539
+ const shell = 'w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-sm text-[color:var(--a-strong)] shadow-xl';
540
+ const reopen = canResolve && onResolve ? (_jsx("button", { type: "button", "data-comment-reopen": true, className: "ml-3 text-xs underline", onClick: async () => setError(await onResolve(item, false)), children: "Reopen" })) : null;
541
+ if (resolved && !showResolved) {
542
+ return (_jsxs("div", { "data-comment-thread": true, "data-comment-thread-resolved": true, "data-comment-ui": true, "data-nospeak": true, className: shell, children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsxs("p", { "data-comment-resolved-line": true, className: "m-0 flex-1 text-[14px]", children: [_jsx("span", { "aria-hidden": true, style: { color: accent }, children: "\u2713 " }), resolvedLine(resolved)] }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close", className: "opacity-70 hover:opacity-100", children: "\u00D7" })] }), _jsxs("p", { className: "m-0 mt-1 text-xs opacity-60", children: [resolved.by ? `by ${resolved.by}` : null, _jsxs("button", { type: "button", "data-comment-expand": true, className: resolved.by ? 'ml-3 underline' : 'underline', onClick: () => setShowResolved(true), children: ["Show comment", replies.length ? ` and ${replies.length} ${replies.length === 1 ? 'reply' : 'replies'}` : ''] }), reopen] }), error ? _jsx("p", { role: "alert", className: "text-xs text-amber-500", children: error }) : null] }));
543
+ }
544
+ return (_jsxs("div", { "data-comment-thread": true, "data-comment-ui": true, "data-nospeak": true, className: shell, children: [_jsxs("div", { className: "mb-2 flex items-center justify-between text-[11px] uppercase tracking-[0.15em]", style: { color: accent }, children: [_jsx("span", { children: item.kind === 'personal' ? 'Only you' : resolved ? 'Resolved comment' : 'Comment' }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close", className: "opacity-70 hover:opacity-100", children: "\u00D7" })] }), resolved ? _jsxs("p", { "data-comment-resolved-line": true, className: "m-0 mb-2 text-[13px]", style: { color: accent }, children: ["\u2713 ", resolvedLine(resolved), resolved.by ? _jsx("span", { className: "opacity-70", children: ` · ${resolved.by}` }) : null] }) : null, item.value.anchor.kind === 'text' ? _jsx("p", { className: "mb-2 line-clamp-2 border-l-2 pl-2 text-[13px] italic opacity-70", style: { borderColor: accent }, children: item.value.anchor.quote }) : null, _jsx(Entry, { item: item, accent: accent, audioDb: audioDb, onRemove: onRemove, onEdit: onEdit }), replies.length ? _jsx("ul", { className: "m-0 mt-2 grid list-none gap-2 border-l pl-3 p-0", children: replies.map((r) => _jsx("li", { className: "m-0", children: _jsx(Entry, { item: r, accent: accent, audioDb: audioDb, onRemove: onRemove, onEdit: onEdit }) }, r.id)) }) : null, canReply ? (replying
512
545
  ? _jsx(InlineForm, { label: "Reply", accent: accent, onCancel: () => setReplying(false), onSubmit: async (b) => { const e = await onReply(item, b); if (!e)
513
546
  setReplying(false); return e; } })
514
- : _jsx("button", { type: "button", "data-comment-reply": true, onClick: () => setReplying(true), className: "mt-2 text-xs underline", children: "Reply" })) : null] }));
547
+ : _jsx("button", { type: "button", "data-comment-reply": true, onClick: () => setReplying(true), className: "mt-2 text-xs underline", children: "Reply" })) : null, canResolve && onResolve && !replying ? (resolved ? _jsx("p", { className: "m-0 mt-2", children: reopen })
548
+ : resolving
549
+ ? _jsx(InlineForm, { label: "Resolve", accent: accent, optional: true, placeholder: "What changed (optional)", maxLength: MAX_RESOLVE_NOTE_CHARS, onCancel: () => setResolving(false), onSubmit: async (note) => { const e = await onResolve(item, true, note || undefined); if (!e)
550
+ setResolving(false); return e; } })
551
+ : _jsx("button", { type: "button", "data-comment-resolve": true, onClick: () => setResolving(true), className: canReply ? 'ml-3 mt-2 text-xs underline' : 'mt-2 text-xs underline', children: "Resolve" })) : null, error ? _jsx("p", { role: "alert", className: "text-xs text-amber-500", children: error }) : null] }));
515
552
  }
516
553
  /** Where a comment's recording plays from: its signed URL, or this browser's copy of a device note's. */
517
554
  function useAudioSrc(item, audioDb) {
@@ -539,9 +576,9 @@ function Entry({ item, accent, audioDb, onRemove, onEdit }) {
539
576
  setEditing(false); return e; } });
540
577
  return (_jsxs("div", { children: [src ? _jsx(AudioPlay, { src: src, accent: accent }) : null, item.value.body ? _jsx("p", { className: "m-0 whitespace-pre-wrap text-[15px]", children: item.value.body }) : null, _jsxs("p", { className: "m-0 text-xs opacity-60", children: [item.kind === 'personal' ? (item.where === 'device' ? 'only you, on this device' : 'only you') : item.name, item.mine ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "data-comment-edit": true, onClick: () => setEditing(true), className: "ml-2 underline", children: "edit" }), _jsx("button", { type: "button", "data-comment-delete": true, onClick: () => void onRemove(item), className: "ml-2 underline", children: "delete" })] })) : null] })] }));
541
578
  }
542
- function InlineForm({ label, initial = '', accent = 'currentColor', onSubmit, onCancel }) {
579
+ function InlineForm({ label, initial = '', accent = 'currentColor', optional = false, placeholder, maxLength, onSubmit, onCancel }) {
543
580
  const [text, setText] = useState(initial);
544
581
  const [error, setError] = useState(null);
545
- return (_jsxs("form", { className: "mt-2", onSubmit: async (e) => { e.preventDefault(); if (text.trim())
546
- 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" })] })] }));
582
+ return (_jsxs("form", { className: "mt-2", onSubmit: async (e) => { e.preventDefault(); if (optional || text.trim())
583
+ setError(await onSubmit(text.trim())); }, children: [_jsx("textarea", { value: text, autoFocus: true, rows: 2, onChange: (e) => setText(optional ? e.target.value.replace(/[\r\n]+/g, ' ') : e.target.value), "aria-label": optional && placeholder ? placeholder : label, placeholder: placeholder, maxLength: maxLength, 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: !optional && !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" })] })] }));
547
584
  }
@@ -156,6 +156,11 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
156
156
  }>;
157
157
  }) => Promise<NextResponse<unknown>>;
158
158
  COMMENTS_FEED: (req: NextRequest) => Promise<NextResponse<unknown>>;
159
+ RESOLVE: (req: NextRequest, { params }: {
160
+ params: Promise<{
161
+ id: string;
162
+ }>;
163
+ }) => Promise<NextResponse<unknown>>;
159
164
  Page: ({ params, searchParams }: PageProps) => Promise<import("react").JSX.Element>;
160
165
  generateMetadata: ({ params }: Params) => Promise<Metadata>;
161
166
  VersionPage: ({ params, searchParams }: VersionProps) => Promise<import("react").JSX.Element>;
@@ -50,5 +50,6 @@ export declare function createStateRoutes(ctx: StateRoutesContext): {
50
50
  STATE_POST: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown> | undefined>;
51
51
  RESPONSES: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
52
52
  COMMENTS_FEED: (req: NextRequest) => Promise<NextResponse<unknown>>;
53
+ RESOLVE: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
53
54
  };
54
55
  export {};
@@ -5,6 +5,9 @@
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
7
  // GET /api/comments?since=<ISO> publish key; every shared comment on any page after a time
8
+ // (&open=1: leave out threads that are resolved)
9
+ // POST /api/artifacts/<id>/resolve publish key, or the owner or an admin signed in; resolve or
10
+ // reopen one comment thread { entry, resolved?, note?, by? }
8
11
  //
9
12
  // Who is writing: the grant a gated page already uses, or on a page with `writers: anyone`, an
10
13
  // anonymous id in an HttpOnly cookie. When both are present the anonymous answers move to the
@@ -16,7 +19,7 @@ import { ARTIFACT_ID_RE } from './ids.js';
16
19
  import { GRANT_COOKIE, decide, firstName, signInUrl, verifyGrant } from '../artifacts/reader.js';
17
20
  import { isUnlocked, unlockCookieName } from '../artifacts/unlock.js';
18
21
  import { ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, checkValue, effectiveWriters, slotVisibility } from '../artifacts/state.js';
19
- import { COMMENTS_SLOT, validateComment } from '../artifacts/comments.js';
22
+ import { COMMENTS_SLOT, MAX_COMMENT_NAME_CHARS, cleanResolveNote, resolutionOf, validateComment } from '../artifacts/comments.js';
20
23
  import { isCommentAudioPath } from '../artifacts/audio.js';
21
24
  import { readerKeyFor } from '../artifacts/state-store.js';
22
25
  import { NOTES_SLOT, checkNoteValue, checklistAnswerIds, hasNotesWidget } from '../artifacts/widgets.js';
@@ -24,6 +27,7 @@ import { CHECKLIST_SLOT, checkChecklistValue } from '../artifacts/checklist.js';
24
27
  import { responsesCsv, responsesOf, stateView } from '../artifacts/state-view.js';
25
28
  export const ANON_COOKIE = 'artifact_anon';
26
29
  const ANON_ID = /^[A-Za-z0-9]{24}$/;
30
+ const ENTRY_ID = /^[A-Za-z0-9_-]{1,128}$/;
27
31
  const MAX_BODY = 16 * 1024;
28
32
  /** The most comments one feed answer carries. A caller that gets this many moves its cursor to the
29
33
  * last row's `at` and asks again. */
@@ -266,6 +270,10 @@ export function createStateRoutes(ctx) {
266
270
  if (op === 'replace' && seen.has(entryId) && (seen.get(entryId).parent ?? null) !== (c.value.parent ?? null))
267
271
  return json({ error: 'an edit cannot move a comment into or out of a thread' }, 400);
268
272
  value = c.value;
273
+ // Resolving is the owner's, never the writer's: an edit keeps the thread as it was.
274
+ const prev = op === 'replace' ? resolutionOf(seen.get(entryId)) : null;
275
+ if (prev)
276
+ value = { ...c.value, resolved: prev };
269
277
  }
270
278
  if (op === 'replace') {
271
279
  const r = await ctx.state.replace({ artifactId: id, slot, readerKey: writer.key, entryId: entryId, value });
@@ -321,13 +329,23 @@ export function createStateRoutes(ctx) {
321
329
  /** GET /api/comments?since=<ISO>: every SHARED comment on any of this host's pages written after
322
330
  * `since` (exclusive), oldest first, at most FEED_MAX, each with a link that opens it on the
323
331
  * page. It reads the `comments` slot of the state core only; personal notes live in a store no
324
- * publisher route reads, so they cannot appear here. The publisher's own notifier polls it. */
332
+ * publisher route reads, so they cannot appear here. The publisher's own notifier polls it.
333
+ *
334
+ * Each row carries `resolved`: its THREAD's resolution (a reply carries its parent's), or null.
335
+ * `&open=1` leaves out every row of a resolved thread, except a reply written after the thread
336
+ * was resolved, which is news about it. `cursor` is the `at` of the last comment READ, filtered
337
+ * or not, and `more` says a full page was read; a caller paging with `open=1` follows `cursor`,
338
+ * since the last row it was given can sit well before it. */
325
339
  async function COMMENTS_FEED(req) {
326
340
  if (!isPublisherAuthed(req, ctx))
327
341
  return json({ error: 'Unauthorized' }, 401);
328
342
  const since = feedSince(req.nextUrl.searchParams.get('since'));
329
343
  if (!since)
330
344
  return json({ error: 'since must be an ISO 8601 time with a zone, like 2026-09-28T12:00:00Z' }, 400);
345
+ const openParam = req.nextUrl.searchParams.get('open');
346
+ if (openParam !== null && !['1', 'true', '0', 'false'].includes(openParam))
347
+ return json({ error: 'open is 1 or 0' }, 400);
348
+ const openOnly = openParam === '1' || openParam === 'true';
331
349
  if (!ctx.state)
332
350
  return json({ error: 'this host keeps no answers' }, 501);
333
351
  if (!ctx.state.slotSince)
@@ -337,6 +355,14 @@ export function createStateRoutes(ctx) {
337
355
  const pages = new Map();
338
356
  const pageOf = (id) => { if (!pages.has(id))
339
357
  pages.set(id, ctx.store.get(id)); return pages.get(id); };
358
+ // A reply's thread is resolved on its parent, which may sit outside this window: read the page's
359
+ // comments once, and only for a page that has a reply in it.
360
+ const threads = new Map();
361
+ const commentsOf = (id) => {
362
+ if (!threads.has(id))
363
+ threads.set(id, ctx.state.entries(id).then((all) => new Map(all.filter((e) => e.slot === COMMENTS_SLOT).map((e) => [e.id, e]))));
364
+ return threads.get(id);
365
+ };
340
366
  const comments = [];
341
367
  for (const e of entries) {
342
368
  const a = await pageOf(e.artifactId);
@@ -344,16 +370,98 @@ export function createStateRoutes(ctx) {
344
370
  if (!a)
345
371
  continue;
346
372
  const v = e.value;
373
+ const resolved = resolutionOf(v.parent ? (await commentsOf(e.artifactId)).get(v.parent)?.value : v);
374
+ if (openOnly && resolved && !(v.parent && e.at > resolved.at))
375
+ continue;
347
376
  const audio = isCommentAudioPath(v.audio) && ctx.audioUrl ? await ctx.audioUrl(e.artifactId, v.audio) : null;
348
377
  comments.push({
349
378
  artifactId: e.artifactId, title: a.title, entryId: e.id, parent: v.parent ?? null,
350
379
  name: e.writer.anonymous ? null : e.writer.name?.trim() || 'a signed-in reader',
351
380
  quote: v.anchor?.kind === 'text' ? v.anchor.quote : null, region: v.anchor?.kind === 'region',
352
- body: v.body, transcript: v.transcript ?? null, audioUrl: audio, at: e.at,
381
+ body: v.body, transcript: v.transcript ?? null, audioUrl: audio, at: e.at, resolved,
353
382
  link: `${ctx.pageUrl(e.artifactId)}#comment-${e.id}`,
354
383
  });
355
384
  }
356
- return json({ now, comments });
385
+ return json({ now, comments, cursor: entries.length ? entries[entries.length - 1].at : null, more: entries.length >= FEED_MAX });
386
+ }
387
+ /** POST /api/artifacts/<id>/resolve: mark one comment thread done, or open it again (0.33.0).
388
+ * Body { entry, resolved?: true|false (default true), note?: one line, at most 280, by?: one line }.
389
+ * `entry` may name a reply: the thread it belongs to is what is resolved, and the answer's
390
+ * `entry` names that thread's top comment.
391
+ *
392
+ * Who: the publish key or a publisher pass (an agent on the operator's machine resolves with the
393
+ * credential it publishes with, and may say who it is with `by`), or a reader signed in as the
394
+ * page's owner or one of this hub's admins (named by their first name; `by` is ignored), from
395
+ * this host's own pages as JSON. Everyone else is refused before the page is looked up. */
396
+ async function RESOLVE(req, { params }) {
397
+ const publisher = isPublisherAuthed(req, ctx);
398
+ let by = 'the publisher';
399
+ if (!publisher) {
400
+ const reader = verifyGrant(ctx.readerSecret(), req.cookies.get(GRANT_COOKIE)?.value);
401
+ if (!reader)
402
+ return json({ error: 'sign in as the page\'s owner, or send the publish key' }, 401);
403
+ if (!isOwner(reader))
404
+ return json({ error: 'only the page\'s owner, this hub\'s admins and the publisher resolve comments' }, 403);
405
+ const origin = req.headers.get('origin');
406
+ const site = req.headers.get('sec-fetch-site');
407
+ if ((origin !== null && origin !== siteOrigin) || (site !== null && site !== 'same-origin'))
408
+ return json({ error: 'wrong origin' }, 403);
409
+ if (!/^application\/json\b/i.test(req.headers.get('content-type') ?? ''))
410
+ return json({ error: 'send JSON' }, 415);
411
+ by = reader.name?.trim().split(/\s+/)[0] || 'the owner';
412
+ }
413
+ const raw = await req.text();
414
+ if (Buffer.byteLength(raw, 'utf8') > 4096)
415
+ return json({ error: 'request over 4 KB' }, 413);
416
+ let b;
417
+ try {
418
+ const parsed = JSON.parse(raw);
419
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
420
+ return json({ error: 'body must be a JSON object' }, 400);
421
+ b = parsed;
422
+ }
423
+ catch {
424
+ return json({ error: 'body must be JSON' }, 400);
425
+ }
426
+ const unknownKeys = Object.keys(b).filter((k) => !['entry', 'resolved', 'note', 'by'].includes(k));
427
+ if (unknownKeys.length)
428
+ return json({ error: `unknown field: ${unknownKeys.join(', ')}; the body is { entry, resolved?, note?, by? }` }, 400);
429
+ if (typeof b.entry !== 'string' || !ENTRY_ID.test(b.entry))
430
+ return json({ error: 'entry is the id of the comment to resolve' }, 400);
431
+ if (b.resolved !== undefined && typeof b.resolved !== 'boolean')
432
+ return json({ error: 'resolved is true or false' }, 400);
433
+ const resolve = b.resolved !== false;
434
+ const note = cleanResolveNote(b.note);
435
+ if (!note.ok)
436
+ return json({ error: note.error }, 400);
437
+ if (!resolve && note.note)
438
+ return json({ error: 'reopening a thread takes no note' }, 400);
439
+ if (publisher && b.by !== undefined) {
440
+ if (typeof b.by !== 'string' || /[\r\n]/.test(b.by) || !b.by.trim() || b.by.trim().length > MAX_COMMENT_NAME_CHARS)
441
+ return json({ error: `by is one line of at most ${MAX_COMMENT_NAME_CHARS} characters` }, 400);
442
+ by = b.by.trim();
443
+ }
444
+ if (!ctx.state)
445
+ return json({ error: 'this host keeps no answers' }, 501);
446
+ if (!ctx.state.setResolved)
447
+ return json({ error: 'this host cannot resolve comments' }, 501);
448
+ const { id } = await params;
449
+ const a = ARTIFACT_ID_RE.test(id) ? await ctx.store.get(id) : null;
450
+ if (!a)
451
+ return json({ error: `no artifact with id ${id}` }, 404);
452
+ const all = new Map((await ctx.state.entries(id)).filter((e) => e.slot === COMMENTS_SLOT).map((e) => [e.id, e]));
453
+ const asked = all.get(b.entry);
454
+ if (!asked)
455
+ return json({ error: 'no comment with that id on this page' }, 404);
456
+ const parent = asked.value.parent;
457
+ const root = parent ? all.get(parent) : asked;
458
+ if (!root)
459
+ return json({ error: 'the comment this reply answers is gone' }, 404);
460
+ const resolution = resolve ? { at: new Date().toISOString(), by, ...(note.note ? { note: note.note } : {}) } : null;
461
+ const r = await ctx.state.setResolved({ artifactId: id, slot: COMMENTS_SLOT, entryId: root.id, resolved: resolution });
462
+ if (!r)
463
+ return json({ error: 'no comment with that id on this page' }, 404);
464
+ return json({ id, entry: root.id, resolved: resolution });
357
465
  }
358
- return { STATE_GET, STATE_POST, RESPONSES, COMMENTS_FEED };
466
+ return { STATE_GET, STATE_POST, RESPONSES, COMMENTS_FEED, RESOLVE };
359
467
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.32.0",
3
+ "version": "0.33.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",