@supersuit/artifacts 0.39.0 → 0.40.1

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.
@@ -14,7 +14,9 @@ import { NO_ZOOM_FONT } from './no-zoom.js';
14
14
  import { MAX_COMMENT_CHARS, MAX_COMMENT_NAME_CHARS } from '../artifacts/comments.js';
15
15
  import { VoiceRecorder } from './voice-recorder.js';
16
16
  /** The warning, word for word. `They` is fixed copy: the card never guesses a pronoun. */
17
- export function warningLine(ownerName, mode) {
17
+ export function warningLine(ownerName, mode, closed = false) {
18
+ if (closed)
19
+ return `Only you will see this. Feedback on this draft is closed, so this is a personal note. ${ownerName} won't see it.`;
18
20
  const why = mode === 'off'
19
21
  ? `${ownerName} has not opened this page to comments`
20
22
  : `${ownerName} takes comments on this page from signed-in readers`;
@@ -23,17 +25,17 @@ export function warningLine(ownerName, mode) {
23
25
  export const DEVICE_LINE = 'Saved on this device. Sign in to keep it everywhere.';
24
26
  /** Where a reader's declared name is remembered, so their next comment is already signed. */
25
27
  export const NAME_KEY = 'artifacts:comment-name';
26
- const recall = () => { try {
28
+ export const recallName = () => { try {
27
29
  return localStorage.getItem(NAME_KEY) ?? '';
28
30
  }
29
31
  catch {
30
32
  return '';
31
33
  } };
32
- const remember = (name) => { try {
34
+ export const rememberName = (name) => { try {
33
35
  localStorage.setItem(NAME_KEY, name);
34
36
  }
35
37
  catch { /* a browser that keeps nothing still comments */ } };
36
- export function CommentCard({ ownerName, mode, canShare, signedIn, quote, initial = '', accent = 'currentColor', signIn, voice = null, onSave, onCancel, }) {
38
+ export function CommentCard({ ownerName, mode, canShare, signedIn, closed = false, requireName = false, knownName = null, quote, initial = '', accent = 'currentColor', signIn, voice = null, onSave, onCancel, }) {
37
39
  const [text, setText] = useState(initial);
38
40
  const [share, setShare] = useState(canShare);
39
41
  const [busy, setBusy] = useState(false);
@@ -41,7 +43,7 @@ export function CommentCard({ ownerName, mode, canShare, signedIn, quote, initia
41
43
  const [memo, setMemo] = useState(null);
42
44
  const [transcript, setTranscript] = useState(undefined);
43
45
  const [status, setStatus] = useState(null);
44
- const [name, setName] = useState(recall);
46
+ const [name, setName] = useState(recallName);
45
47
  // Refs, because the host's transcript arrives after an await and must see the box as it is then.
46
48
  const base = useRef('');
47
49
  const edited = useRef(false);
@@ -49,7 +51,7 @@ export function CommentCard({ ownerName, mode, canShare, signedIn, quote, initia
49
51
  const personal = !canShare || !share;
50
52
  // A reader with no account is asked for a name, so the owner can credit the feedback. Optional:
51
53
  // a comment without one still goes through, signed "a reader".
52
- const askName = !personal && !signedIn;
54
+ const askName = !personal && !knownName && (requireName || !signedIn);
53
55
  // Where a memo goes: a shared comment, the reader's account, or (neither) this device only.
54
56
  const scope = () => (canShare && share ? 'comment' : signedIn ? 'personal' : null);
55
57
  const recorded = async (m) => {
@@ -76,15 +78,19 @@ export function CommentCard({ ownerName, mode, canShare, signedIn, quote, initia
76
78
  const body = text.trim();
77
79
  if ((!body && !memo) || busy)
78
80
  return;
81
+ if (requireName && askName && !name.trim()) {
82
+ setError('Add your first name first.');
83
+ return;
84
+ }
79
85
  setBusy(true);
80
86
  const by = askName && name.trim() ? name.trim() : undefined;
81
87
  if (by)
82
- remember(by);
88
+ rememberName(by);
83
89
  const saved = memo ? { memo, uploaded: uploaded.current, ...(body && transcript ? { transcript } : {}) } : undefined;
84
90
  const err = saved || by ? await onSave(body, canShare && share, saved, by) : await onSave(body, canShare && share);
85
91
  setBusy(false);
86
92
  setError(err);
87
93
  };
88
- 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))
89
- 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, askName ? (_jsxs("label", { "data-comment-name": true, className: "mt-2 block text-[13px]", children: [_jsx("span", { className: "block opacity-80", children: `Your name, so ${ownerName} can credit your feedback` }), _jsx("input", { value: name, onChange: (e) => setName(e.target.value), maxLength: MAX_COMMENT_NAME_CHARS, autoComplete: "name", placeholder: "Your name", style: NO_ZOOM_FONT, className: "mt-1 w-full rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-ground,transparent)] px-2 py-1 text-[15px]" })] })) : 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" })] })] }));
94
+ 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, closed) })) : 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))
95
+ 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, askName ? (_jsxs("label", { "data-comment-name": true, className: "mt-2 block text-[13px]", children: [_jsx("span", { className: "block opacity-80", children: requireName ? `Your first name, so ${ownerName} knows who this is from` : `Your name, so ${ownerName} can credit your feedback` }), _jsx("input", { value: name, onChange: (e) => setName(e.target.value), maxLength: MAX_COMMENT_NAME_CHARS, autoComplete: "name", placeholder: "Your name", style: NO_ZOOM_FONT, className: "mt-1 w-full rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-ground,transparent)] px-2 py-1 text-[15px]" })] })) : 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" })] })] }));
90
96
  }
@@ -5,7 +5,7 @@ import { type DeviceAudio } from './device-audio.js';
5
5
  export declare const resolvedLine: (r: CommentResolution) => string;
6
6
  /** A finger rather than a mouse: the comment button then goes where the OS menu is not. */
7
7
  export declare function onTouch(): boolean;
8
- export declare function CommentLayer({ artifactId, rootId, ownerName, comments, canShare, signedIn, isOwner, version, accent, ground, signIn, voice, audioStore, }: {
8
+ export declare function CommentLayer({ artifactId, rootId, ownerName, comments, canShare, signedIn, isOwner, version, accent, ground, signIn, voice, audioStore, closesAt, requireName, knownName, }: {
9
9
  artifactId: string;
10
10
  /** The element holding the page's prose, the same root the read-aloud lights. */
11
11
  rootId: string;
@@ -24,4 +24,10 @@ export declare function CommentLayer({ artifactId, rootId, ownerName, comments,
24
24
  voice?: VoiceHost;
25
25
  /** Where an anonymous reader's recordings live; defaults to this browser's IndexedDB. */
26
26
  audioStore?: DeviceAudio;
27
+ /** When this reader's feedback window closes (an ISO time), or null. Past it, a note is personal. */
28
+ closesAt?: string | null;
29
+ /** A shared comment needs a first name (`comment_name: required`, on an open link). */
30
+ requireName?: boolean;
31
+ /** Who the reader is, on a named link of a `comment_name: required` page: the card does not ask. */
32
+ knownName?: string | null;
27
33
  }): import("react").JSX.Element;
@@ -31,8 +31,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
31
31
  import { createPortal } from 'react-dom';
32
32
  import { PINS_SET, announcePins, signInBack } from './page-bridge.js';
33
33
  import { MAX_QUOTE_CHARS, textAnchorFrom } from '../artifacts/anchor.js';
34
- import { COMMENTS_SLOT, MAX_RESOLVE_NOTE_CHARS, resolutionOf } from '../artifacts/comments.js';
35
- import { CommentCard } from './comment-card.js';
34
+ import { COMMENTS_SLOT, MAX_COMMENT_NAME_CHARS, MAX_RESOLVE_NOTE_CHARS, resolutionOf } from '../artifacts/comments.js';
35
+ import { CommentCard, recallName, rememberName } from './comment-card.js';
36
36
  import { AudioPlay, hostVoice, newMemoId } from './voice-recorder.js';
37
37
  import { deviceAudio as defaultDeviceAudio, isDeviceAudio } from './device-audio.js';
38
38
  import { audioExtFor } from '../artifacts/audio.js';
@@ -87,7 +87,7 @@ const personalFrom = (notes, where) => notes.filter((n) => isValue(n.value)).map
87
87
  export function onTouch() {
88
88
  return typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches;
89
89
  }
90
- export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare, signedIn, isOwner, version, accent, ground, signIn, voice, audioStore, }) {
90
+ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare, signedIn, isOwner, version, accent, ground, signIn, voice, audioStore, closesAt = null, requireName = false, knownName = null, }) {
91
91
  const host = useMemo(() => voice ?? hostVoice(artifactId), [voice, artifactId]);
92
92
  const audioDb = useMemo(() => audioStore ?? defaultDeviceAudio, [audioStore]);
93
93
  const stateUrl = `/api/artifacts/${artifactId}/state`;
@@ -116,6 +116,49 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
116
116
  const [scrollTo, setScrollTo] = useState(null);
117
117
  /** A resolved thread the address named, opened expanded rather than collapsed. */
118
118
  const [expanded, setExpanded] = useState(null);
119
+ // The window is checked on mount and again the moment it passes, so a page left open stops
120
+ // offering to share without a reload. A time that cannot be read is closed.
121
+ const [closed, setClosed] = useState(false);
122
+ useEffect(() => {
123
+ if (!closesAt) {
124
+ setClosed(false);
125
+ return;
126
+ }
127
+ const ms = Date.parse(closesAt) - Date.now();
128
+ if (!(ms > 0)) {
129
+ setClosed(true);
130
+ return;
131
+ }
132
+ setClosed(false);
133
+ // setTimeout cannot wait longer than about 24 days: past that, wake early and look at the clock again.
134
+ let t;
135
+ const arm = () => {
136
+ const left = Date.parse(closesAt) - Date.now();
137
+ if (!(left > 0)) {
138
+ setClosed(true);
139
+ return;
140
+ }
141
+ t = setTimeout(arm, Math.min(left, 2_147_000_000));
142
+ };
143
+ t = setTimeout(arm, Math.min(ms, 2_147_000_000));
144
+ return () => clearTimeout(t);
145
+ }, [closesAt]);
146
+ const share = canShare && !closed;
147
+ /** Scroll to where a publisher reply is pinned (the passage that changed) and select it. */
148
+ const jump = (i) => {
149
+ const root = document.getElementById(rootId);
150
+ if (!root)
151
+ return;
152
+ const at = placeAnchor(root, indexText(root), i.value.anchor);
153
+ if (!at)
154
+ return;
155
+ window.scrollTo({ top: Math.max(0, boxOf(at).top - 120), behavior: 'smooth' });
156
+ if (at.kind === 'text') {
157
+ const sel = document.getSelection();
158
+ sel?.removeAllRanges();
159
+ sel?.addRange(at.range);
160
+ }
161
+ };
119
162
  const call = useCallback(async (url, init) => {
120
163
  try {
121
164
  const r = await fetch(url, { credentials: 'same-origin', cache: 'no-store', ...init });
@@ -492,8 +535,10 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
492
535
  setShared(sharedFrom(r.body));
493
536
  return null;
494
537
  };
495
- const reply = async (parent, body) => {
496
- const value = { anchor: parent.value.anchor, body, version, parent: parent.id };
538
+ const reply = async (parent, body, by) => {
539
+ const value = { anchor: parent.value.anchor, body, version, parent: parent.id, ...(requireName && !knownName && by ? { by } : {}) };
540
+ if (requireName && !knownName && by)
541
+ rememberName(by);
497
542
  const r = await call(stateUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slot: COMMENTS_SLOT, op: 'append', value }) });
498
543
  if (r.status !== 200)
499
544
  return typeof r.body.error === 'string' ? r.body.error : 'that did not save; try again';
@@ -516,8 +561,8 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
516
561
  // On a phone the button sits at the TOP of the screen, never beside the selection: that is
517
562
  // where iOS draws its own Copy / Look Up menu, and the two fought for the same tap (Gary,
518
563
  // 2026-09-29: "I keep trying to fight with the Apple pop-up"). A mouse keeps it by the text.
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] }));
564
+ _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: share, closed: closed, requireName: requireName, knownName: knownName, 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' && share, awaiting: closesAt != null, askName: requireName && !knownName, onJump: jump, 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;
565
+ return (_jsxs(_Fragment, { children: [_jsx("span", { hidden: true, "data-comment-layer": comments, "data-comment-can-share": share ? '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] }));
521
566
  }
522
567
  function Pin({ item, left, top, accent, ground, onOpen }) {
523
568
  const personal = item.kind === 'personal';
@@ -530,7 +575,7 @@ function Floating({ x, y, children }) {
530
575
  const left = typeof window === 'undefined' ? x : Math.max(window.scrollX + 8, Math.min(x, window.scrollX + window.innerWidth - width - 8));
531
576
  return _jsx("div", { className: "absolute z-50", style: { left, top: y }, children: children });
532
577
  }
533
- function Thread({ item, replies, canReply, canResolve = false, startExpanded = false, accent, audioDb, onClose, onRemove, onEdit, onReply, onResolve }) {
578
+ function Thread({ item, replies, canReply, awaiting = false, askName = false, onJump, canResolve = false, startExpanded = false, accent, audioDb, onClose, onRemove, onEdit, onReply, onResolve }) {
534
579
  const [replying, setReplying] = useState(false);
535
580
  const [resolving, setResolving] = useState(false);
536
581
  const [error, setError] = useState(null);
@@ -541,8 +586,13 @@ function Thread({ item, replies, canReply, canResolve = false, startExpanded = f
541
586
  if (resolved && !showResolved) {
542
587
  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
588
  }
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
545
- ? _jsx(InlineForm, { label: "Reply", accent: accent, onCancel: () => setReplying(false), onSubmit: async (b) => { const e = await onReply(item, b); if (!e)
589
+ 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) => {
590
+ // A publisher reply pinned somewhere other than its thread points at the passage that changed.
591
+ const moved = r.value.publisher === true && r.value.anchor.kind === 'text' && (item.value.anchor.kind !== 'text' || r.value.anchor.quote !== item.value.anchor.quote);
592
+ return _jsx("li", { className: "m-0", children: _jsx(Entry, { item: r, accent: accent, audioDb: audioDb, onRemove: onRemove, onEdit: onEdit, onJump: moved ? onJump : undefined }) }, r.id);
593
+ }) }) : null, awaiting && item.mine && item.kind === 'shared' && !resolved && !replies.some((r) => r.value.publisher === true)
594
+ ? _jsx("p", { "data-comment-waiting": true, className: "m-0 mt-2 text-xs opacity-70", children: "Waiting for a reply." }) : null, canReply ? (replying
595
+ ? _jsx(InlineForm, { label: "Reply", accent: accent, askName: askName, onCancel: () => setReplying(false), onSubmit: async (b, by) => { const e = await onReply(item, b, by); if (!e)
546
596
  setReplying(false); return e; } })
547
597
  : _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
598
  : resolving
@@ -568,17 +618,26 @@ function useAudioSrc(item, audioDb) {
568
618
  }, [key, audioDb]);
569
619
  return item.audioUrl ?? local;
570
620
  }
571
- function Entry({ item, accent, audioDb, onRemove, onEdit }) {
621
+ function Entry({ item, accent, audioDb, onRemove, onEdit, onJump }) {
572
622
  const [editing, setEditing] = useState(false);
573
623
  const src = useAudioSrc(item, audioDb);
574
624
  if (editing)
575
625
  return _jsx(InlineForm, { label: "Save", initial: item.value.body, onCancel: () => setEditing(false), onSubmit: async (b) => { const e = await onEdit(item, b); if (!e)
576
626
  setEditing(false); return e; } });
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] })] }));
627
+ 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] }), onJump ? _jsx("button", { type: "button", "data-comment-jump": true, onClick: () => onJump(item), className: "mt-1 text-xs underline", children: "Show what changed" }) : null] }));
578
628
  }
