@supersuit/artifacts 0.11.1 → 0.12.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.
@@ -23,7 +23,11 @@ import { createPortal } from 'react-dom';
23
23
  import { MAX_QUOTE_CHARS, textAnchorFrom } from '../artifacts/anchor.js';
24
24
  import { COMMENTS_SLOT } from '../artifacts/comments.js';
25
25
  import { CommentCard } from './comment-card.js';
26
+ import { AudioPlay, hostVoice, newMemoId } from './voice-recorder.js';
27
+ import { deviceAudio as defaultDeviceAudio, isDeviceAudio } from './device-audio.js';
28
+ import { audioExtFor } from '../artifacts/audio.js';
26
29
  import { themeVarsAround } from './theme-vars.js';
30
+ import { NO_ZOOM_FONT } from './no-zoom.js';
27
31
  import { addDeviceNote, deviceNotes, removeDeviceNote, replaceDeviceNote } from './device-notes.js';
28
32
  import { boxOf, headingAt, indexText, offsetAt, placeAnchor, regionFrom } from './comment-place.js';
29
33
  const isValue = (v) => !!v && typeof v === 'object' && typeof v.body === 'string' && !!v.anchor && typeof v.anchor === 'object';
@@ -52,12 +56,20 @@ function sharedFrom(body) {
52
56
  for (const r of rows.values()) {
53
57
  if (!isValue(r.value))
54
58
  continue;
55
- out.push({ id: String(r.id), kind: 'shared', where: 'server', value: r.value, name: r.mine === true ? 'You' : typeof r.name === 'string' ? r.name : 'a reader', mine: r.mine === true, at: String(r.at ?? '') });
59
+ out.push({
60
+ id: String(r.id), kind: 'shared', where: 'server', value: r.value, name: r.mine === true ? 'You' : typeof r.name === 'string' ? r.name : 'a reader', mine: r.mine === true, at: String(r.at ?? ''),
61
+ ...(typeof r.audioUrl === 'string' ? { audioUrl: r.audioUrl } : {}),
62
+ });
56
63
  }
57
64
  return out.sort((a, b) => a.at.localeCompare(b.at));
58
65
  }
59
- const personalFrom = (notes, where) => notes.filter((n) => isValue(n.value)).map((n) => ({ id: n.id, kind: 'personal', where, value: n.value, name: 'You', mine: true, at: n.at }));
60
- export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare, signedIn, isOwner, version, accent, ground, signIn, }) {
66
+ const personalFrom = (notes, where) => notes.filter((n) => isValue(n.value)).map((n) => ({
67
+ id: n.id, kind: 'personal', where, value: n.value, name: 'You', mine: true, at: n.at,
68
+ ...(typeof n.audioUrl === 'string' ? { audioUrl: n.audioUrl } : {}),
69
+ }));
70
+ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare, signedIn, isOwner, version, accent, ground, signIn, voice, audioStore, }) {
71
+ const host = useMemo(() => voice ?? hostVoice(artifactId), [voice, artifactId]);
72
+ const audioDb = useMemo(() => audioStore ?? defaultDeviceAudio, [audioStore]);
61
73
  const stateUrl = `/api/artifacts/${artifactId}/state`;
62
74
  const personalUrl = `/api/artifacts/${artifactId}/personal`;
63
75
  const [mounted, setMounted] = useState(false);
@@ -110,12 +122,34 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
110
122
  }
111
123
  let notes = r.body.notes ?? [];
112
124
  // Notes kept on this device before signing in move to the account, then leave the device.
125
+ // A recording kept on the device goes up to the account first, then the note names it there.
113
126
  for (const n of deviceNotes(artifactId)) {
114
- const m = await call(personalUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value: n.value }) });
127
+ const value = { ...n.value };
128
+ const key = isDeviceAudio(value.audio) ? value.audio : null;
129
+ if (key) {
130
+ const blob = await audioDb.get(key);
131
+ const path = blob ? await host.upload({ blob, mime: blob.type }, 'personal') : null;
132
+ if (blob && !path)
133
+ break;
134
+ if (path)
135
+ value.audio = path;
136
+ else {
137
+ // The recording is gone from this device: keep the words, or nothing is left to keep.
138
+ delete value.audio;
139
+ delete value.transcript;
140
+ if (!value.body.trim()) {
141
+ removeDeviceNote(artifactId, n.id);
142
+ continue;
143
+ }
144
+ }
145
+ }
146
+ const m = await call(personalUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
115
147
  if (m.status !== 200)
116
148
  break;
117
149
  notes = m.body.notes ?? notes;
118
150
  removeDeviceNote(artifactId, n.id);
151
+ if (key)
152
+ await audioDb.remove(key);
119
153
  }
120
154
  if (!live)
121
155
  return;
@@ -130,7 +164,7 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
130
164
  if (ro && root)
131
165
  ro.observe(root);
132
166
  return () => { live = false; window.removeEventListener('resize', relayout); ro?.disconnect(); };
133
- }, [artifactId, comments, signedIn, stateUrl, personalUrl, rootId, call, refreshDevice]);
167
+ }, [artifactId, comments, signedIn, stateUrl, personalUrl, rootId, call, refreshDevice, host, audioDb]);
134
168
  // Text selection inside the page shows the Comment chip.
