@hachej/boring-ui-kit 0.4.25 → 0.4.26

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.
Files changed (74) hide show
  1. package/README.md +26 -2
  2. package/dist/chat-artifacts.d.ts +157 -0
  3. package/dist/chat-artifacts.js +274 -0
  4. package/dist/chat-react-controller.d.ts +25 -0
  5. package/dist/chat-react-controller.js +72 -0
  6. package/dist/chat-react-conversations.d.ts +40 -0
  7. package/dist/chat-react-conversations.js +68 -0
  8. package/dist/chat-react-draft.d.ts +11 -0
  9. package/dist/chat-react-draft.js +91 -0
  10. package/dist/chat-react-remote.d.ts +33 -0
  11. package/dist/chat-react-remote.js +72 -0
  12. package/dist/chat-react-transcript.d.ts +39 -0
  13. package/dist/chat-react-transcript.js +123 -0
  14. package/dist/chat-react.d.ts +60 -0
  15. package/dist/chat-react.js +94 -0
  16. package/dist/chat-text.d.ts +122 -0
  17. package/dist/chat-text.js +216 -0
  18. package/dist/chat-view.d.ts +2 -17
  19. package/dist/chat-view.js +1 -0
  20. package/dist/run-notifications.d.ts +42 -0
  21. package/dist/run-notifications.js +67 -0
  22. package/dist/shared-chat/index.d.ts +1 -1
  23. package/dist/shared-chat/index.js +5 -4
  24. package/dist/voice-capture.d.ts +12 -0
  25. package/dist/voice-capture.js +17 -2
  26. package/package.json +10 -2
  27. package/r/agent-target.json +5 -9
  28. package/r/app-shell.json +16 -25
  29. package/r/board-viewer.json +7 -11
  30. package/r/button.json +2 -9
  31. package/r/diagram-viewer.json +0 -5
  32. package/r/dock.json +2 -2
  33. package/r/documents.json +13 -18
  34. package/r/feedback-preview.json +1 -7
  35. package/r/feedback.json +12 -19
  36. package/r/file-tree.json +4 -14
  37. package/r/highlights.json +4 -9
  38. package/r/html-viewer.json +3 -3
  39. package/r/looks-viewer.json +2 -6
  40. package/r/maquette-viewer.json +2 -20
  41. package/r/markdown-editor.json +4 -7
  42. package/r/mockup-viewer.json +2 -5
  43. package/r/pi-ambient.json +11 -19
  44. package/r/pi-app-kinds.json +10 -10
  45. package/r/pi-app.json +16 -26
  46. package/r/pi-artifacts.json +3 -4
  47. package/r/pi-chat-approval.json +2 -5
  48. package/r/pi-chat-attachments.json +2 -4
  49. package/r/pi-chat-markdown.json +3 -5
  50. package/r/pi-chat-mentions.json +2 -3
  51. package/r/pi-chat-pickers.json +2 -4
  52. package/r/pi-chat-plugins.json +11 -11
  53. package/r/pi-chat-question.json +1 -7
  54. package/r/pi-chat-slash.json +3 -6
  55. package/r/pi-chat-subagent.json +3 -8
  56. package/r/pi-chat-voice.json +6 -9
  57. package/r/pi-chat.json +17 -45
  58. package/r/pi-workspace.json +4 -7
  59. package/r/placed-agent.json +4 -5
  60. package/r/provider-setup.json +2 -8
  61. package/r/record-viewer.json +0 -9
  62. package/r/registry.json +163 -396
  63. package/r/requests-board.json +2 -10
  64. package/r/shared-chat.json +9 -23
  65. package/r/task-list-viewer.json +3 -9
  66. package/r/version-history.json +3 -5
  67. package/r/viewer-code.json +2 -3
  68. package/r/viewer-frame.json +7 -15
  69. package/r/viewer-html.json +4 -5
  70. package/r/viewer-image.json +3 -4
  71. package/r/viewer-markdown.json +6 -8
  72. package/r/viewer-pdf.json +5 -7
  73. package/r/viewers.json +6 -6
  74. package/r/windows.json +2 -7
package/r/feedback.json CHANGED
@@ -4,47 +4,40 @@
4
4
  "title": "Feedback",
5
5
  "description": "Feedback from the chat composer: one Feedback button starts feedback mode on the application page (hover, click to pin a numbered note, hold ⌥ to use the app, optional voice), and one chip carries the notes to the next message. FeedbackBar, NoteBubble, FeedbackChip with its review, useComposerFeedback for the pi-chat composer, and PickerOverlay styles. Optional, not mounted by default: PointButton, AnnotateSheet, FeedbackList and FeedbackReport. Picking, masking, anchoring and placement stay in @hachej/boring-feedback/page and /ui, so restyling cannot weaken them.",
6
6
  "dependencies": [
7
- "@hachej/boring-feedback@^0.4.25",
8
- "@hachej/boring-ui-kit@^0.4.25",
9
- "@hachej/boring-files@^0.4.25",
7
+ "@hachej/boring-feedback@^0.4.26",
8
+ "@hachej/boring-ui-kit@^0.4.26",
9
+ "@hachej/boring-files@^0.4.26",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0",
12
12
  "lucide-react@^1.51.0"
13
13
  ],
14
14
  "registryDependencies": [
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-chat-voice.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/utils.json",
18
- "button",
19
- "toggle",
20
- "badge",
21
- "item",
22
- "card",
23
- "textarea",
24
- "switch"
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/pi-chat-voice.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/utils.json"
25
18
  ],