579
- function InlineForm({ label, initial = '', accent = 'currentColor', optional = false, placeholder, maxLength, onSubmit, onCancel }) {
629
+ function InlineForm({ label, initial = '', accent = 'currentColor', optional = false, askName = false, placeholder, maxLength, onSubmit, onCancel }) {
580
630
  const [text, setText] = useState(initial);
581
631
  const [error, setError] = useState(null);
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" })] })] }));
632
+ const [name, setName] = useState(askName ? recallName : '');
633
+ return (_jsxs("form", { className: "mt-2", onSubmit: async (e) => {
634
+ e.preventDefault();
635
+ if (!optional && !text.trim())
636
+ return;
637
+ if (askName && !name.trim()) {
638
+ setError('Add your first name first.');
639
+ return;
640
+ }
641
+ setError(await onSubmit(text.trim(), askName ? name.trim() : undefined));
642
+ }, 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 }), askName ? (_jsxs("label", { "data-comment-name": true, className: "mt-1 block text-xs", children: [_jsx("span", { className: "block opacity-80", children: "Your first name" }), _jsx("input", { value: name, onChange: (e) => setName(e.target.value), maxLength: MAX_COMMENT_NAME_CHARS, autoComplete: "name", placeholder: "Your name", style: NO_ZOOM_FONT, className: "mt-1 w-full rounded-lg border border-[color:var(--a-line)] bg-transparent px-2 py-1" })] })) : null, 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" })] })] }));
584
643
  }
@@ -0,0 +1,13 @@
1
+ export declare const FEEDBACK_CLOSED_LINE = "Feedback is closed. You can still read the draft and your own notes.";
2
+ /** "3 days left", "23 hours left", "45 minutes left"; null once the window has passed or cannot be read. */
3
+ export declare function timeLeft(closesAt: string, now: number): string | null;
4
+ export declare function FeedbackWindow({ owner, label, note, group, closesAt, now }: {
5
+ owner: string;
6
+ label: string;
7
+ note: string | null;
8
+ /** An open link, shared by several people: nobody is named. */
9
+ group: boolean;
10
+ closesAt: string;
11
+ /** The clock, for tests. Left out, the banner reads the real one and ticks. */
12
+ now?: number;
13
+ }): import("react").JSX.Element;
@@ -0,0 +1,39 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // The banner a reviewer sees on a draft sent for feedback (0.40.0): whose draft it is, the one line
4
+ // the owner wrote about what they want, and how long the reader has, counted down while the page is
5
+ // open. Everything here is data-nospeak: it is furniture, never part of the piece.
6
+ import { useEffect, useState } from 'react';
7
+ export const FEEDBACK_CLOSED_LINE = 'Feedback is closed. You can still read the draft and your own notes.';
8
+ /** "3 days left", "23 hours left", "45 minutes left"; null once the window has passed or cannot be read. */
9
+ export function timeLeft(closesAt, now) {
10
+ const ms = Date.parse(closesAt) - now;
11
+ if (!(ms > 0))
12
+ return null;
13
+ const minutes = Math.ceil(ms / 60_000);
14
+ if (minutes >= 2880)
15
+ return `${Math.floor(minutes / 1440)} days left`;
16
+ if (minutes >= 120)
17
+ return `${Math.floor(minutes / 60)} hours left`;
18
+ if (minutes >= 2)
19
+ return `${minutes} minutes left`;
20
+ return '1 minute left';
21
+ }
22
+ export function FeedbackWindow({ owner, label, note, group, closesAt, now }) {
23
+ const [at, setAt] = useState(() => now ?? Date.now());
24
+ // Markup the server rendered (or a render held for a while) keeps its text through hydration,
25
+ // because the line suppresses the mismatch. So on mount the clock is read again and the line is
26
+ // drawn afresh (a new key), before the first tick: a stale count, or an open window that has
27
+ // already closed, never waits 30 seconds to be corrected.
28
+ const [live, setLive] = useState(false);
29
+ useEffect(() => {
30
+ if (now !== undefined)
31
+ return;
32
+ setAt(Date.now());
33
+ setLive(true);
34
+ const t = setInterval(() => setAt(Date.now()), 30_000);
35
+ return () => clearInterval(t);
36
+ }, [now]);
37
+ const left = timeLeft(closesAt, at);
38
+ return (_jsx("aside", { "data-feedback-window": true, "data-nospeak": true, className: "mx-auto mt-6 mb-0 max-w-2xl px-6", children: _jsxs("div", { className: "rounded-xl border border-[color:var(--a-line-strong)] px-4 py-3 text-[15px]", children: [_jsx("p", { className: "m-0 font-medium", children: `A draft from ${owner}${group ? '' : `, for ${label}`}` }), note ? _jsx("p", { "data-feedback-note": true, className: "m-0 mt-1", children: note }) : null, _jsx("p", { "data-feedback-left": true, "aria-live": "polite", suppressHydrationWarning: true, className: "m-0 mt-1 opacity-80", children: left ? `Feedback is open: ${left}.` : FEEDBACK_CLOSED_LINE }, live ? 'live' : 'first')] }) }));
39
+ }
@@ -203,6 +203,11 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
203
203
  id: string;
204
204
  }>;
205
205
  }) => Promise<NextResponse<unknown>>;
