@supersuit/artifacts 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,35 @@
3
3
  `@supersuit/artifacts`. One entry per version, newest first. Each entry says what changed, how a
4
4
  host can tell whether it is affected (DETECTOR), what a host does about it (REMEDY), and the tests.
5
5
 
6
+ ## 0.12.0 (2026-09-28)
7
+
8
+ **Say a comment instead of typing it.** The comment card has a microphone. While you speak, a
9
+ browser with speech recognition (Chrome, Edge, Safari) fills the box live; when you stop, a host
10
+ that configured transcription replaces it with a better transcript unless you already edited it.
11
+ With neither, the recording is kept and the box stays yours to type in. Memos cap at 3 minutes.
12
+ The recording travels with the comment and plays back from the pin, the thread and `/responses`.
13
+
14
+ - Host config: `transcribe?: Transcriber`, with `deepgramTranscriber(key)` (Nova-3) and
15
+ `openaiTranscriber(key, model?)` from `./artifacts`. The key never reaches the browser and never
16
+ appears in an error.
17
+ - New handler `TRANSCRIBE` (`POST /api/artifacts/<id>/transcribe`): 404 when the host passed no
18
+ transcriber, so the reader falls back to the browser; only readers who may write comments, or
19
+ signed-in readers on their own notes; 20 per reader per page per hour.
20
+ - Recordings upload through the existing `UPLOAD` route with a signed PUT (`audio` in the body),
21
+ under `comments/` or `personal/<hashed reader>/`; a comment may only name a `comments/` path and
22
+ a note only its own reader's. Playback is by signed URL (`audioUrl` on rows). Reader audio has
23
+ its own allowlist (webm, m4a, mp3, ogg; iPhone `audio/mp4` is stored as `.m4a`).
24
+ - Anonymous personal memos stay in the browser (IndexedDB) and move to the account on sign-in.
25
+ - The privacy test now covers personal audio: no publisher route returns it or its URL.
26
+ - DETECTOR: a comment card with no microphone.
27
+ - REMEDY: bump; add `app/api/artifacts/[id]/transcribe/route.ts` delegating to `TRANSCRIBE`;
28
+ pass `transcribe` to get server transcripts; the bucket's CORS must allow `PUT` from the site
29
+ origin with `content-type`, `cache-control` and `x-goog-content-length-range`.
30
+ - Tests: adapters against a stubbed fetch, the transcribe route (404, rate limit, refusal),
31
+ upload path guards, the recorder (live transcript, host transcript vs an edited box, no-support
32
+ path, the 3-minute cap, device-only memos), the extended privacy test. All key rules
33
+ mutation-checked.
34
+
6
35
  ## 0.11.1 (2026-09-28)
7
36
 
8
37
  **The comment controls draw in the page's colours.** The comment overlay is portalled onto
package/README.md CHANGED
@@ -446,6 +446,46 @@ export function DELETE(request: NextRequest, ctx: Ctx) { return artifacts.PERSON
446
446
  Without `personal`, every reader's notes stay on their device. Without `state`, no page takes
447
447
  shared comments.
448
448
 
449
+ ### Voice memos
450
+
451
+ Every comment card has a Record button where the browser can record (Chrome records
452
+ `audio/webm`, iPhone Safari `audio/mp4`, stored as `.m4a`). Where the browser has speech
453
+ recognition, the words appear in the box as the reader speaks. A memo stops at three minutes.
454
+
455
+ Recordings go up through the same `uploads/<name>` route the publisher uses, to
456
+ `<page>/comments/<memo>.<ext>` or, for a signed-in reader's personal note,
457
+ `<page>/personal/<dir>/<memo>.<ext>`. They are never made public: playback is a signed URL
458
+ issued beside the comment or note it belongs to. That needs the four audio methods
459
+ `createArtifactAssets` provides (`signAudioUpload`, `audioSize`, `readAudio`, `audioUrl`); a host
460
+ with its own `ArtifactAssets` and none of them keeps memos on the reader's device. On a bucket
461
+ with bucket-wide public read, a recording is readable by anyone holding its path (the paths are
462
+ unguessable); keep the bucket per-object public to have the signed-URL guarantee.
463
+
464
+ To transcribe on the server, pass `transcribe` and add the route. The key stays on the server;
465
+ without `transcribe` the route answers 404 and readers keep the browser's live transcript (or an
466
+ empty box). 20 transcriptions per reader per page per hour.
467
+
468
+ ```ts
469
+ import { deepgramTranscriber } from '@supersuit/artifacts/artifacts'
470
+ export const artifacts = createArtifactRoutes({
471
+ // ...
472
+ transcribe: process.env.DEEPGRAM_API_KEY ? deepgramTranscriber(process.env.DEEPGRAM_API_KEY) : undefined,
473
+ // or openaiTranscriber(key, 'gpt-4o-mini-transcribe')
474
+ })
475
+ ```
476
+
477
+ ```ts
478
+ // app/api/artifacts/[id]/transcribe/route.ts
479
+ import type { NextRequest } from 'next/server'
480
+ import { artifacts } from '@/lib/artifacts'
481
+ type Ctx = { params: Promise<{ id: string }> }
482
+ export function POST(request: NextRequest, ctx: Ctx) { return artifacts.TRANSCRIBE(request, ctx) }
483
+ ```
484
+
485
+ The bucket also needs CORS allowing `PUT` from the site's origin with the `content-type`,
486
+ `cache-control` and `x-goog-content-length-range` headers, since the reader's browser uploads
487
+ directly.
488
+
449
489
  ## Brand packs