26
19
  "files": [
27
20
  {
28
21
  "path": "registry/feedback/feedback-session.tsx",
29
- "content": "// The feedback session's look (FEEDBACK.md, \"UX\"): the note pin beside a picked element (NoteBubble: a text field with a mic inside it),\n// the slim bar and the chip with its review (optional, for hosts with no chat). Everything they show and do is the headless\n// `createFeedbackSession` from `@hachej/boring-feedback/ui` (hover labels, pins, steps, the draft and Send come from there, through the\n// privacy policy); this file is how it looks. `useComposerFeedback` adapts a session to the chat composer's optional `feedback` prop\n// (registry/pi-chat): ONE annotate toggle and a count chip, structurally, so neither item imports the other.\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';\nimport { createPortal } from 'react-dom';\nimport { MicIcon, SquareIcon, XIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Badge } from '@/components/ui/badge';\nimport { Card } from '@/components/ui/card';\nimport { Textarea } from '@/components/ui/textarea';\nimport { readableElement } from '@hachej/boring-feedback/format';\nimport { createVoiceCapture, type VoiceCapture } from '@hachej/boring-feedback/page';\nimport { pinMark, type FeedbackSession, type FeedbackSessionSnapshot, type SessionNote } from '@hachej/boring-feedback/ui';\nimport { useHoldToRecord } from '../pi-chat-voice/use-hold-to-record';\nimport { useExternal } from './use-external';\nimport { plainElement, withLabels, type FeedbackLabels } from './feedback-labels';\n\nconst plural = (count: number, one: string, many: string): string => `${count} ${count === 1 ? one : many}`;\n/** Notes the chip and the bar count: every note that says something (not a pin still being typed), plus the general note. */\nexport function noteCount(state: FeedbackSessionSnapshot): number {\n return state.notes.filter(note => note.text.trim()).length + (state.general.trim() ? 1 : 0);\n}\n\nexport interface FeedbackBarProps {\n readonly session: FeedbackSession;\n readonly className?: string;\n /** Docked under the chat composer (the composer's `feedback.bar`) instead of floating at the top of the page. */\n readonly docked?: boolean;\n}\n\n/** Pins dropped while recording that have no note yet (nothing transcribed for them, or transcription failed). */\nexport function emptyPinCount(state: FeedbackSessionSnapshot): number {\n return state.notes.filter(note => note.recorded && !note.text.trim()).length;\n}\n\n/** `mm:ss` since `since` (ms), for the recording timer. */\nexport function elapsed(ms: number): string {\n const seconds = Math.max(0, Math.floor(ms / 1000));\n return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;\n}\n\n/** The running time of a recording, from when the bar saw it start; 00:00 when not recording. */\nfunction useRecordingTime(recording: boolean): string {\n const [since, setSince] = useState<number | undefined>(undefined);\n const [, tick] = useState(0);\n useEffect(() => {\n if (!recording) { setSince(undefined); return; }\n setSince(Date.now());\n const timer = setInterval(() => tick(value => value + 1), 500);\n return () => clearInterval(timer);\n }, [recording]);\n return elapsed(since === undefined ? 0 : Date.now() - since);\n}\n\nfunction barHint(state: FeedbackSessionSnapshot): string {\n if (state.phase === 'processing') return state.transcribing ? 'Transcribing…' : 'Preparing your feedback…';\n if (state.problem) return state.problem;\n if (state.voice.kind === 'starting') return 'Allow the microphone if your browser asks…';\n if (state.voice.kind === 'recording') return state.using ? 'Recording — using the app' : 'Recording — point and talk';\n return state.using ? 'Using the app: your clicks are recorded as steps' : 'Click what you want to comment on';\n}\n\n/**\n * Feedback mode's bar: `● Feedback · N notes · 🎙 Record · Use app · Done · ✕`. While recording: a red dot, the\n * running time, \"Recording — point and talk\" and Stop. A refused microphone is said in the bar itself, never only in a tooltip.\n */\nexport function FeedbackBar({ session, className, docked = false }: FeedbackBarProps) {\n const state = useExternal(session);\n const recording = state.voice.kind === 'recording';\n const time = useRecordingTime(recording && (state.phase === 'active' || state.phase === 'processing'));\n if (state.phase !== 'active' && state.phase !== 'processing') return null;\n const processing = state.phase === 'processing';\n const pins = emptyPinCount(state);\n const voiceProblem = state.voice.kind === 'refused' ? state.voice : undefined;\n return <Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"toolbar\" aria-label=\"Feedback mode\" data-testid=\"feedback-bar\" data-docked={docked ? 'true' : 'false'}\n data-using={state.using ? 'true' : 'false'} data-recording={recording ? 'true' : 'false'} data-transcribing={state.transcribing ? 'true' : 'false'}\n className={['boring-feedback-bar', className].filter(Boolean).join(' ')}\n onKeyDown={event => { if (event.key === 'Escape' && state.bubble) { event.stopPropagation(); session.cancelBubble(); } }}>\n <span className=\"boring-feedback-bar-title\">Feedback</span>\n <span className=\"boring-feedback-bar-meta\" data-testid=\"feedback-bar-count\">{plural(noteCount(state), 'note', 'notes')}{pins ? ` · ${plural(pins, 'pin', 'pins')}` : ''}</span>\n {state.voiceAvailable && <Toggle data-testid=\"feedback-voice\" pressed={recording}\n disabled={processing || state.voice.kind === 'starting' || state.voice.kind === 'stopping'} title={recording ? 'Stop recording' : 'Record your voice while you point: each click drops a pin, what you say becomes its note'}\n onPressedChange={() => { void session.toggleVoice(); }}>\n {recording ? <><SquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" />Stop</> : <><MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{state.voice.kind === 'starting' ? 'Starting…' : state.voice.kind === 'stopping' ? 'Stopping…' : 'Record'}</>}\n </Toggle>}\n {recording && <Badge variant=\"destructive\" role=\"status\" data-testid=\"feedback-recording\">\n <span className=\"boring-feedback-timer\" data-testid=\"feedback-recording-time\">{time}</span></Badge>}\n <span className=\"boring-feedback-hint\" role=\"status\" aria-live=\"polite\" data-testid=\"feedback-bar-hint\">{barHint(state)}</span>\n {voiceProblem && <span className=\"boring-feedback-voice-problem\" role=\"alert\" data-testid=\"feedback-voice-problem\" data-code={voiceProblem.code}>{voiceProblem.reason}</span>}\n {!processing && <Toggle data-testid=\"feedback-use-app\" pressed={state.useToggle} onPressedChange={pressed => session.setUseToggle(pressed)}>Use app</Toggle>}\n <Button variant=\"default\" data-testid=\"feedback-done\" disabled={processing} onClick={() => { void session.done(); }}>Done</Button>\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"feedback-discard\" aria-label=\"Discard feedback\" title=\"Discard feedback\" disabled={processing} onClick={() => session.discard()}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </Card>;\n}\n\nconst BUBBLE_WIDTH = 280;\n/** Pixels at the bottom of the viewport taken by comment mode's banner on a phone (CommentBanner, `data-feedback-point-bar=\"bottom\"`), plus a gap; 0 without it. */\nexport function pointBarInset(document: Document): number {\n const bar = document.querySelector('[data-feedback-point-bar=\"bottom\"]');\n const height = document.defaultView?.innerHeight ?? 768;\n return bar ? Math.max(0, height - bar.getBoundingClientRect().top + 8) : 0;\n}\n\n/**\n * Beside the element: below it when the bubble (`size`, its measured height once drawn) fits above point mode's bar (if any), else\n * above it, else as low as it fits. Every branch is clamped to the space above the bar, so the bubble never covers it; a bubble taller\n * than that space scrolls inside it.\n */\nfunction bubblePlace(element: Element, size = 120): CSSProperties {\n const view = element.ownerDocument.defaultView;\n const width = view?.innerWidth ?? 1024, height = (view?.innerHeight ?? 768) - pointBarInset(element.ownerDocument);\n const rect = element.getBoundingClientRect();\n const left = Math.min(Math.max(8, rect.left), Math.max(8, width - BUBBLE_WIDTH - 8));\n const below = rect.bottom + 8;\n const above = rect.top - size - 8;\n const lowest = Math.max(8, height - size);\n const top = Math.min(below + size <= height ? below : above >= 8 ? above : lowest, lowest);\n return { position: 'fixed', top, left, width: Math.min(BUBBLE_WIDTH, width - 16), maxHeight: Math.max(0, height - top), overflowY: 'auto', boxSizing: 'border-box' };\n}\n\ntype PinVoice = 'idle' | 'starting' | 'recording' | 'transcribing';\n\n/**\n * The mic inside the pin's field, held like the chat's voice message (`useHoldToRecord`, the same gesture policy as the composer and the\n * shell): press and hold to talk (Space or Enter from the keyboard), release and the transcript is added to the note and the note is kept\n * (with `useSendEachNote`, sent at once). A slide off the button, Escape or a hidden page drops the recording. A refused microphone, nothing\n * heard or a rejected transcription is said under the field and never touches the typed text. One capture (`createVoiceCapture`) per pin.\n */\nfunction PinMic({ session, transcribe, words }: { readonly session: FeedbackSession; readonly transcribe: PinTranscribe; readonly words: FeedbackLabels }) {\n const [phase, setPhase] = useState<PinVoice>('idle');\n const [problem, setProblem] = useState<string | undefined>(undefined);\n const capture = useRef<VoiceCapture | null>(null);\n const run = useRef(0);\n const work = useRef<AbortController | null>(null);\n // Let go before the microphone was ready: the recording ends as soon as it starts.\n const released = useRef(false);\n capture.current ??= createVoiceCapture();\n // A closed pin releases the microphone and drops a transcription still in flight.\n useEffect(() => () => { run.current += 1; capture.current?.cancel(); work.current?.abort(); }, []);\n const finish = async (): Promise<void> => {\n const attempt = run.current;\n setPhase('transcribing');\n const recording = await capture.current!.stop().catch(() => null);\n if (attempt !== run.current) return;\n if (!recording) { setPhase('idle'); setProblem(words.voiceNothing); return; }\n const abort = new AbortController();\n work.current = abort;\n try {\n const spoken = (await transcribe(recording.audio, abort.signal)).trim();\n if (attempt !== run.current) return;\n if (!spoken) { setProblem(words.voiceNothing); return; }\n // Appended to what is typed NOW (the person may have kept typing), never replacing it.\n const typed = session.getSnapshot().bubble?.text ?? '';\n session.setBubbleText(`${typed}${typed === '' || /\\s$/.test(typed) ? '' : ' '}${spoken}`);\n session.saveBubble();\n } catch { if (attempt === run.current) setProblem(words.voiceFailed); }\n finally { if (work.current === abort) work.current = null; if (attempt === run.current) setPhase('idle'); }\n };\n const hold = useHoldToRecord({\n // Called straight from the press, so the browser ties the permission prompt to the person's gesture.\n start: () => {\n const attempt = ++run.current;\n released.current = false;\n setProblem(undefined); setPhase('starting');\n void capture.current!.start().then(result => {\n if (attempt !== run.current) return;\n if (result.kind === 'refused') { setPhase('idle'); setProblem(result.reason); return; }\n setPhase('recording');\n if (released.current) void finish();\n });\n },\n finish: () => { if (phase === 'recording') void finish(); else released.current = true; },\n cancel: () => { run.current += 1; capture.current?.cancel(); setPhase('idle'); },\n }, phase === 'transcribing');\n const recording = phase === 'recording';\n return <>\n <Button {...hold} variant=\"ghost\" size=\"icon\" className=\"boring-feedback-pin-mic min-h-11 min-w-11 touch-none select-none\" data-testid=\"feedback-bubble-mic\" data-state={phase}\n aria-pressed={recording || phase === 'starting'} aria-label={recording ? words.voiceStop : words.voiceRecord} title={recording ? words.voiceStop : words.voiceRecord} disabled={phase === 'transcribing'}>\n {phase === 'transcribing' ? <span role=\"status\" aria-label={words.voiceTranscribing}>…</span>\n : recording ? <SquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Button>\n {problem && <span className=\"boring-feedback-voice-problem\" role=\"alert\" data-testid=\"feedback-bubble-voice-problem\">{problem}</span>}\n </>;\n}\n\n/** Turns a recording into text (`signal` aborts when the pin closes); a rejection keeps what was typed. */\nexport type PinTranscribe = (audio: Blob, signal: AbortSignal) => Promise<string>;\n\n/**\n * The pin beside a picked element: a text field with a mic inside it, \"Add note\" (Enter) and Cancel (Esc). Enter saves, Shift-Enter for a\n * new line, Esc discards the pin. The element is named in plain words (`plainElement`). On a phone it keeps clear of the comment banner. `transcribe` turns the mic's recording into text; without it there is no mic.\n */\nexport function NoteBubble({ session, labels, transcribe }: { readonly session: FeedbackSession; readonly labels?: Partial<FeedbackLabels>; readonly transcribe?: PinTranscribe }) {\n const state = useExternal(session);\n const words = withLabels(labels);\n const bubble = state.bubble;\n const field = useRef<HTMLTextAreaElement>(null);\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n const [, redraw] = useState(0);\n const [announce, setAnnounce] = useState('');\n const box = useRef<HTMLDivElement>(null);\n // The bubble's natural height (its content plus borders), not the height it is clamped to, so it grows back when there is room.\n const size = (): number | undefined => (box.current ? box.current.scrollHeight + box.current.offsetHeight - box.current.clientHeight : 0) || undefined;\n useLayoutEffect(() => { if (bubble) setPlace(bubblePlace(bubble.element, size())); }, [bubble?.noteId, bubble?.element]);\n // Once drawn, place it again with its real height (taller with 44px touch targets and wrapped buttons).\n useLayoutEffect(() => {\n if (!bubble || !place || !box.current) return;\n const next = bubblePlace(bubble.element, size());\n if (next.top !== place.top || next.left !== place.left) setPlace(next);\n });\n useEffect(() => { if (bubble) field.current?.focus({ preventScroll: true }); }, [bubble?.noteId]);\n useEffect(() => {\n if (!bubble) return;\n const view = bubble.element.ownerDocument.defaultView;\n const follow = () => { setPlace(bubblePlace(bubble.element, size())); redraw(value => value + 1); };\n view?.addEventListener('scroll', follow, { capture: true, passive: true });\n view?.addEventListener('resize', follow);\n return () => { view?.removeEventListener('scroll', follow, { capture: true }); view?.removeEventListener('resize', follow); };\n }, [bubble?.noteId, bubble?.element]);\n // A polite live region outlives the bubble, so the cancellation is heard even though the dialog is gone.\n const cancel = (): void => { session.cancelSelection(); setAnnounce(words.selectionCancelled); };\n const live = <span data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"status\" aria-live=\"polite\" className=\"boring-feedback-sr\" data-testid=\"feedback-bubble-announce\"\n style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap' }}>{announce}</span>;\n if (!bubble || state.phase !== 'active') return state.phase === 'active' ? live : null;\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => {\n if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing && event.target === field.current) { event.preventDefault(); session.saveBubble(); }\n else if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); session.cancelBubble(); }\n };\n return <>{live}<Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label={`Note ${bubble.number}`} data-testid=\"feedback-bubble\" data-note={bubble.number}\n ref={box} className=\"boring-feedback-bubble\" style={place} onKeyDown={onKeyDown}>\n <span className=\"boring-feedback-bubble-head\"><span className=\"boring-feedback-pin\" aria-hidden=\"true\">{pinMark(bubble.number)}</span>\n <span className=\"boring-feedback-bubble-label\" data-testid=\"feedback-bubble-label\">{plainElement(bubble.label, words)}</span></span>\n <span className=\"boring-feedback-pin-field\">\n <Textarea ref={field} data-testid=\"feedback-bubble-input\" rows={2} placeholder={words.bubblePlaceholder} aria-label={words.bubblePlaceholder} value={bubble.text}\n onChange={event => session.setBubbleText(event.target.value)} />\n {transcribe && <PinMic key={bubble.noteId} session={session} transcribe={transcribe} words={words} />}\n </span>\n <span className=\"boring-feedback-bubble-actions\">\n <Button variant=\"ghost\" className=\"min-h-11 min-w-11\" data-testid=\"feedback-cancel-selection\" onClick={cancel}>{words.cancelSelection}</Button>\n <Button variant=\"default\" className=\"min-h-11 min-w-11\" data-testid=\"feedback-bubble-save\" onClick={() => session.saveBubble()}>{words.add}</Button>\n </span>\n </Card></>;\n}\n\nexport interface FeedbackChipProps {\n readonly session: FeedbackSession;\n /** Opens the review on first render (journeys). */\n readonly defaultOpen?: boolean;\n /** Offer Copy report in the review: the way to use feedback with no chat and no store. */\n readonly copy?: boolean;\n}\n\n/** The one composer chip after Done: `💬 Feedback · 3 notes · /settings/:section`. Click opens the review; × discards. */\nexport function FeedbackChip({ session, defaultOpen = false, copy = false }: FeedbackChipProps) {\n const state = useExternal(session);\n const [open, setOpen] = useState(defaultOpen);\n const chip = useRef<HTMLSpanElement>(null);\n if (state.phase !== 'review') return null;\n const count = noteCount(state);\n const pins = emptyPinCount(state);\n const refusal = state.send.kind !== 'idle' && state.send.kind !== 'sending' && state.send.kind !== 'saved' ? state.send.reason : undefined;\n return <span ref={chip} data-boring=\"feedback\" data-feedback-ignore=\"\" data-testid=\"feedback-chip\" data-send={state.send.kind} className=\"boring-feedback-chip\">\n <Button variant=\"outline\" className=\"min-w-0 max-w-full\" data-testid=\"feedback-chip-open\" aria-expanded={open} onClick={() => setOpen(value => !value)}\n title={refusal ?? 'Review your feedback before sending'}>\n <span aria-hidden=\"true\">💬</span> Feedback · {plural(count, 'note', 'notes')}{pins ? ` · ${plural(pins, 'pin', 'pins')} to describe` : ''} · <span className=\"boring-feedback-chip-route\">{state.route}</span>\n </Button>\n <Button variant=\"ghost\" size=\"icon-sm\" data-testid=\"feedback-chip-discard\" aria-label=\"Discard feedback\" title=\"Discard feedback\" onClick={() => { session.highlight(undefined); session.discard(); }}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n {state.problem && <span className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-chip-voice\">{state.problem}</span>}\n {refusal && <span className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-chip-problem\">{refusal}</span>}\n {/* In the page's body: the chip may sit in a transformed chat bar, where a fixed panel would not be placed against the viewport. */}\n {open && createPortal(<FeedbackReview session={session} anchor={chip.current} copy={copy} onClose={() => setOpen(false)} />, chip.current?.ownerDocument.body ?? document.body)}\n </span>;\n}\n\nfunction reviewPlace(anchor: Element | null): CSSProperties | undefined {\n if (!anchor) return undefined;\n const view = anchor.ownerDocument.defaultView;\n const width = view?.innerWidth ?? 1024, height = view?.innerHeight ?? 768;\n const rect = anchor.getBoundingClientRect();\n const panel = Math.min(416, width - 24);\n return { position: 'fixed', left: Math.min(Math.max(12, rect.left), width - panel - 12), bottom: Math.max(12, height - rect.top + 8), width: panel };\n}\n\n/** The compact review: notes in order, editable and removable; hovering a note highlights its element in the page, or says it is not here. */\nexport function FeedbackReview({ session, anchor = null, copy = false, onClose }: { readonly session: FeedbackSession; readonly anchor?: Element | null; readonly copy?: boolean; readonly onClose: () => void }) {\n const state = useExternal(session);\n const [where, setWhere] = useState<Readonly<Record<number, 'shown' | 'not-on-page'>>>({});\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n useLayoutEffect(() => { setPlace(reviewPlace(anchor)); }, [anchor]);\n useEffect(() => () => { session.highlight(undefined); }, [session]);\n const hover = (id: number | undefined): void => {\n const result = session.highlight(id);\n if (id !== undefined && result !== 'none') setWhere(value => ({ ...value, [id]: result }));\n };\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => { if (event.key === 'Escape') { event.stopPropagation(); onClose(); } };\n return <Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label=\"Review feedback\" data-testid=\"feedback-review\" className=\"boring-feedback-review\"\n style={place} onKeyDown={onKeyDown} onMouseLeave={() => hover(undefined)}>\n <header className=\"boring-feedback-sheet-header\"><h2>Feedback · {state.route}</h2>\n <Button variant=\"ghost\" size=\"icon\" aria-label=\"Close review\" title=\"Close review\" data-testid=\"feedback-review-close\" onClick={onClose}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button></header>\n {state.problem && <p className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-review-problem\">{state.problem}</p>}\n <ol className=\"boring-feedback-notes\">\n {state.notes.map(note => <li key={note.id} data-testid=\"feedback-review-note\" data-note={note.number} data-where={where[note.id]}\n onMouseEnter={() => hover(note.id)} onFocus={() => hover(note.id)}>\n <span className=\"boring-feedback-note-head\"><span className=\"boring-feedback-pin\" aria-hidden=\"true\">{pinMark(note.number)}</span>\n <ElementName note={note} />\n {note.from === 'voice' && <Badge variant=\"secondary\" data-testid=\"feedback-review-voice\">voice</Badge>}\n {where[note.id] === 'not-on-page' && <Badge variant=\"secondary\" data-testid=\"feedback-review-elsewhere\">not on this page</Badge>}\n <Button variant=\"ghost\" size=\"icon\" aria-label={`Remove note ${note.number}`} title={`Remove note ${note.number}`} data-testid=\"feedback-review-remove\" onClick={() => session.removeNote(note.id)}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button></span>\n <Textarea rows={2} aria-label={`Note ${note.number}`} data-testid=\"feedback-review-text\" value={note.text} placeholder={note.recorded ? 'Nothing was said here: type a note, or remove the pin' : undefined}\n onChange={event => session.setNoteText(note.id, event.target.value)} />\n </li>)}\n </ol>\n <label className=\"boring-feedback-field\"><span>General note</span>\n <Textarea rows={2} data-testid=\"feedback-review-general\" placeholder=\"Anything else? (optional)\" value={state.general} onChange={event => session.setGeneral(event.target.value)} /></label>\n <p className=\"boring-feedback-meta\">{state.stepCount > 1 ? `${plural(state.stepCount, 'step', 'steps')} recorded between the notes. ` : ''}{copy ? 'Copy the report and paste it anywhere.' : 'Sent with your next message.'}</p>\n {copy && <div className=\"boring-feedback-actions\">\n <Button variant=\"default\" data-testid=\"feedback-copy\" onClick={() => { void session.copy(); }}>Copy report</Button></div>}\n {copy && <p role=\"status\" aria-live=\"polite\" className=\"boring-feedback-status\" data-testid=\"feedback-status\" data-copy={state.copy.kind}>\n {state.copy.kind === 'copied' ? 'Report copied. Paste it anywhere.' : state.copy.kind === 'manual' ? 'The clipboard is not available here: select the report below and copy it.' : state.copy.kind === 'refused' ? state.copy.reason : ''}</p>}\n {copy && state.copy.kind === 'manual' && <Textarea readOnly className=\"boring-feedback-manual\" data-testid=\"feedback-manual-copy\" value={state.copy.text} rows={6} onFocus={event => event.currentTarget.select()} />}\n {copy && state.copy.kind === 'copied' && <details className=\"boring-feedback-copied\"><summary>Copied report</summary><pre data-testid=\"feedback-copy-text\">{state.copy.text}</pre></details>}\n </Card>;\n}\n\n/**\n * A note's element as one calm line: the readable name (semibold) and the file basename (muted, monospace), the full path in the\n * tooltip. The same reading as the pi-chat feedback card's element line (`readableElement` from `@hachej/boring-feedback/format`).\n */\nexport function ElementName({ note }: { readonly note: SessionNote }) {\n const readable = note.fallback ? readableElement(note.fallback, note.source) : { name: note.label };\n return <span className=\"boring-feedback-element\" data-testid=\"feedback-review-element\" title={'path' in readable && readable.path ? readable.path : note.label}>\n <span className=\"boring-feedback-element-name\" data-testid=\"feedback-review-name\">{readable.name}</span>\n {'file' in readable && readable.file && <span className=\"boring-feedback-element-file\" data-testid=\"feedback-review-file\">{readable.file}</span>}\n </span>;\n}\n\n/** What `useComposerFeedback` returns: the structural shape of pi-chat's `ComposerFeedback`. */\nexport interface ComposerFeedbackProp {\n readonly annotate: () => void;\n readonly active: boolean;\n readonly count: number;\n readonly clear: () => void;\n readonly attach: (text: string) => Promise<{ readonly kind: 'ok'; readonly text: string } | { readonly kind: 'refused'; readonly reason: string }>;\n readonly sent: () => void;\n}\n\n/** A Markdown fence longer than any backtick run in the text. */\nfunction fenced(text: string): string {\n const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map(match => match[0].length));\n const fence = '`'.repeat(Math.max(3, longest + 1));\n return `${fence}markdown\\n${text.endsWith('\\n') ? text : `${text}\\n`}${fence}`;\n}\n\n/** Touch or a compact screen: the panel covers the app and there is no hover, so annotating is point mode (use the app, then point). */\nfunction pointsInstead(): boolean {\n return globalThis.matchMedia?.('(pointer: coarse)').matches === true || (globalThis.innerWidth ?? 1024) < 768;\n}\n\n/**\n * Starts (or resumes) annotating in either mode, with Esc as a way out. The session swallows Esc on the page (it only closes a pin), so\n * this listens on the window's capture phase BEFORE the session starts, and ends the mode (Done: the notes wait for Send) when no pin is\n * open. From the review, `resume` keeps the notes, anchors and save guards: a second round adds to the same draft.\n */\nfunction startAnnotating(session: FeedbackSession, mode: 'point' | undefined = pointsInstead() ? 'point' : undefined, fresh = true): void {\n if (fresh) sentInRound.delete(session);\n const view = globalThis.window;\n const onKey = (event: globalThis.KeyboardEvent): void => {\n // Esc that ends annotating is spent: a host layer around the composer (a panel that closes on Esc) must not also react to it.\n if (event.key === 'Escape' && !session.getSnapshot().bubble) { event.preventDefault(); event.stopPropagation(); void session.done(); }\n };\n view?.addEventListener('keydown', onKey, { capture: true });\n session.resume(mode ? { mode } : {});\n const stop = session.subscribe(() => {\n if (session.getSnapshot().phase === 'active') return;\n view?.removeEventListener('keydown', onKey, { capture: true });\n stop();\n });\n}\n\n/**\n * The composer's `feedback` prop for a session: ONE annotate toggle (targeting with a hover highlight; point mode on touch and compact\n * screens, and again after Esc to add more), a count of the notes taken, and Send attaching them: a saved report as its `@` mention (the host's mention resolver inlines\n * the stored report with the person's access), otherwise the rendered report inline in a fenced block. Pair it with `NoteBubble`.\n */\nexport function useComposerFeedback(session: FeedbackSession | undefined): ComposerFeedbackProp | undefined {\n const state = useSyncState(session);\n if (!session || !state) return undefined;\n const open = state.phase === 'active' || state.phase === 'processing';\n return {\n annotate: () => {\n const phase = session.getSnapshot().phase;\n if (phase === 'active') void session.done();\n else if (phase === 'idle' || phase === 'review') startAnnotating(session);\n },\n active: open,\n // A note being typed in the pin counts: Send saves it.\n count: noteCount(state) + (state.bubble?.fresh && state.bubble.text.trim() ? 1 : 0),\n clear: () => { session.highlight(undefined); session.discard(); },\n attach: async text => {\n // Send while annotating ends it first: the pin being typed is kept.\n if (session.getSnapshot().phase === 'active') await session.done();\n const attached = await session.attach();\n if (attached.kind === 'refused') return attached;\n const body = attached.kind === 'mention' ? `@${attached.path}` : fenced(attached.text);\n return { kind: 'ok', text: text.trim() ? `${text.trimEnd()}\\n\\n${body}` : body };\n },\n sent: () => session.sent(),\n };\n}\n\n/** Notes sent one by one in the current round of comment mode (`useSendEachNote`), for the banner; a new round starts at zero. */\nconst sentInRound = new WeakMap<FeedbackSession, number>();\nexport const notesSent = (session: FeedbackSession): number => sentInRound.get(session) ?? 0;\n\n/**\n * Each note goes to the chat the moment it is added: its element and text as one message (`send`, the chat's follow-up send), and\n * comment mode stays on for the next note. Nothing waits in a hidden batch, and the agent's reply follows in the conversation. Without\n * `send`, the notes wait for the person's next message as before. A refused report or a failed send stays in the review, where the composer shows it.\n */\nexport function useSendEachNote(session: FeedbackSession | undefined, send: ((text: string) => Promise<unknown>) | undefined): void {\n const state = useSyncState(session);\n const busy = useRef(false);\n useEffect(() => {\n if (!session || !send || !state || busy.current) return;\n if (state.phase !== 'active' || state.bubble || !state.notes.some(note => note.text.trim())) return;\n busy.current = true;\n const mode = state.mode === 'point' ? 'point' : undefined;\n void (async () => {\n let again = false;\n try {\n await session.done();\n const attached = await session.attach();\n if (attached.kind === 'refused') return;\n const submission = await send(attached.kind === 'mention' ? `@${attached.path}` : fenced(attached.text)).catch(() => undefined);\n if (submission === false || submission === undefined || submission === null) return;\n sentInRound.set(session, notesSent(session) + noteCount(session.getSnapshot()));\n session.sent();\n again = true;\n } finally {\n busy.current = false;\n if (again) startAnnotating(session, mode, false);\n }\n })();\n }, [session, send, state]);\n}\n\nconst NO_STATE = { getSnapshot: () => undefined, subscribe: () => () => {} };\nfunction useSyncState(session: FeedbackSession | undefined): FeedbackSessionSnapshot | undefined {\n return useExternal<FeedbackSessionSnapshot | undefined>(session ?? NO_STATE);\n}\n\n/** What `usePointComments` returns: the structural shape of the maquette viewer's `MaquetteComments` (any viewer may take it). */\nexport interface PointCommentsProp {\n /** A host-owned proxy element for an opaque frame pick, checked by the session's root and privacy policy. */\n readonly pick: (element: Element) => void;\n readonly reposition: () => void;\n /** Clicks are currently captured, false during Use app or processing. */\n readonly picking: boolean;\n /** The session is annotating now (point mode or feedback mode). */\n readonly active: boolean;\n /** Point mode on the page, keeping the notes already taken (from the review too); Done when it is on. Esc ends it like the composer's toggle. */\n readonly point: () => void;\n /** Every pin with a marked element: its number and the `data-feedback-id` it sits in (`SessionNote.feedbackId`). */\n readonly pins: readonly { readonly number: number; readonly target: string }[];\n}\n\n/**\n * Comment pins for a viewer inside the page (the maquette viewer's `comments`): the same session, notes, bubble, review and Send as the\n * composer's toggle, entered in point mode from the viewer's own icon. A viewer marks its parts with `data-feedback-id` and matches the\n * pins on them. Undefined without a session, so the viewer shows no icon.\n */\nexport function usePointComments(session: FeedbackSession | undefined): PointCommentsProp | undefined {\n const state = useSyncState(session);\n // One object per session state, so a host that puts it in a memoized kind list re-renders only when the pins or the mode change.\n return useMemo(() => !session || !state ? undefined : {\n active: state.phase === 'active' || state.phase === 'processing',\n pick: session.pick,\n reposition: session.reposition,\n picking: state.phase === 'active' && !state.using,\n point: () => {\n const phase = session.getSnapshot().phase;\n if (phase === 'active') void session.done();\n else if (phase === 'idle' || phase === 'review') startAnnotating(session, 'point');\n },\n pins: state.notes.flatMap(note => note.feedbackId ? [{ number: note.number, target: note.feedbackId }] : []),\n }, [session, state]);\n}\n",
22
+ "content": "// The feedback session's look (FEEDBACK.md, \"UX\"): the note pin beside a picked element (NoteBubble: a text field with a mic inside it),\n// the slim bar and the chip with its review (optional, for hosts with no chat). Everything they show and do is the headless\n// `createFeedbackSession` from `@hachej/boring-feedback/ui` (hover labels, pins, steps, the draft and Send come from there, through the\n// privacy policy); this file is how it looks. `useComposerFeedback` adapts a session to the chat composer's optional `feedback` prop\n// (registry/pi-chat): ONE annotate toggle and a count chip, structurally, so neither item imports the other.\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';\nimport { createPortal } from 'react-dom';\nimport { MicIcon, SquareIcon, XIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Badge } from '@/components/ui/badge';\nimport { Card } from '@/components/ui/card';\nimport { Textarea } from '@/components/ui/textarea';\nimport { readableElement } from '@hachej/boring-feedback/format';\nimport { createVoiceCapture, type VoiceCapture } from '@hachej/boring-feedback/page';\nimport { pinMark, type FeedbackSession, type FeedbackSessionSnapshot, type SessionNote } from '@hachej/boring-feedback/ui';\nimport { useHoldToRecord } from '../pi-chat-voice/use-hold-to-record';\nimport { useExternal } from './use-external';\nimport { plainElement, withLabels, type FeedbackLabels } from './feedback-labels';\n\nconst plural = (count: number, one: string, many: string): string => `${count} ${count === 1 ? one : many}`;\n/** Notes the chip and the bar count: every note that says something (not a pin still being typed), plus the general note. */\nexport function noteCount(state: FeedbackSessionSnapshot): number {\n return state.notes.filter(note => note.text.trim()).length + (state.general.trim() ? 1 : 0);\n}\n\nexport interface FeedbackBarProps {\n readonly session: FeedbackSession;\n readonly className?: string;\n /** Docked under the chat composer (the composer's `feedback.bar`) instead of floating at the top of the page. */\n readonly docked?: boolean;\n}\n\n/** Pins dropped while recording that have no note yet (nothing transcribed for them, or transcription failed). */\nexport function emptyPinCount(state: FeedbackSessionSnapshot): number {\n return state.notes.filter(note => note.recorded && !note.text.trim()).length;\n}\n\n/** `mm:ss` since `since` (ms), for the recording timer. */\nexport function elapsed(ms: number): string {\n const seconds = Math.max(0, Math.floor(ms / 1000));\n return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;\n}\n\n/** The running time of a recording, from when the bar saw it start; 00:00 when not recording. */\nfunction useRecordingTime(recording: boolean): string {\n const [since, setSince] = useState<number | undefined>(undefined);\n const [, tick] = useState(0);\n useEffect(() => {\n // eslint-disable-next-line react-hooks/set-state-in-effect -- the start time is taken when the bar sees the recording start (after commit), once per change of recording\n if (!recording) { setSince(undefined); return; }\n setSince(Date.now());\n const timer = setInterval(() => tick(value => value + 1), 500);\n return () => clearInterval(timer);\n }, [recording]);\n // eslint-disable-next-line react-hooks/purity -- a running clock: each tick re-renders to read the current time\n return elapsed(since === undefined ? 0 : Date.now() - since);\n}\n\nfunction barHint(state: FeedbackSessionSnapshot): string {\n if (state.phase === 'processing') return state.transcribing ? 'Transcribing…' : 'Preparing your feedback…';\n if (state.problem) return state.problem;\n if (state.voice.kind === 'starting') return 'Allow the microphone if your browser asks…';\n if (state.voice.kind === 'recording') return state.using ? 'Recording — using the app' : 'Recording — point and talk';\n return state.using ? 'Using the app: your clicks are recorded as steps' : 'Click what you want to comment on';\n}\n\n/**\n * Feedback mode's bar: `● Feedback · N notes · 🎙 Record · Use app · Done · ✕`. While recording: a red dot, the\n * running time, \"Recording — point and talk\" and Stop. A refused microphone is said in the bar itself, never only in a tooltip.\n */\nexport function FeedbackBar({ session, className, docked = false }: FeedbackBarProps) {\n const state = useExternal(session);\n const recording = state.voice.kind === 'recording';\n const time = useRecordingTime(recording && (state.phase === 'active' || state.phase === 'processing'));\n if (state.phase !== 'active' && state.phase !== 'processing') return null;\n const processing = state.phase === 'processing';\n const pins = emptyPinCount(state);\n const voiceProblem = state.voice.kind === 'refused' ? state.voice : undefined;\n return <Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"toolbar\" aria-label=\"Feedback mode\" data-testid=\"feedback-bar\" data-docked={docked ? 'true' : 'false'}\n data-using={state.using ? 'true' : 'false'} data-recording={recording ? 'true' : 'false'} data-transcribing={state.transcribing ? 'true' : 'false'}\n className={['boring-feedback-bar', className].filter(Boolean).join(' ')}\n onKeyDown={event => { if (event.key === 'Escape' && state.bubble) { event.stopPropagation(); session.cancelBubble(); } }}>\n <span className=\"boring-feedback-bar-title\">Feedback</span>\n <span className=\"boring-feedback-bar-meta\" data-testid=\"feedback-bar-count\">{plural(noteCount(state), 'note', 'notes')}{pins ? ` · ${plural(pins, 'pin', 'pins')}` : ''}</span>\n {state.voiceAvailable && <Toggle data-testid=\"feedback-voice\" pressed={recording}\n disabled={processing || state.voice.kind === 'starting' || state.voice.kind === 'stopping'} title={recording ? 'Stop recording' : 'Record your voice while you point: each click drops a pin, what you say becomes its note'}\n onPressedChange={() => { void session.toggleVoice(); }}>\n {recording ? <><SquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" />Stop</> : <><MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{state.voice.kind === 'starting' ? 'Starting…' : state.voice.kind === 'stopping' ? 'Stopping…' : 'Record'}</>}\n </Toggle>}\n {recording && <Badge variant=\"destructive\" role=\"status\" data-testid=\"feedback-recording\">\n <span className=\"boring-feedback-timer\" data-testid=\"feedback-recording-time\">{time}</span></Badge>}\n <span className=\"boring-feedback-hint\" role=\"status\" aria-live=\"polite\" data-testid=\"feedback-bar-hint\">{barHint(state)}</span>\n {voiceProblem && <span className=\"boring-feedback-voice-problem\" role=\"alert\" data-testid=\"feedback-voice-problem\" data-code={voiceProblem.code}>{voiceProblem.reason}</span>}\n {!processing && <Toggle data-testid=\"feedback-use-app\" pressed={state.useToggle} onPressedChange={pressed => session.setUseToggle(pressed)}>Use app</Toggle>}\n <Button variant=\"default\" data-testid=\"feedback-done\" disabled={processing} onClick={() => { void session.done(); }}>Done</Button>\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"feedback-discard\" aria-label=\"Discard feedback\" title=\"Discard feedback\" disabled={processing} onClick={() => session.discard()}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </Card>;\n}\n\nconst BUBBLE_WIDTH = 280;\n/** Pixels at the bottom of the viewport taken by comment mode's banner on a phone (CommentBanner, `data-feedback-point-bar=\"bottom\"`), plus a gap; 0 without it. */\nexport function pointBarInset(document: Document): number {\n const bar = document.querySelector('[data-feedback-point-bar=\"bottom\"]');\n const height = document.defaultView?.innerHeight ?? 768;\n return bar ? Math.max(0, height - bar.getBoundingClientRect().top + 8) : 0;\n}\n\n/**\n * Beside the element: below it when the bubble (`size`, its measured height once drawn) fits above point mode's bar (if any), else\n * above it, else as low as it fits. Every branch is clamped to the space above the bar, so the bubble never covers it; a bubble taller\n * than that space scrolls inside it.\n */\nfunction bubblePlace(element: Element, size = 120): CSSProperties {\n const view = element.ownerDocument.defaultView;\n const width = view?.innerWidth ?? 1024, height = (view?.innerHeight ?? 768) - pointBarInset(element.ownerDocument);\n const rect = element.getBoundingClientRect();\n const left = Math.min(Math.max(8, rect.left), Math.max(8, width - BUBBLE_WIDTH - 8));\n const below = rect.bottom + 8;\n const above = rect.top - size - 8;\n const lowest = Math.max(8, height - size);\n const top = Math.min(below + size <= height ? below : above >= 8 ? above : lowest, lowest);\n return { position: 'fixed', top, left, width: Math.min(BUBBLE_WIDTH, width - 16), maxHeight: Math.max(0, height - top), overflowY: 'auto', boxSizing: 'border-box' };\n}\n\n/** The bubble's natural height (its content plus borders), not the height it is clamped to, so it grows back when there is room. */\nconst naturalHeight = (node: HTMLElement | null): number | undefined => (node ? node.scrollHeight + node.offsetHeight - node.clientHeight : 0) || undefined;\n\ntype PinVoice = 'idle' | 'starting' | 'recording' | 'transcribing';\n\n/**\n * The mic inside the pin's field, held like the chat's voice message (`useHoldToRecord`, the same gesture policy as the composer and the\n * shell): press and hold to talk (Space or Enter from the keyboard), release and the transcript is added to the note and the note is kept\n * (with `useSendEachNote`, sent at once). A slide off the button, Escape or a hidden page drops the recording. A refused microphone, nothing\n * heard or a rejected transcription is said under the field and never touches the typed text. One capture (`createVoiceCapture`) per pin.\n */\nfunction PinMic({ session, transcribe, words }: { readonly session: FeedbackSession; readonly transcribe: PinTranscribe; readonly words: FeedbackLabels }) {\n const [phase, setPhase] = useState<PinVoice>('idle');\n const [problem, setProblem] = useState<string | undefined>(undefined);\n const capture = useRef<VoiceCapture | null>(null);\n const run = useRef(0);\n const work = useRef<AbortController | null>(null);\n // Let go before the microphone was ready: the recording ends as soon as it starts.\n const released = useRef(false);\n capture.current ??= createVoiceCapture();\n // A closed pin releases the microphone and drops a transcription still in flight.\n useEffect(() => () => { run.current += 1; capture.current?.cancel(); work.current?.abort(); }, []);\n const finish = async (): Promise<void> => {\n const attempt = run.current;\n setPhase('transcribing');\n const recording = await capture.current!.stop().catch(() => null);\n if (attempt !== run.current) return;\n if (!recording) { setPhase('idle'); setProblem(words.voiceNothing); return; }\n const abort = new AbortController();\n work.current = abort;\n try {\n const spoken = (await transcribe(recording.audio, abort.signal)).trim();\n if (attempt !== run.current) return;\n if (!spoken) { setProblem(words.voiceNothing); return; }\n // Appended to what is typed NOW (the person may have kept typing), never replacing it.\n const typed = session.getSnapshot().bubble?.text ?? '';\n session.setBubbleText(`${typed}${typed === '' || /\\s$/.test(typed) ? '' : ' '}${spoken}`);\n session.saveBubble();\n } catch { if (attempt === run.current) setProblem(words.voiceFailed); }\n finally { if (work.current === abort) work.current = null; if (attempt === run.current) setPhase('idle'); }\n };\n const hold = useHoldToRecord({\n // Called straight from the press, so the browser ties the permission prompt to the person's gesture.\n start: () => {\n const attempt = ++run.current;\n released.current = false;\n setProblem(undefined); setPhase('starting');\n void capture.current!.start().then(result => {\n if (attempt !== run.current) return;\n if (result.kind === 'refused') { setPhase('idle'); setProblem(result.reason); return; }\n setPhase('recording');\n if (released.current) void finish();\n });\n },\n finish: () => { if (phase === 'recording') void finish(); else released.current = true; },\n cancel: () => { run.current += 1; capture.current?.cancel(); setPhase('idle'); },\n }, phase === 'transcribing');\n const recording = phase === 'recording';\n return <>\n <Button {...hold} variant=\"ghost\" size=\"icon\" className=\"boring-feedback-pin-mic min-h-11 min-w-11 touch-none select-none\" data-testid=\"feedback-bubble-mic\" data-state={phase}\n aria-pressed={recording || phase === 'starting'} aria-label={recording ? words.voiceStop : words.voiceRecord} title={recording ? words.voiceStop : words.voiceRecord} disabled={phase === 'transcribing'}>\n {phase === 'transcribing' ? <span role=\"status\" aria-label={words.voiceTranscribing}>…</span>\n : recording ? <SquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Button>\n {problem && <span className=\"boring-feedback-voice-problem\" role=\"alert\" data-testid=\"feedback-bubble-voice-problem\">{problem}</span>}\n </>;\n}\n\n/** Turns a recording into text (`signal` aborts when the pin closes); a rejection keeps what was typed. */\nexport type PinTranscribe = (audio: Blob, signal: AbortSignal) => Promise<string>;\n\n/**\n * The pin beside a picked element: a text field with a mic inside it, \"Add note\" (Enter) and Cancel (Esc). Enter saves, Shift-Enter for a\n * new line, Esc discards the pin. The element is named in plain words (`plainElement`). On a phone it keeps clear of the comment banner. `transcribe` turns the mic's recording into text; without it there is no mic.\n */\nexport function NoteBubble({ session, labels, transcribe }: { readonly session: FeedbackSession; readonly labels?: Partial<FeedbackLabels>; readonly transcribe?: PinTranscribe }) {\n const state = useExternal(session);\n const words = withLabels(labels);\n const bubble = state.bubble;\n const field = useRef<HTMLTextAreaElement>(null);\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n const [, redraw] = useState(0);\n const [announce, setAnnounce] = useState('');\n const box = useRef<HTMLDivElement>(null);\n const noteId = bubble?.noteId, element = bubble?.element;\n useLayoutEffect(() => { if (element) setPlace(bubblePlace(element, naturalHeight(box.current))); }, [noteId, element]);\n // Once drawn, place it again with its real height (taller with 44px touch targets and wrapped buttons).\n // eslint-disable-next-line react-hooks/exhaustive-deps -- measures after every commit on purpose (content, labels and voice state change the height); it sets state only when the place moved, so it settles\n useLayoutEffect(() => {\n if (!bubble || !place || !box.current) return;\n const next = bubblePlace(bubble.element, naturalHeight(box.current));\n if (next.top !== place.top || next.left !== place.left) setPlace(next);\n });\n useEffect(() => { if (noteId !== undefined) field.current?.focus({ preventScroll: true }); }, [noteId]);\n useEffect(() => {\n if (!element) return;\n const view = element.ownerDocument.defaultView;\n const follow = () => { setPlace(bubblePlace(element, naturalHeight(box.current))); redraw(value => value + 1); };\n view?.addEventListener('scroll', follow, { capture: true, passive: true });\n view?.addEventListener('resize', follow);\n return () => { view?.removeEventListener('scroll', follow, { capture: true }); view?.removeEventListener('resize', follow); };\n }, [noteId, element]);\n // A polite live region outlives the bubble, so the cancellation is heard even though the dialog is gone.\n const cancel = (): void => { session.cancelSelection(); setAnnounce(words.selectionCancelled); };\n const live = <span data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"status\" aria-live=\"polite\" className=\"boring-feedback-sr\" data-testid=\"feedback-bubble-announce\"\n style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap' }}>{announce}</span>;\n if (!bubble || state.phase !== 'active') return state.phase === 'active' ? live : null;\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => {\n if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing && event.target === field.current) { event.preventDefault(); session.saveBubble(); }\n else if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); session.cancelBubble(); }\n };\n return <>{live}<Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label={`Note ${bubble.number}`} data-testid=\"feedback-bubble\" data-note={bubble.number}\n ref={box} className=\"boring-feedback-bubble\" style={place} onKeyDown={onKeyDown}>\n <span className=\"boring-feedback-bubble-head\"><span className=\"boring-feedback-pin\" aria-hidden=\"true\">{pinMark(bubble.number)}</span>\n <span className=\"boring-feedback-bubble-label\" data-testid=\"feedback-bubble-label\">{plainElement(bubble.label, words)}</span></span>\n <span className=\"boring-feedback-pin-field\">\n <Textarea ref={field} data-testid=\"feedback-bubble-input\" rows={2} placeholder={words.bubblePlaceholder} aria-label={words.bubblePlaceholder} value={bubble.text}\n onChange={event => session.setBubbleText(event.target.value)} />\n {transcribe && <PinMic key={bubble.noteId} session={session} transcribe={transcribe} words={words} />}\n </span>\n <span className=\"boring-feedback-bubble-actions\">\n <Button variant=\"ghost\" className=\"min-h-11 min-w-11\" data-testid=\"feedback-cancel-selection\" onClick={cancel}>{words.cancelSelection}</Button>\n <Button variant=\"default\" className=\"min-h-11 min-w-11\" data-testid=\"feedback-bubble-save\" onClick={() => session.saveBubble()}>{words.add}</Button>\n </span>\n </Card></>;\n}\n\nexport interface FeedbackChipProps {\n readonly session: FeedbackSession;\n /** Opens the review on first render (journeys). */\n readonly defaultOpen?: boolean;\n /** Offer Copy report in the review: the way to use feedback with no chat and no store. */\n readonly copy?: boolean;\n}\n\n/** The one composer chip after Done: `💬 Feedback · 3 notes · /settings/:section`. Click opens the review; × discards. */\nexport function FeedbackChip({ session, defaultOpen = false, copy = false }: FeedbackChipProps) {\n const state = useExternal(session);\n const [open, setOpen] = useState(defaultOpen);\n const chip = useRef<HTMLSpanElement>(null);\n if (state.phase !== 'review') return null;\n const count = noteCount(state);\n const pins = emptyPinCount(state);\n const refusal = state.send.kind !== 'idle' && state.send.kind !== 'sending' && state.send.kind !== 'saved' ? state.send.reason : undefined;\n return <span ref={chip} data-boring=\"feedback\" data-feedback-ignore=\"\" data-testid=\"feedback-chip\" data-send={state.send.kind} className=\"boring-feedback-chip\">\n <Button variant=\"outline\" className=\"min-w-0 max-w-full\" data-testid=\"feedback-chip-open\" aria-expanded={open} onClick={() => setOpen(value => !value)}\n title={refusal ?? 'Review your feedback before sending'}>\n <span aria-hidden=\"true\">💬</span> Feedback · {plural(count, 'note', 'notes')}{pins ? ` · ${plural(pins, 'pin', 'pins')} to describe` : ''} · <span className=\"boring-feedback-chip-route\">{state.route}</span>\n </Button>\n <Button variant=\"ghost\" size=\"icon-sm\" data-testid=\"feedback-chip-discard\" aria-label=\"Discard feedback\" title=\"Discard feedback\" onClick={() => { session.highlight(undefined); session.discard(); }}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n {state.problem && <span className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-chip-voice\">{state.problem}</span>}\n {refusal && <span className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-chip-problem\">{refusal}</span>}\n {/* In the page's body: the chip may sit in a transformed chat bar, where a fixed panel would not be placed against the viewport. */}\n {/* eslint-disable-next-line react-hooks/refs -- the chip element is read when the review opens (after a click, so it is attached); reading it does not drive a re-render */}\n {open && createPortal(<FeedbackReview session={session} anchor={chip.current} copy={copy} onClose={() => setOpen(false)} />, chip.current?.ownerDocument.body ?? document.body)}\n </span>;\n}\n\nfunction reviewPlace(anchor: Element | null): CSSProperties | undefined {\n if (!anchor) return undefined;\n const view = anchor.ownerDocument.defaultView;\n const width = view?.innerWidth ?? 1024, height = view?.innerHeight ?? 768;\n const rect = anchor.getBoundingClientRect();\n const panel = Math.min(416, width - 24);\n return { position: 'fixed', left: Math.min(Math.max(12, rect.left), width - panel - 12), bottom: Math.max(12, height - rect.top + 8), width: panel };\n}\n\n/** The compact review: notes in order, editable and removable; hovering a note highlights its element in the page, or says it is not here. */\nexport function FeedbackReview({ session, anchor = null, copy = false, onClose }: { readonly session: FeedbackSession; readonly anchor?: Element | null; readonly copy?: boolean; readonly onClose: () => void }) {\n const state = useExternal(session);\n const [where, setWhere] = useState<Readonly<Record<number, 'shown' | 'not-on-page'>>>({});\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- the place is measured from the anchor's layout, which exists only after commit; set before paint, once per anchor\n useLayoutEffect(() => { setPlace(reviewPlace(anchor)); }, [anchor]);\n useEffect(() => () => { session.highlight(undefined); }, [session]);\n const hover = (id: number | undefined): void => {\n const result = session.highlight(id);\n if (id !== undefined && result !== 'none') setWhere(value => ({ ...value, [id]: result }));\n };\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => { if (event.key === 'Escape') { event.stopPropagation(); onClose(); } };\n return <Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label=\"Review feedback\" data-testid=\"feedback-review\" className=\"boring-feedback-review\"\n style={place} onKeyDown={onKeyDown} onMouseLeave={() => hover(undefined)}>\n <header className=\"boring-feedback-sheet-header\"><h2>Feedback · {state.route}</h2>\n <Button variant=\"ghost\" size=\"icon\" aria-label=\"Close review\" title=\"Close review\" data-testid=\"feedback-review-close\" onClick={onClose}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button></header>\n {state.problem && <p className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-review-problem\">{state.problem}</p>}\n <ol className=\"boring-feedback-notes\">\n {state.notes.map(note => <li key={note.id} data-testid=\"feedback-review-note\" data-note={note.number} data-where={where[note.id]}\n onMouseEnter={() => hover(note.id)} onFocus={() => hover(note.id)}>\n <span className=\"boring-feedback-note-head\"><span className=\"boring-feedback-pin\" aria-hidden=\"true\">{pinMark(note.number)}</span>\n <ElementName note={note} />\n {note.from === 'voice' && <Badge variant=\"secondary\" data-testid=\"feedback-review-voice\">voice</Badge>}\n {where[note.id] === 'not-on-page' && <Badge variant=\"secondary\" data-testid=\"feedback-review-elsewhere\">not on this page</Badge>}\n <Button variant=\"ghost\" size=\"icon\" aria-label={`Remove note ${note.number}`} title={`Remove note ${note.number}`} data-testid=\"feedback-review-remove\" onClick={() => session.removeNote(note.id)}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button></span>\n <Textarea rows={2} aria-label={`Note ${note.number}`} data-testid=\"feedback-review-text\" value={note.text} placeholder={note.recorded ? 'Nothing was said here: type a note, or remove the pin' : undefined}\n onChange={event => session.setNoteText(note.id, event.target.value)} />\n </li>)}\n </ol>\n <label className=\"boring-feedback-field\"><span>General note</span>\n <Textarea rows={2} data-testid=\"feedback-review-general\" placeholder=\"Anything else? (optional)\" value={state.general} onChange={event => session.setGeneral(event.target.value)} /></label>\n <p className=\"boring-feedback-meta\">{state.stepCount > 1 ? `${plural(state.stepCount, 'step', 'steps')} recorded between the notes. ` : ''}{copy ? 'Copy the report and paste it anywhere.' : 'Sent with your next message.'}</p>\n {copy && <div className=\"boring-feedback-actions\">\n <Button variant=\"default\" data-testid=\"feedback-copy\" onClick={() => { void session.copy(); }}>Copy report</Button></div>}\n {copy && <p role=\"status\" aria-live=\"polite\" className=\"boring-feedback-status\" data-testid=\"feedback-status\" data-copy={state.copy.kind}>\n {state.copy.kind === 'copied' ? 'Report copied. Paste it anywhere.' : state.copy.kind === 'manual' ? 'The clipboard is not available here: select the report below and copy it.' : state.copy.kind === 'refused' ? state.copy.reason : ''}</p>}\n {copy && state.copy.kind === 'manual' && <Textarea readOnly className=\"boring-feedback-manual\" data-testid=\"feedback-manual-copy\" value={state.copy.text} rows={6} onFocus={event => event.currentTarget.select()} />}\n {copy && state.copy.kind === 'copied' && <details className=\"boring-feedback-copied\"><summary>Copied report</summary><pre data-testid=\"feedback-copy-text\">{state.copy.text}</pre></details>}\n </Card>;\n}\n\n/**\n * A note's element as one calm line: the readable name (semibold) and the file basename (muted, monospace), the full path in the\n * tooltip. The same reading as the pi-chat feedback card's element line (`readableElement` from `@hachej/boring-feedback/format`).\n */\nexport function ElementName({ note }: { readonly note: SessionNote }) {\n const readable = note.fallback ? readableElement(note.fallback, note.source) : { name: note.label };\n return <span className=\"boring-feedback-element\" data-testid=\"feedback-review-element\" title={'path' in readable && readable.path ? readable.path : note.label}>\n <span className=\"boring-feedback-element-name\" data-testid=\"feedback-review-name\">{readable.name}</span>\n {'file' in readable && readable.file && <span className=\"boring-feedback-element-file\" data-testid=\"feedback-review-file\">{readable.file}</span>}\n </span>;\n}\n\n/** What `useComposerFeedback` returns: the structural shape of pi-chat's `ComposerFeedback`. */\nexport interface ComposerFeedbackProp {\n readonly annotate: () => void;\n readonly active: boolean;\n readonly count: number;\n readonly clear: () => void;\n readonly attach: (text: string) => Promise<{ readonly kind: 'ok'; readonly text: string } | { readonly kind: 'refused'; readonly reason: string }>;\n readonly sent: () => void;\n}\n\n/** A Markdown fence longer than any backtick run in the text. */\nfunction fenced(text: string): string {\n const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map(match => match[0].length));\n const fence = '`'.repeat(Math.max(3, longest + 1));\n return `${fence}markdown\\n${text.endsWith('\\n') ? text : `${text}\\n`}${fence}`;\n}\n\n/** Touch or a compact screen: the panel covers the app and there is no hover, so annotating is point mode (use the app, then point). */\nfunction pointsInstead(): boolean {\n return globalThis.matchMedia?.('(pointer: coarse)').matches === true || (globalThis.innerWidth ?? 1024) < 768;\n}\n\n/**\n * Starts (or resumes) annotating in either mode, with Esc as a way out. The session swallows Esc on the page (it only closes a pin), so\n * this listens on the window's capture phase BEFORE the session starts, and ends the mode (Done: the notes wait for Send) when no pin is\n * open. From the review, `resume` keeps the notes, anchors and save guards: a second round adds to the same draft.\n */\nfunction startAnnotating(session: FeedbackSession, mode: 'point' | undefined = pointsInstead() ? 'point' : undefined, fresh = true): void {\n if (fresh) sentInRound.delete(session);\n const view = globalThis.window;\n const onKey = (event: globalThis.KeyboardEvent): void => {\n // Esc that ends annotating is spent: a host layer around the composer (a panel that closes on Esc) must not also react to it.\n if (event.key === 'Escape' && !session.getSnapshot().bubble) { event.preventDefault(); event.stopPropagation(); void session.done(); }\n };\n view?.addEventListener('keydown', onKey, { capture: true });\n session.resume(mode ? { mode } : {});\n const stop = session.subscribe(() => {\n if (session.getSnapshot().phase === 'active') return;\n view?.removeEventListener('keydown', onKey, { capture: true });\n stop();\n });\n}\n\n/**\n * The composer's `feedback` prop for a session: ONE annotate toggle (targeting with a hover highlight; point mode on touch and compact\n * screens, and again after Esc to add more), a count of the notes taken, and Send attaching them: a saved report as its `@` mention (the host's mention resolver inlines\n * the stored report with the person's access), otherwise the rendered report inline in a fenced block. Pair it with `NoteBubble`.\n */\nexport function useComposerFeedback(session: FeedbackSession | undefined): ComposerFeedbackProp | undefined {\n const state = useSyncState(session);\n if (!session || !state) return undefined;\n const open = state.phase === 'active' || state.phase === 'processing';\n return {\n annotate: () => {\n const phase = session.getSnapshot().phase;\n if (phase === 'active') void session.done();\n else if (phase === 'idle' || phase === 'review') startAnnotating(session);\n },\n active: open,\n // A note being typed in the pin counts: Send saves it.\n count: noteCount(state) + (state.bubble?.fresh && state.bubble.text.trim() ? 1 : 0),\n clear: () => { session.highlight(undefined); session.discard(); },\n attach: async text => {\n // Send while annotating ends it first: the pin being typed is kept.\n if (session.getSnapshot().phase === 'active') await session.done();\n const attached = await session.attach();\n if (attached.kind === 'refused') return attached;\n const body = attached.kind === 'mention' ? `@${attached.path}` : fenced(attached.text);\n return { kind: 'ok', text: text.trim() ? `${text.trimEnd()}\\n\\n${body}` : body };\n },\n sent: () => session.sent(),\n };\n}\n\n/** Notes sent one by one in the current round of comment mode (`useSendEachNote`), for the banner; a new round starts at zero. */\nconst sentInRound = new WeakMap<FeedbackSession, number>();\nexport const notesSent = (session: FeedbackSession): number => sentInRound.get(session) ?? 0;\n\n/**\n * Each note goes to the chat the moment it is added: its element and text as one message (`send`, the chat's follow-up send), and\n * comment mode stays on for the next note. Nothing waits in a hidden batch, and the agent's reply follows in the conversation. Without\n * `send`, the notes wait for the person's next message as before. A refused report or a failed send stays in the review, where the composer shows it.\n */\nexport function useSendEachNote(session: FeedbackSession | undefined, send: ((text: string) => Promise<unknown>) | undefined): void {\n const state = useSyncState(session);\n const busy = useRef(false);\n useEffect(() => {\n if (!session || !send || !state || busy.current) return;\n if (state.phase !== 'active' || state.bubble || !state.notes.some(note => note.text.trim())) return;\n busy.current = true;\n const mode = state.mode === 'point' ? 'point' : undefined;\n void (async () => {\n let again = false;\n try {\n await session.done();\n const attached = await session.attach();\n if (attached.kind === 'refused') return;\n const submission = await send(attached.kind === 'mention' ? `@${attached.path}` : fenced(attached.text)).catch(() => undefined);\n if (submission === false || submission === undefined || submission === null) return;\n sentInRound.set(session, notesSent(session) + noteCount(session.getSnapshot()));\n session.sent();\n again = true;\n } finally {\n busy.current = false;\n if (again) startAnnotating(session, mode, false);\n }\n })();\n }, [session, send, state]);\n}\n\nconst NO_STATE = { getSnapshot: () => undefined, subscribe: () => () => {} };\nfunction useSyncState(session: FeedbackSession | undefined): FeedbackSessionSnapshot | undefined {\n return useExternal<FeedbackSessionSnapshot | undefined>(session ?? NO_STATE);\n}\n\n/** What `usePointComments` returns: the structural shape of the maquette viewer's `MaquetteComments` (any viewer may take it). */\nexport interface PointCommentsProp {\n /** A host-owned proxy element for an opaque frame pick, checked by the session's root and privacy policy. */\n readonly pick: (element: Element) => void;\n readonly reposition: () => void;\n /** Clicks are currently captured, false during Use app or processing. */\n readonly picking: boolean;\n /** The session is annotating now (point mode or feedback mode). */\n readonly active: boolean;\n /** Point mode on the page, keeping the notes already taken (from the review too); Done when it is on. Esc ends it like the composer's toggle. */\n readonly point: () => void;\n /** Every pin with a marked element: its number and the `data-feedback-id` it sits in (`SessionNote.feedbackId`). */\n readonly pins: readonly { readonly number: number; readonly target: string }[];\n}\n\n/**\n * Comment pins for a viewer inside the page (the maquette viewer's `comments`): the same session, notes, bubble, review and Send as the\n * composer's toggle, entered in point mode from the viewer's own icon. A viewer marks its parts with `data-feedback-id` and matches the\n * pins on them. Undefined without a session, so the viewer shows no icon.\n */\nexport function usePointComments(session: FeedbackSession | undefined): PointCommentsProp | undefined {\n const state = useSyncState(session);\n // One object per session state, so a host that puts it in a memoized kind list re-renders only when the pins or the mode change.\n return useMemo(() => !session || !state ? undefined : {\n active: state.phase === 'active' || state.phase === 'processing',\n pick: session.pick,\n reposition: session.reposition,\n picking: state.phase === 'active' && !state.using,\n point: () => {\n const phase = session.getSnapshot().phase;\n if (phase === 'active') void session.done();\n else if (phase === 'idle' || phase === 'review') startAnnotating(session, 'point');\n },\n pins: state.notes.flatMap(note => note.feedbackId ? [{ number: note.number, target: note.feedbackId }] : []),\n }, [session, state]);\n}\n",
30
23
  "type": "registry:component",
31
24
  "target": "components/feedback/feedback-session.tsx"
32
25
  },