135
169
  useEffect(() => {
136
170
  const root = document.getElementById(rootId);
@@ -284,8 +318,26 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
284
318
  return { placed, earlier };
285
319
  // `tick` re-measures after a resize.
286
320
  }, [mounted, rootId, tops, tick]); // eslint-disable-line react-hooks/exhaustive-deps
287
- const saveNew = async (body, share, d) => {
288
- const value = { anchor: d.anchor, body, version };
321
+ /** The recording's path for where it is being saved: reused when the card already uploaded it
322
+ * there, uploaded now when the reader switched between Share and Just for me after recording. */
323
+ const memoPath = async (m, scope) => m.uploaded?.scope === scope ? m.uploaded.path : host.upload(m.memo, scope);
324
+ const LOST = 'The recording did not upload. Try again, or discard it and save the text.';
325
+ const saveNew = async (body, share, d, memo) => {
326
+ const value = { anchor: d.anchor, body, version, ...(memo?.transcript ? { transcript: memo.transcript } : {}) };
327
+ const toDevice = !share && !(signedIn && onServer);
328
+ if (memo && !toDevice) {
329
+ const path = await memoPath(memo, share ? 'comment' : 'personal');
330
+ if (!path)
331
+ return LOST;
332
+ value.audio = path;
333
+ }
334
+ if (memo && toDevice) {
335
+ // Not signed in: the recording stays in this browser with the note, and never leaves it.
336
+ const key = `device/${newMemoId()}.${audioExtFor(memo.memo.mime) ?? 'webm'}`;
337
+ if (!(await audioDb.put(key, memo.memo.blob)))
338
+ return 'This browser would not keep the recording. Discard it and save the text.';
339
+ value.audio = key;
340
+ }
289
341
  if (share) {
290
342
  const r = await call(stateUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slot: COMMENTS_SLOT, op: 'append', value }) });
291
343
  if (r.status !== 200)