450
490
 
451
491
  A `BrandPack` is data plus at most two components: colours, type, the kicker line above a title,
@@ -14,6 +14,17 @@ export interface ArtifactAssets {
14
14
  }>;
15
15
  /** After a signed upload: make it readable and return its URL, or null if nothing arrived. */
16
16
  finishUpload?(id: string, name: string, digest: string): Promise<string | null>;
17
+ /** A signed URL a reader PUTs one recording to, at most MAX_AUDIO_BYTES. */
18
+ signAudioUpload?(id: string, path: string, contentType: string): Promise<{
19
+ uploadUrl: string;
20
+ headers: Record<string, string>;
21
+ }>;
22
+ /** After the PUT: the stored size, or null if nothing arrived. */
23
+ audioSize?(id: string, path: string): Promise<number | null>;
24
+ /** The recording's bytes, for the transcriber; null when there is none. */
25
+ readAudio?(id: string, path: string): Promise<Buffer | null>;
26
+ /** A signed URL to play the recording, valid for about an hour. */
27
+ audioUrl?(id: string, path: string): Promise<string>;
17
28
  }
18
29
  /** The digest half of a stored name: the first 8 hex of the bytes' sha256. */
19
30
  export declare const ASSET_DIGEST: RegExp;
@@ -3,6 +3,7 @@
3
3
  // publisher shelled out to gsutil against the maintainer's bucket, which no other operator
4
4
  // could do, so every operator gets the same door: PUT bytes with their key, get a URL back.
5
5
  import { createHash } from 'node:crypto';
6
+ import { MAX_AUDIO_BYTES } from './audio.js';
6
7
  /** The digest half of a stored name: the first 8 hex of the bytes' sha256. */
7
8
  export const ASSET_DIGEST = /^[0-9a-f]{8}$/;
8
9
  const CACHE_CONTROL = 'public, max-age=31536000, immutable';
@@ -96,5 +97,39 @@ export function createArtifactAssets(bucket, prefix) {
96
97
  }
97
98
  return `https://storage.googleapis.com/${bucket.name}/${key}`;
98
99
  },