33
26
  {
34
27
  "path": "registry/feedback/point-button.tsx",
35
- "content": "// PointButton: enters pick mode on the application root. Picking itself (events captured and stopped, keys, touch, labels) is\n// `createPicker` from `@hachej/boring-feedback/page`; this file is the button and the hint people see while they point.\nimport { useEffect, useRef, useState, type ReactNode } from 'react';\nimport { createPicker, type Picker, type PickerOverlay, type PickerState, type PrivacyPolicy } from '@hachej/boring-feedback/page';\nimport { Toggle } from '@/components/ui/toggle';\n\nexport interface PointButtonProps {\n /** The application root; only elements inside it can be picked. */\n readonly root: () => Element | null;\n readonly policy: PrivacyPolicy;\n /** The page's shared overlay (`usePickerOverlay`). */\n readonly overlay: PickerOverlay | undefined;\n /** Called with the pinned elements; the overlay still shows them. */\n readonly onPinned: (elements: readonly Element[]) => void;\n readonly onCancel?: () => void;\n /** Shift-click pins several elements. Default true. */\n readonly multiple?: boolean;\n readonly children?: ReactNode;\n readonly className?: string;\n}\n\nexport function PointButton({ root, policy, overlay, onPinned, onCancel, multiple = true, children = 'Point', className }: PointButtonProps) {\n const picker = useRef<Picker | null>(null);\n const [state, setState] = useState<PickerState | null>(null);\n const callbacks = useRef({ onPinned, onCancel });\n callbacks.current = { onPinned, onCancel };\n useEffect(() => () => { picker.current?.dispose(); picker.current = null; }, []);\n\n const picking = state?.mode === 'picking';\n const toggle = (): void => {\n if (picking) { picker.current?.cancel(); return; }\n const element = root();\n if (!element || !overlay) return;\n picker.current?.dispose();\n const created = createPicker({ root: element, policy, overlay, multiple, onChange: setState });\n picker.current = created;\n void created.start().then(result => {\n if (result.kind === 'pinned') callbacks.current.onPinned(result.elements);\n else callbacks.current.onCancel?.();\n });\n };\n\n return <span data-boring=\"feedback\" data-feedback-ignore=\"\" className={['boring-feedback-point', className].filter(Boolean).join(' ')}>\n <Toggle data-testid=\"feedback-point\" pressed={picking} disabled={!overlay} onPressedChange={toggle}>\n {picking ? 'Cancel' : children}\n </Toggle>\n {picking && <span role=\"status\" aria-live=\"polite\" data-testid=\"feedback-point-hint\" className=\"boring-feedback-hint\">\n {state?.label ?? state?.refusal ?? 'Point at an element'}\n <span className=\"boring-feedback-keys\"> · Click to pin{multiple ? ', Shift-click for several' : ''} · ↑ parent ↓ child · Esc</span>\n </span>}\n </span>;\n}\n",
28
+ "content": "// PointButton: enters pick mode on the application root. Picking itself (events captured and stopped, keys, touch, labels) is\n// `createPicker` from `@hachej/boring-feedback/page`; this file is the button and the hint people see while they point.\nimport { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';\nimport { createPicker, type Picker, type PickerOverlay, type PickerState, type PrivacyPolicy } from '@hachej/boring-feedback/page';\nimport { Toggle } from '@/components/ui/toggle';\n\nexport interface PointButtonProps {\n /** The application root; only elements inside it can be picked. */\n readonly root: () => Element | null;\n readonly policy: PrivacyPolicy;\n /** The page's shared overlay (`usePickerOverlay`). */\n readonly overlay: PickerOverlay | undefined;\n /** Called with the pinned elements; the overlay still shows them. */\n readonly onPinned: (elements: readonly Element[]) => void;\n readonly onCancel?: () => void;\n /** Shift-click pins several elements. Default true. */\n readonly multiple?: boolean;\n readonly children?: ReactNode;\n readonly className?: string;\n}\n\nexport function PointButton({ root, policy, overlay, onPinned, onCancel, multiple = true, children = 'Point', className }: PointButtonProps) {\n const picker = useRef<Picker | null>(null);\n const [state, setState] = useState<PickerState | null>(null);\n const callbacks = useRef({ onPinned, onCancel });\n useLayoutEffect(() => { callbacks.current = { onPinned, onCancel }; });\n useEffect(() => () => { picker.current?.dispose(); picker.current = null; }, []);\n\n const picking = state?.mode === 'picking';\n const toggle = (): void => {\n if (picking) { picker.current?.cancel(); return; }\n const element = root();\n if (!element || !overlay) return;\n picker.current?.dispose();\n const created = createPicker({ root: element, policy, overlay, multiple, onChange: setState });\n picker.current = created;\n void created.start().then(result => {\n if (result.kind === 'pinned') callbacks.current.onPinned(result.elements);\n else callbacks.current.onCancel?.();\n });\n };\n\n return <span data-boring=\"feedback\" data-feedback-ignore=\"\" className={['boring-feedback-point', className].filter(Boolean).join(' ')}>\n <Toggle data-testid=\"feedback-point\" pressed={picking} disabled={!overlay} onPressedChange={toggle}>\n {picking ? 'Cancel' : children}\n </Toggle>\n {picking && <span role=\"status\" aria-live=\"polite\" data-testid=\"feedback-point-hint\" className=\"boring-feedback-hint\">\n {state?.label ?? state?.refusal ?? 'Point at an element'}\n <span className=\"boring-feedback-keys\"> · Click to pin{multiple ? ', Shift-click for several' : ''} · ↑ parent ↓ child · Esc</span>\n </span>}\n </span>;\n}\n",
36
29
  "type": "registry:component",
37
30
  "target": "components/feedback/point-button.tsx"
38
31
  },