@@ -315,6 +367,8 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
315
367
  const remove = async (item) => {
316
368
  if (item.where === 'device') {
317
369
  removeDeviceNote(artifactId, item.id);
370
+ if (isDeviceAudio(item.value.audio))
371
+ await audioDb.remove(item.value.audio);
318
372
  refreshDevice();
319
373
  }
320
374
  else if (item.kind === 'personal') {
@@ -359,7 +413,7 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
359
413
  const open = layout.placed.find((p) => p.item.id === thread);
360
414
  const openItem = open?.item ?? layout.earlier.find((i) => i.id === thread);
361
415
  const count = tops.length;
362
- const overlay = mounted ? (_jsxs("div", { "data-comment-ui": true, "data-nospeak": true, className: "font-sans", style: theme, children: [showPins ? layout.placed.map(({ item, left, top, box }) => (_jsxs("div", { children: [box ? (_jsx("div", { "data-comment-box": item.id, "aria-hidden": true, className: "pointer-events-none absolute z-30 rounded-md border-2", style: { left: box.left, top: box.top, width: box.width, height: box.height, borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid', opacity: thread === item.id ? 1 : 0.55 } })) : null, _jsx(Pin, { item: item, left: left, top: top, accent: accent, ground: ground, onOpen: () => setThread(thread === item.id ? null : item.id) })] }, item.id))) : null, chip && !draft ? (_jsx("button", { type: "button", "data-comment-chip": true, className: "absolute z-50 -translate-x-full rounded-full px-3 py-1 text-xs font-medium shadow-lg", style: { left: chip.x, top: chip.y, background: accent, color: 'var(--a-on-accent, #111)' }, onMouseDown: (e) => e.preventDefault(), onClick: () => { setDraft(chip); setChip(null); }, children: "Comment" })) : null, draft ? (_jsx(Floating, { x: draft.x, y: draft.y, children: _jsx(CommentCard, { ownerName: ownerName, mode: comments, canShare: canShare, signedIn: signedIn && onServer, quote: draft.quote, accent: accent, signIn: signIn, onSave: (body, share) => saveNew(body, share, draft), onCancel: () => setDraft(null) }) })) : null, openItem && open ? (_jsx(Floating, { x: open.left + 28, y: open.top, children: _jsx(Thread, { item: openItem, replies: items.filter((i) => i.value.parent === openItem.id), canReply: openItem.kind === 'shared' && canShare, accent: accent, onClose: () => setThread(null), onRemove: remove, onEdit: edit, onReply: reply }) })) : null, drag ? (_jsx("div", { "aria-hidden": true, className: "pointer-events-none fixed z-40 rounded-md border-2 border-dashed", style: { left: Math.min(drag.x1, drag.x2), top: Math.min(drag.y1, drag.y2), width: Math.abs(drag.x2 - drag.x1), height: Math.abs(drag.y2 - drag.y1), borderColor: accent, background: `color-mix(in srgb, ${accent} 12%, transparent)` } })) : null, _jsxs("div", { className: "fixed right-4 z-40 flex flex-col items-end gap-2", style: { bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)' }, children: [panel ? (_jsxs("div", { "data-comment-panel": true, className: "w-64 rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-xs text-[color:var(--a-strong)] shadow-xl", children: [_jsx("p", { className: "opacity-80", children: "Select any text, or hold and drag a box over anything, to leave a comment." }), isOwner ? _jsx("p", { "data-comment-owner": true, className: "mt-1 opacity-80", children: "This is your page: you see every comment, with the name of who left it." }) : null, _jsx("button", { type: "button", "data-comment-pins-toggle": true, className: "mt-2 underline", onClick: () => setShowPins((v) => !v), children: showPins ? 'Hide comments' : 'Show comments' })] })) : null, _jsx("button", { type: "button", "data-comment-button": true, "aria-expanded": panel, onClick: () => setPanel((v) => !v), className: "rounded-full border px-3 py-1.5 text-xs font-medium shadow-lg backdrop-blur", style: { borderColor: accent, color: accent, background: `color-mix(in srgb, ${ground} 85%, transparent)` }, children: count ? `Comments · ${count}` : 'Comment' })] })] })) : null;
416
+ const overlay = mounted ? (_jsxs("div", { "data-comment-ui": true, "data-nospeak": true, className: "font-sans", style: theme, children: [showPins ? layout.placed.map(({ item, left, top, box }) => (_jsxs("div", { children: [box ? (_jsx("div", { "data-comment-box": item.id, "aria-hidden": true, className: "pointer-events-none absolute z-30 rounded-md border-2", style: { left: box.left, top: box.top, width: box.width, height: box.height, borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid', opacity: thread === item.id ? 1 : 0.55 } })) : null, _jsx(Pin, { item: item, left: left, top: top, accent: accent, ground: ground, onOpen: () => setThread(thread === item.id ? null : item.id) })] }, item.id))) : null, chip && !draft ? (_jsx("button", { type: "button", "data-comment-chip": true, className: "absolute z-50 -translate-x-full rounded-full px-3 py-1 text-xs font-medium shadow-lg", style: { left: chip.x, top: chip.y, background: accent, color: 'var(--a-on-accent, #111)' }, onMouseDown: (e) => e.preventDefault(), onClick: () => { setDraft(chip); setChip(null); }, children: "Comment" })) : null, draft ? (_jsx(Floating, { x: draft.x, y: draft.y, children: _jsx(CommentCard, { ownerName: ownerName, mode: comments, canShare: canShare, signedIn: signedIn && onServer, quote: draft.quote, accent: accent, signIn: signIn, voice: host, onSave: (body, share, memo) => saveNew(body, share, draft, memo), onCancel: () => setDraft(null) }) })) : null, openItem && open ? (_jsx(Floating, { x: open.left + 28, y: open.top, children: _jsx(Thread, { item: openItem, replies: items.filter((i) => i.value.parent === openItem.id), canReply: openItem.kind === 'shared' && canShare, accent: accent, audioDb: audioDb, onClose: () => setThread(null), onRemove: remove, onEdit: edit, onReply: reply }) })) : null, drag ? (_jsx("div", { "aria-hidden": true, className: "pointer-events-none fixed z-40 rounded-md border-2 border-dashed", style: { left: Math.min(drag.x1, drag.x2), top: Math.min(drag.y1, drag.y2), width: Math.abs(drag.x2 - drag.x1), height: Math.abs(drag.y2 - drag.y1), borderColor: accent, background: `color-mix(in srgb, ${accent} 12%, transparent)` } })) : null, _jsxs("div", { className: "fixed right-4 z-40 flex flex-col items-end gap-2", style: { bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)' }, children: [panel ? (_jsxs("div", { "data-comment-panel": true, className: "w-64 rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-xs text-[color:var(--a-strong)] shadow-xl", children: [_jsx("p", { className: "opacity-80", children: "Select any text, or hold and drag a box over anything, to leave a comment." }), isOwner ? _jsx("p", { "data-comment-owner": true, className: "mt-1 opacity-80", children: "This is your page: you see every comment, with the name of who left it." }) : null, _jsx("button", { type: "button", "data-comment-pins-toggle": true, className: "mt-2 underline", onClick: () => setShowPins((v) => !v), children: showPins ? 'Hide comments' : 'Show comments' })] })) : null, _jsx("button", { type: "button", "data-comment-button": true, "aria-expanded": panel, onClick: () => setPanel((v) => !v), className: "rounded-full border px-3 py-1.5 text-xs font-medium shadow-lg backdrop-blur", style: { borderColor: accent, color: accent, background: `color-mix(in srgb, ${ground} 85%, transparent)` }, children: count ? `Comments · ${count}` : 'Comment' })] })] })) : null;
363
417
  return (_jsxs(_Fragment, { children: [_jsx("span", { hidden: true, "data-comment-layer": comments, "data-comment-can-share": canShare ? 'yes' : 'no' }), mounted && layout.earlier.length ? (_jsxs("section", { "data-comments-earlier": true, "data-comment-ui": true, "data-nospeak": true, className: "mx-auto max-w-2xl px-6 pb-16 text-sm", children: [_jsx("p", { className: "text-[11px] uppercase tracking-[0.2em]", style: { color: accent }, children: "Comments on earlier versions" }), _jsx("ul", { className: "m-0 mt-2 grid list-none gap-3 p-0", children: layout.earlier.map((item) => (_jsxs("li", { className: "m-0 border-l-2 py-1 pl-3", style: { borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid' }, children: [item.value.anchor.kind === 'text' ? _jsxs("span", { className: "block text-[13px] italic opacity-70", children: ["\u201C", item.value.anchor.quote, "\u201D"] }) : _jsx("span", { className: "block text-[13px] italic opacity-70", children: "A box on part of the page that has changed" }), _jsx("span", { className: "block whitespace-pre-wrap text-[15px] text-[color:var(--a-strong)]", children: item.value.body }), _jsxs("span", { className: "block text-xs opacity-60", children: [item.kind === 'personal' ? 'only you' : item.name, ` · version ${item.value.version}`, item.mine ? _jsx("button", { type: "button", onClick: () => void remove(item), className: "ml-2 underline", children: "delete" }) : null] })] }, item.id))) })] })) : null, overlay ? createPortal(overlay, document.body) : null] }));
364
418
  }
365
419
  function Pin({ item, left, top, accent, ground, onOpen }) {
@@ -372,23 +426,42 @@ function Floating({ x, y, children }) {
372
426
  const left = typeof window === 'undefined' ? x : Math.max(window.scrollX + 8, Math.min(x, window.scrollX + window.innerWidth - width - 8));
373
427
  return _jsx("div", { className: "absolute z-50", style: { left, top: y }, children: children });
374
428
  }
375
- function Thread({ item, replies, canReply, accent, onClose, onRemove, onEdit, onReply }) {
429
+ function Thread({ item, replies, canReply, accent, audioDb, onClose, onRemove, onEdit, onReply }) {
376
430
  const [replying, setReplying] = useState(false);
377
- return (_jsxs("div", { "data-comment-thread": true, "data-comment-ui": true, "data-nospeak": true, className: "w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-sm text-[color:var(--a-strong)] shadow-xl", children: [_jsxs("div", { className: "mb-2 flex items-center justify-between text-[11px] uppercase tracking-[0.15em]", style: { color: accent }, children: [_jsx("span", { children: item.kind === 'personal' ? 'Only you' : 'Comment' }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close", className: "opacity-70 hover:opacity-100", children: "\u00D7" })] }), item.value.anchor.kind === 'text' ? _jsx("p", { className: "mb-2 line-clamp-2 border-l-2 pl-2 text-[13px] italic opacity-70", style: { borderColor: accent }, children: item.value.anchor.quote }) : null, _jsx(Entry, { item: item, 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, onRemove: onRemove, onEdit: onEdit }) }, r.id)) }) : null, canReply ? (replying
431
+ return (_jsxs("div", { "data-comment-thread": true, "data-comment-ui": true, "data-nospeak": true, className: "w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-sm text-[color:var(--a-strong)] shadow-xl", children: [_jsxs("div", { className: "mb-2 flex items-center justify-between text-[11px] uppercase tracking-[0.15em]", style: { color: accent }, children: [_jsx("span", { children: item.kind === 'personal' ? 'Only you' : 'Comment' }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close", className: "opacity-70 hover:opacity-100", children: "\u00D7" })] }), item.value.anchor.kind === 'text' ? _jsx("p", { className: "mb-2 line-clamp-2 border-l-2 pl-2 text-[13px] italic opacity-70", style: { borderColor: accent }, children: item.value.anchor.quote }) : null, _jsx(Entry, { item: item, accent: accent, audioDb: audioDb, onRemove: onRemove, onEdit: onEdit }), replies.length ? _jsx("ul", { className: "m-0 mt-2 grid list-none gap-2 border-l pl-3 p-0", children: replies.map((r) => _jsx("li", { className: "m-0", children: _jsx(Entry, { item: r, accent: accent, audioDb: audioDb, onRemove: onRemove, onEdit: onEdit }) }, r.id)) }) : null, canReply ? (replying
378
432
  ? _jsx(InlineForm, { label: "Reply", accent: accent, onCancel: () => setReplying(false), onSubmit: async (b) => { const e = await onReply(item, b); if (!e)
379
433
  setReplying(false); return e; } })
380
434
  : _jsx("button", { type: "button", "data-comment-reply": true, onClick: () => setReplying(true), className: "mt-2 text-xs underline", children: "Reply" })) : null] }));
381
435
  }
382
- function Entry({ item, onRemove, onEdit }) {
436
+ /** Where a comment's recording plays from: its signed URL, or this browser's copy of a device note's. */
437
+ function useAudioSrc(item, audioDb) {
438
+ const [local, setLocal] = useState(null);
439
+ const key = item.where === 'device' && isDeviceAudio(item.value.audio) ? item.value.audio : null;
440
+ useEffect(() => {
441
+ if (!key || typeof URL.createObjectURL !== 'function')
442
+ return;
443
+ let url = null;
444
+ let live = true;
445
+ void audioDb.get(key).then((b) => { if (live && b) {
446
+ url = URL.createObjectURL(b);
447
+ setLocal(url);
448
+ } });
449
+ return () => { live = false; if (url)
450
+ URL.revokeObjectURL(url); };
451
+ }, [key, audioDb]);
452
+ return item.audioUrl ?? local;
453
+ }
454
+ function Entry({ item, accent, audioDb, onRemove, onEdit }) {
383
455
  const [editing, setEditing] = useState(false);
456
+ const src = useAudioSrc(item, audioDb);
384
457
  if (editing)
385
458
  return _jsx(InlineForm, { label: "Save", initial: item.value.body, onCancel: () => setEditing(false), onSubmit: async (b) => { const e = await onEdit(item, b); if (!e)
386
459
  setEditing(false); return e; } });
387
- return (_jsxs("div", { children: [_jsx("p", { className: "m-0 whitespace-pre-wrap text-[15px]", children: item.value.body }), _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] })] }));
460
+ 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] })] }));
388
461
  }
389
462
  function InlineForm({ label, initial = '', accent = 'currentColor', onSubmit, onCancel }) {
390
463
  const [text, setText] = useState(initial);
391
464
  const [error, setError] = useState(null);
392
465
  return (_jsxs("form", { className: "mt-2", onSubmit: async (e) => { e.preventDefault(); if (text.trim())
393
- setError(await onSubmit(text.trim())); }, children: [_jsx("textarea", { value: text, autoFocus: true, rows: 2, onChange: (e) => setText(e.target.value), "aria-label": label, className: "w-full rounded-lg border border-[color:var(--a-line)] bg-transparent p-2 text-[15px]" }), error ? _jsx("p", { role: "alert", className: "text-xs text-amber-500", children: error }) : null, _jsxs("div", { className: "mt-1 flex gap-3 text-xs", children: [_jsx("button", { type: "submit", disabled: !text.trim(), className: "rounded-full px-3 py-0.5 font-medium disabled:opacity-40", style: { background: accent, color: 'var(--a-on-accent, #111)' }, children: label }), _jsx("button", { type: "button", onClick: onCancel, className: "opacity-70", children: "Cancel" })] })] }));
466
+ setError(await onSubmit(text.trim())); }, children: [_jsx("textarea", { value: text, autoFocus: true, rows: 2, onChange: (e) => setText(e.target.value), "aria-label": label, className: "w-full rounded-lg border border-[color:var(--a-line)] bg-transparent p-2", style: NO_ZOOM_FONT }), error ? _jsx("p", { role: "alert", className: "text-xs text-amber-500", children: error }) : null, _jsxs("div", { className: "mt-1 flex gap-3 text-xs", children: [_jsx("button", { type: "submit", disabled: !text.trim(), className: "rounded-full px-3 py-0.5 font-medium disabled:opacity-40", style: { background: accent, color: 'var(--a-on-accent, #111)' }, children: label }), _jsx("button", { type: "button", onClick: onCancel, className: "opacity-70", children: "Cancel" })] })] }));
394
467
  }
@@ -0,0 +1,7 @@
1
+ export declare const isDeviceAudio: (p: unknown) => p is string;
2
+ export type DeviceAudio = {
3
+ put(key: string, blob: Blob): Promise<boolean>;
4
+ get(key: string): Promise<Blob | null>;
5
+ remove(key: string): Promise<void>;
6
+ };
7
+ export declare const deviceAudio: DeviceAudio;
@@ -0,0 +1,43 @@
1
+ // Recordings on personal notes for a reader who is not signed in: kept in this browser's IndexedDB
2
+ // and nowhere else, like the notes themselves (device-notes.ts). A note names its recording as
3
+ // `device/<memo>.<ext>`; on sign-in the comment layer uploads it to the reader's account and
4
+ // deletes it here. A browser without IndexedDB (or a private window that refuses it) keeps no
5
+ // recording, and nothing breaks: every call answers null or false.
6
+ const DB = 'artifact-voice';
7
+ const STORE = 'memos';
8
+ export const isDeviceAudio = (p) => typeof p === 'string' && p.startsWith('device/');
9
+ function open() {
10
+ return new Promise((resolve) => {
11
+ try {
12
+ if (typeof indexedDB === 'undefined')
13
+ return resolve(null);
14
+ const req = indexedDB.open(DB, 1);
15
+ req.onupgradeneeded = () => { req.result.createObjectStore(STORE); };
16
+ req.onsuccess = () => resolve(req.result);
17
+ req.onerror = () => resolve(null);
18
+ }
19
+ catch {
20
+ resolve(null);
21
+ }
22
+ });
23
+ }
24
+ async function run(mode, fn) {
25
+ const db = await open();
26
+ if (!db)
27
+ return null;
28
+ return new Promise((resolve) => {
29
+ try {
30
+ const req = fn(db.transaction(STORE, mode).objectStore(STORE));
31
+ req.onsuccess = () => resolve(req.result ?? null);
32
+ req.onerror = () => resolve(null);
33
+ }
34
+ catch {
35
+ resolve(null);
36
+ }
37
+ });
38
+ }
39
+ export const deviceAudio = {
40
+ put: async (key, blob) => (await run('readwrite', (s) => s.put(blob, key))) !== null,
41
+ get: async (key) => { const b = await run('readonly', (s) => s.get(key)); return b instanceof Blob ? b : null; },
42
+ remove: async (key) => { await run('readwrite', (s) => s.delete(key)); },
43
+ };
@@ -6,3 +6,6 @@ export { CommentLayer } from './comment-layer.js';
6
6
  export { CommentCard, warningLine, DEVICE_LINE } from './comment-card.js';
7
7
  export { deviceNotes, deviceNotesKey, addDeviceNote, replaceDeviceNote, removeDeviceNote } from './device-notes.js';
8
8
  export { indexText, offsetAt, rangeOf, headingAt, placeAnchor, boxOf, regionFrom, type TextIndex, type Placement } from './comment-place.js';
9
+ export { VoiceRecorder, AudioPlay, hostVoice, pickRecordingType, newMemoId, recognitionCtor, LIVE_LINE, type VoiceHost, type VoiceMemo, type VoiceScope } from './voice-recorder.js';
10
+ export { deviceAudio, isDeviceAudio, type DeviceAudio } from './device-audio.js';
11
+ export { type SavedMemo } from './comment-card.js';
@@ -8,3 +8,5 @@ export { CommentLayer } from './comment-layer.js';
8
8
  export { CommentCard, warningLine, DEVICE_LINE } from './comment-card.js';
9
9
  export { deviceNotes, deviceNotesKey, addDeviceNote, replaceDeviceNote, removeDeviceNote } from './device-notes.js';
10
10
  export { indexText, offsetAt, rangeOf, headingAt, placeAnchor, boxOf, regionFrom } from './comment-place.js';
11
+ export { VoiceRecorder, AudioPlay, hostVoice, pickRecordingType, newMemoId, recognitionCtor, LIVE_LINE } from './voice-recorder.js';
12
+ export { deviceAudio, isDeviceAudio } from './device-audio.js';
@@ -0,0 +1,3 @@
1
+ export declare const NO_ZOOM_FONT: {
2
+ readonly fontSize: 16;
3
+ };
@@ -0,0 +1,6 @@
1
+ // iOS Safari zooms the whole page when a text field under 16px takes focus, and does not zoom
2
+ // back out, so a reader who taps into a comment box loses the page they were commenting on
3
+ // (reported from an iPhone on 2026-09-28). Every text field the reader draws uses this size.
4
+ // A viewport `maximum-scale` would also stop the zoom, but it takes pinch-zoom away from readers
5
+ // who need it, so the size is the fix.
6
+ export const NO_ZOOM_FONT = { fontSize: 16 };
@@ -0,0 +1,52 @@
1
+ export type VoiceMemo = {
2
+ blob: Blob;
3
+ mime: string;
4
+ };
5
+ export type VoiceScope = 'comment' | 'personal';
6
+ /** The page's own voice routes, from the reader's browser. */
7
+ export type VoiceHost = {
8
+ /** Upload a memo; the stored path, or null when the page could not keep it. */
9
+ upload(memo: VoiceMemo, scope: VoiceScope): Promise<string | null>;
10
+ /** The host's transcript of an uploaded memo; null when it has no transcriber (404) or failed. */
11
+ transcribe(path: string): Promise<string | null>;
12
+ };
13
+ export declare const LIVE_LINE = "Your browser transcribes this as you speak.";
14
+ /** The recording type to ask for: the first this browser supports, else its default. */
15
+ export declare function pickRecordingType(isSupported?: (t: string) => boolean): string | undefined;
16
+ /** A memo's random file id: 22 url-safe characters, drawn in the browser. */
17
+ export declare function newMemoId(): string;
18
+ type Recognition = {
19
+ continuous: boolean;
20
+ interimResults: boolean;
21
+ lang: string;
22
+ onresult: ((e: {
23
+ results: ArrayLike<ArrayLike<{
24
+ transcript: string;
25
+ }> & {
26
+ isFinal?: boolean;
27
+ }>;
28
+ }) => void) | null;
29
+ onerror: (() => void) | null;
30
+ start(): void;
31
+ stop(): void;
32
+ };
33
+ type RecognitionCtor = new () => Recognition;
34
+ export declare function recognitionCtor(): RecognitionCtor | null;
35
+ /** The page's own routes as a VoiceHost. Every failure is a null, never a throw. */
36
+ export declare function hostVoice(artifactId: string): VoiceHost;
37
+ /** The mic button. Renders nothing where the browser cannot record. */
38
+ export declare function VoiceRecorder({ onStart, onLive, onStop, onError, accent, maxMs }: {
39
+ onStart?: () => void;
40
+ /** The live transcript so far, whole, each time it changes. */
41
+ onLive?: (text: string) => void;
42
+ onStop: (memo: VoiceMemo) => void;
43
+ onError?: (message: string) => void;
44
+ accent?: string;
45
+ maxMs?: number;
46
+ }): import("react").JSX.Element | null;
47
+ /** A small play control for a saved memo. `src` is a URL (signed, or a blob: URL on the device). */
48
+ export declare function AudioPlay({ src, accent }: {
49
+ src: string;
50
+ accent?: string;
51
+ }): import("react").JSX.Element;
52
+ export {};
@@ -0,0 +1,213 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // Say it instead of typing it. The microphone in the comment card: MediaRecorder for the memo
4
+ // itself, and, where the browser has it, speech recognition writing into the box while you speak.
5
+ //
6
+ // Chrome records audio/webm (opus), iPhone Safari audio/mp4; the first the browser supports is
7
+ // used, else the browser's own default, and a type the page cannot keep is refused before upload.
8
+ // A memo stops itself at three minutes. The host's transcription (if it has one) happens after the
9
+ // recording stops, in the card, through `hostVoice`, which never sees a key: it only calls this
10
+ // page's own routes.
11
+ //
12
+ // Everything here draws with the page's theme variables (--a-*) and the accent it is handed: the
13
+ // card is portalled onto document.body, and a hard-coded colour there is dark text on a dark page.
14
+ import { useEffect, useRef, useState } from 'react';
15
+ import { MAX_MEMO_MS, audioExtFor, baseMime } from '../artifacts/audio.js';
16
+ export const LIVE_LINE = 'Your browser transcribes this as you speak.';
17
+ const SEEN_KEY = 'artifact-voice-live-seen';
18
+ /** The recording type to ask for: the first this browser supports, else its default. */
19
+ export function pickRecordingType(isSupported = (t) => typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(t)) {
20
+ for (const t of ['audio/webm;codecs=opus', 'audio/mp4']) {
21
+ try {
22
+ if (isSupported(t))
23
+ return t;
24
+ }
25
+ catch { /* an engine that throws on the question supports neither */ }
26
+ }
27
+ return undefined;
28
+ }
29
+ /** A memo's random file id: 22 url-safe characters, drawn in the browser. */
30
+ export function newMemoId() {
31
+ const bytes = new Uint8Array(16);
32
+ crypto.getRandomValues(bytes);
33
+ let s = '';
34
+ for (const b of bytes)
35
+ s += 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'[b & 63];
36
+ return s + Date.now().toString(36).slice(-6);
37
+ }
38
+ export function recognitionCtor() {
39
+ if (typeof window === 'undefined')
40
+ return null;
41
+ const w = window;
42
+ return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
43
+ }
44
+ const canRecord = () => typeof window !== 'undefined' && typeof MediaRecorder !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
45
+ /** The page's own routes as a VoiceHost. Every failure is a null, never a throw. */
46
+ export function hostVoice(artifactId) {
47
+ const post = (url, body) => fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
48
+ return {
49
+ async upload(memo, scope) {
50
+ try {
51
+ const ext = audioExtFor(memo.mime);
52
+ if (!ext)
53
+ return null;
54
+ const url = `/api/artifacts/${artifactId}/uploads/${newMemoId()}.${ext}`;
55
+ const signed = await post(url, { audio: scope });
56
+ if (signed.status !== 201)
57
+ return null;
58
+ const { uploadUrl, headers } = (await signed.json());
59
+ const put = await fetch(uploadUrl, { method: 'PUT', headers, body: memo.blob });
60
+ if (!put.ok)
61
+ return null;
62
+ const done = await post(url, { audio: scope, done: true });
63
+ return done.status === 201 ? (await done.json()).path : null;
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ },
69
+ async transcribe(path) {
70
+ try {
71
+ const r = await post(`/api/artifacts/${artifactId}/transcribe`, { asset: path });
72
+ if (r.status !== 200)
73
+ return null;
74
+ const b = (await r.json());
75
+ return typeof b.text === 'string' && b.text.trim() ? b.text.trim() : null;
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ },
81
+ };
82
+ }
83
+ const clock = (ms) => { const s = Math.floor(ms / 1000); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; };
84
+ /** The mic button. Renders nothing where the browser cannot record. */
85
+ export function VoiceRecorder({ onStart, onLive, onStop, onError, accent = 'currentColor', maxMs = MAX_MEMO_MS }) {
86
+ const [supported, setSupported] = useState(false);
87
+ const [live, setLive] = useState(false);
88
+ const [note, setNote] = useState(false);
89
+ const [recording, setRecording] = useState(false);
90
+ const [elapsed, setElapsed] = useState(0);
91
+ const rec = useRef(null);
92
+ useEffect(() => {
93
+ setSupported(canRecord());
94
+ const has = !!recognitionCtor();
95
+ setLive(has);
96
+ try {
97
+ setNote(has && !window.localStorage.getItem(SEEN_KEY));
98
+ }
99
+ catch {
100
+ setNote(has);
101
+ }
102
+ return () => { const r = rec.current; if (r) {
103
+ clearTimeout(r.timer);
104
+ clearInterval(r.tick);
105
+ r.recog?.stop();
106
+ r.stream.getTracks().forEach((t) => t.stop());
107
+ } };
108
+ }, []);
109
+ const stop = () => {
110
+ const r = rec.current;
111
+ if (!r)
112
+ return;
113
+ clearTimeout(r.timer);
114
+ clearInterval(r.tick);
115
+ try {
116
+ r.recog?.stop();
117
+ }
118
+ catch { /* already stopped */ }
119
+ if (r.recorder.state !== 'inactive')
120
+ r.recorder.stop();
121
+ };
122
+ const start = async () => {
123
+ let stream;
124
+ try {
125
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
126
+ }
127
+ catch {
128
+ onError?.('The microphone is blocked. Allow it for this site in your browser, then try again.');
129
+ return;
130
+ }
131
+ const asked = pickRecordingType();
132
+ let recorder;
133
+ try {
134
+ recorder = asked ? new MediaRecorder(stream, { mimeType: asked }) : new MediaRecorder(stream);
135
+ }
136
+ catch {
137
+ stream.getTracks().forEach((t) => t.stop());
138
+ onError?.('This browser cannot record here.');
139
+ return;
140
+ }
141
+ const chunks = [];
142
+ recorder.ondataavailable = (e) => { if (e.data && e.data.size)
143
+ chunks.push(e.data); };
144
+ recorder.onstop = () => {
145
+ stream.getTracks().forEach((t) => t.stop());
146
+ rec.current = null;
147
+ setRecording(false);
148
+ const type = recorder.mimeType || asked || chunks[0]?.type || '';
149
+ if (!audioExtFor(type)) {
150
+ onError?.('This browser records in a format the page cannot keep.');
151
+ return;
152
+ }
153
+ onStop({ blob: new Blob(chunks, { type: baseMime(type) }), mime: baseMime(type) });
154
+ };
155
+ let recog = null;
156
+ const Ctor = recognitionCtor();
157
+ if (Ctor) {
158
+ try {
159
+ recog = new Ctor();
160
+ recog.continuous = true;
161
+ recog.interimResults = true;
162
+ recog.lang = document.documentElement.lang || navigator.language || 'en-US';
163
+ recog.onresult = (e) => {
164
+ let text = '';
165
+ for (let i = 0; i < e.results.length; i++)
166
+ text += e.results[i][0]?.transcript ?? '';
167
+ onLive?.(text.replace(/\s+/g, ' ').trim());
168
+ };
169
+ recog.onerror = () => { };
170
+ recog.start();
171
+ }
172
+ catch {
173
+ recog = null;
174
+ }
175
+ try {
176
+ window.localStorage.setItem(SEEN_KEY, '1');
177
+ }
178
+ catch { /* private window */ }
179
+ }
180
+ const began = Date.now();
181
+ rec.current = {
182
+ recorder, stream, recog,
183
+ timer: setTimeout(stop, maxMs),
184
+ tick: setInterval(() => setElapsed(Date.now() - began), 250),
185
+ };
186
+ setElapsed(0);
187
+ setRecording(true);
188
+ onStart?.();
189
+ recorder.start(1000);
190
+ };
191
+ if (!supported)
192
+ return null;
193
+ return (_jsxs("div", { "data-voice-recorder": true, className: "mt-2 text-[13px] text-[color:var(--a-strong)]", children: [_jsxs("button", { type: "button", "data-voice-mic": true, "aria-pressed": recording, onClick: () => (recording ? stop() : void start()), className: "inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium", style: recording ? { borderColor: accent, background: accent, color: 'var(--a-on-accent, currentColor)' } : { borderColor: accent, color: accent }, children: [_jsx("span", { "aria-hidden": true, children: recording ? '■' : '●' }), recording ? `Stop · ${clock(elapsed)}` : 'Record'] }), live && note ? _jsx("p", { "data-voice-live-note": true, className: "m-0 mt-1 text-xs opacity-70", children: LIVE_LINE }) : null] }));
194
+ }
195
+ /** A small play control for a saved memo. `src` is a URL (signed, or a blob: URL on the device). */
196
+ export function AudioPlay({ src, accent = 'currentColor' }) {
197
+ const [playing, setPlaying] = useState(false);
198
+ const el = useRef(null);
199
+ useEffect(() => () => { el.current?.pause(); }, []);
200
+ const toggle = () => {
201
+ if (!el.current) {
202
+ el.current = new Audio(src);
203
+ el.current.onended = () => setPlaying(false);
204
+ el.current.onpause = () => setPlaying(false);
205
+ el.current.onplay = () => setPlaying(true);
206
+ }
207
+ if (el.current.paused)
208
+ void el.current.play()?.catch?.(() => setPlaying(false));
209
+ else
210
+ el.current.pause();
211
+ };
212
+ return (_jsxs("button", { type: "button", "data-voice-play": true, onClick: toggle, "aria-label": playing ? 'Pause the recording' : 'Play the recording', className: "mr-2 inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs text-[color:var(--a-strong)]", style: { borderColor: accent }, children: [_jsx("span", { "aria-hidden": true, style: { color: accent }, children: playing ? '❚❚' : '▶' }), playing ? 'Pause' : 'Play'] }));
213
+ }
@@ -7,6 +7,7 @@ import type { BrandPack } from '../brand/pack.js';
7
7
  import { type Access } from '../artifacts/reader.js';
8
8
  import { type ReadersStore } from '../artifacts/readers-store.js';
9
9
  import type { PersonalStore } from '../artifacts/personal-store.js';
10
+ import type { Transcriber } from '../artifacts/transcribe.js';
10
11
  export type ArtifactRoutesConfig = {
11
12
  store: ArtifactStore;
12
13
  /** Where readers' answers live. Without it every state route answers 501. */
@@ -49,6 +50,11 @@ export type ArtifactRoutesConfig = {
49
50
  * its writer's name, whatever `comments_visible:` says. Without it nobody is the owner, and
50
51
  * the publisher reads comments through /responses. */
51
52
  ownerEmail?: string;
53
+ /** Turns a reader's voice memo into text on the server, so the service's key never reaches a
54
+ * browser: `deepgramTranscriber(key)` or `openaiTranscriber(key)` from `./artifacts`, or your
55
+ * own. Without it the transcribe route answers 404 and readers get the browser's live
56
+ * transcript, or an empty box to type into. */
57
+ transcribe?: Transcriber;
52
58
  };
53
59
  /** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
54
60
  export declare const ARTIFACT_ID: RegExp;
@@ -152,13 +158,7 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
152
158
  id: string;
153
159
  name: string;
154
160
  }>;
155
- }) => Promise<NextResponse<{
156
- error: string;
157
- }> | NextResponse<{
158
- id: string;
159
- name: string;
160
- url: string;
161
- }>>;
161
+ }) => Promise<NextResponse<unknown>>;
162
162
  SHARE_IMAGE: (_request: NextRequest, { params }: Params) => Promise<Response>;
163
163
  ENTER: (request: NextRequest) => Promise<NextResponse<unknown>>;
164
164
  LEAVE: (request: NextRequest) => Promise<NextResponse<unknown>>;
@@ -191,5 +191,10 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
191
191
  access: Access | null;
192
192
  views: number;
193
193
  }>>;
194
+ TRANSCRIBE: (req: NextRequest, { params }: {
195
+ params: Promise<{
196
+ id: string;
197
+ }>;
198
+ }) => Promise<NextResponse<unknown>>;
194
199
  };
195
200
  export {};