100
+ // The size cap rides in the signature (`x-goog-content-length-range`), so the bucket itself
101
+ // refuses an oversize body; the browser must send the returned headers verbatim.
102
+ async signAudioUpload(id, path, contentType) {
103
+ const range = `0,${MAX_AUDIO_BYTES}`;
104
+ const headers = { 'content-type': contentType, 'cache-control': 'private, max-age=3600', 'x-goog-content-length-range': range };
105
+ const [uploadUrl] = await bucket.file(`${clean}/${id}/${path}`).getSignedUrl({
106
+ version: 'v4',
107
+ action: 'write',
108
+ expires: Date.now() + 15 * 60 * 1000,
109
+ contentType,
110
+ extensionHeaders: { 'cache-control': headers['cache-control'], 'x-goog-content-length-range': range },
111
+ });
112
+ return { uploadUrl, headers };
113
+ },
114
+ async audioSize(id, path) {
115
+ const file = bucket.file(`${clean}/${id}/${path}`);
116
+ const [there] = await file.exists();
117
+ if (!there)
118
+ return null;
119
+ const [meta] = await file.getMetadata();
120
+ return Number(meta.size ?? 0);
121
+ },
122
+ async readAudio(id, path) {
123
+ const file = bucket.file(`${clean}/${id}/${path}`);
124
+ const [there] = await file.exists();
125
+ if (!there)
126
+ return null;
127
+ const [bytes] = await file.download();
128
+ return bytes;
129
+ },
130
+ async audioUrl(id, path) {
131
+ const [url] = await bucket.file(`${clean}/${id}/${path}`).getSignedUrl({ version: 'v4', action: 'read', expires: Date.now() + 60 * 60 * 1000 });
132
+ return url;
133
+ },
99
134
  };
100
135
  }
@@ -0,0 +1,21 @@
1
+ /** Extension -> the type it is stored and transcribed as. */
2
+ export declare const AUDIO_TYPES: Record<string, string>;
3
+ /** The type without parameters: `audio/webm;codecs=opus` -> `audio/webm`. */
4
+ export declare const baseMime: (mime: string) => string;
5
+ /** The extension a recording of this type is stored under, or null when a page does not take it. */
6
+ export declare function audioExtFor(mime: string): string | null;
7
+ /** The type of a stored recording, from its path's extension; null when it is not one. */
8
+ export declare function audioTypeFor(path: string): string | null;
9
+ /** A memo is capped at 3 minutes in the recorder, a bit under 3 MB at voice bitrate; this is the
10
+ * ceiling the upload and the transcriber enforce whatever the browser sent. */
11
+ export declare const MAX_AUDIO_BYTES: number;
12
+ export declare const MAX_MEMO_MS: number;
13
+ /** A memo's file name: a random id the browser draws, and the extension of its type. */
14
+ export declare const MEMO_NAME: RegExp;
15
+ /** Where a shared comment's recording lives under its page. */
16
+ export declare const commentAudioPath: (name: string) => string;
17
+ export declare const isCommentAudioPath: (p: unknown) => p is string;
18
+ /** A signed-in reader's personal-note recording, under a directory only their uid derives
19
+ * (`personalAudioDir` in personal-store.ts). Anything else is not theirs. */
20
+ export declare const personalAudioPath: (dir: string, name: string) => string;
21
+ export declare const isPersonalAudioPath: (p: unknown, dir: string) => p is string;
@@ -0,0 +1,43 @@
1
+ // Voice memos: which recordings a page takes, and what each one is called. Pure, so the reader's
2
+ // browser, the routes and the transcribers all agree on one table.
3
+ //
4
+ // Chrome records `audio/webm` (with a codec suffix), iPhone Safari records `audio/mp4`. Both have
5
+ // to survive the whole trip: the recorder's choice, the upload allowlist, the stored extension,
6
+ // and the Content-Type the transcription service is told. `.m4a`, never `.mp4`, for mp4 audio:
7
+ // the publisher's `.mp4` is a video, and a transcriber that detects by file name needs to know
8
+ // this one is audio.
9
+ /** Extension -> the type it is stored and transcribed as. */
10
+ export const AUDIO_TYPES = {
11
+ webm: 'audio/webm',
12
+ m4a: 'audio/mp4',
13
+ mp3: 'audio/mpeg',
14
+ ogg: 'audio/ogg',
15
+ };
16
+ /** The type without parameters: `audio/webm;codecs=opus` -> `audio/webm`. */
17
+ export const baseMime = (mime) => mime.split(';')[0].trim().toLowerCase();
18
+ /** The extension a recording of this type is stored under, or null when a page does not take it. */
19
+ export function audioExtFor(mime) {
20
+ const base = baseMime(mime);
21
+ for (const [ext, type] of Object.entries(AUDIO_TYPES))
22
+ if (type === base)
23
+ return ext;
24
+ return null;
25
+ }
26
+ /** The type of a stored recording, from its path's extension; null when it is not one. */
27
+ export function audioTypeFor(path) {
28
+ const ext = path.split('.').pop()?.toLowerCase() ?? '';
29
+ return Object.hasOwn(AUDIO_TYPES, ext) ? AUDIO_TYPES[ext] : null;
30
+ }
31
+ /** A memo is capped at 3 minutes in the recorder, a bit under 3 MB at voice bitrate; this is the
32
+ * ceiling the upload and the transcriber enforce whatever the browser sent. */
33
+ export const MAX_AUDIO_BYTES = 4 * 1024 * 1024;
34
+ export const MAX_MEMO_MS = 3 * 60 * 1000;
35
+ /** A memo's file name: a random id the browser draws, and the extension of its type. */
36
+ export const MEMO_NAME = /^[A-Za-z0-9_-]{16,64}\.(webm|m4a|mp3|ogg)$/;
37
+ /** Where a shared comment's recording lives under its page. */
38
+ export const commentAudioPath = (name) => `comments/${name}`;
39
+ export const isCommentAudioPath = (p) => typeof p === 'string' && p.startsWith('comments/') && MEMO_NAME.test(p.slice('comments/'.length));
40
+ /** A signed-in reader's personal-note recording, under a directory only their uid derives
41
+ * (`personalAudioDir` in personal-store.ts). Anything else is not theirs. */
42
+ export const personalAudioPath = (dir, name) => `personal/${dir}/${name}`;
43
+ export const isPersonalAudioPath = (p, dir) => typeof p === 'string' && p.startsWith(`personal/${dir}/`) && MEMO_NAME.test(p.slice(`personal/${dir}/`.length));
@@ -13,4 +13,6 @@ export { stateView, responsesOf, responsesCsv, type SlotView, type Tally, type S
13
13
  export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS, type Heading, type NotesWidget, type NoteValue, type PlacedNote } from './widgets.js';