39
32
  {
40
33
  "path": "registry/feedback/picker-overlay.ts",
41
- "content": "// PickerOverlay: styles only. The overlay itself (its shadow root, pointer-events: none, and what it draws) is `createOverlay` from\n// `@hachej/boring-feedback/page`; this file is the editable look, passed into that shadow root. Labels come from the privacy policy there, so\n// restyling cannot change what they say. The host keeps one overlay per page and shares it between PointButton and FeedbackReport.\nimport { useEffect, useState } from 'react';\nimport { createOverlay, type PickerOverlay } from '@hachej/boring-feedback/page';\n\n/** CSS added to the overlay's shadow root, over its base styles: fonts, sizes, weights, corners and elevation read the app's\n * tokens only; tones (hover, pinned, reveal, candidate) keep the base styles' borders. Parts: `box`, `label`, `toolbar` (and `box-<tone>`, `label-<tone>` for hover, pinned,\n * reveal and candidate). The hover and pinned labels are the policy's technical names (`button · masked`): hidden, the note bubble names the element in plain words. The colors read the host's `--boring-feedback-*` variables, which inherit through the shadow boundary. */\nexport const pickerOverlayStyles = `\n.box { border-radius: var(--radius-sm, calc(var(--radius, 0.625rem) * 0.6)); }\n.label { border-radius: var(--radius-sm, calc(var(--radius, 0.625rem) * 0.6)); box-shadow: var(--shadow-sm, none); font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); font-size: var(--text-xs, 0.75rem); font-weight: var(--font-weight-medium, 500); line-height: var(--leading-normal, 1.5); }\n.label[data-tone=\"candidate\"] { font-weight: var(--font-weight-bold, 700); }\n.label[data-tone=\"hover\"], .label[data-tone=\"pinned\"] { display: none; }\n.toolbar { border-radius: var(--radius-xl, calc(var(--radius, 0.625rem) * 1.4)); box-shadow: var(--shadow-lg, none); }\n.toolbar button { border-radius: var(--radius-md, calc(var(--radius, 0.625rem) * 0.8)); font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); font-size: var(--text-sm, 0.875rem); font-weight: var(--font-weight-semibold, 600); line-height: var(--leading-tight, 1.25); }\n`;\n\n/** One overlay for the page, created after mount and removed on unmount. */\nexport function usePickerOverlay(styles: string = pickerOverlayStyles): PickerOverlay | undefined {\n const [overlay, setOverlay] = useState<PickerOverlay>();\n useEffect(() => {\n const created = createOverlay({ document, styles });\n setOverlay(created);\n return () => { created.dispose(); };\n }, [styles]);\n return overlay;\n}\n",
34
+ "content": "// PickerOverlay: styles only. The overlay itself (its shadow root, pointer-events: none, and what it draws) is `createOverlay` from\n// `@hachej/boring-feedback/page`; this file is the editable look, passed into that shadow root. Labels come from the privacy policy there, so\n// restyling cannot change what they say. The host keeps one overlay per page and shares it between PointButton and FeedbackReport.\nimport { useEffect, useState } from 'react';\nimport { createOverlay, type PickerOverlay } from '@hachej/boring-feedback/page';\n\n/** CSS added to the overlay's shadow root, over its base styles: fonts, sizes, weights, corners and elevation read the app's\n * tokens only; tones (hover, pinned, reveal, candidate) keep the base styles' borders. Parts: `box`, `label`, `toolbar` (and `box-<tone>`, `label-<tone>` for hover, pinned,\n * reveal and candidate). The hover and pinned labels are the policy's technical names (`button · masked`): hidden, the note bubble names the element in plain words. The colors read the host's `--boring-feedback-*` variables, which inherit through the shadow boundary. */\nexport const pickerOverlayStyles = `\n.box { border-radius: var(--radius-sm, calc(var(--radius, 0.625rem) * 0.6)); }\n.label { border-radius: var(--radius-sm, calc(var(--radius, 0.625rem) * 0.6)); box-shadow: var(--shadow-sm, none); font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); font-size: var(--text-xs, 0.75rem); font-weight: var(--font-weight-medium, 500); line-height: var(--leading-normal, 1.5); }\n.label[data-tone=\"candidate\"] { font-weight: var(--font-weight-bold, 700); }\n.label[data-tone=\"hover\"], .label[data-tone=\"pinned\"] { display: none; }\n.toolbar { border-radius: var(--radius-xl, calc(var(--radius, 0.625rem) * 1.4)); box-shadow: var(--shadow-lg, none); }\n.toolbar button { border-radius: var(--radius-md, calc(var(--radius, 0.625rem) * 0.8)); font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); font-size: var(--text-sm, 0.875rem); font-weight: var(--font-weight-semibold, 600); line-height: var(--leading-tight, 1.25); }\n`;\n\n/** One overlay for the page, created after mount and removed on unmount. */\nexport function usePickerOverlay(styles: string = pickerOverlayStyles): PickerOverlay | undefined {\n const [overlay, setOverlay] = useState<PickerOverlay>();\n useEffect(() => {\n const created = createOverlay({ document, styles });\n // eslint-disable-next-line react-hooks/set-state-in-effect -- the overlay is a DOM resource created after mount (and disposed on unmount); its handle has to reach render.\n setOverlay(created);\n return () => { created.dispose(); };\n }, [styles]);\n return overlay;\n}\n",
42
35
  "type": "registry:lib",
43
36
  "target": "components/feedback/picker-overlay.ts"
44
37
  },
