@supersuit/artifacts 0.38.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.
@@ -32,3 +32,10 @@ export declare function parseSimulatorFence(value: string, line: number): {
32
32
  error: string;
33
33
  };
34
34
  export declare const simulatorSrc: (embed: string, scenario: string) => string | null;
35
+ /** The message an embed page sends its frame, and the heights a frame will take. */
36
+ export declare const FIT_MESSAGE = "embed:fit";
37
+ export declare const FIT_MIN = 240;
38
+ export declare const FIT_MAX = 2000;
39
+ /** The height (px) a frame takes from a message, or null: only `embed:fit` with a finite number,
40
+ * only from the origin of the page the frame loaded, and never outside FIT_MIN..FIT_MAX. */
41
+ export declare function fitFrom(data: unknown, origin: string, src: string): number | null;
@@ -11,8 +11,16 @@
11
11
  // (ArtifactRoutesConfig.simulatorEmbed / messagesEmbed), so this public package points at nobody's
12
12
  // service by default, and a host without the player draws the caption alone rather than the block's
13
13
  // source as code. `simulator` keeps the syntax the Superskills book's reader takes.
14
+ //
15
+ // A FRAME THAT FITS (0.39.0). `height:` is the height a frame OPENS at, and the room its player is
16
+ // given. A session demo draws a different device for each viewer (a short laptop on a desktop, a
17
+ // tall phone on a phone), so no one height suits both: the frame asked its embed page `fit=content`,
18
+ // and a page that understands posts `{ type: 'embed:fit', height: <px> }` to its parent, which the
19
+ // frame then takes (EmbedFrame). A page that posts nothing keeps the block's height. The embed
20
+ // page must hold its player's size once asked, never size it from its own viewport, or the two
21
+ // chase each other; that is why the frame listens only for this message and for no player's own.
14
22
  const KINDS = {
15
- simulator: { key: 'scenario', required: true, fixed: { timeline: 'off' }, height: '720px' },
23
+ simulator: { key: 'scenario', required: true, fixed: { timeline: 'off', fit: 'content' }, height: '720px' },
16
24
  messages: { key: 'chat', required: false, fixed: {}, height: '760px' },
17
25
  };
18
26
  export const EMBED_KINDS = Object.keys(KINDS);
@@ -74,3 +82,26 @@ export function parseSimulatorFence(value, line) {
74
82
  return r.ok ? { ok: true, block: { scenario: r.block.value, caption: r.block.caption, height: r.block.height } } : r;
75
83
  }
76
84
  export const simulatorSrc = (embed, scenario) => embedSrc('simulator', embed, scenario);