14
14
  export { textAnchorFrom, findText, blockId, parseAnchor, CONTEXT_CHARS, MAX_QUOTE_CHARS, BLOCK_ID, type Anchor, type TextAnchor, type RegionAnchor } from './anchor.js';
15
15
  export { COMMENTS_SLOT, COMMENTS_MODES, COMMENT_TRANSCRIPTS, MAX_COMMENT_CHARS, mergeCommentsState, validateComment, type CommentsMode, type CommentsVisible, type CommentValue } from './comments.js';
16
- export { createPersonalStore, createMemoryPersonalStore, validatePersonal, MAX_PERSONAL_PER_READER, type PersonalStore, type PersonalNote, type PersonalNoteValue } from './personal-store.js';
16
+ export { createPersonalStore, createMemoryPersonalStore, validatePersonal, personalAudioDir, MAX_PERSONAL_PER_READER, type PersonalStore, type PersonalNote, type PersonalNoteValue } from './personal-store.js';
17
+ export { deepgramTranscriber, openaiTranscriber, type Transcriber } from './transcribe.js';
18
+ export { AUDIO_TYPES, audioExtFor, audioTypeFor, baseMime, MAX_AUDIO_BYTES, MAX_MEMO_MS, MEMO_NAME, commentAudioPath, isCommentAudioPath, personalAudioPath, isPersonalAudioPath } from './audio.js';
@@ -13,4 +13,6 @@ export { stateView, responsesOf, responsesCsv } from './state-view.js';
13
13
  export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS } from './widgets.js';
14
14
  export { textAnchorFrom, findText, blockId, parseAnchor, CONTEXT_CHARS, MAX_QUOTE_CHARS, BLOCK_ID } from './anchor.js';
15
15
  export { COMMENTS_SLOT, COMMENTS_MODES, COMMENT_TRANSCRIPTS, MAX_COMMENT_CHARS, mergeCommentsState, validateComment } from './comments.js';