45
38
  {
46
39
  "path": "registry/feedback/annotate-sheet.tsx",
47
- "content": "// AnnotateSheet: the note, Cancel selection (back to a general note, nothing attached), Copy report and, when the host has a save endpoint, Save. The draft, its anchors and the Save operation id are\n// the headless `createAnnotation` from `@hachej/boring-feedback/ui`; this file is how the sheet looks and what it says.\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';\nimport { protectionNotice, type Annotation, type CopyState, type FeedbackProtection, type SaveState } from '@hachej/boring-feedback/ui';\nimport { Button } from '@/components/ui/button';\nimport { Card } from '@/components/ui/card';\nimport { Textarea } from '@/components/ui/textarea';\nimport { XIcon } from 'lucide-react';\nimport { useExternal } from './use-external';\nimport { withLabels, type FeedbackLabels } from './feedback-labels';\n\nexport interface AnnotateSheetProps {\n readonly annotation: Annotation;\n readonly onClose: () => void;\n /** The pinned element the sheet opens beside. Without it the sheet docks at the bottom. */\n readonly near?: Element;\n /** The store's declared protection, when the host saves. An unprotected store is stated. */\n readonly protection?: FeedbackProtection;\n readonly title?: string;\n /** Words of the controls (`feedbackLabelsFr` for French); what is left out keeps the English default. */\n readonly labels?: Partial<FeedbackLabels>;\n /**\n * Called after the selection was cancelled (Esc, the Cancel selection button, or clicking the selected element again). The host\n * returns to the picker or to no zone here, typically by closing the sheet. Without it the sheet stays open as a general note, with\n * what was typed kept.\n */\n readonly onCancelSelection?: () => void;\n}\n\nconst copyMessage = (copy: CopyState): string => {\n switch (copy.kind) {\n case 'copied': return 'Report copied. Paste it anywhere.';\n case 'manual': return 'The clipboard is not available here: select the report below and copy it.';\n case 'refused': return copy.reason;\n default: return '';\n }\n};\nconst saveMessage = (save: SaveState): string => {\n switch (save.kind) {\n case 'saving': return 'Saving…';\n case 'saved': return 'Saved.';\n case 'unknown': return `${save.reason} Nothing is lost: Save again uses the same operation, so it cannot be stored twice.`;\n case 'denied': return `Not saved: ${save.reason}`;\n case 'unavailable': return `Not saved: ${save.reason}`;\n case 'conflict': return `Not saved: ${save.reason}`;\n case 'invalid': return save.reason;\n default: return '';\n }\n};\n\nconst SHEET_WIDTH = 360;\nfunction placeNear(element: Element | undefined): CSSProperties | undefined {\n if (!element || !element.isConnected) return undefined;\n const view = element.ownerDocument.defaultView;\n if (!view || view.innerWidth < 640) return undefined;\n const rect = element.getBoundingClientRect();\n const right = rect.right + 12;\n const left = right + SHEET_WIDTH <= view.innerWidth - 12 ? right : Math.max(12, rect.left - SHEET_WIDTH - 12);\n const top = Math.min(Math.max(12, rect.top), Math.max(12, view.innerHeight - 420));\n return { position: 'fixed', top, left, width: SHEET_WIDTH };\n}\n\nexport function AnnotateSheet({ annotation, onClose, near, protection, title, labels, onCancelSelection }: AnnotateSheetProps) {\n const state = useExternal(annotation);\n const words = withLabels(labels);\n const heading = state.selected ? (title ?? words.title) : words.askGeneral;\n const note = useRef<HTMLTextAreaElement>(null);\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n useLayoutEffect(() => { setPlace(placeNear(near)); }, [near]);\n const [cancelled, setCancelled] = useState(false);\n const callbacks = useRef({ onCancelSelection });\n callbacks.current = { onCancelSelection };\n useEffect(() => { note.current?.focus({ preventScroll: true }); }, []);\n // Focus goes back to where it was when the sheet opened (the Point button, after the picker restored it), not to the top of the page.\n useEffect(() => {\n const opener = note.current?.ownerDocument.activeElement;\n const before = opener && opener !== note.current ? opener : undefined;\n return () => { if (before && before.isConnected && typeof (before as HTMLElement).focus === 'function') (before as HTMLElement).focus({ preventScroll: true }); };\n }, []);\n const cancelSelection = (): void => {\n if (!annotation.clearSelection()) return;\n setCancelled(true);\n note.current?.focus({ preventScroll: true });\n callbacks.current.onCancelSelection?.();\n };\n // Clicking the selected element again cancels the selection instead of activating it.\n const selected = state.selected;\n useEffect(() => {\n const view = near?.ownerDocument.defaultView;\n if (!near || !view || !selected) return;\n const onClick = (event: Event): void => {\n const path = event.composedPath();\n if (path.some(item => (item as Element).hasAttribute?.('data-feedback-ignore')) || !path.includes(near)) return;\n event.preventDefault(); event.stopPropagation();\n if (annotation.clearSelection()) { setCancelled(true); note.current?.focus({ preventScroll: true }); callbacks.current.onCancelSelection?.(); }\n };\n view.addEventListener('click', onClick, { capture: true });\n return () => view.removeEventListener('click', onClick, { capture: true });\n }, [near, selected, annotation]);\n const notice = protection ? protectionNotice(protection) : undefined;\n // Esc cancels the selection first (one step back: to no zone); on a general note it closes the sheet.\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => {\n if (event.key !== 'Escape') return;\n event.stopPropagation();\n if (state.selected && state.save.kind !== 'saved' && state.save.kind !== 'saving') cancelSelection(); else onClose();\n };\n const copyText = state.copy.kind === 'copied' || state.copy.kind === 'manual' ? state.copy.text : undefined;\n const saved = state.save.kind === 'saved';\n\n return <Card data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label={heading} data-testid=\"feedback-sheet\" data-selected={state.selected ? 'true' : 'false'}\n className=\"boring-feedback-sheet\" data-docked={place ? undefined : 'bottom'} style={place} onKeyDown={onKeyDown}>\n <header className=\"boring-feedback-sheet-header\">\n <h2>{heading}</h2>\n <Button variant=\"ghost\" size=\"icon\" aria-label={words.close} title={words.close} data-testid=\"feedback-close\" onClick={onClose}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </header>\n {state.selected && <ul className=\"boring-feedback-targets\" data-testid=\"feedback-targets\">\n {state.targets.map((target, index) => <li key={index}>{target}</li>)}\n </ul>}\n {state.refused.length > 0 && <p role=\"alert\" className=\"boring-feedback-warning\" data-testid=\"feedback-refused\">\n {state.refused.length === 1 ? 'One element could not be pointed at' : `${state.refused.length} elements could not be pointed at`}: {state.refused.map(item => item.reason).join('; ')}.\n </p>}\n <label className=\"boring-feedback-field\">\n <span>{state.selected ? words.changeField : words.generalField}</span>\n <Textarea ref={note} data-testid=\"feedback-note\" rows={4} value={state.said} readOnly={saved} onChange={event => annotation.setSaid(event.target.value)} />\n </label>\n <div className=\"boring-feedback-actions\">\n {state.selected && state.save.kind !== 'saved' && <Button variant=\"ghost\" data-testid=\"feedback-cancel-selection\" disabled={state.save.kind === 'saving'}\n onClick={cancelSelection}>{words.cancelSelection}</Button>}\n <Button variant=\"ghost\" data-testid=\"feedback-copy\" onClick={() => { void annotation.copy(); }}>{words.copyReport}</Button>\n {state.canSave && <Button variant=\"default\" data-testid=\"feedback-save\" disabled={state.save.kind === 'saving' || saved}\n onClick={() => { void annotation.save(); }}>{state.save.kind === 'unknown' ? words.saveAgain : saved ? words.saved : words.save}</Button>}\n </div>\n <p role=\"status\" aria-live=\"polite\" className=\"boring-feedback-status\" data-testid=\"feedback-status\" data-copy={state.copy.kind} data-save={state.save.kind}>\n {[copyMessage(state.copy), saveMessage(state.save), cancelled && !state.selected ? words.selectionCancelled : ''].filter(Boolean).join(' ')}\n </p>\n {state.copy.kind === 'manual' && copyText !== undefined && <Textarea readOnly className=\"boring-feedback-manual\" data-testid=\"feedback-manual-copy\" value={copyText}\n onFocus={event => event.currentTarget.select()} rows={6} />}\n {state.copy.kind === 'copied' && copyText !== undefined && <details className=\"boring-feedback-copied\"><summary>Copied report</summary>\n <pre data-testid=\"feedback-copy-text\">{copyText}</pre></details>}\n {notice && <p className=\"boring-feedback-notice\" data-testid=\"feedback-protection\">{notice}</p>}\n </Card>;\n}\n",
40
+ "content": "// AnnotateSheet: the note, Cancel selection (back to a general note, nothing attached), Copy report and, when the host has a save endpoint, Save. The draft, its anchors and the Save operation id are\n// the headless `createAnnotation` from `@hachej/boring-feedback/ui`; this file is how the sheet looks and what it says.\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';\nimport { protectionNotice, type Annotation, type CopyState, type FeedbackProtection, type SaveState } from '@hachej/boring-feedback/ui';\nimport { Button } from '@/components/ui/button';\nimport { Card } from '@/components/ui/card';\nimport { Textarea } from '@/components/ui/textarea';\nimport { XIcon } from 'lucide-react';\nimport { useExternal } from './use-external';\nimport { withLabels, type FeedbackLabels } from './feedback-labels';\n\nexport interface AnnotateSheetProps {\n readonly annotation: Annotation;\n readonly onClose: () => void;\n /** The pinned element the sheet opens beside. Without it the sheet docks at the bottom. */\n readonly near?: Element;\n /** The store's declared protection, when the host saves. An unprotected store is stated. */\n readonly protection?: FeedbackProtection;\n readonly title?: string;\n /** Words of the controls (`feedbackLabelsFr` for French); what is left out keeps the English default. */\n readonly labels?: Partial<FeedbackLabels>;\n /**\n * Called after the selection was cancelled (Esc, the Cancel selection button, or clicking the selected element again). The host\n * returns to the picker or to no zone here, typically by closing the sheet. Without it the sheet stays open as a general note, with\n * what was typed kept.\n */\n readonly onCancelSelection?: () => void;\n}\n\nconst copyMessage = (copy: CopyState): string => {\n switch (copy.kind) {\n case 'copied': return 'Report copied. Paste it anywhere.';\n case 'manual': return 'The clipboard is not available here: select the report below and copy it.';\n case 'refused': return copy.reason;\n default: return '';\n }\n};\nconst saveMessage = (save: SaveState): string => {\n switch (save.kind) {\n case 'saving': return 'Saving…';\n case 'saved': return 'Saved.';\n case 'unknown': return `${save.reason} Nothing is lost: Save again uses the same operation, so it cannot be stored twice.`;\n case 'denied': return `Not saved: ${save.reason}`;\n case 'unavailable': return `Not saved: ${save.reason}`;\n case 'conflict': return `Not saved: ${save.reason}`;\n case 'invalid': return save.reason;\n default: return '';\n }\n};\n\nconst SHEET_WIDTH = 360;\nfunction placeNear(element: Element | undefined): CSSProperties | undefined {\n if (!element || !element.isConnected) return undefined;\n const view = element.ownerDocument.defaultView;\n if (!view || view.innerWidth < 640) return undefined;\n const rect = element.getBoundingClientRect();\n const right = rect.right + 12;\n const left = right + SHEET_WIDTH <= view.innerWidth - 12 ? right : Math.max(12, rect.left - SHEET_WIDTH - 12);\n const top = Math.min(Math.max(12, rect.top), Math.max(12, view.innerHeight - 420));\n return { position: 'fixed', top, left, width: SHEET_WIDTH };\n}\n\nexport function AnnotateSheet({ annotation, onClose, near, protection, title, labels, onCancelSelection }: AnnotateSheetProps) {\n const state = useExternal(annotation);\n const words = withLabels(labels);\n const heading = state.selected ? (title ?? words.title) : words.askGeneral;\n const note = useRef<HTMLTextAreaElement>(null);\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- placed from the page's layout after commit and before paint, once per anchor\n useLayoutEffect(() => { setPlace(placeNear(near)); }, [near]);\n const [cancelled, setCancelled] = useState(false);\n const callbacks = useRef({ onCancelSelection });\n useLayoutEffect(() => { callbacks.current = { onCancelSelection }; });\n useEffect(() => { note.current?.focus({ preventScroll: true }); }, []);\n // Focus goes back to where it was when the sheet opened (the Point button, after the picker restored it), not to the top of the page.\n useEffect(() => {\n const opener = note.current?.ownerDocument.activeElement;\n const before = opener && opener !== note.current ? opener : undefined;\n return () => { if (before && before.isConnected && typeof (before as HTMLElement).focus === 'function') (before as HTMLElement).focus({ preventScroll: true }); };\n }, []);\n const cancelSelection = (): void => {\n if (!annotation.clearSelection()) return;\n setCancelled(true);\n note.current?.focus({ preventScroll: true });\n callbacks.current.onCancelSelection?.();\n };\n // Clicking the selected element again cancels the selection instead of activating it.\n const selected = state.selected;\n useEffect(() => {\n const view = near?.ownerDocument.defaultView;\n if (!near || !view || !selected) return;\n const onClick = (event: Event): void => {\n const path = event.composedPath();\n if (path.some(item => (item as Element).hasAttribute?.('data-feedback-ignore')) || !path.includes(near)) return;\n event.preventDefault(); event.stopPropagation();\n if (annotation.clearSelection()) { setCancelled(true); note.current?.focus({ preventScroll: true }); callbacks.current.onCancelSelection?.(); }\n };\n view.addEventListener('click', onClick, { capture: true });\n return () => view.removeEventListener('click', onClick, { capture: true });\n }, [near, selected, annotation]);\n const notice = protection ? protectionNotice(protection) : undefined;\n // Esc cancels the selection first (one step back: to no zone); on a general note it closes the sheet.\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => {\n if (event.key !== 'Escape') return;\n event.stopPropagation();\n if (state.selected && state.save.kind !== 'saved' && state.save.kind !== 'saving') cancelSelection(); else onClose();\n };\n const copyText = state.copy.kind === 'copied' || state.copy.kind === 'manual' ? state.copy.text : undefined;\n const saved = state.save.kind === 'saved';\n\n return <Card data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label={heading} data-testid=\"feedback-sheet\" data-selected={state.selected ? 'true' : 'false'}\n className=\"boring-feedback-sheet\" data-docked={place ? undefined : 'bottom'} style={place} onKeyDown={onKeyDown}>\n <header className=\"boring-feedback-sheet-header\">\n <h2>{heading}</h2>\n <Button variant=\"ghost\" size=\"icon\" aria-label={words.close} title={words.close} data-testid=\"feedback-close\" onClick={onClose}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </header>\n {state.selected && <ul className=\"boring-feedback-targets\" data-testid=\"feedback-targets\">\n {state.targets.map((target, index) => <li key={index}>{target}</li>)}\n </ul>}\n {state.refused.length > 0 && <p role=\"alert\" className=\"boring-feedback-warning\" data-testid=\"feedback-refused\">\n {state.refused.length === 1 ? 'One element could not be pointed at' : `${state.refused.length} elements could not be pointed at`}: {state.refused.map(item => item.reason).join('; ')}.\n </p>}\n <label className=\"boring-feedback-field\">\n <span>{state.selected ? words.changeField : words.generalField}</span>\n <Textarea ref={note} data-testid=\"feedback-note\" rows={4} value={state.said} readOnly={saved} onChange={event => annotation.setSaid(event.target.value)} />\n </label>\n <div className=\"boring-feedback-actions\">\n {state.selected && state.save.kind !== 'saved' && <Button variant=\"ghost\" data-testid=\"feedback-cancel-selection\" disabled={state.save.kind === 'saving'}\n onClick={cancelSelection}>{words.cancelSelection}</Button>}\n <Button variant=\"ghost\" data-testid=\"feedback-copy\" onClick={() => { void annotation.copy(); }}>{words.copyReport}</Button>\n {state.canSave && <Button variant=\"default\" data-testid=\"feedback-save\" disabled={state.save.kind === 'saving' || saved}\n onClick={() => { void annotation.save(); }}>{state.save.kind === 'unknown' ? words.saveAgain : saved ? words.saved : words.save}</Button>}\n </div>\n <p role=\"status\" aria-live=\"polite\" className=\"boring-feedback-status\" data-testid=\"feedback-status\" data-copy={state.copy.kind} data-save={state.save.kind}>\n {[copyMessage(state.copy), saveMessage(state.save), cancelled && !state.selected ? words.selectionCancelled : ''].filter(Boolean).join(' ')}\n </p>\n {state.copy.kind === 'manual' && copyText !== undefined && <Textarea readOnly className=\"boring-feedback-manual\" data-testid=\"feedback-manual-copy\" value={copyText}\n onFocus={event => event.currentTarget.select()} rows={6} />}\n {state.copy.kind === 'copied' && copyText !== undefined && <details className=\"boring-feedback-copied\"><summary>Copied report</summary>\n <pre data-testid=\"feedback-copy-text\">{copyText}</pre></details>}\n {notice && <p className=\"boring-feedback-notice\" data-testid=\"feedback-protection\">{notice}</p>}\n </Card>;\n}\n",
48
41
  "type": "registry:component",
49
42
  "target": "components/feedback/annotate-sheet.tsx"
50
43
  },
@@ -56,7 +49,7 @@
56
49
  },