85
+ /** The message an embed page sends its frame, and the heights a frame will take. */
86
+ export const FIT_MESSAGE = 'embed:fit';
87
+ export const FIT_MIN = 240;
88
+ export const FIT_MAX = 2000;
89
+ /** The height (px) a frame takes from a message, or null: only `embed:fit` with a finite number,
90
+ * only from the origin of the page the frame loaded, and never outside FIT_MIN..FIT_MAX. */
91
+ export function fitFrom(data, origin, src) {
92
+ if (!data || typeof data !== 'object')
93
+ return null;
94
+ const d = data;
95
+ if (d.type !== FIT_MESSAGE || typeof d.height !== 'number' || !Number.isFinite(d.height))
96
+ return null;
97
+ let expected;
98
+ try {
99
+ expected = new URL(src).origin;
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ if (origin !== expected)
105
+ return null;
106
+ return Math.max(FIT_MIN, Math.min(FIT_MAX, Math.ceil(d.height)));
107
+ }
@@ -82,6 +82,11 @@ export declare const readerKeyFor: {
82
82
  signedIn: (uid: string) => string;
83
83
  anonymous: (id: string) => string;
84
84
  recipient: (rid: string) => string;
85
+ /** One browser on a group link, on a page with `comment_name: required`: the link and the id
86
+ * `browserIdFor` derives. `ridOfReaderKey` reads the link back out of either `r:` shape. */
87
+ groupBrowser: (rid: string, browser: string) => string;
88
+ /** The publisher's one reply in a thread, keyed by the thread's top comment. */
89
+ publisher: (entryId: string) => string;
85
90
  };
86
91
  export declare function createStateStore(db: Firestore, base: string): StateStore;
87
92
  /** The same contract in memory: for tests, and for hosts' own tests. */
@@ -18,6 +18,11 @@ export const readerKeyFor = {
18
18
  signedIn: (uid) => `u:${uid}`,
19
19
  anonymous: (id) => `a:${id}`,
20
20
  recipient: (rid) => `r:${rid}`,
21
+ /** One browser on a group link, on a page with `comment_name: required`: the link and the id
22
+ * `browserIdFor` derives. `ridOfReaderKey` reads the link back out of either `r:` shape. */
23
+ groupBrowser: (rid, browser) => `r:${rid}.${browser}`,
24
+ /** The publisher's one reply in a thread, keyed by the thread's top comment. */
25
+ publisher: (entryId) => `p:${entryId}`,
21
26
  };
22
27
  const writerOf = (w) => ({
23
28
  ...(w.uid ? { uid: w.uid } : {}), ...(w.email ? { email: w.email } : {}), name: w.name, anonymous: w.anonymous,
@@ -32,6 +32,7 @@ export type ArtifactRecord = {
32
32
  /** The brand pack the page asked for by name; see ArtifactMeta.pack. */
33
33
  pack?: string;
34
34
  toc?: boolean;
35
+ commentName?: 'required';
35
36
  definitions?: Definition[];
36
37
  /** Who may leave shared comments; absent is off. Personal notes need nothing. */
37
38
  comments?: CommentsMode;
@@ -163,6 +163,7 @@ async function saveArtifact(col, input) {
163
163
  ...(input.meta.theme ? { theme: input.meta.theme } : {}),
164
164
  ...(input.meta.pack ? { pack: input.meta.pack } : {}),
165
165
  ...(input.meta.toc !== undefined ? { toc: input.meta.toc } : {}),
166
+ ...(input.meta.commentName ? { commentName: input.meta.commentName } : {}),
166
167
  ...(input.meta.definitions?.length ? { definitions: input.meta.definitions } : {}),
167
168
  };
168
169
  if (input.id) {
@@ -215,6 +216,7 @@ async function saveArtifact(col, input) {
215
216
  ...(input.meta.theme ? {} : { theme: FieldValue.delete() }),
216
217
  ...(input.meta.pack ? {} : { pack: FieldValue.delete() }),
217
218
  ...(input.meta.toc !== undefined ? {} : { toc: FieldValue.delete() }),
219
+ ...(input.meta.commentName ? {} : { commentName: FieldValue.delete() }),
218
220
  ...(input.meta.definitions?.length ? {} : { definitions: FieldValue.delete() }),
219
221
  };
220
222
  // `voice: none` is the page declaring it has no audio. An update merges, so the recording it
@@ -11,15 +11,23 @@ export type SavedMemo = {
11
11
  transcript?: CommentValue['transcript'];
12
12
  };
13
13
  /** The warning, word for word. `They` is fixed copy: the card never guesses a pronoun. */
14
- export declare function warningLine(ownerName: string, mode: CommentsMode): string;
14
+ export declare function warningLine(ownerName: string, mode: CommentsMode, closed?: boolean): string;
15
15
  export declare const DEVICE_LINE = "Saved on this device. Sign in to keep it everywhere.";
16
16
  /** Where a reader's declared name is remembered, so their next comment is already signed. */
17
17
  export declare const NAME_KEY = "artifacts:comment-name";
18
- export declare function CommentCard({ ownerName, mode, canShare, signedIn, quote, initial, accent, signIn, voice, onSave, onCancel, }: {
18
+ export declare const recallName: () => string;
19
+ export declare const rememberName: (name: string) => void;
20
+ export declare function CommentCard({ ownerName, mode, canShare, signedIn, closed, requireName, knownName, quote, initial, accent, signIn, voice, onSave, onCancel, }: {
19
21
  ownerName: string;
20
22
  mode: CommentsMode;
21
23
  canShare: boolean;
22
24
  signedIn: boolean;
25
+ /** The reader's feedback window has closed: the warning says so. */
26
+ closed?: boolean;
27
+ /** A shared comment is not sent without a first name (`comment_name: required`, on an open link). */
28
+ requireName?: boolean;
29
+ /** A named link on a `comment_name: required` page already says who this is: no name field. */
30
+ knownName?: string | null;
23
31
  /** The quoted text the comment is pinned to, shown above the box. */
24
32
  quote?: string;
25
33
  initial?: string;
@@ -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) {