206
+ REPLY: (req: NextRequest, { params }: {
207
+ params: Promise<{
208
+ id: string;
209
+ }>;
210
+ }) => Promise<NextResponse<unknown>>;
206
211
  Page: ({ params, searchParams }: PageProps) => Promise<import("react").JSX.Element>;
207
212
  generateMetadata: ({ params }: Params) => Promise<Metadata>;
208
213
  VersionPage: ({ params, searchParams }: VersionProps) => Promise<import("react").JSX.Element>;
@@ -21,6 +21,7 @@ import { ArtifactReader } from '../reader/artifact-reader.js';
21
21
  import { CommentLayer } from '../reader/comment-layer.js';
22
22
  import { ReaderWatch } from '../reader/reader-watch.js';
23
23
  import { UpdatedTime } from '../reader/updated-time.js';
24
+ import { FeedbackWindow } from '../reader/feedback-window.js';
24
25
  import { perReaderGate, withPrivateUrls, NO_LINK, UNAVAILABLE } from './per-reader.js';
25
26
  import { ridCookieName, mintRenderToken, usableSecret, isUnfurlAgent, RID_COOKIE_MAX_AGE } from '../artifacts/recipients.js';
26
27
  import { PageMenu } from '../reader/page-menu.js';
@@ -325,7 +326,7 @@ export function createArtifactRoutes(config) {
325
326
  const remember = g.fromLink && !bot
326
327
  ? [pagePath(id), `/api/artifacts/${id}`].map((p) => `document.cookie=${JSON.stringify(`${ridCookieName(id)}=${g.rid}.${g.sig}; Path=${p}; Max-Age=${RID_COOKIE_MAX_AGE}; SameSite=Lax; Secure`)}`).join(';')
327
328
  : null;
328
- const perReader = bot ? undefined : { rid: g.rid, sig: g.sig, label: g.label, note: g.note, owner: g.owner, token: mintRenderToken(secret, id, g.rid) };
329
+ const perReader = bot ? undefined : { rid: g.rid, sig: g.sig, label: g.label, note: g.note, owner: g.owner, group: g.group, closesAt: g.closesAt, token: mintRenderToken(secret, id, g.rid) };
329
330
  const markdown = await media(a.markdown);
330
331
  const body = broadcast
331
332
  ? await BroadcastCard({ ...a, markdown }, { reader: null, perReader, bot })
@@ -457,7 +458,7 @@ export function createArtifactRoutes(config) {
457
458
  const cover = !bot && a.cover && !/!\[[^\]]*\]\(/.test(a.markdown) ? a.cover : null;
458
459
  return (_jsxs(_Fragment, { children: [admin ? (_jsx(PageMenu, { role: roleOf(reader), name: reader ? reader.name ?? reader.email : null, share: null, comments: mode !== 'off', signIn: null, signOut: signOutUrl(a.id), history: null, markdown: null, settings: null })) : null, _jsx("main", { "data-broadcast": true, className: "mx-auto max-w-lg px-4 pt-16 pb-20 sm:pt-24", children: _jsxs("article", { id: "broadcast-root", "data-broadcast-card": true, className: "rounded-2xl border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-5 py-6 sm:px-7 sm:py-8", children: [_jsx("p", { "data-nospeak": true, "data-broadcast-from": true, className: "text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: `From ${owner}` }), _jsx("h1", { className: "mt-3 text-2xl sm:text-3xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), cover ? (
459
460
  // eslint-disable-next-line @next/next/no-img-element
460
- _jsx("img", { src: cover, alt: "", className: "mt-5 w-full rounded-xl border border-[color:var(--a-line)]" })) : null, _jsx("div", { "data-broadcast-body": true, className: "mt-4", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, checklist: { artifactId: a.id, owner, accent: brand.accent }, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined }) }), perReader?.note ? (_jsxs("div", { "data-for-reader": true, className: "mt-6 rounded-xl border border-[color:var(--a-line)] px-4 py-3", children: [_jsx("p", { "data-nospeak": true, className: "text-[11px] uppercase tracking-[0.2em] opacity-70", children: `For ${perReader.label}` }), _jsx("p", { className: "mt-1 text-lg", children: perReader.note })] })) : null] }) }), mode !== 'off' ? (_jsx(CommentLayer, { artifactId: a.id, rootId: "broadcast-root", ownerName: owner, comments: mode, canShare: reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone', signedIn: reader !== null, isOwner: admin, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: null })) : null, perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null] }));
461
+ _jsx("img", { src: cover, alt: "", className: "mt-5 w-full rounded-xl border border-[color:var(--a-line)]" })) : null, _jsx("div", { "data-broadcast-body": true, className: "mt-4", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, checklist: { artifactId: a.id, owner, accent: brand.accent }, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined }) }), perReader?.note ? (_jsxs("div", { "data-for-reader": true, className: "mt-6 rounded-xl border border-[color:var(--a-line)] px-4 py-3", children: [_jsx("p", { "data-nospeak": true, className: "text-[11px] uppercase tracking-[0.2em] opacity-70", children: `For ${perReader.label}` }), _jsx("p", { className: "mt-1 text-lg", children: perReader.note })] })) : null] }) }), mode !== 'off' ? (_jsx(CommentLayer, { artifactId: a.id, rootId: "broadcast-root", ownerName: owner, comments: mode, canShare: reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone', signedIn: reader !== null, isOwner: admin, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null })) : null, perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null] }));
461
462
  }
462
463
  /** The page itself, shared by open and gated pages. */
463
464
  async function Body(a, { top, reader, perReader }) {
@@ -482,10 +483,10 @@ export function createArtifactRoutes(config) {
482
483
  const n = a.version ?? (a.versions?.length ?? 0) + 1;
483
484
  const meta = (_jsxs(_Fragment, { children: [_jsx(VersionLine, { n: n, at: a.updatedAt }), top && reader ? (_jsxs("p", { "data-nospeak": true, "data-reader-line": true, className: "mt-2 text-xs opacity-60", children: [`Signed in as ${reader.email} · `, _jsx("a", { href: switchUrl(a.id), className: "underline", children: "Use a different account" }), ' · ', _jsx("a", { href: signOutUrl(a.id), className: "underline", children: "Sign out" })] })) : null] }));
484
485
  const header = pack.Cover ? (_jsx(pack.Cover, { title: a.title, subtitle: a.subtitle, summary: a.summary, to: a.to, markdown: a.markdown, kicker: _jsx(Kicker, { to: a.to, brand: brand }), meta: meta, version: n, updatedAt: a.updatedAt, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx(Kicker, { to: a.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary }), meta] }));
485
- return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [perReader?.note ? (_jsx("p", { "data-for-reader": true, className: "mx-auto mt-6 mb-0 max-w-2xl px-6 text-lg", children: `For ${perReader.label}: ${perReader.note}` })) : null, header, a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined,
486
+ return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [perReader?.closesAt && !perReader.owner ? (_jsx(FeedbackWindow, { owner: owner, label: perReader.label, note: perReader.note, group: perReader.group, closesAt: perReader.closesAt })) : perReader?.note ? (_jsx("p", { "data-for-reader": true, className: "mx-auto mt-6 mb-0 max-w-2xl px-6 text-lg", children: `For ${perReader.label}: ${perReader.note}` })) : null, header, a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined,
486
487
  // Ticks are kept in the reader's browser on any host; Send needs one that keeps answers.
487
488
  checklist: { artifactId: a.id, send: !!config.state && !!a.state && Object.hasOwn(a.state.slots, CHECKLIST_SLOT), owner, accent: brand.accent }, Section: pack.Section, figure: pack.figure, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined }) }));