57
50
  {
58
51
  "path": "registry/feedback/feedback-report.tsx",
59
- "content": "// FeedbackReport: one stored report and Show. Show resolves each anchor against the live page now (`showAnchor` from\n// `@hachej/boring-feedback/ui`): exact and moved placements are revealed on the overlay, ambiguous ones are numbered for the person to choose,\n// and missing, stale or unsupported results are said as they are. Nothing uncertain is revealed without that choice.\nimport { useEffect, useState } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Card } from '@/components/ui/card';\nimport { XIcon } from 'lucide-react';\nimport type { FeedbackReport as Report } from '@hachej/boring-feedback/format';\nimport type { PickerOverlay, PrivacyPolicy } from '@hachej/boring-feedback/page';\nimport { reportView, showAnchor, type ShowOutcome } from '@hachej/boring-feedback/ui';\n\nexport interface FeedbackReportProps {\n readonly report: Report;\n /** Show works only in the application page itself: pass its root, policy and overlay. Without them Show is not offered. */\n readonly page?: { readonly root: () => Element | null; readonly policy: PrivacyPolicy; readonly overlay: PickerOverlay | undefined };\n readonly onClose?: () => void;\n}\n\ntype Shown = { readonly anchor: number; readonly outcome: ShowOutcome | 'working' };\n\nconst outcomeText = (outcome: ShowOutcome): string => {\n switch (outcome.kind) {\n case 'revealed': return outcome.placement === 'chosen' ? 'Shown: the element you chose.' : outcome.placement === 'moved' ? 'Shown. It has moved since the feedback was left.' : 'Shown.';\n case 'choose': return `${outcome.candidates.length} elements could be the one meant. Choose the right one:`;\n case 'stale': return `Not shown: ${outcome.reason}`;\n case 'missing': return `Not shown: ${outcome.reason}`;\n default: return `Not shown: ${outcome.reason}`;\n }\n};\n\nexport function FeedbackReport({ report, page, onClose }: FeedbackReportProps) {\n const view = reportView(report);\n const [shown, setShown] = useState<Shown | null>(null);\n useEffect(() => () => { page?.overlay?.clear(); }, [page?.overlay]);\n useEffect(() => { setShown(null); page?.overlay?.clear(); }, [report.id]);\n\n const show = async (index: number): Promise<void> => {\n const root = page?.root();\n if (!page || !root) { setShown({ anchor: index, outcome: { kind: 'unsupported', reason: 'Open the application page to see it.' } }); return; }\n setShown({ anchor: index, outcome: 'working' });\n const outcome = await showAnchor({ anchor: report.anchors[index], root, policy: page.policy, ...(page.overlay ? { overlay: page.overlay } : {}), note: view.title });\n setShown({ anchor: index, outcome });\n };\n const choose = (outcome: Extract<ShowOutcome, { kind: 'choose' }>, number: number, index: number): void => setShown({ anchor: index, outcome: outcome.choose(number) });\n\n return <Card size=\"sm\" role=\"article\" data-boring=\"feedback\" data-feedback-ignore=\"\" className=\"boring-feedback-report\" data-testid=\"feedback-report\" data-id={view.id}>\n <header className=\"boring-feedback-sheet-header\">\n <h2>{view.title}</h2>\n {onClose && <Button variant=\"ghost\" size=\"icon\" aria-label=\"Close\" title=\"Close\" onClick={onClose}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n </header>\n <p className=\"boring-feedback-meta\">\n <span data-status={view.status}>{view.status}</span>\n {view.author !== undefined && <> · {view.author}</>}\n {view.app !== undefined && <> · {view.app} {view.route}</>}\n {view.build !== undefined && <> · build {view.build}</>}\n </p>\n <p className=\"boring-feedback-said\" data-testid=\"feedback-said\">{view.said}</p>\n <ol className=\"boring-feedback-anchors\">\n {view.anchors.map(anchor => {\n const current = shown?.anchor === anchor.index ? shown.outcome : undefined;\n return <li key={anchor.index} data-testid=\"feedback-anchor\">\n <span>{anchor.fallback}</span>\n {anchor.source !== undefined && <code>{anchor.source}</code>}\n {page && anchor.placeable && <Button variant=\"ghost\" data-testid=\"feedback-show\" disabled={current === 'working'} onClick={() => { void show(anchor.index); }}>Show</Button>}\n {!anchor.placeable && <span className=\"boring-feedback-meta\">This page cannot place {anchor.kind}.</span>}\n {current !== undefined && current !== 'working' && <div role=\"status\" className=\"boring-feedback-status\" data-testid=\"feedback-show-result\" data-kind={current.kind}>\n {outcomeText(current)}\n {current.kind === 'choose' && <ol className=\"boring-feedback-candidates\">\n {current.candidates.map(candidate => <li key={candidate.number}>\n <Button variant=\"outline\" data-testid=\"feedback-candidate\" data-number={candidate.number}\n onClick={() => choose(current, candidate.number, anchor.index)}>{candidate.number} · {candidate.label}</Button>\n </li>)}\n </ol>}\n </div>}\n </li>;\n })}\n </ol>\n {view.widened.length > 0 && <p className=\"boring-feedback-meta\" data-testid=\"feedback-widened\">Privacy policy widened by the app: {view.widened.join(', ')}</p>}\n {view.resolutions.length > 0 && <section className=\"boring-feedback-resolutions\"><h3>Resolution</h3>\n {view.resolutions.map((entry, index) => <p key={index}><strong>{entry.by}</strong> · {entry.note}</p>)}</section>}\n </Card>;\n}\n",
52
+ "content": "// FeedbackReport: one stored report and Show. Show resolves each anchor against the live page now (`showAnchor` from\n// `@hachej/boring-feedback/ui`): exact and moved placements are revealed on the overlay, ambiguous ones are numbered for the person to choose,\n// and missing, stale or unsupported results are said as they are. Nothing uncertain is revealed without that choice.\nimport { useEffect, useState } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Card } from '@/components/ui/card';\nimport { XIcon } from 'lucide-react';\nimport type { FeedbackReport as Report } from '@hachej/boring-feedback/format';\nimport type { PickerOverlay, PrivacyPolicy } from '@hachej/boring-feedback/page';\nimport { reportView, showAnchor, type ShowOutcome } from '@hachej/boring-feedback/ui';\n\nexport interface FeedbackReportProps {\n readonly report: Report;\n /** Show works only in the application page itself: pass its root, policy and overlay. Without them Show is not offered. */\n readonly page?: { readonly root: () => Element | null; readonly policy: PrivacyPolicy; readonly overlay: PickerOverlay | undefined };\n readonly onClose?: () => void;\n}\n\ntype Shown = { readonly anchor: number; readonly outcome: ShowOutcome | 'working' };\n\nconst outcomeText = (outcome: ShowOutcome): string => {\n switch (outcome.kind) {\n case 'revealed': return outcome.placement === 'chosen' ? 'Shown: the element you chose.' : outcome.placement === 'moved' ? 'Shown. It has moved since the feedback was left.' : 'Shown.';\n case 'choose': return `${outcome.candidates.length} elements could be the one meant. Choose the right one:`;\n case 'stale': return `Not shown: ${outcome.reason}`;\n case 'missing': return `Not shown: ${outcome.reason}`;\n default: return `Not shown: ${outcome.reason}`;\n }\n};\n\nexport function FeedbackReport({ report, page, onClose }: FeedbackReportProps) {\n const view = reportView(report);\n const [shown, setShown] = useState<Shown | null>(null);\n const overlay = page?.overlay;\n useEffect(() => () => { overlay?.clear(); }, [overlay]);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- another report starts with nothing shown; runs once per report id\n useEffect(() => { setShown(null); overlay?.clear(); }, [report.id]); // eslint-disable-line react-hooks/exhaustive-deps -- clears on a new report only; a new overlay is cleared by the effect above\n\n const show = async (index: number): Promise<void> => {\n const root = page?.root();\n if (!page || !root) { setShown({ anchor: index, outcome: { kind: 'unsupported', reason: 'Open the application page to see it.' } }); return; }\n setShown({ anchor: index, outcome: 'working' });\n const outcome = await showAnchor({ anchor: report.anchors[index], root, policy: page.policy, ...(page.overlay ? { overlay: page.overlay } : {}), note: view.title });\n setShown({ anchor: index, outcome });\n };\n const choose = (outcome: Extract<ShowOutcome, { kind: 'choose' }>, number: number, index: number): void => setShown({ anchor: index, outcome: outcome.choose(number) });\n\n return <Card size=\"sm\" role=\"article\" data-boring=\"feedback\" data-feedback-ignore=\"\" className=\"boring-feedback-report\" data-testid=\"feedback-report\" data-id={view.id}>\n <header className=\"boring-feedback-sheet-header\">\n <h2>{view.title}</h2>\n {onClose && <Button variant=\"ghost\" size=\"icon\" aria-label=\"Close\" title=\"Close\" onClick={onClose}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n </header>\n <p className=\"boring-feedback-meta\">\n <span data-status={view.status}>{view.status}</span>\n {view.author !== undefined && <> · {view.author}</>}\n {view.app !== undefined && <> · {view.app} {view.route}</>}\n {view.build !== undefined && <> · build {view.build}</>}\n </p>\n <p className=\"boring-feedback-said\" data-testid=\"feedback-said\">{view.said}</p>\n <ol className=\"boring-feedback-anchors\">\n {view.anchors.map(anchor => {\n const current = shown?.anchor === anchor.index ? shown.outcome : undefined;\n return <li key={anchor.index} data-testid=\"feedback-anchor\">\n <span>{anchor.fallback}</span>\n {anchor.source !== undefined && <code>{anchor.source}</code>}\n {page && anchor.placeable && <Button variant=\"ghost\" data-testid=\"feedback-show\" disabled={current === 'working'} onClick={() => { void show(anchor.index); }}>Show</Button>}\n {!anchor.placeable && <span className=\"boring-feedback-meta\">This page cannot place {anchor.kind}.</span>}\n {current !== undefined && current !== 'working' && <div role=\"status\" className=\"boring-feedback-status\" data-testid=\"feedback-show-result\" data-kind={current.kind}>\n {outcomeText(current)}\n {current.kind === 'choose' && <ol className=\"boring-feedback-candidates\">\n {current.candidates.map(candidate => <li key={candidate.number}>\n <Button variant=\"outline\" data-testid=\"feedback-candidate\" data-number={candidate.number}\n onClick={() => choose(current, candidate.number, anchor.index)}>{candidate.number} · {candidate.label}</Button>\n </li>)}\n </ol>}\n </div>}\n </li>;\n })}\n </ol>\n {view.widened.length > 0 && <p className=\"boring-feedback-meta\" data-testid=\"feedback-widened\">Privacy policy widened by the app: {view.widened.join(', ')}</p>}\n {view.resolutions.length > 0 && <section className=\"boring-feedback-resolutions\"><h3>Resolution</h3>\n {view.resolutions.map((entry, index) => <p key={index}><strong>{entry.by}</strong> · {entry.note}</p>)}</section>}\n </Card>;\n}\n",
60
53
  "type": "registry:component",
61
54
  "target": "components/feedback/feedback-report.tsx"
62
55
  },
@@ -74,7 +67,7 @@
74
67
  },
75
68
  {
76
69
  "path": "registry/feedback/voice-input.tsx",
77
- "content": "// A microphone button for the composer: records on the first click, stops on the second, transcribes the recording and appends the\n// text to the composer draft. It never sends: the person reads the transcript and presses Send. Pass it to `PiChat` as `tools`.\nimport { useEffect, useRef, useState } from 'react';\nimport { Loader2Icon, MicIcon, SquareIcon, XIcon } from 'lucide-react';\nimport { createVoiceCapture } from '@hachej/boring-feedback/page';\nimport type { VoiceCapture, VoiceRefusal } from '@hachej/boring-feedback/page';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { useChatText } from '../pi-chat/labels';\nimport type { ChatLabels } from '../pi-chat/labels';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\n\nexport interface VoiceInputProps {\n /** The chat whose composer draft receives the transcript. */\n readonly controller: NativeChatController;\n /** Turns the recording into text. `signal` aborts when the person cancels or leaves the page; a rejection shows an error. */\n readonly transcribe: (audio: Blob, signal: AbortSignal) => Promise<string>;\n}\n\ntype Phase = 'idle' | 'starting' | 'recording' | 'transcribing';\n\n/** The words for each reason the microphone could not be used (the package's own English refusals are not shown). */\nconst REFUSAL: Readonly<Record<VoiceRefusal, (labels: ChatLabels) => string>> = {\n denied: labels => labels.voiceDenied,\n 'no-microphone': labels => labels.voiceNoMicrophone,\n busy: labels => labels.voiceBusy,\n insecure: labels => labels.voiceInsecure,\n unsupported: labels => labels.voiceUnsupported,\n failed: labels => labels.voiceStartFailed,\n};\n\nexport function VoiceInput({ controller, transcribe }: VoiceInputProps) {\n const { labels } = useChatText();\n const [phase, setPhase] = useState<Phase>('idle');\n const [problem, setProblem] = useState<string | undefined>();\n // One capture per mounted button. Each recording gets a run number: a cancel or unmount bumps it, so late results are dropped.\n const capture = useRef<VoiceCapture | null>(null);\n const run = useRef(0);\n const work = useRef<AbortController | null>(null);\n capture.current ??= createVoiceCapture();\n\n // Leaving the page (or the chat) releases the microphone and abandons any transcription in flight.\n useEffect(() => () => {\n run.current += 1;\n capture.current?.cancel();\n work.current?.abort();\n }, []);\n\n const append = (spoken: string) => {\n const draft = controller.getSnapshot().draft.text;\n controller.setText(`${draft}${draft === '' || /\\s$/.test(draft) ? '' : ' '}${spoken}`);\n };\n\n const start = async () => {\n const attempt = ++run.current;\n setProblem(undefined);\n setPhase('starting');\n // Called synchronously from the click, so the browser ties the permission prompt to the person's gesture.\n const result = await capture.current!.start();\n if (attempt !== run.current) return;\n if (result.kind === 'refused') {\n setPhase('idle');\n setProblem(result.code ? REFUSAL[result.code](labels) : result.reason);\n return;\n }\n setPhase('recording');\n };\n\n const finish = async () => {\n const attempt = run.current;\n setPhase('transcribing');\n const recording = await capture.current!.stop().catch(() => null);\n if (attempt !== run.current) return;\n if (!recording) {\n setPhase('idle');\n setProblem(labels.voiceNothingHeard);\n return;\n }\n const abort = new AbortController();\n work.current = abort;\n try {\n const spoken = (await transcribe(recording.audio, abort.signal)).trim();\n if (attempt !== run.current) return;\n if (spoken) append(spoken);\n else setProblem(labels.voiceNothingHeard);\n } catch {\n if (attempt === run.current) setProblem(labels.voiceTranscribeFailed);\n } finally {\n if (work.current === abort) work.current = null;\n if (attempt === run.current) setPhase('idle');\n }\n };\n\n const cancel = () => {\n run.current += 1;\n capture.current?.cancel();\n work.current?.abort();\n work.current = null;\n setProblem(undefined);\n setPhase('idle');\n };\n\n const onClick = () => {\n if (phase === 'idle') void start();\n else if (phase === 'recording') void finish();\n };\n\n const recording = phase === 'recording';\n const busy = phase === 'transcribing';\n return (\n <div className=\"flex items-center gap-1.5\">\n {(recording || busy) && <Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-voice-cancel\" aria-label={labels.voiceCancel} title={labels.voiceCancel} onClick={cancel}>\n <XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n <Toggle data-testid=\"composer-voice\" data-state={phase} pressed={recording} aria-label={recording ? labels.voiceStop : labels.voiceRecord}\n title={recording ? labels.voiceStop : labels.voiceRecord} disabled={phase === 'starting' || busy} onPressedChange={onClick}>\n {busy ? <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" role=\"status\" aria-label={labels.voiceTranscribing} />\n : recording ? <SquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n : <MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Toggle>\n {problem && <span role=\"alert\" data-testid=\"composer-voice-problem\" className=\"max-w-56 text-xs leading-4 text-destructive\">{problem}</span>}\n </div>\n );\n}\n",
70
+ "content": "// A microphone button for the composer: records on the first click, stops on the second, transcribes the recording and appends the\n// text to the composer draft. It never sends: the person reads the transcript and presses Send. Pass it to `PiChat` as `tools`.\nimport { useEffect, useRef, useState } from 'react';\nimport { Loader2Icon, MicIcon, SquareIcon, XIcon } from 'lucide-react';\nimport { createVoiceCapture } from '@hachej/boring-feedback/page';\nimport type { VoiceCapture, VoiceRefusal } from '@hachej/boring-feedback/page';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { appendToDraft } from '@hachej/boring-ui-kit/chat-text';\nimport { useChatText } from '../pi-chat/labels';\nimport type { ChatLabels } from '../pi-chat/labels';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\n\nexport interface VoiceInputProps {\n /** The chat whose composer draft receives the transcript. */\n readonly controller: NativeChatController;\n /** Turns the recording into text. `signal` aborts when the person cancels or leaves the page; a rejection shows an error. */\n readonly transcribe: (audio: Blob, signal: AbortSignal) => Promise<string>;\n}\n\ntype Phase = 'idle' | 'starting' | 'recording' | 'transcribing';\n\n/** The words for each reason the microphone could not be used (the package's own English refusals are not shown). */\nconst REFUSAL: Readonly<Record<VoiceRefusal, (labels: ChatLabels) => string>> = {\n denied: labels => labels.voiceDenied,\n 'no-microphone': labels => labels.voiceNoMicrophone,\n busy: labels => labels.voiceBusy,\n insecure: labels => labels.voiceInsecure,\n unsupported: labels => labels.voiceUnsupported,\n failed: labels => labels.voiceStartFailed,\n};\n\nexport function VoiceInput({ controller, transcribe }: VoiceInputProps) {\n const { labels } = useChatText();\n const [phase, setPhase] = useState<Phase>('idle');\n const [problem, setProblem] = useState<string | undefined>();\n // One capture per mounted button. Each recording gets a run number: a cancel or unmount bumps it, so late results are dropped.\n const capture = useRef<VoiceCapture | null>(null);\n const run = useRef(0);\n const work = useRef<AbortController | null>(null);\n capture.current ??= createVoiceCapture();\n\n // Leaving the page (or the chat) releases the microphone and abandons any transcription in flight.\n useEffect(() => () => {\n run.current += 1;\n capture.current?.cancel();\n work.current?.abort();\n }, []);\n\n const append = (spoken: string) => {\n const draft = controller.getSnapshot().draft.text;\n controller.setText(appendToDraft(draft, spoken));\n };\n\n const start = async () => {\n const attempt = ++run.current;\n setProblem(undefined);\n setPhase('starting');\n // Called synchronously from the click, so the browser ties the permission prompt to the person's gesture.\n const result = await capture.current!.start();\n if (attempt !== run.current) return;\n if (result.kind === 'refused') {\n setPhase('idle');\n setProblem(result.code ? REFUSAL[result.code](labels) : result.reason);\n return;\n }\n setPhase('recording');\n };\n\n const finish = async () => {\n const attempt = run.current;\n setPhase('transcribing');\n const recording = await capture.current!.stop().catch(() => null);\n if (attempt !== run.current) return;\n if (!recording) {\n setPhase('idle');\n setProblem(labels.voiceNothingHeard);\n return;\n }\n const abort = new AbortController();\n work.current = abort;\n try {\n const spoken = (await transcribe(recording.audio, abort.signal)).trim();\n if (attempt !== run.current) return;\n if (spoken) append(spoken);\n else setProblem(labels.voiceNothingHeard);\n } catch {\n if (attempt === run.current) setProblem(labels.voiceTranscribeFailed);\n } finally {\n if (work.current === abort) work.current = null;\n if (attempt === run.current) setPhase('idle');\n }\n };\n\n const cancel = () => {\n run.current += 1;\n capture.current?.cancel();\n work.current?.abort();\n work.current = null;\n setProblem(undefined);\n setPhase('idle');\n };\n\n const onClick = () => {\n if (phase === 'idle') void start();\n else if (phase === 'recording') void finish();\n };\n\n const recording = phase === 'recording';\n const busy = phase === 'transcribing';\n return (\n <div className=\"flex items-center gap-1.5\">\n {(recording || busy) && <Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-voice-cancel\" aria-label={labels.voiceCancel} title={labels.voiceCancel} onClick={cancel}>\n <XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n <Toggle data-testid=\"composer-voice\" data-state={phase} pressed={recording} aria-label={recording ? labels.voiceStop : labels.voiceRecord}\n title={recording ? labels.voiceStop : labels.voiceRecord} disabled={phase === 'starting' || busy} onPressedChange={onClick}>\n {busy ? <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" role=\"status\" aria-label={labels.voiceTranscribing} />\n : recording ? <SquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n : <MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Toggle>\n {problem && <span role=\"alert\" data-testid=\"composer-voice-problem\" className=\"max-w-56 text-xs leading-4 text-destructive\">{problem}</span>}\n </div>\n );\n}\n",
78
71
  "type": "registry:component",
79
72
  "target": "components/feedback/voice-input.tsx"
80
73
  },