16
- export { createPersonalStore, createMemoryPersonalStore, validatePersonal, MAX_PERSONAL_PER_READER } from './personal-store.js';
16
+ export { createPersonalStore, createMemoryPersonalStore, validatePersonal, personalAudioDir, MAX_PERSONAL_PER_READER } from './personal-store.js';
17
+ export { deepgramTranscriber, openaiTranscriber } from './transcribe.js';
18
+ export { AUDIO_TYPES, audioExtFor, audioTypeFor, baseMime, MAX_AUDIO_BYTES, MAX_MEMO_MS, MEMO_NAME, commentAudioPath, isCommentAudioPath, personalAudioPath, isPersonalAudioPath } from './audio.js';
@@ -8,6 +8,10 @@ export type PersonalNote = {
8
8
  updatedAt?: string;
9
9
  };
10
10
  export declare const MAX_PERSONAL_PER_READER = 500;
11
+ /** The directory a signed-in reader's voice-note recordings live in under a page: derived from
12
+ * their uid, so a path names its owner without spelling out an account id, and a route can tell
13
+ * in one comparison whether a recording is the asker's own. */
14
+ export declare const personalAudioDir: (readerUid: string) => string;
11
15
  export interface PersonalStore {
12
16
  list(artifactId: string, readerUid: string): Promise<PersonalNote[]>;
13
17
  add(artifactId: string, readerUid: string, value: PersonalNoteValue): Promise<PersonalNote | {
@@ -12,9 +12,13 @@
12
12
  // `base` is the artifacts collection path (`tenants/<id>/artifacts`), the same base the readers
13
13
  // store uses, so each tenant's notes sit beside its own pages. A reader who is not signed in keeps
14
14
  // notes on their device instead (reader/device-notes.ts): those never reach any server at all.
15
- import { randomUUID } from 'node:crypto';
15
+ import { createHash, randomUUID } from 'node:crypto';
16
16
  import { validateComment } from './comments.js';
17
17
  export const MAX_PERSONAL_PER_READER = 500;
18
+ /** The directory a signed-in reader's voice-note recordings live in under a page: derived from
19
+ * their uid, so a path names its owner without spelling out an account id, and a route can tell
20
+ * in one comparison whether a recording is the asker's own. */
21
+ export const personalAudioDir = (readerUid) => createHash('sha256').update(`personal-audio|${readerUid}`).digest('hex').slice(0, 24);
18
22
  /** A personal note is a comment with no thread: nobody else can see it to reply. */
19
23
  export function validatePersonal(v) {
20
24
  if (v && typeof v === 'object' && 'parent' in v)
@@ -0,0 +1,8 @@
1
+ /** A finished recording in, the transcript out; null when the service could not produce one. */
2
+ export type Transcriber = (audio: Blob, mime: string) => Promise<{
3
+ text: string;
4
+ } | null>;
5
+ /** Deepgram Nova-3: the raw recording as the body, its type as Content-Type. */
6
+ export declare function deepgramTranscriber(key: string): Transcriber;
7
+ /** OpenAI's transcription endpoint: a multipart form, the file named so the format is detected. */
8
+ export declare function openaiTranscriber(key: string, model?: string): Transcriber;
@@ -0,0 +1,56 @@
1
+ // Transcription: the host decides, the browser is the floor.
2
+ //
3
+ // A host that has a key for a transcription service passes `transcribe` to createArtifactRoutes,
4
+ // and the reader's finished voice memo is sent there from the server, so the key never reaches a
5
+ // browser. Without it the transcribe route answers 404 and the reader keeps whatever the browser
6
+ // transcribed live (or an empty box). The two adapters below are ready to pass as they are.
7
+ //
8
+ // THE KEY NEVER LEAVES THIS FILE. An adapter answers null on any failure and never throws: a
9
+ // thrown error travels into logs and error bodies, and a service that echoes the credential back
10
+ // in its own error text (some do) would carry the key with it. Nothing here reads a response body
11
+ // on a failure, and nothing here puts the key anywhere but the Authorization header.
12
+ import { audioExtFor, baseMime } from './audio.js';
13
+ // Under the route's own 30-second budget, so a slow service ends in a clean null, not a timeout.
14
+ const TIMEOUT_MS = 25_000;
15
+ async function post(url, init) {
16
+ try {
17
+ const r = await fetch(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });
18
+ if (!r.ok)
19
+ return null;
20
+ return await r.json();
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ const textOf = (t) => (typeof t === 'string' ? { text: t.trim() } : null);
27
+ /** Deepgram Nova-3: the raw recording as the body, its type as Content-Type. */
28
+ export function deepgramTranscriber(key) {
29
+ return async (audio, mime) => {
30
+ if (!key)
31
+ return null;
32
+ const body = await post('https://api.deepgram.com/v1/listen?model=nova-3&smart_format=true', {
33
+ method: 'POST',
34
+ headers: { Authorization: `Token ${key}`, 'Content-Type': baseMime(mime) },
35
+ body: audio,
36
+ });
37
+ return textOf(body?.results?.channels?.[0]?.alternatives?.[0]?.transcript);
38
+ };
39
+ }
40
+ /** OpenAI's transcription endpoint: a multipart form, the file named so the format is detected. */
41
+ export function openaiTranscriber(key, model = 'gpt-4o-mini-transcribe') {
42
+ return async (audio, mime) => {
43
+ const ext = audioExtFor(mime);
44
+ if (!key || !ext)
45
+ return null;
46
+ const form = new FormData();
47
+ form.append('file', new File([audio], `memo.${ext}`, { type: baseMime(mime) }));
48
+ form.append('model', model);
49
+ const body = await post('https://api.openai.com/v1/audio/transcriptions', {
50
+ method: 'POST',
51
+ headers: { Authorization: `Bearer ${key}` },
52
+ body: form,
53
+ });
54
+ return textOf(body?.text);
55
+ };
56
+ }
@@ -1,8 +1,19 @@
1
- import { type CommentsMode } from '../artifacts/comments.js';
1
+ import { type CommentValue, type CommentsMode } from '../artifacts/comments.js';
2
+ import { type VoiceHost, type VoiceMemo, type VoiceScope } from './voice-recorder.js';
3
+ /** A memo as the card hands it to be saved: the recording, where it was already uploaded (if it
4
+ * was, and for which kind), and where the words in the box came from. */
5
+ export type SavedMemo = {
6
+ memo: VoiceMemo;
7
+ uploaded: {
8
+ scope: VoiceScope;
9
+ path: string;
10
+ } | null;
11
+ transcript?: CommentValue['transcript'];
12
+ };
2
13
  /** The warning, word for word. `They` is fixed copy: the card never guesses a pronoun. */
3
14
  export declare function warningLine(ownerName: string, mode: CommentsMode): string;
4
15
  export declare const DEVICE_LINE = "Saved on this device. Sign in to keep it everywhere.";
5
- export declare function CommentCard({ ownerName, mode, canShare, signedIn, quote, initial, accent, signIn, onSave, onCancel, }: {
16
+ export declare function CommentCard({ ownerName, mode, canShare, signedIn, quote, initial, accent, signIn, voice, onSave, onCancel, }: {
6
17
  ownerName: string;
7
18
  mode: CommentsMode;
8
19
  canShare: boolean;
@@ -12,7 +23,9 @@ export declare function CommentCard({ ownerName, mode, canShare, signedIn, quote
12
23
  initial?: string;
13
24
  accent?: string;
14
25
  signIn?: string | null;
26
+ /** The page's voice routes. Null: a memo is kept but never uploaded or transcribed here. */
27
+ voice?: VoiceHost | null;
15
28
  /** Resolves to an error to show, or null when saved. */
16
- onSave: (body: string, share: boolean) => Promise<string | null>;
29
+ onSave: (body: string, share: boolean, memo?: SavedMemo) => Promise<string | null>;
17
30
  onCancel: () => void;
18
31
  }): import("react").JSX.Element;
@@ -5,8 +5,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
5
5
  // owner will never see, shown every time, because the moment it matters is the moment someone is
6
6
  // about to write something they meant for the owner. When they can share, a switch picks Share
7
7
  // (the default) or Just for me.
8
- import { useState } from 'react';
8
+ //
9
+ // A voice memo (voice-recorder.tsx) writes into the same box: the browser's live transcript while
10
+ // you speak, then the host's transcript when the recording stops, unless you have edited the box
11
+ // since, in which case your words win. With neither, the memo is kept and the box stays empty.
12
+ import { useRef, useState } from 'react';
9
13
  import { MAX_COMMENT_CHARS } from '../artifacts/comments.js';
14
+ import { VoiceRecorder } from './voice-recorder.js';
10
15
  /** The warning, word for word. `They` is fixed copy: the card never guesses a pronoun. */
11
16
  export function warningLine(ownerName, mode) {
12
17
  const why = mode === 'off'
@@ -15,22 +20,52 @@ export function warningLine(ownerName, mode) {
15
20
  return `Only you will see this. ${why}, so this is a personal note. They won't see it.`;
16
21
  }
17
22
  export const DEVICE_LINE = 'Saved on this device. Sign in to keep it everywhere.';
18
- export function CommentCard({ ownerName, mode, canShare, signedIn, quote, initial = '', accent = 'currentColor', signIn, onSave, onCancel, }) {
23
+ export function CommentCard({ ownerName, mode, canShare, signedIn, quote, initial = '', accent = 'currentColor', signIn, voice = null, onSave, onCancel, }) {
19
24
  const [text, setText] = useState(initial);
20
25
  const [share, setShare] = useState(canShare);
21
26
  const [busy, setBusy] = useState(false);
22
27
  const [error, setError] = useState(null);
28
+ const [memo, setMemo] = useState(null);
29
+ const [transcript, setTranscript] = useState(undefined);
30
+ const [status, setStatus] = useState(null);
31
+ // Refs, because the host's transcript arrives after an await and must see the box as it is then.
32
+ const base = useRef('');
33
+ const edited = useRef(false);
34
+ const uploaded = useRef(null);
23
35
  const personal = !canShare || !share;
36
+ // Where a memo goes: a shared comment, the reader's account, or (neither) this device only.
37
+ const scope = () => (canShare && share ? 'comment' : signedIn ? 'personal' : null);
38
+ const recorded = async (m) => {
39
+ setMemo(m);
40
+ uploaded.current = null;
41
+ edited.current = false;
42
+ const where = scope();
43
+ if (!voice || !where)
44
+ return;
45
+ setStatus('Transcribing');
46
+ const path = await voice.upload(m, where);
47
+ if (path) {
48
+ uploaded.current = { scope: where, path };
49
+ const heard = await voice.transcribe(path);
50
+ if (heard && !edited.current) {
51
+ setText(base.current ? `${base.current} ${heard}` : heard);
52
+ setTranscript('host');
53
+ }
54
+ }
55
+ setStatus(null);
56
+ };
24
57
  const submit = async (e) => {
25
58
  e?.preventDefault();
26
59
  const body = text.trim();
27
- if (!body || busy)
60
+ if ((!body && !memo) || busy)
28
61
  return;
29
62
  setBusy(true);
30
- const err = await onSave(body, canShare && share);
63
+ const err = memo
64
+ ? await onSave(body, canShare && share, { memo, uploaded: uploaded.current, ...(body && transcript ? { transcript } : {}) })
65
+ : await onSave(body, canShare && share);
31
66
  setBusy(false);
32
67
  setError(err);
33
68
  };
34
- return (_jsxs("form", { "data-comment-card": true, "data-comment-ui": true, "data-nospeak": true, onSubmit: submit, className: "w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-left font-sans text-sm text-[color:var(--a-strong)] shadow-xl", children: [!canShare ? (_jsx("p", { "data-comment-warning": true, role: "note", className: "mb-2 rounded-lg border px-3 py-2 text-[13px] leading-snug", style: { borderColor: accent }, children: warningLine(ownerName, mode) })) : null, quote ? _jsx("p", { className: "mb-2 line-clamp-3 border-l-2 pl-2 text-[13px] italic opacity-75", style: { borderColor: accent }, children: quote }) : null, _jsx("textarea", { value: text, autoFocus: true, onChange: (e) => setText(e.target.value), onKeyDown: (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey))
35
- 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]" }), 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(), 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" })] })] }));
69
+ return (_jsxs("form", { "data-comment-card": true, "data-comment-ui": true, "data-nospeak": true, onSubmit: submit, className: "w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-left font-sans text-sm text-[color:var(--a-strong)] shadow-xl", children: [!canShare ? (_jsx("p", { "data-comment-warning": true, role: "note", className: "mb-2 rounded-lg border px-3 py-2 text-[13px] leading-snug", style: { borderColor: accent }, children: warningLine(ownerName, mode) })) : null, quote ? _jsx("p", { className: "mb-2 line-clamp-3 border-l-2 pl-2 text-[13px] italic opacity-75", style: { borderColor: accent }, children: quote }) : null, _jsx("textarea", { value: text, autoFocus: true, onChange: (e) => { setText(e.target.value); edited.current = true; setTranscript('typed'); }, onKeyDown: (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey))
70
+ void submit(); }, maxLength: MAX_COMMENT_CHARS, rows: 3, "aria-label": personal ? 'A personal note' : `A comment for ${ownerName}`, placeholder: personal ? 'A note for yourself' : `A comment for ${ownerName}`, className: "w-full rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-ground,transparent)] p-2 text-[15px]" }), _jsx(VoiceRecorder, { accent: accent, onStart: () => { base.current = text.trim(); setMemo(null); setTranscript(undefined); setError(null); }, onLive: (heard) => { setText(base.current ? `${base.current} ${heard}` : heard); setTranscript('browser'); }, onStop: (m) => void recorded(m), onError: setError }), memo ? (_jsxs("p", { "data-voice-kept": true, className: "m-0 mt-1 text-xs opacity-70", children: [status ?? 'Recording kept with this comment.', _jsx("button", { type: "button", "data-voice-discard": true, className: "ml-2 underline", onClick: () => { setMemo(null); uploaded.current = null; }, children: "Discard recording" })] })) : null, canShare ? (_jsxs("fieldset", { className: "mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[13px]", children: [_jsx("legend", { className: "sr-only", children: "Who sees this" }), _jsxs("label", { className: "flex items-center gap-1.5", children: [_jsx("input", { type: "radio", name: "comment-share", "data-comment-share": "share", checked: share, onChange: () => setShare(true) }), `Share with ${ownerName}`] }), _jsxs("label", { className: "flex items-center gap-1.5", children: [_jsx("input", { type: "radio", name: "comment-share", "data-comment-share": "mine", checked: !share, onChange: () => setShare(false) }), "Just for me"] })] })) : null, personal && !signedIn ? (_jsx("p", { "data-comment-device": true, className: "mt-2 text-xs opacity-70", children: signIn ? _jsxs(_Fragment, { children: ["Saved on this device. ", _jsx("a", { href: signIn, className: "underline", children: "Sign in to keep it everywhere." })] }) : DEVICE_LINE })) : null, error ? _jsx("p", { role: "alert", className: "mt-2 text-xs text-amber-500", children: error }) : null, _jsxs("div", { className: "mt-3 flex items-center gap-3", children: [_jsx("button", { type: "submit", "data-comment-save": true, disabled: busy || (!text.trim() && !memo), className: "rounded-full px-4 py-1 text-sm font-medium disabled:opacity-40", style: { background: accent, color: 'var(--a-on-accent, #111)' }, children: personal ? 'Save note' : 'Comment' }), _jsx("button", { type: "button", onClick: onCancel, className: "text-sm opacity-70 hover:opacity-100", children: "Cancel" })] })] }));
36
71
  }
@@ -1,5 +1,7 @@
1
1
  import { type CommentsMode } from '../artifacts/comments.js';
2
- export declare function CommentLayer({ artifactId, rootId, ownerName, comments, canShare, signedIn, isOwner, version, accent, ground, signIn, }: {
2
+ import { type VoiceHost } from './voice-recorder.js';
3
+ import { type DeviceAudio } from './device-audio.js';
4
+ export declare function CommentLayer({ artifactId, rootId, ownerName, comments, canShare, signedIn, isOwner, version, accent, ground, signIn, voice, audioStore, }: {
3
5
  artifactId: string;
4
6
  /** The element holding the page's prose, the same root the read-aloud lights. */
5
7
  rootId: string;
@@ -14,4 +16,8 @@ export declare function CommentLayer({ artifactId, rootId, ownerName, comments,
14
16
  accent: string;
15
17
  ground: string;
16
18
  signIn?: string | null;
19
+ /** The page's voice routes; defaults to this page's own. Tests pass a stub. */
20
+ voice?: VoiceHost;
21
+ /** Where an anonymous reader's recordings live; defaults to this browser's IndexedDB. */
22
+ audioStore?: DeviceAudio;
17
23
  }): import("react").JSX.Element;