488
- })() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: isAdmin(reader), version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
489
+ })() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: isAdmin(reader), version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null }), perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
489
490
  2026-09-29: "NEVER PUT DEFAULT SAFARI VOICE DICTATION"); the audio is Kokoro or ElevenLabs,
490
491
  recorded at publish, or there is none. */] }));
491
492
  }
@@ -6,6 +6,8 @@ export type PerReaderOpen = {
6
6
  label: string;
7
7
  note: string | null;
8
8
  owner: boolean;
9
+ group: boolean;
10
+ closesAt: string | null;
9
11
  fromLink: boolean;
10
12
  };
11
13
  export type PerReaderShut = {
@@ -23,7 +23,7 @@ export async function perReaderGate(o) {
23
23
  // A malformed expireAt parses to NaN: treat it as expired, never as no expiry.
24
24
  if (rec.expireAt && !(Date.parse(rec.expireAt) > (o.now ?? Date.now())))
25
25
  return { state: 'shut', why: 'expired' };
26
- return { state: 'open', rid: pick.rid, sig: pick.sig, label: r.label, note: r.note ?? null, owner: r.owner === true, fromLink: pick.fromLink };
26
+ return { state: 'open', rid: pick.rid, sig: pick.sig, label: r.label, note: r.note ?? null, owner: r.owner === true, group: r.group === true, closesAt: r.closesAt ?? null, fromLink: pick.fromLink };
27
27
  }
28
28
  const REF_IN_MD = /\]\((private:[A-Za-z0-9][A-Za-z0-9._-]{0,140})\)/g;