package/r/file-tree.json CHANGED
@@ -4,24 +4,14 @@
4
4
  "title": "File tree",
5
5
  "description": "Browse, search, upload, rename, move, delete and inspect retained versions through one revisionProvider binding. Uses the consumer's shadcn Button, dropdown-menu and dialog with shared version-history rows; Base UI style only.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.25",
8
- "@hachej/boring-files@^0.4.25",
7
+ "@hachej/boring-ui-kit@^0.4.26",
8
+ "@hachej/boring-files@^0.4.26",
9
9
  "react@^19.0.0",
10
10
  "lucide-react@^1.51.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/theme.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/utils.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/version-history.json",
16
- "button",
17
- "dialog",
18
- "dropdown-menu",
19
- "collapsible",
20
- "empty",
21
- "input",
22
- "progress",
23
- "separator",
24
- "sidebar"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/utils.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/version-history.json"
25
15
  ],
26
16
  "files": [
27
17
  {
package/r/highlights.json CHANGED
@@ -4,23 +4,18 @@
4
4
  "title": "Anchored highlights",
5
5
  "description": "Exact Markdown highlights and comments in a conditional workspace sidecar.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.25",
8
- "@hachej/boring-files@^0.4.25",
7
+ "@hachej/boring-ui-kit@^0.4.26",
8
+ "@hachej/boring-files@^0.4.26",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/markdown-editor.json",
14
- "button",
15
- "textarea",
16
- "label",
17
- "item",
18
- "dropdown-menu"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/markdown-editor.json"
19
14
  ],
20
15
  "files": [
21
16
  {
22
17
  "path": "registry/highlights/highlights.tsx",
23
- "content": "'use client';\n\nimport { Fragment, useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ResourceLocator } from '@hachej/boring-files';\nimport { randomUUID } from '@hachej/boring-files/platform';\nimport type { MarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport { captureMarkdownQuote, markdownQuoteResolution, type MarkdownQuoteAnchor, type MarkdownQuoteRange } from '@hachej/boring-ui-kit/markdown-quote';\nimport { createMarkStore, type MarkStore, type MarkResource, type MarkColour } from '@hachej/boring-ui-kit/mark-store';\nimport { markdownMarkerBackground, type MarkdownQuoteCapture } from '@hachej/boring-ui-kit/markdown-editor';\nimport { Button } from '@/components/ui/button';\nimport { Textarea } from '@/components/ui/textarea';\nimport { Label } from '@/components/ui/label';\nimport { Item, ItemContent, ItemDescription, ItemGroup } from '@/components/ui/item';\nimport { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem } from '@/components/ui/dropdown-menu';\nimport { MarkdownEditor } from '../markdown-editor/markdown-editor';\n\nexport interface HighlightAction { readonly id: string; readonly label: string; readonly run: (id: string) => void }\nexport interface Highlight { readonly id: string; readonly anchor: MarkdownQuoteAnchor; readonly colour: MarkColour; readonly note?: string; readonly actions?: readonly HighlightAction[] }\nexport interface HighlightsProps {\n readonly controller: MarkdownController;\n readonly resource?: MarkResource;\n readonly locator?: ResourceLocator;\n readonly store?: MarkStore;\n readonly marks?: readonly Highlight[];\n readonly locale?: 'en' | 'fr';\n}\nconst labels = {\n en: { title: 'Highlights', add: 'Highlight selection', note: 'Comment', save: 'Save comment', remove: 'Remove highlight', actions: 'Highlight actions', tray: (count: number) => `${count} ${count === 1 ? 'highlight could' : 'highlights could'} not be placed`, missing: 'This passage is no longer in the document', ambiguous: 'This passage appears several times — pick the right one', candidate: 'Choose passage', attach: 'Attach to selection', hint: 'Select a passage in the document first.', refused: \"This selection can't be highlighted here. Try selecting it in Markdown source.\", conflict: 'Highlights changed elsewhere. Reload before editing.', unknown: \"We couldn't confirm the save. Check before making another change.\", check: 'Check save outcome', reload: 'Reload highlights', failed: \"Your highlights couldn't be saved.\", unavailable: \"Highlights aren't available.\", source: 'View marked text', confirmed: 'Your choice is kept for this version.', commented: 'Has a comment' },\n fr: { title: 'Surlignages', add: 'Surligner la sélection', note: 'Commentaire', save: 'Enregistrer le commentaire', remove: 'Supprimer le surlignage', actions: 'Actions du surlignage', tray: (count: number) => count === 1 ? \"1 surlignage n'a pas pu être placé\" : `${count} surlignages n'ont pas pu être placés`, missing: \"Ce passage n'est plus dans le document.\", ambiguous: 'Ce passage apparaît plusieurs fois — choisissez le bon.', candidate: 'Choisir ce passage', attach: 'Rattacher à la sélection', hint: \"Sélectionnez d'abord un passage dans le document.\", refused: 'Ce passage ne peut pas être surligné ici. Essayez de le sélectionner dans la source Markdown.', conflict: 'Les surlignages ont changé ailleurs. Rechargez avant de modifier.', unknown: \"L'enregistrement n'a pas été confirmé. Vérifiez avant de continuer.\", check: 'Vérifier le résultat', reload: 'Recharger les surlignages', failed: \"Les surlignages n'ont pas pu être enregistrés.\", unavailable: 'Les surlignages ne sont pas disponibles.', source: 'Voir le texte surligné', confirmed: 'Votre choix est conservé pour cette version.', commented: 'Avec un commentaire' },\n};\nconst empty = { marks: [], save: { kind: 'idle' }, dirty: false, lifecycle: 'active' } as const;\nconst idleSubscribe = () => () => {};\ntype Choice = { readonly source: string; readonly range: MarkdownQuoteRange };\n\n/** Borrows supplied controllers/stores. Only the default sidecar store is owned here. */\nexport function Highlights({ controller, resource, locator, store: supplied, marks: suppliedMarks, locale = 'en' }: HighlightsProps) {\n const l = labels[locale];\n const [owned, setOwned] = useState<{ store: MarkStore; client: MarkResource['client']; key: string } | null>(null);\n const [error, setError] = useState<string | null>(null);\n const [active, setActive] = useState<string | null>(null);\n const [note, setNote] = useState('');\n const [capture, setCapture] = useState<{ controller: MarkdownController; text: string; value: MarkdownQuoteCapture } | null>(null);\n const [choices, setChoices] = useState<Readonly<Record<string, Choice>>>({});\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const path = locator?.resource.path, providerId = locator?.resource.providerId, view = JSON.stringify(locator?.view);\n const client = resource?.client, scope = resource?.identity.scopeId, principal = resource?.identity.principalId, initiator = resource?.identity.initiatorId;\n const ownerKey = JSON.stringify([scope, principal, initiator, path, providerId, view]);\n useEffect(() => {\n if (supplied || !resource || !locator) { setOwned(null); return; }\n let live = true, acquired: MarkStore | undefined;\n setOwned(null); setError(null);\n void createMarkStore(resource, locator).then(store => { acquired = store; if (live) setOwned({ store, client: resource.client, key: ownerKey }); else store.dispose(); }, () => { if (live) setError(l.unavailable); });\n return () => { live = false; acquired?.dispose(); };\n }, [supplied, client, scope, principal, initiator, path, providerId, view]);\n const store = supplied ?? (owned?.client === client && owned?.key === ownerKey ? owned.store : null);\n const binding = useRef({ controller, store }); binding.current = { controller, store };\n const live = useRef(false);\n useEffect(() => { live.current = true; return () => { live.current = false; }; }, []);\n const snapshot = useSyncExternalStore(store?.subscribe ?? idleSubscribe, store?.getSnapshot ?? (() => empty), store?.getSnapshot ?? (() => empty));\n const marks: readonly Highlight[] = suppliedMarks ?? snapshot.marks;\n const result = snapshot.save.kind === 'settled' ? snapshot.save.result : null;\n const busy = snapshot.lifecycle !== 'active' || snapshot.save.kind === 'pending' || !!result && result.kind !== 'saved';\n const currentBinding = () => live.current && binding.current.controller === controller && binding.current.store === store && controller.getSnapshot().lifecycle === 'active';\n const currentText = () => currentBinding() && controller.getSnapshot().text === state.text;\n const selection = capture?.controller === controller && capture.text === state.text && capture.value.kind === 'captured' ? capture.value.anchor : null;\n const selectQuote = useCallback((value: MarkdownQuoteCapture) => { setCapture({ controller, text: controller.getSnapshot().text, value }); }, [controller]);\n const placed = useMemo(() => marks.map(mark => ({ mark, placement: markdownQuoteResolution.resolve(mark.anchor, state.text, String(state.bufferVersion)) })), [marks, state.text, state.bufferVersion]);\n const decorations = useMemo(() => marks.map(mark => ({ ...mark, active: mark.id === active, commented: !!mark.note?.trim(), commentLabel: l.commented, ...(choices[mark.id]?.source === state.text ? { confirmed: choices[mark.id] } : {}) })), [marks, active, l.commented, choices, state.text]);\n const current = marks.find(m => m.id === active);\n const activate = useCallback((id: string) => { setActive(id); setNote(store?.getSnapshot().marks.find(m => m.id === id)?.note ?? ''); }, [store]);\n useEffect(() => { setActive(null); setCapture(null); setChoices({}); setError(null); }, [controller, store]);\n const run = async (operation: () => Promise<unknown>) => {\n if (!currentBinding()) return;\n setError(null);\n try {\n const value = await operation();\n if (currentBinding() && value && typeof value === 'object' && 'kind' in value && !['saved', 'available', 'missing'].includes(String(value.kind))) setError(value.kind === 'conflict' ? l.conflict : value.kind === 'unknown' ? l.unknown : l.failed);\n } catch { if (currentBinding()) setError(l.failed); }\n };\n const add = () => {\n if (!currentText() || !selection || !store || busy) return;\n void run(() => store.add({ id: randomUUID(), anchor: selection, colour: 'accent', note: '', author: resource?.identity.principalId ?? state.identity.principalId, time: new Date().toISOString() }));\n };\n const choose = (mark: Highlight, range: MarkdownQuoteRange) => {\n if (!currentText() || busy) return;\n const fresh = markdownQuoteResolution.resolve(mark.anchor, controller.getSnapshot().text, String(state.bufferVersion));\n if (fresh.kind !== 'ambiguous' || !fresh.candidates.some(r => r.start === range.start && r.end === range.end)) return;\n setChoices(previous => ({ ...previous, [mark.id]: { source: state.text, range } })); activate(mark.id);\n const captured = captureMarkdownQuote(state.text, range);\n if (store && captured.kind === 'captured') void run(() => store.edit(mark.id, { anchor: captured.anchor }));\n };\n const lost = placed.filter(p => (p.placement.kind === 'missing' || p.placement.kind === 'ambiguous') && choices[p.mark.id]?.source !== state.text);\n const noteId = useId();\n const hintId = `${noteId}-selection-hint`;\n const hint = capture?.controller === controller && capture.text === state.text && capture.value.kind === 'refused' && capture.value.reason.includes('no exact source projection') ? l.refused : l.hint;\n return <section data-boring=\"highlights\" className=\"flex min-w-0 flex-col gap-4\" lang={locale}>\n <header className=\"flex flex-col gap-2\">\n <div className=\"flex flex-wrap items-center justify-between gap-2\"><h2>{l.title}</h2>\n {store && <Button variant=\"secondary\" size=\"sm\" disabled={busy || !selection} aria-describedby={!selection ? hintId : undefined} onMouseDown={event => event.preventDefault()} onClick={add}>{l.add}</Button>}\n </div>\n {store && !selection && <ItemDescription id={hintId}>{hint}</ItemDescription>}\n </header>\n {error && <p role=\"alert\">{error}</p>}\n {result && result.kind !== 'saved' && <div role=\"alert\"><p>{result.kind === 'unknown' ? l.unknown : result.kind === 'conflict' ? l.conflict : l.failed}</p>\n {result.kind === 'unknown' ? <Button variant=\"outline\" size=\"sm\" onClick={() => void run(() => store!.reconcile())}>{l.check}</Button> : <Button variant=\"outline\" size=\"sm\" onClick={() => void run(async () => { await store!.reload(); if (currentBinding()) { setActive(null); setChoices({}); } })}>{l.reload}</Button>}\n </div>}\n <MarkdownEditor controller={controller} locale={locale} highlights={decorations} onQuoteSelection={selectQuote} onHighlightActivate={activate} autosave={false} />\n <details><summary>{l.source}</summary><AnnotatedSource source={state.text} placements={placed} choices={choices} active={active} commentLabel={l.commented} activate={activate} /></details>\n {current && <div className=\"flex flex-col items-start gap-2\" data-testid=\"highlight-context\">\n {(store || current.actions?.length) && <DropdownMenu><DropdownMenuTrigger render={<Button variant=\"outline\" size=\"sm\" />}>{l.actions}</DropdownMenuTrigger>\n <DropdownMenuContent><DropdownMenuGroup>\n {current.actions?.map(action => <DropdownMenuItem key={action.id} onClick={() => { if (currentText()) action.run(current.id); }}>{action.label}</DropdownMenuItem>)}\n {store && <DropdownMenuItem disabled={busy} onClick={() => { if (currentText()) void run(() => store.remove(current.id)); }}>{l.remove}</DropdownMenuItem>}\n </DropdownMenuGroup></DropdownMenuContent>\n </DropdownMenu>}\n {choices[current.id]?.source === state.text && <p role=\"status\">{l.confirmed}</p>}\n {store && <><Label htmlFor={noteId}>{l.note}</Label><Textarea id={noteId} value={note} disabled={busy} onChange={event => setNote(event.target.value)} />\n <Button variant=\"secondary\" size=\"sm\" disabled={busy} onClick={() => { if (currentText()) void run(() => store.edit(current.id, { note })); }}>{l.save}</Button></>}\n </div>}\n {lost.length > 0 && <aside aria-label={l.tray(lost.length)} className=\"flex flex-col gap-3\"><h3>{l.tray(lost.length)}</h3>\n <ItemGroup>{lost.map(({ mark, placement }, lostIndex) => <Item key={mark.id} role=\"listitem\" variant=\"outline\" size=\"sm\" data-testid=\"lost-mark\">\n <ItemContent className=\"min-w-0 gap-3\">\n <div className=\"flex flex-col gap-1\"><p><q>{mark.anchor.exact}</q></p><ItemDescription>{placement.kind === 'missing' ? l.missing : l.ambiguous}</ItemDescription></div>\n {placement.kind === 'ambiguous' && <div className=\"grid gap-3 sm:grid-cols-2\">{placement.candidates.map((range, index) => <div key={range.start} className=\"flex min-w-0 flex-col items-start gap-2\" data-testid=\"highlight-candidate\">\n <CandidateContext source={state.text} range={range} />\n <Button variant=\"outline\" size=\"sm\" disabled={busy} onClick={() => choose(mark, range)}>{l.candidate} {index + 1}</Button>\n </div>)}</div>}\n {store && <div className=\"flex flex-col items-start gap-2\">\n <Button variant=\"outline\" size=\"sm\" disabled={!selection} aria-describedby={!selection ? `${hintId}-${lostIndex}` : undefined} onClick={() => { if (currentText() && selection) void run(() => store.edit(mark.id, { anchor: selection })); }}>{l.attach}</Button>\n {!selection && <ItemDescription id={`${hintId}-${lostIndex}`}>{hint}</ItemDescription>}\n </div>}\n </ItemContent>\n </Item>)}</ItemGroup>\n </aside>}\n </section>;\n}\n\nfunction CandidateContext({ source, range }: { readonly source: string; readonly range: MarkdownQuoteRange }) {\n const before = source.slice(0, range.start), lineStart = before.lastIndexOf('\\n') + 1;\n const previousWords = [...before.slice(lineStart).matchAll(/\\S+/gu)];\n const from = lineStart + (previousWords.slice(-5)[0]?.index ?? before.length - lineStart);\n const lineEnd = source.indexOf('\\n', range.end);\n const after = source.slice(range.end, lineEnd === -1 ? source.length : lineEnd);\n const nextWords = [...after.matchAll(/\\S+/gu)].slice(0, 5), last = nextWords.at(-1);\n const to = range.end + (last ? last.index + last[0].length : 0);\n return <p className=\"break-words\" data-testid=\"candidate-context\">\n {source.slice(0, from).trim() ? '… ' : ''}{source.slice(from, range.start).replace(/\\s+/gu, ' ')}\n <mark style={{ backgroundColor: markdownMarkerBackground('primary'), color: 'var(--foreground)' }}>{source.slice(range.start, range.end)}</mark>\n {source.slice(range.end, to).replace(/\\s+/gu, ' ')}{source.slice(to).trim() ? ' …' : ''}\n </p>;\n}\n\nfunction CommentIndicator({ id, label, activate }: { readonly id: string; readonly label: string; readonly activate: (id: string) => void }) {\n return <span data-mark-comment={id} role=\"img\" aria-label={label} className=\"inline-flex align-super\" style={{ color: 'var(--muted-foreground)', marginInlineStart: '0.15em' }} onClick={() => activate(id)}>\n <svg viewBox=\"0 0 16 16\" className=\"size-3\" aria-hidden=\"true\"><path d=\"M3 2.5h10v8H7l-4 3v-11Z\" fill=\"none\" stroke=\"currentColor\" /></svg>\n </span>;\n}\n\nfunction AnnotatedSource({ source, placements, choices, active, commentLabel, activate }: { readonly source: string; readonly placements: readonly { mark: Highlight; placement: ReturnType<typeof markdownQuoteResolution.resolve> }[]; readonly choices: Readonly<Record<string, Choice>>; readonly active: string | null; readonly commentLabel: string; readonly activate: (id: string) => void }) {\n const ranges = placements.flatMap(({ mark, placement }) => {\n if (!['accent', 'primary', 'muted'].includes(mark.colour)) return [];\n const choice = choices[mark.id];\n const range = choice?.source === source && placement.kind === 'ambiguous' && placement.candidates.some(r => r.start === choice.range.start && r.end === choice.range.end) ? choice.range : placement.kind === 'exact' || placement.kind === 'moved' ? placement.range : null;\n return range ? [{ mark, range }] : [];\n });\n const cuts = [...new Set([0, source.length, ...ranges.flatMap(r => [r.range.start, r.range.end])])].sort((a, b) => a - b);\n return <pre className=\"whitespace-pre-wrap break-words\">{cuts.slice(0, -1).map((start, index) => {\n const end = cuts[index + 1]!, covering = ranges.filter(r => r.range.start <= start && r.range.end >= end);\n return covering.length ? <Fragment key={start}><mark role=\"button\" tabIndex={0} aria-label={covering.map(r => r.mark.anchor.fallback).join('; ')} style={{ backgroundColor: markdownMarkerBackground(covering[0]!.mark.colour), color: 'var(--foreground)', ...(covering.some(r => r.mark.id === active) ? { outline: 'var(--border-width, 1px) solid var(--ring)' } : {}) }}\n onClick={() => activate(covering[0]!.mark.id)} onKeyDown={event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); activate(covering[0]!.mark.id); } }}>{source.slice(start, end)}</mark>\n {covering.filter(r => r.range.end === end && r.mark.note?.trim()).map(r => <CommentIndicator key={r.mark.id} id={r.mark.id} label={commentLabel} activate={activate} />)}\n </Fragment> : source.slice(start, end);\n })}</pre>;\n}\n",
18
+ "content": "'use client';\n\nimport { Fragment, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ResourceLocator } from '@hachej/boring-files';\nimport { randomUUID } from '@hachej/boring-files/platform';\nimport type { MarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport { captureMarkdownQuote, markdownQuoteResolution, type MarkdownQuoteAnchor, type MarkdownQuoteRange } from '@hachej/boring-ui-kit/markdown-quote';\nimport { createMarkStore, type MarkStore, type MarkResource, type MarkColour } from '@hachej/boring-ui-kit/mark-store';\nimport { markdownMarkerBackground, type MarkdownQuoteCapture } from '@hachej/boring-ui-kit/markdown-editor';\nimport { Button } from '@/components/ui/button';\nimport { Textarea } from '@/components/ui/textarea';\nimport { Label } from '@/components/ui/label';\nimport { Item, ItemContent, ItemDescription, ItemGroup } from '@/components/ui/item';\nimport { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem } from '@/components/ui/dropdown-menu';\nimport { MarkdownEditor } from '../markdown-editor/markdown-editor';\n\nexport interface HighlightAction { readonly id: string; readonly label: string; readonly run: (id: string) => void }\nexport interface Highlight { readonly id: string; readonly anchor: MarkdownQuoteAnchor; readonly colour: MarkColour; readonly note?: string; readonly actions?: readonly HighlightAction[] }\nexport interface HighlightsProps {\n readonly controller: MarkdownController;\n readonly resource?: MarkResource;\n readonly locator?: ResourceLocator;\n readonly store?: MarkStore;\n readonly marks?: readonly Highlight[];\n readonly locale?: 'en' | 'fr';\n}\nconst labels = {\n en: { title: 'Highlights', add: 'Highlight selection', note: 'Comment', save: 'Save comment', remove: 'Remove highlight', actions: 'Highlight actions', tray: (count: number) => `${count} ${count === 1 ? 'highlight could' : 'highlights could'} not be placed`, missing: 'This passage is no longer in the document', ambiguous: 'This passage appears several times — pick the right one', candidate: 'Choose passage', attach: 'Attach to selection', hint: 'Select a passage in the document first.', refused: \"This selection can't be highlighted here. Try selecting it in Markdown source.\", conflict: 'Highlights changed elsewhere. Reload before editing.', unknown: \"We couldn't confirm the save. Check before making another change.\", check: 'Check save outcome', reload: 'Reload highlights', failed: \"Your highlights couldn't be saved.\", unavailable: \"Highlights aren't available.\", source: 'View marked text', confirmed: 'Your choice is kept for this version.', commented: 'Has a comment' },\n fr: { title: 'Surlignages', add: 'Surligner la sélection', note: 'Commentaire', save: 'Enregistrer le commentaire', remove: 'Supprimer le surlignage', actions: 'Actions du surlignage', tray: (count: number) => count === 1 ? \"1 surlignage n'a pas pu être placé\" : `${count} surlignages n'ont pas pu être placés`, missing: \"Ce passage n'est plus dans le document.\", ambiguous: 'Ce passage apparaît plusieurs fois — choisissez le bon.', candidate: 'Choisir ce passage', attach: 'Rattacher à la sélection', hint: \"Sélectionnez d'abord un passage dans le document.\", refused: 'Ce passage ne peut pas être surligné ici. Essayez de le sélectionner dans la source Markdown.', conflict: 'Les surlignages ont changé ailleurs. Rechargez avant de modifier.', unknown: \"L'enregistrement n'a pas été confirmé. Vérifiez avant de continuer.\", check: 'Vérifier le résultat', reload: 'Recharger les surlignages', failed: \"Les surlignages n'ont pas pu être enregistrés.\", unavailable: 'Les surlignages ne sont pas disponibles.', source: 'Voir le texte surligné', confirmed: 'Votre choix est conservé pour cette version.', commented: 'Avec un commentaire' },\n};\nconst empty = { marks: [], save: { kind: 'idle' }, dirty: false, lifecycle: 'active' } as const;\nconst idleSubscribe = () => () => {};\ntype Choice = { readonly source: string; readonly range: MarkdownQuoteRange };\n\n/** Borrows supplied controllers/stores. Only the default sidecar store is owned here. */\nexport function Highlights({ controller, resource, locator, store: supplied, marks: suppliedMarks, locale = 'en' }: HighlightsProps) {\n const l = labels[locale];\n const [owned, setOwned] = useState<{ store: MarkStore; client: MarkResource['client']; key: string } | null>(null);\n const [error, setError] = useState<string | null>(null);\n const [active, setActive] = useState<string | null>(null);\n const [note, setNote] = useState('');\n const [capture, setCapture] = useState<{ controller: MarkdownController; text: string; value: MarkdownQuoteCapture } | null>(null);\n const [choices, setChoices] = useState<Readonly<Record<string, Choice>>>({});\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const path = locator?.resource.path, providerId = locator?.resource.providerId, view = JSON.stringify(locator?.view);\n const client = resource?.client, scope = resource?.identity.scopeId, principal = resource?.identity.principalId, initiator = resource?.identity.initiatorId;\n const ownerKey = JSON.stringify([scope, principal, initiator, path, providerId, view]);\n useEffect(() => {\n // eslint-disable-next-line react-hooks/set-state-in-effect -- the owned store is a resource acquired by this effect run (disposed in the cleanup); a new owner key clears the previous one first\n if (supplied || !resource || !locator) { setOwned(null); return; }\n let live = true, acquired: MarkStore | undefined;\n setOwned(null); setError(null);\n void createMarkStore(resource, locator).then(store => { acquired = store; if (live) setOwned({ store, client: resource.client, key: ownerKey }); else store.dispose(); }, () => { if (live) setError(l.unavailable); });\n return () => { live = false; acquired?.dispose(); };\n // eslint-disable-next-line react-hooks/exhaustive-deps -- the store follows the resource's identity and the locator's place by value, not their object identity\n }, [supplied, client, scope, principal, initiator, path, providerId, view]);\n const store = supplied ?? (owned?.client === client && owned?.key === ownerKey ? owned.store : null);\n const binding = useRef({ controller, store });\n useLayoutEffect(() => { binding.current = { controller, store }; });\n const live = useRef(false);\n useEffect(() => { live.current = true; return () => { live.current = false; }; }, []);\n const snapshot = useSyncExternalStore(store?.subscribe ?? idleSubscribe, store?.getSnapshot ?? (() => empty), store?.getSnapshot ?? (() => empty));\n const marks: readonly Highlight[] = suppliedMarks ?? snapshot.marks;\n const result = snapshot.save.kind === 'settled' ? snapshot.save.result : null;\n const busy = snapshot.lifecycle !== 'active' || snapshot.save.kind === 'pending' || !!result && result.kind !== 'saved';\n const currentBinding = () => live.current && binding.current.controller === controller && binding.current.store === store && controller.getSnapshot().lifecycle === 'active';\n const currentText = () => currentBinding() && controller.getSnapshot().text === state.text;\n const selection = capture?.controller === controller && capture.text === state.text && capture.value.kind === 'captured' ? capture.value.anchor : null;\n const selectQuote = useCallback((value: MarkdownQuoteCapture) => { setCapture({ controller, text: controller.getSnapshot().text, value }); }, [controller]);\n const placed = useMemo(() => marks.map(mark => ({ mark, placement: markdownQuoteResolution.resolve(mark.anchor, state.text, String(state.bufferVersion)) })), [marks, state.text, state.bufferVersion]);\n const decorations = useMemo(() => marks.map(mark => ({ ...mark, active: mark.id === active, commented: !!mark.note?.trim(), commentLabel: l.commented, ...(choices[mark.id]?.source === state.text ? { confirmed: choices[mark.id] } : {}) })), [marks, active, l.commented, choices, state.text]);\n const current = marks.find(m => m.id === active);\n const activate = useCallback((id: string) => { setActive(id); setNote(store?.getSnapshot().marks.find(m => m.id === id)?.note ?? ''); }, [store]);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a new controller or store drops the previous binding's selection and choices, once per change\n useEffect(() => { setActive(null); setCapture(null); setChoices({}); setError(null); }, [controller, store]);\n const run = async (operation: () => Promise<unknown>) => {\n if (!currentBinding()) return;\n setError(null);\n try {\n const value = await operation();\n if (currentBinding() && value && typeof value === 'object' && 'kind' in value && !['saved', 'available', 'missing'].includes(String(value.kind))) setError(value.kind === 'conflict' ? l.conflict : value.kind === 'unknown' ? l.unknown : l.failed);\n } catch { if (currentBinding()) setError(l.failed); }\n };\n const add = () => {\n if (!currentText() || !selection || !store || busy) return;\n void run(() => store.add({ id: randomUUID(), anchor: selection, colour: 'accent', note: '', author: resource?.identity.principalId ?? state.identity.principalId, time: new Date().toISOString() }));\n };\n const choose = (mark: Highlight, range: MarkdownQuoteRange) => {\n if (!currentText() || busy) return;\n const fresh = markdownQuoteResolution.resolve(mark.anchor, controller.getSnapshot().text, String(state.bufferVersion));\n if (fresh.kind !== 'ambiguous' || !fresh.candidates.some(r => r.start === range.start && r.end === range.end)) return;\n setChoices(previous => ({ ...previous, [mark.id]: { source: state.text, range } })); activate(mark.id);\n const captured = captureMarkdownQuote(state.text, range);\n if (store && captured.kind === 'captured') void run(() => store.edit(mark.id, { anchor: captured.anchor }));\n };\n const lost = placed.filter(p => (p.placement.kind === 'missing' || p.placement.kind === 'ambiguous') && choices[p.mark.id]?.source !== state.text);\n const noteId = useId();\n const hintId = `${noteId}-selection-hint`;\n const hint = capture?.controller === controller && capture.text === state.text && capture.value.kind === 'refused' && capture.value.reason.includes('no exact source projection') ? l.refused : l.hint;\n return <section data-boring=\"highlights\" className=\"flex min-w-0 flex-col gap-4\" lang={locale}>\n <header className=\"flex flex-col gap-2\">\n <div className=\"flex flex-wrap items-center justify-between gap-2\"><h2>{l.title}</h2>\n {store && <Button variant=\"secondary\" size=\"sm\" disabled={busy || !selection} aria-describedby={!selection ? hintId : undefined} onMouseDown={event => event.preventDefault()} onClick={add}>{l.add}</Button>}\n </div>\n {store && !selection && <ItemDescription id={hintId}>{hint}</ItemDescription>}\n </header>\n {error && <p role=\"alert\">{error}</p>}\n {result && result.kind !== 'saved' && <div role=\"alert\"><p>{result.kind === 'unknown' ? l.unknown : result.kind === 'conflict' ? l.conflict : l.failed}</p>\n {result.kind === 'unknown' ? <Button variant=\"outline\" size=\"sm\" onClick={() => void run(() => store!.reconcile())}>{l.check}</Button> : <Button variant=\"outline\" size=\"sm\" onClick={() => void run(async () => { await store!.reload(); if (currentBinding()) { setActive(null); setChoices({}); } })}>{l.reload}</Button>}\n </div>}\n <MarkdownEditor controller={controller} locale={locale} highlights={decorations} onQuoteSelection={selectQuote} onHighlightActivate={activate} autosave={false} />\n <details><summary>{l.source}</summary><AnnotatedSource source={state.text} placements={placed} choices={choices} active={active} commentLabel={l.commented} activate={activate} /></details>\n {current && <div className=\"flex flex-col items-start gap-2\" data-testid=\"highlight-context\">\n {(store || current.actions?.length) && <DropdownMenu><DropdownMenuTrigger render={<Button variant=\"outline\" size=\"sm\" />}>{l.actions}</DropdownMenuTrigger>\n <DropdownMenuContent><DropdownMenuGroup>\n {current.actions?.map(action => <DropdownMenuItem key={action.id} onClick={() => { if (currentText()) action.run(current.id); }}>{action.label}</DropdownMenuItem>)}\n {store && <DropdownMenuItem disabled={busy} onClick={() => { if (currentText()) void run(() => store.remove(current.id)); }}>{l.remove}</DropdownMenuItem>}\n </DropdownMenuGroup></DropdownMenuContent>\n </DropdownMenu>}\n {choices[current.id]?.source === state.text && <p role=\"status\">{l.confirmed}</p>}\n {store && <><Label htmlFor={noteId}>{l.note}</Label><Textarea id={noteId} value={note} disabled={busy} onChange={event => setNote(event.target.value)} />\n <Button variant=\"secondary\" size=\"sm\" disabled={busy} onClick={() => { if (currentText()) void run(() => store.edit(current.id, { note })); }}>{l.save}</Button></>}\n </div>}\n {lost.length > 0 && <aside aria-label={l.tray(lost.length)} className=\"flex flex-col gap-3\"><h3>{l.tray(lost.length)}</h3>\n <ItemGroup>{lost.map(({ mark, placement }, lostIndex) => <Item key={mark.id} role=\"listitem\" variant=\"outline\" size=\"sm\" data-testid=\"lost-mark\">\n <ItemContent className=\"min-w-0 gap-3\">\n <div className=\"flex flex-col gap-1\"><p><q>{mark.anchor.exact}</q></p><ItemDescription>{placement.kind === 'missing' ? l.missing : l.ambiguous}</ItemDescription></div>\n {placement.kind === 'ambiguous' && <div className=\"grid gap-3 sm:grid-cols-2\">{placement.candidates.map((range, index) => <div key={range.start} className=\"flex min-w-0 flex-col items-start gap-2\" data-testid=\"highlight-candidate\">\n <CandidateContext source={state.text} range={range} />\n <Button variant=\"outline\" size=\"sm\" disabled={busy} onClick={() => choose(mark, range)}>{l.candidate} {index + 1}</Button>\n </div>)}</div>}\n {store && <div className=\"flex flex-col items-start gap-2\">\n <Button variant=\"outline\" size=\"sm\" disabled={!selection} aria-describedby={!selection ? `${hintId}-${lostIndex}` : undefined} onClick={() => { if (currentText() && selection) void run(() => store.edit(mark.id, { anchor: selection })); }}>{l.attach}</Button>\n {!selection && <ItemDescription id={`${hintId}-${lostIndex}`}>{hint}</ItemDescription>}\n </div>}\n </ItemContent>\n </Item>)}</ItemGroup>\n </aside>}\n </section>;\n}\n\nfunction CandidateContext({ source, range }: { readonly source: string; readonly range: MarkdownQuoteRange }) {\n const before = source.slice(0, range.start), lineStart = before.lastIndexOf('\\n') + 1;\n const previousWords = [...before.slice(lineStart).matchAll(/\\S+/gu)];\n const from = lineStart + (previousWords.slice(-5)[0]?.index ?? before.length - lineStart);\n const lineEnd = source.indexOf('\\n', range.end);\n const after = source.slice(range.end, lineEnd === -1 ? source.length : lineEnd);\n const nextWords = [...after.matchAll(/\\S+/gu)].slice(0, 5), last = nextWords.at(-1);\n const to = range.end + (last ? last.index + last[0].length : 0);\n return <p className=\"break-words\" data-testid=\"candidate-context\">\n {source.slice(0, from).trim() ? '… ' : ''}{source.slice(from, range.start).replace(/\\s+/gu, ' ')}\n <mark style={{ backgroundColor: markdownMarkerBackground('primary'), color: 'var(--foreground)' }}>{source.slice(range.start, range.end)}</mark>\n {source.slice(range.end, to).replace(/\\s+/gu, ' ')}{source.slice(to).trim() ? ' …' : ''}\n </p>;\n}\n\nfunction CommentIndicator({ id, label, activate }: { readonly id: string; readonly label: string; readonly activate: (id: string) => void }) {\n return <span data-mark-comment={id} role=\"img\" aria-label={label} className=\"inline-flex align-super\" style={{ color: 'var(--muted-foreground)', marginInlineStart: '0.15em' }} onClick={() => activate(id)}>\n <svg viewBox=\"0 0 16 16\" className=\"size-3\" aria-hidden=\"true\"><path d=\"M3 2.5h10v8H7l-4 3v-11Z\" fill=\"none\" stroke=\"currentColor\" /></svg>\n </span>;\n}\n\nfunction AnnotatedSource({ source, placements, choices, active, commentLabel, activate }: { readonly source: string; readonly placements: readonly { mark: Highlight; placement: ReturnType<typeof markdownQuoteResolution.resolve> }[]; readonly choices: Readonly<Record<string, Choice>>; readonly active: string | null; readonly commentLabel: string; readonly activate: (id: string) => void }) {\n const ranges = placements.flatMap(({ mark, placement }) => {\n if (!['accent', 'primary', 'muted'].includes(mark.colour)) return [];\n const choice = choices[mark.id];\n const range = choice?.source === source && placement.kind === 'ambiguous' && placement.candidates.some(r => r.start === choice.range.start && r.end === choice.range.end) ? choice.range : placement.kind === 'exact' || placement.kind === 'moved' ? placement.range : null;\n return range ? [{ mark, range }] : [];\n });\n const cuts = [...new Set([0, source.length, ...ranges.flatMap(r => [r.range.start, r.range.end])])].sort((a, b) => a - b);\n return <pre className=\"whitespace-pre-wrap break-words\">{cuts.slice(0, -1).map((start, index) => {\n const end = cuts[index + 1]!, covering = ranges.filter(r => r.range.start <= start && r.range.end >= end);\n return covering.length ? <Fragment key={start}><mark role=\"button\" tabIndex={0} aria-label={covering.map(r => r.mark.anchor.fallback).join('; ')} style={{ backgroundColor: markdownMarkerBackground(covering[0]!.mark.colour), color: 'var(--foreground)', ...(covering.some(r => r.mark.id === active) ? { outline: 'var(--border-width, 1px) solid var(--ring)' } : {}) }}\n onClick={() => activate(covering[0]!.mark.id)} onKeyDown={event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); activate(covering[0]!.mark.id); } }}>{source.slice(start, end)}</mark>\n {covering.filter(r => r.range.end === end && r.mark.note?.trim()).map(r => <CommentIndicator key={r.mark.id} id={r.mark.id} label={commentLabel} activate={activate} />)}\n </Fragment> : source.slice(start, end);\n })}</pre>;\n}\n",
24
19
  "type": "registry:component",
25
20
  "target": "components/highlights/highlights.tsx"
26
21
  }
@@ -4,13 +4,13 @@
4
4
  "title": "HTML source and passive preview",
5
5
  "description": "Exact HTML source editing with a reconstructed passive preview and borrowed controller. Browser security qualification remains separate.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.25",
8
- "@hachej/boring-files@^0.4.25",
7
+ "@hachej/boring-ui-kit@^0.4.26",
8
+ "@hachej/boring-files@^0.4.26",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/button.json"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/button.json"
14
14
  ],
15
15
  "files": [
16
16
  {