29
29
  export async function withPrivateUrls(markdown, id, privateUrl) {
@@ -65,7 +65,7 @@ export function createRecipientsRoutes(given) {
65
65
  const rec = (await ctx.recipients.get(id)) ?? { recipients: {}, expireAt: null, keep: false };
66
66
  return {
67
67
  id, expireAt: rec.expireAt, keep: rec.keep,
68
- recipients: Object.values(rec.recipients).map((r) => ({ rid: r.rid, label: r.label, group: r.group === true, owner: r.owner === true, revoked: !!r.revokedAt, url: ridLink(ctx.pageUrl(id), id, r.rid, secret) })),
68
+ recipients: Object.values(rec.recipients).map((r) => ({ rid: r.rid, label: r.label, group: r.group === true, owner: r.owner === true, revoked: !!r.revokedAt, closesAt: r.closesAt ?? null, url: ridLink(ctx.pageUrl(id), id, r.rid, secret) })),
69
69
  };
70
70
  }
71
71
  /** GET and POST /api/artifacts/<id>/recipients: who a per-reader page was sent to (publish key). */
@@ -93,17 +93,35 @@ export function createRecipientsRoutes(given) {
93
93
  return json({ error: 'expires must be an ISO time or null' }, 400);
94
94
  if (typeof b.expires === 'string' && Date.parse(b.expires) <= Date.now())
95
95
  return json({ error: 'expires must be in the future' }, 400);
96
- if (Array.isArray(b.add) && b.add.length) {
97
- const made = await ctx.recipients.add(id, b.add);
96
+ const adds = Array.isArray(b.add) ? b.add : [];
97
+ if (adds.some((x) => x.closes !== undefined && (typeof x.closes !== 'string' || Number.isNaN(Date.parse(x.closes)))))
98
+ return json({ error: 'closes must be an ISO time' }, 400);
99
+ const win = b.closes;
100
+ if (win !== undefined) {
101
+ const ridsOk = !!win && (win.rids === '*' || (Array.isArray(win.rids) && win.rids.every((x) => typeof x === 'string' && RID_RE.test(x))));
102
+ const atOk = !!win && (win.at === null || (typeof win.at === 'string' && !Number.isNaN(Date.parse(win.at))));
103
+ if (!ridsOk || !atOk)
104
+ return json({ error: 'closes is { rids: [rid] or "*", at: an ISO time or null }' }, 400);
105
+ if (!ctx.recipients.setCloses)
106
+ return json({ error: 'this host cannot set a feedback window' }, 501);
107
+ }
108
+ if (adds.length) {
109
+ const made = await ctx.recipients.add(id, adds.map((x) => ({
110
+ label: x.label,
111
+ ...(typeof x.note === 'string' ? { note: x.note } : {}), ...(x.owner === true ? { owner: true } : {}), ...(x.group === true ? { group: true } : {}),
112
+ // Never for the owner, whatever store the host brought: their link has no window.
113
+ ...(typeof x.closes === 'string' && x.owner !== true ? { closesAt: new Date(x.closes).toISOString() } : {}),
114
+ })));
98
115
  added = made.map((r) => ({ rid: r.rid, label: r.label, url: ridLink(ctx.pageUrl(id), id, r.rid, secret) }));
99
116
  }
100
117
  const revoked = b.revoke === '*' || Array.isArray(b.revoke)
101
118
  ? await ctx.recipients.revoke(id, b.revoke === '*' ? '*' : b.revoke.filter((x) => typeof x === 'string' && RID_RE.test(x)))
102
119
  : [];
120
+ const windowed = win ? await ctx.recipients.setCloses(id, win.rids, win.at === null ? null : new Date(win.at).toISOString()) : [];
103
121
  if (b.expires === null || typeof b.expires === 'string') {
104
122
  await ctx.recipients.setExpiry(id, b.expires === null ? null : new Date(b.expires).toISOString());
105
123
  }
106
- return json({ ...(await listing(id)), added, revoked });
124
+ return json({ ...(await listing(id)), added, revoked, windowed });
107
125
  }
108
126
  /** GET /api/artifacts/<id>/views: one line per reader, with their comments (publish key). */
109
127
  async function VIEWS(req, { params }) {
@@ -61,5 +61,6 @@ export declare function createStateRoutes(ctx: StateRoutesContext): {
61
61
  RESPONSES: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
62
62
  COMMENTS_FEED: (req: NextRequest) => Promise<NextResponse<unknown>>;
63
63
  RESOLVE: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
64
+ REPLY: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
64
65
  };
65
66
  export {};