@hachej/boring-ui-kit 0.2.0 → 0.2.2

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.
@@ -0,0 +1,688 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "feedback",
4
+ "title": "Feedback",
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
+ "dependencies": [
7
+ "@hachej/boring-feedback@^0.2.2",
8
+ "@hachej/boring-ui-kit@^0.2.2",
9
+ "@hachej/boring-files@^0.2.2",
10
+ "react@^19.0.0",
11
+ "react-dom@^19.0.0",
12
+ "lucide-react@^1.51.0"
13
+ ],
14
+ "registryDependencies": [
15
+ "@boring-ui/pi-chat",
16
+ "@boring-ui/utils"
17
+ ],
18
+ "files": [
19
+ {
20
+ "path": "registry/feedback/feedback-session.tsx",
21
+ "content": "// The feedback session's look (FEEDBACK.md, \"UX\"): the slim bar of feedback mode, the note bubble beside a pin, and the one chip the\n// composer shows afterwards with its review. Everything they show and do is the headless `createFeedbackSession` from\n// `@hachej/boring-feedback/ui` (hover labels, pins, steps, voice, the draft and Send come from there, through the privacy policy); this file is\n// how it looks. `useComposerFeedback` adapts a session to the chat composer's optional `feedback` prop (registry/pi-chat), structurally,\n// so neither item imports the other.\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react';\nimport { createPortal } from 'react-dom';\nimport { readableElement } from '@hachej/boring-feedback/format';\nimport { pinMark, type FeedbackSession, type FeedbackSessionSnapshot, type SessionNote } from '@hachej/boring-feedback/ui';\nimport { useExternal } from './use-external';\nimport { withLabels, type FeedbackLabels } from './feedback-labels';\n\n/** A microphone outline drawn inline: emoji glyphs (🎙) are missing from some system fonts. */\nconst MicIcon = () => <svg className=\"boring-feedback-mic\" viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden=\"true\" focusable=\"false\">\n <rect x=\"9\" y=\"2\" width=\"6\" height=\"13\" rx=\"3\" /><path d=\"M19 10v2a7 7 0 0 1-14 0v-2\" /><path d=\"M12 19v3\" />\n</svg>;\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 /** Words of the controls (`feedbackLabelsFr` for French); what is left out keeps the English default. */\n readonly labels?: Partial<FeedbackLabels>;\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 · Hold ⌥ to use the app · 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, labels }: FeedbackBarProps) {\n const state = useExternal(session);\n const words = withLabels(labels);\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 <div 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 || state.generalOpen)) { event.stopPropagation(); if (state.bubble) session.cancelBubble(); else session.closeGeneral(); } }}>\n <span className=\"boring-feedback-bar-title\"><span className=\"boring-feedback-dot\" aria-hidden=\"true\" />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 && <button type=\"button\" className=\"boring-feedback-button boring-feedback-record\" data-testid=\"feedback-voice\" aria-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 onClick={() => { void session.toggleVoice(); }}>\n {recording ? <><span className=\"boring-feedback-stop\" aria-hidden=\"true\" />Stop</> : <><MicIcon />{state.voice.kind === 'starting' ? 'Starting…' : state.voice.kind === 'stopping' ? 'Stopping…' : 'Record'}</>}\n </button>}\n {recording && <span className=\"boring-feedback-recording\" role=\"status\" data-testid=\"feedback-recording\"><span className=\"boring-feedback-rec\" aria-hidden=\"true\" />\n <span className=\"boring-feedback-timer\" data-testid=\"feedback-recording-time\">{time}</span></span>}\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 && <button type=\"button\" className=\"boring-feedback-button\" data-testid=\"feedback-general\" aria-pressed={state.generalOpen} onClick={() => session.askGeneral()}>{words.askGeneral}</button>}\n {!processing && <span className=\"boring-feedback-keys boring-feedback-alt\">Hold ⌥ to use the app</span>}\n {!processing && <button type=\"button\" className=\"boring-feedback-button\" data-testid=\"feedback-use-app\" aria-pressed={state.useToggle} onClick={() => session.setUseToggle(!state.useToggle)}>Use app</button>}\n <button type=\"button\" className=\"boring-feedback-button\" data-variant=\"primary\" data-testid=\"feedback-done\" disabled={processing} onClick={() => { void session.done(); }}>Done</button>\n <button type=\"button\" className=\"boring-feedback-icon\" data-testid=\"feedback-discard\" aria-label=\"Discard feedback\" title=\"Discard feedback\" disabled={processing} onClick={() => session.discard()}>×</button>\n </div>;\n}\n\nconst BUBBLE_WIDTH = 280;\nfunction bubblePlace(element: Element): CSSProperties {\n const view = element.ownerDocument.defaultView;\n const width = view?.innerWidth ?? 1024, height = view?.innerHeight ?? 768;\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 top = below + 120 <= height ? below : Math.max(8, rect.top - 128);\n return { position: 'fixed', top, left, width: Math.min(BUBBLE_WIDTH, width - 16) };\n}\n\n/** After a bubble closes with focus nowhere (the focused field left the page), focus goes back to the bar's general-question entry. */\nfunction useFocusReturn(open: boolean, document: Document | undefined): void {\n const was = useRef(false);\n useEffect(() => {\n if (was.current && !open && document) {\n const active = document.activeElement;\n if (!active || active === document.body) document.querySelector<HTMLElement>('[data-testid=\"feedback-general\"]')?.focus({ preventScroll: true });\n }\n was.current = open;\n }, [open, document]);\n}\n\n/**\n * The note bubble beside a new or reopened pin: \"What's wrong here?\", Enter saves, Shift-Enter for a new line, Esc discards the pin,\n * and \"Cancel selection\" (also clicking the pinned element again) drops the pin and keeps what was typed for a general question.\n * With the general question open it is a bubble with no element: the same field, docked at the bottom, Esc closes it.\n */\nexport function NoteBubble({ session, labels }: { readonly session: FeedbackSession; readonly labels?: Partial<FeedbackLabels> }) {\n const state = useExternal(session);\n const words = withLabels(labels);\n const bubble = state.bubble;\n const field = useRef<HTMLTextAreaElement>(null);\n const general = useRef<HTMLTextAreaElement>(null);\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n const [, redraw] = useState(0);\n const [announce, setAnnounce] = useState('');\n const generalOpen = state.generalOpen && state.phase === 'active';\n useLayoutEffect(() => { if (bubble) setPlace(bubblePlace(bubble.element)); }, [bubble?.noteId, bubble?.element]);\n useEffect(() => { if (bubble) field.current?.focus({ preventScroll: true }); }, [bubble?.noteId]);\n useEffect(() => { if (generalOpen) { general.current?.focus({ preventScroll: true }); setAnnounce(words.generalOpened); } }, [generalOpen]);\n useEffect(() => {\n if (!bubble) return;\n const view = bubble.element.ownerDocument.defaultView;\n const follow = () => { setPlace(bubblePlace(bubble.element)); 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 useFocusReturn(Boolean(bubble) || generalOpen, typeof document === 'undefined' ? undefined : document);\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 (generalOpen && !bubble) {\n const onGeneralKey = (event: KeyboardEvent<HTMLTextAreaElement>): void => {\n if (event.key === 'Escape' || (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing)) { event.preventDefault(); event.stopPropagation(); session.closeGeneral(); }\n };\n return <>{live}<div data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label={words.askGeneral} data-testid=\"feedback-general-bubble\"\n className=\"boring-feedback-bubble\" style={{ position: 'fixed', left: 8, right: 8, bottom: 72, margin: '0 auto', maxWidth: 420 }}>\n <span className=\"boring-feedback-bubble-head\"><span className=\"boring-feedback-bubble-label\">{words.askGeneral}</span></span>\n <textarea ref={general} data-testid=\"feedback-general-input\" rows={3} placeholder={words.generalPlaceholder} aria-label={words.generalField} value={state.general}\n onChange={event => session.setGeneral(event.target.value)} onKeyDown={onGeneralKey} />\n <span className=\"boring-feedback-keys\">{words.generalHint}</span>\n </div></>;\n }\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}<div data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label={`Note ${bubble.number}`} data-testid=\"feedback-bubble\" data-note={bubble.number}\n 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\">{bubble.label}</span></span>\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 <span className=\"boring-feedback-bubble-actions\">\n <button type=\"button\" className=\"boring-feedback-button\" data-testid=\"feedback-cancel-selection\" onClick={cancel}>{words.cancelSelection}</button>\n <button type=\"button\" className=\"boring-feedback-button\" data-testid=\"feedback-bubble-general\" onClick={() => session.askGeneral()}>{words.askGeneral}</button>\n </span>\n <span className=\"boring-feedback-keys\">{words.bubbleHint}</span>\n </div></>;\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 type=\"button\" className=\"boring-feedback-chip-open\" 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 type=\"button\" className=\"boring-feedback-icon boring-feedback-chip-remove\" data-testid=\"feedback-chip-discard\" aria-label=\"Discard feedback\" onClick={() => { session.highlight(undefined); session.discard(); }}>×</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 <section 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 type=\"button\" className=\"boring-feedback-icon\" aria-label=\"Close review\" data-testid=\"feedback-review-close\" onClick={onClose}>×</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' && <span className=\"boring-feedback-badge\" data-testid=\"feedback-review-voice\">voice</span>}\n {where[note.id] === 'not-on-page' && <span className=\"boring-feedback-badge\" data-testid=\"feedback-review-elsewhere\">not on this page</span>}\n <button type=\"button\" className=\"boring-feedback-icon\" aria-label={`Remove note ${note.number}`} data-testid=\"feedback-review-remove\" onClick={() => session.removeNote(note.id)}>×</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 type=\"button\" className=\"boring-feedback-button\" data-variant=\"primary\" 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 </section>;\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 start: () => void;\n readonly active: boolean;\n readonly pending: boolean;\n readonly chip?: ReactNode;\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/**\n * The composer's `feedback` prop for a session: the Feedback button starts it, the chip shows the draft, and Send attaches the report:\n * a saved report as its `@` mention (the host's mention resolver inlines the stored report with the person's access), otherwise the\n * rendered report inline in a fenced block.\n */\nexport function useComposerFeedback(session: FeedbackSession | undefined): ComposerFeedbackProp | undefined {\n const state = useSyncState(session);\n if (!session || !state) return undefined;\n return {\n start: () => session.start(),\n active: state.phase === 'active' || state.phase === 'processing',\n pending: state.phase === 'review',\n ...(state.phase === 'active' || state.phase === 'processing' ? { bar: <FeedbackBar session={session} docked /> } : {}),\n ...(state.phase === 'review' ? { chip: <FeedbackChip session={session} /> } : {}),\n attach: async text => {\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\nconst NO_STATE = { getSnapshot: () => undefined, subscribe: () => () => {} };\nfunction useSyncState(session: FeedbackSession | undefined): FeedbackSessionSnapshot | undefined {\n return useExternal<FeedbackSessionSnapshot | undefined>(session ?? NO_STATE);\n}\n",
22
+ "type": "registry:component",
23
+ "target": "components/feedback/feedback-session.tsx"
24
+ },
25
+ {
26
+ "path": "registry/feedback/point-button.tsx",
27
+ "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';\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 <button type=\"button\" data-testid=\"feedback-point\" aria-pressed={picking} disabled={!overlay} onClick={toggle} className=\"boring-feedback-button\">\n {picking ? 'Cancel' : children}\n </button>\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
+ "type": "registry:component",
29
+ "target": "components/feedback/point-button.tsx"
30
+ },
31
+ {
32
+ "path": "registry/feedback/picker-overlay.ts",
33
+ "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. Parts: `box`, `label`, `toolbar` (and `box-<tone>`, `label-<tone>` for hover, pinned,\n * reveal and candidate). The colors read the host's `--boring-feedback-*` variables, which inherit through the shadow boundary. */\nexport const pickerOverlayStyles = `\n.box { border-radius: 6px; }\n.box[data-tone=\"hover\"] { border-width: 2px; }\n.box[data-tone=\"pinned\"] { box-shadow: 0 0 0 3px color-mix(in srgb, var(--boring-feedback-accent, #2563eb) 25%, transparent); }\n.box[data-tone=\"reveal\"] { animation: boring-feedback-pulse 1.2s ease-out 2; }\n.label { border-radius: 6px; box-shadow: 0 2px 8px rgb(0 0 0 / 25%); }\n.toolbar { box-shadow: 0 8px 24px rgb(0 0 0 / 30%); }\n@keyframes boring-feedback-pulse { from { box-shadow: 0 0 0 0 color-mix(in srgb, var(--boring-feedback-reveal, #d97706) 55%, transparent); } to { box-shadow: 0 0 0 14px transparent; } }\n@media (prefers-reduced-motion: reduce) { .box[data-tone=\"reveal\"] { animation: none; } }\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
+ "type": "registry:lib",
35
+ "target": "components/feedback/picker-overlay.ts"
36
+ },
37
+ {
38
+ "path": "registry/feedback/annotate-sheet.tsx",
39
+ "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 { 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 <section 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 type=\"button\" className=\"boring-feedback-icon\" aria-label={words.close} data-testid=\"feedback-close\" onClick={onClose}>×</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 type=\"button\" className=\"boring-feedback-button\" data-testid=\"feedback-cancel-selection\" disabled={state.save.kind === 'saving'}\n onClick={cancelSelection}>{words.cancelSelection}</button>}\n <button type=\"button\" className=\"boring-feedback-button\" data-testid=\"feedback-copy\" onClick={() => { void annotation.copy(); }}>{words.copyReport}</button>\n {state.canSave && <button type=\"button\" className=\"boring-feedback-button\" data-variant=\"primary\" 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 </section>;\n}\n",
40
+ "type": "registry:component",
41
+ "target": "components/feedback/annotate-sheet.tsx"
42
+ },
43
+ {
44
+ "path": "registry/feedback/feedback-list.tsx",
45
+ "content": "// FeedbackList: stored feedback, newest first, as the host's authorized list route returns it. Rows are `listRows` from\n// `@hachej/boring-feedback/ui`; an unprotected store is stated above them.\nimport type { FeedbackListItem } from '@hachej/boring-feedback/format';\nimport { listRows, protectionNotice, type FeedbackProtection } from '@hachej/boring-feedback/ui';\n\nexport interface FeedbackListProps {\n readonly items: readonly FeedbackListItem[];\n readonly protection: FeedbackProtection;\n readonly onOpen: (id: string) => void;\n /** The open item, highlighted. */\n readonly activeId?: string;\n /** Why the list could not be loaded (denied, unavailable). */\n readonly problem?: string;\n readonly now?: Date;\n}\n\nexport function FeedbackList({ items, protection, onOpen, activeId, problem, now }: FeedbackListProps) {\n const notice = protectionNotice(protection);\n const rows = listRows(items, now);\n return <section data-boring=\"feedback\" data-feedback-ignore=\"\" className=\"boring-feedback-list\" aria-label=\"Feedback\" data-testid=\"feedback-list\">\n {notice && <p className=\"boring-feedback-notice\" data-testid=\"feedback-protection\">{notice}</p>}\n {problem !== undefined && <p role=\"alert\" className=\"boring-feedback-warning\">{problem}</p>}\n {problem === undefined && rows.length === 0 && <p className=\"boring-feedback-empty\">No feedback yet. Point at something to leave some.</p>}\n <ul>\n {rows.map(row => <li key={row.id}>\n <button type=\"button\" data-testid=\"feedback-item\" data-id={row.id} aria-current={row.id === activeId ? 'true' : undefined} onClick={() => onOpen(row.id)}>\n <span className=\"boring-feedback-title\">{row.title}</span>\n <span className=\"boring-feedback-meta\"><span data-status={row.status}>{row.status}</span> · {row.where} · {row.age}</span>\n </button>\n </li>)}\n </ul>\n </section>;\n}\n",
46
+ "type": "registry:component",
47
+ "target": "components/feedback/feedback-list.tsx"
48
+ },
49
+ {
50
+ "path": "registry/feedback/feedback-report.tsx",
51
+ "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 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 <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 type=\"button\" className=\"boring-feedback-icon\" aria-label=\"Close\" onClick={onClose}>×</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 type=\"button\" className=\"boring-feedback-button\" 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 type=\"button\" className=\"boring-feedback-button\" 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 </article>;\n}\n",
52
+ "type": "registry:component",
53
+ "target": "components/feedback/feedback-report.tsx"
54
+ },
55
+ {
56
+ "path": "registry/feedback/use-external.ts",
57
+ "content": "// Subscribes a component to one of the headless states of `@hachej/boring-feedback/ui` (annotate, list, report).\nimport { useSyncExternalStore } from 'react';\nimport type { ExternalState } from '@hachej/boring-feedback/ui';\n\nexport function useExternal<State>(state: ExternalState<State>): State {\n return useSyncExternalStore(state.subscribe, state.getSnapshot, state.getSnapshot);\n}\n",
58
+ "type": "registry:hook",
59
+ "target": "components/feedback/use-external.ts"
60
+ },
61
+ {
62
+ "path": "registry/feedback/feedback-card.tsx",
63
+ "content": "'use client';\n\nimport { useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\n// Type-only: pi-chat has no alias for these chat types, so the card names them from the model SDK the host already installs.\nimport type { ToolCall, ToolResultMessage } from '@earendil-works/pi-ai';\nimport { FEEDBACK_ID, readableElement } from '@hachej/boring-feedback/format';\nimport { CheckIcon, LocateFixedIcon, MessageSquareTextIcon, ShieldAlertIcon } from 'lucide-react';\nimport type { ChatCard } from '../pi-chat/rows';\nimport { cn } from '../utils/utils';\n\n/*\n * The feedback card (FEEDBACK.md, \"Activating it on an agent\" and \"UX\"): `feedback` tool results in the transcript. A `list`\n * result is a list of reports with their element lines and placements; a `show` result is an OFFER for one element line. The\n * server-side tool never reveals anything: hovering (or focusing) an element line asks the host's page (`onShowFeedback`,\n * AmbientChat mounted in the subject's application page) to resolve it against the live page and highlight it, leaving the\n * line clears it (`onHideFeedback`), and clicking it does the same and lets the person choose among ambiguous candidates. The\n * card displays the host's honest result inline as a small badge (✓ only after the host reported a reveal; \"2 matches · choose\",\n * \"confirm\", \"not on this page\" or one short phrase otherwise, the host's full reason in its tooltip). Without the callback an offer\n * says `unavailable`: open the application page. Element lines read as `«Save profile» button SettingsPage.jsx:65` (the readable\n * name, then the file basename; the full path in the tooltip). Everything shown is data, rendered as text.\n */\nexport const FEEDBACK_TOOL = 'feedback';\n\n/** `intent`: a hover only highlights (an ambiguous placement is reported, not chosen); a click may ask the person to choose. */\nexport interface FeedbackShowRequest { readonly id: string; readonly anchor: number; readonly note?: string; readonly intent?: 'hover' | 'click' }\n/**\n * What the host's page did, as a `PresentationResult` without a value. `detail` lets the card say it in a few words: `found`, `moved`\n * or `chosen` for a reveal; `choose` (with `matches`, the number of candidates) when a person must pick one, `missing` when nothing on\n * the page matches, `not-chosen` when the person picked none, `no-page` outside the application page. `reason` stays the full sentence.\n */\nexport type FeedbackShowDetail = 'found' | 'moved' | 'chosen' | 'choose' | 'missing' | 'not-chosen' | 'no-page';\nexport type FeedbackShowOutcome =\n | { readonly kind: 'applied'; readonly reason?: string; readonly detail?: FeedbackShowDetail }\n | { readonly kind: 'stale' | 'unavailable' | 'denied'; readonly reason: string; readonly detail?: FeedbackShowDetail; readonly matches?: number };\nexport interface FeedbackCardConfig {\n /** Resolve and reveal in the live page. Give it only where the chat is mounted in the subject's application page. */\n readonly onShowFeedback?: ((request: FeedbackShowRequest) => Promise<FeedbackShowOutcome>) | undefined;\n /** The pointer or focus left an element line: clear the highlight. */\n readonly onHideFeedback?: (() => void) | undefined;\n}\n\n/** `placement` is a short phrase, or undefined when there is nothing to say (\"checked in the page\": only the person's page can place it). */\nexport interface FeedbackAnchorView { readonly index: number; readonly kind: string; readonly fallback: string; readonly placement?: string; readonly placementDetail?: string; readonly source?: string }\nexport interface FeedbackItemView {\n readonly id: string; readonly title: string; readonly status: string; readonly author: string; readonly age: string; readonly subject: string;\n readonly anchors: readonly FeedbackAnchorView[]; readonly unavailable: boolean;\n}\nexport type FeedbackResultView =\n | { readonly kind: 'list'; readonly items: readonly FeedbackItemView[]; readonly unprotected: boolean; readonly more: boolean }\n | { readonly kind: 'offer'; readonly id: string; readonly anchor: number; readonly note?: string; readonly fallback: string }\n | { readonly kind: 'refused'; readonly outcome: string; readonly reason: string };\n\nconst isObject = (value: unknown): value is Readonly<Record<string, unknown>> => value !== null && typeof value === 'object' && !Array.isArray(value);\nconst text = (value: unknown, fallback = ''): string => typeof value === 'string' ? value : fallback;\nconst MENTION = /(?:^|\\/)([^/]+)\\.md$/;\n\n/** The report id of an `@<root><id>.md` mention, or undefined. The id is checked by `@hachej/boring-feedback/format`. */\nexport function feedbackMentionId(path: string): string | undefined {\n const id = MENTION.exec(path)?.[1];\n return id !== undefined && FEEDBACK_ID.test(id) ? id : undefined;\n}\n\nconst CHECKED_IN_THE_PAGE = 'checked in the page';\n/** How a placement reads in the card: a short phrase (what it was evaluated against goes to the tooltip), or nothing to say. */\nfunction placementOf(value: unknown): { readonly placement?: string; readonly placementDetail?: string } {\n if (typeof value === 'string') return value === CHECKED_IN_THE_PAGE ? {} : { placement: value };\n if (!isObject(value)) return { placement: 'not evaluated' };\n const detail = typeof value['evaluated'] === 'string' ? { placementDetail: value['evaluated'] } : {};\n switch (value['kind']) {\n case 'exact': return { placement: 'found', ...detail };\n case 'moved': return { placement: 'moved', ...detail };\n case 'ambiguous': return { placement: 'several matches', ...detail };\n case 'partial': return { placement: 'partly found', ...detail };\n case 'missing': return { placement: 'not found', ...detail };\n case 'unsupported': return { placement: 'unsupported', ...detail };\n default: return { placement: 'not evaluated' };\n }\n}\n\n/** The card's view of a `feedback` tool result, or undefined when it has no card (read, resolve, malformed, failed). */\nexport function feedbackResultView(call: ToolCall, result: ToolResultMessage | undefined): FeedbackResultView | undefined {\n if (call.name !== FEEDBACK_TOOL || !result || result.isError) return undefined;\n const part = result.content.find(item => item.type === 'text');\n let value: unknown;\n try { value = JSON.parse(part?.type === 'text' ? part.text : ''); } catch { return undefined; }\n if (!isObject(value)) return undefined;\n if (value['action'] === 'list' && value['kind'] === 'available' && Array.isArray(value['items'])) {\n const items = value['items'].flatMap((item): FeedbackItemView[] => {\n if (!isObject(item) || typeof item['id'] !== 'string' || !FEEDBACK_ID.test(item['id'])) return [];\n const anchors = Array.isArray(item['anchors']) ? item['anchors'].flatMap((anchor): FeedbackAnchorView[] => isObject(anchor) && Number.isInteger(anchor['index'])\n ? [{ index: anchor['index'] as number, kind: text(anchor['kind']), fallback: text(anchor['fallback'], 'an anchor'), ...placementOf(anchor['placement']),\n ...(isObject(anchor['signals']) && typeof anchor['signals']['source'] === 'string' ? { source: anchor['signals']['source'] } : {}) }] : []) : [];\n return [{ id: item['id'], title: text(item['title'], item['id']), status: text(item['status']), author: text(item['author']), age: text(item['age']), subject: text(item['subject']), anchors, unavailable: item['unavailable'] !== undefined }];\n });\n return { kind: 'list', items, unprotected: value['protection'] === 'unprotected', more: typeof value['cursor'] === 'string' };\n }\n if (value['action'] !== 'show') return undefined;\n if (value['kind'] === 'offered' && typeof value['id'] === 'string' && FEEDBACK_ID.test(value['id']) && Number.isInteger(value['anchor']) && (value['anchor'] as number) >= 0) {\n return { kind: 'offer', id: value['id'], anchor: value['anchor'] as number, fallback: text(value['fallback'], 'the place this feedback points at'), ...(typeof value['note'] === 'string' && value['note'] ? { note: value['note'] } : {}) };\n }\n if (typeof value['kind'] === 'string' && ['denied', 'unsupported', 'missing', 'unavailable'].includes(value['kind'])) return { kind: 'refused', outcome: value['kind'], reason: text(value['reason']) };\n return undefined;\n}\n\nconst OUTCOME_KINDS = ['applied', 'stale', 'unavailable', 'denied'] as const;\nconst DETAILS: readonly FeedbackShowDetail[] = ['found', 'moved', 'chosen', 'choose', 'missing', 'not-chosen', 'no-page'];\nconst NO_PAGE: FeedbackShowOutcome = { kind: 'unavailable', reason: 'Open the application page to see where this feedback points.', detail: 'no-page' };\n\n/**\n * The few words an outcome reads as in the card, from what the host reported (never more): ✓ only for `applied`; candidates to\n * choose from as \"2 matches · choose\" (one candidate: \"confirm\"); nothing matching as \"not on this page\"; otherwise one short phrase.\n */\nexport function outcomeBadge(outcome: FeedbackShowOutcome): string {\n if (outcome.kind === 'applied') return outcome.detail === 'moved' ? '✓ moved' : outcome.detail === 'chosen' ? '✓ chosen' : '✓';\n if (outcome.kind === 'denied') return 'no access';\n switch (outcome.detail) {\n case 'choose': return outcome.matches === 1 ? 'confirm' : outcome.matches !== undefined && outcome.matches > 1 ? `${outcome.matches} matches · choose` : 'several matches · choose';\n case 'missing': return 'not on this page';\n case 'not-chosen': return 'none chosen';\n case 'no-page': return 'open the app page to see it';\n default: return outcome.kind === 'stale' ? 'changed since' : 'can’t show it here';\n }\n}\n\n/** Ask the host page, and keep only an outcome it really reported. A throw or an unknown value is `unavailable`. */\nasync function askPage(onShow: NonNullable<FeedbackCardConfig['onShowFeedback']>, request: FeedbackShowRequest): Promise<FeedbackShowOutcome> {\n try {\n const outcome: unknown = await onShow(request);\n if (isObject(outcome) && (OUTCOME_KINDS as readonly unknown[]).includes(outcome['kind'])) {\n const reason = text(outcome['reason']);\n const detail = (DETAILS as readonly unknown[]).includes(outcome['detail']) ? { detail: outcome['detail'] as FeedbackShowDetail } : {};\n const matches = Number.isInteger(outcome['matches']) && (outcome['matches'] as number) >= 0 ? { matches: outcome['matches'] as number } : {};\n return outcome['kind'] === 'applied' ? { kind: 'applied', ...(reason ? { reason } : {}), ...detail }\n : { kind: outcome['kind'] as 'stale' | 'unavailable' | 'denied', reason: reason || 'No reason given', ...detail, ...matches };\n }\n return { kind: 'unavailable', reason: 'The page reported no recognised result' };\n } catch { return { kind: 'unavailable', reason: 'The page could not show it' }; }\n}\n\n/**\n * One element line: hover or focus highlights it in the page through the host, leaving clears it, click does the same and may let the\n * person choose among candidates. The outcome is stated inline. Without the host callback the line is plain text.\n */\nfunction ElementLine({ request, label, source, onShowFeedback, onHideFeedback, initial, onOutcome, children }: {\n readonly request: Omit<FeedbackShowRequest, 'intent'>; readonly label: string; readonly source?: string | undefined;\n readonly onShowFeedback: FeedbackCardConfig['onShowFeedback']; readonly onHideFeedback: FeedbackCardConfig['onHideFeedback'];\n readonly initial?: FeedbackShowOutcome | undefined; readonly onOutcome?: ((outcome: FeedbackShowOutcome | undefined) => void) | undefined; readonly children?: ReactNode;\n}) {\n const [state, setState] = useState<FeedbackShowOutcome | 'showing' | undefined>(initial);\n const asked = useRef(0);\n // A click keeps what it showed (and any choice it asked for) after the pointer or focus leaves, until the next hover; a hover is transient.\n const kept = useRef(false);\n const show = async (intent: 'hover' | 'click') => {\n if (!onShowFeedback) return;\n kept.current = intent === 'click';\n const ask = ++asked.current;\n setState('showing');\n const outcome = await askPage(onShowFeedback, { ...request, intent });\n // A later hover or click wins; an earlier answer never overwrites it.\n if (ask !== asked.current) return;\n setState(outcome);\n onOutcome?.(outcome);\n };\n const hide = () => {\n if (kept.current) return;\n asked.current++; setState(current => current === 'showing' ? undefined : current); onHideFeedback?.();\n };\n const outcome = typeof state === 'object' ? state : undefined;\n const readable = readableElement(label, source);\n const badge = state === 'showing' ? <span data-testid=\"feedback-outcome\" role=\"status\" className=\"shrink-0 text-[11px] text-muted-foreground\">looking…</span>\n : outcome ? <span data-testid=\"feedback-outcome\" role=\"status\" title={outcome.reason ?? 'Shown in the page.'} data-outcome={outcome.kind}\n className={cn('inline-flex shrink-0 items-center gap-0.5 rounded-full px-1.5 text-[11px] leading-[1.125rem] font-normal', outcome.kind === 'applied' ? 'text-emerald-700 dark:text-emerald-400' : 'bg-muted text-muted-foreground')}>\n {outcome.kind === 'applied' ? <><CheckIcon className=\"size-3\" aria-hidden=\"true\" />{outcomeBadge(outcome).slice(1).trim() || <span className=\"sr-only\">shown in the page</span>}</> : outcomeBadge(outcome)}</span>\n : children;\n const text = <span className=\"flex min-w-0 flex-wrap items-baseline gap-x-1.5\">\n <span data-testid=\"feedback-fallback\" className=\"font-semibold [overflow-wrap:anywhere]\">{readable.name}</span>\n {readable.file && <span data-testid=\"feedback-source\" title={readable.path ?? readable.file} className=\"font-mono text-[11px] font-normal text-muted-foreground\">{readable.file}</span>}\n {badge}</span>;\n return <span className=\"block\" data-outcome={outcome?.kind}>\n {onShowFeedback\n ? <button type=\"button\" data-testid=\"feedback-element\" data-feedback-id={request.id} data-anchor={request.anchor} title={`${label}${readable.path ? ` · ${readable.path}` : ''}\\nHover to see it on the page; click to choose if it matches several places`}\n onMouseEnter={() => { void show('hover'); }} onFocus={() => { void show('hover'); }} onMouseLeave={hide} onBlur={hide} onClick={() => { void show('click'); }}\n className=\"-mx-1 block w-[calc(100%+0.5rem)] cursor-pointer rounded-md px-1 py-0.5 text-left text-[13px] leading-5 outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/60\">{text}</button>\n : <span data-testid=\"feedback-element\" data-feedback-id={request.id} data-anchor={request.anchor} title={label} className=\"block py-0.5 text-[13px] leading-5\">{text}</span>}\n </span>;\n}\n\n/** A report title (the person's note): two lines at most, with a toggle to read the rest. */\nfunction ClampedTitle({ text: title }: { readonly text: string }) {\n const [open, setOpen] = useState(false);\n const long = title.length > 110 || title.includes('\\n');\n return <span className=\"block\">\n <span data-testid=\"feedback-title\" className={cn('block text-sm leading-5 font-medium whitespace-pre-line [overflow-wrap:anywhere]', !open && 'line-clamp-2')}>{title}</span>\n {long && <button type=\"button\" data-testid=\"feedback-title-more\" aria-expanded={open} onClick={() => setOpen(value => !value)}\n className=\"cursor-pointer text-[11px] text-muted-foreground underline-offset-2 hover:underline\">{open ? 'less' : 'more'}</button>}\n </span>;\n}\n\nfunction Offer({ view, onShowFeedback, onHideFeedback }: { readonly view: Extract<FeedbackResultView, { kind: 'offer' }>; readonly onShowFeedback: FeedbackCardConfig['onShowFeedback']; readonly onHideFeedback: FeedbackCardConfig['onHideFeedback'] }) {\n const [outcome, setOutcome] = useState<FeedbackShowOutcome | undefined>(onShowFeedback ? undefined : NO_PAGE);\n return <section data-testid=\"feedback-card\" data-kind=\"offer\" data-feedback-id={view.id} data-outcome={outcome?.kind} aria-label=\"Feedback offer\"\n className=\"my-2 flex max-w-md flex-col gap-2 rounded-xl border border-border bg-card px-3 py-2.5 text-card-foreground shadow-xs\">\n <div className=\"flex items-start gap-3\">\n <span className=\"flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground\"><LocateFixedIcon className=\"size-4\" aria-hidden=\"true\" /></span>\n <span className=\"min-w-0 flex-1\">\n <span className=\"block text-xs text-muted-foreground\">Feedback {view.id}</span>\n <ElementLine request={{ id: view.id, anchor: view.anchor, ...(view.note === undefined ? {} : { note: view.note }) }} label={view.fallback}\n onShowFeedback={onShowFeedback} onHideFeedback={onHideFeedback} initial={onShowFeedback ? undefined : NO_PAGE} onOutcome={next => setOutcome(next)} />\n {view.note && <span data-testid=\"feedback-note\" className=\"mt-1 block text-sm leading-5 text-muted-foreground [overflow-wrap:anywhere]\">{view.note}</span>}\n </span>\n </div>\n </section>;\n}\n\nfunction List({ view, onShowFeedback, onHideFeedback }: { readonly view: Extract<FeedbackResultView, { kind: 'list' }>; readonly onShowFeedback: FeedbackCardConfig['onShowFeedback']; readonly onHideFeedback: FeedbackCardConfig['onHideFeedback'] }) {\n return <section data-testid=\"feedback-card\" data-kind=\"list\" aria-label=\"Feedback\" className=\"my-2 max-w-xl rounded-xl border border-border bg-card px-3 py-2.5 text-card-foreground shadow-xs\">\n <header className=\"flex items-center gap-2 text-sm font-medium\">\n <MessageSquareTextIcon className=\"size-4 text-muted-foreground\" aria-hidden=\"true\" />\n <span>Feedback · {view.items.length}{view.more ? '+' : ''}</span>\n {view.unprotected && <span data-testid=\"feedback-unprotected\" className=\"ml-auto inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[11px] text-muted-foreground\"><ShieldAlertIcon className=\"size-3\" aria-hidden=\"true\" />Unprotected store</span>}\n </header>\n {view.items.length === 0 && <p className=\"m-0 mt-1 text-xs text-muted-foreground\">No feedback.</p>}\n <ul className=\"m-0 mt-2 list-none space-y-1.5 p-0\">\n {view.items.map(item => <li key={item.id} data-testid=\"feedback-item\" data-feedback-id={item.id} data-status={item.status} className=\"rounded-lg bg-muted/40 px-2.5 py-1.5\">\n <ClampedTitle text={item.title} />\n <span data-testid=\"feedback-meta\" className=\"block text-[11px] leading-4 text-muted-foreground/80\">{[item.status, item.author, item.age, item.unavailable ? 'unavailable' : ''].filter(Boolean).join(' · ')}</span>\n {item.anchors.length > 0 && <ul className=\"m-0 mt-1 list-none p-0\">\n {item.anchors.map(anchor => <li key={anchor.index} data-testid=\"feedback-anchor\" className=\"[overflow-wrap:anywhere]\">\n <ElementLine request={{ id: item.id, anchor: anchor.index }} label={anchor.fallback} source={anchor.source}\n onShowFeedback={anchor.kind === 'app.element@1' ? onShowFeedback : undefined} onHideFeedback={onHideFeedback}>\n {anchor.placement && <span data-testid=\"feedback-placement\" title={anchor.placementDetail} className=\"shrink-0 rounded-full bg-muted px-1.5 text-[11px] leading-[1.125rem] font-normal text-muted-foreground\">{anchor.placement}</span>}\n </ElementLine></li>)}\n </ul>}\n </li>)}\n </ul>\n </section>;\n}\n\n/** One `feedback` tool result: a list, an offer, or a refusal stated as such. Element lines highlight in the page on hover. */\nexport function FeedbackCard({ view, onShowFeedback, onHideFeedback }: { readonly view: FeedbackResultView; readonly onShowFeedback?: FeedbackCardConfig['onShowFeedback']; readonly onHideFeedback?: FeedbackCardConfig['onHideFeedback'] }) {\n if (view.kind === 'list') return <List view={view} onShowFeedback={onShowFeedback} onHideFeedback={onHideFeedback} />;\n if (view.kind === 'offer') return <Offer view={view} onShowFeedback={onShowFeedback} onHideFeedback={onHideFeedback} />;\n return <section data-testid=\"feedback-card\" data-kind=\"refused\" data-outcome={view.outcome} aria-label=\"Feedback\" className=\"my-2 max-w-md rounded-xl border border-dashed border-border px-3 py-2 text-sm text-muted-foreground\">\n Feedback not shown ({view.outcome}){view.reason ? `: ${view.reason}` : ''}</section>;\n}\n\n/** An `@<root><id>.md` mention of a report in a sent message. */\nexport function FeedbackMention({ path, id, onOpen }: { readonly path: string; readonly id: string; readonly onOpen?: ((path: string) => void) | undefined }) {\n const className = 'inline-flex items-center gap-1 rounded-md bg-background px-1.5 py-0.5 align-baseline font-mono text-[0.8125rem] font-medium text-foreground ring-1 ring-border';\n const body = <><MessageSquareTextIcon className=\"size-3.5 text-muted-foreground\" aria-hidden=\"true\" />{id}</>;\n return onOpen\n ? <button type=\"button\" data-testid=\"feedback-mention\" data-path={path} aria-label={`Open feedback ${id}`} onClick={() => onOpen(path)} className={cn(className, 'cursor-pointer outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/60')}>{body}</button>\n : <span data-testid=\"feedback-mention\" data-path={path} title={`Feedback ${id}`} className={className}>{body}</span>;\n}\n\n/**\n * The `mentions.render` of a pi-chat host (`MentionsConfig`): `@<root><id>.md` report mentions in sent messages become feedback chips,\n * any other mention keeps pi-chat's default. `onOpen` (the chat's `mentions.open`) makes a chip a button.\n */\nexport function feedbackMentionRenderer(onOpen?: ((path: string) => void) | undefined): (path: string) => ReactNode | undefined {\n return path => {\n const id = feedbackMentionId(path);\n return id === undefined ? undefined : <FeedbackMention path={path} id={id} onOpen={onOpen} />;\n };\n}\n\n/**\n * A `renderTool` for `PiChat` or `AmbientChat`: cards for `feedback` list results, offers and refusals, `inline` so they\n * stay in the transcript in expert mode. Memoize it; compose with another renderer as `(c, r) => feedback(c, r) ?? mine(c, r)`.\n */\nexport function feedbackRenderTool(config: FeedbackCardConfig = {}): (call: ToolCall, result: ToolResultMessage | undefined) => ChatCard | undefined {\n return (call, result) => {\n const view = feedbackResultView(call, result);\n return view ? { content: <FeedbackCard view={view} onShowFeedback={config.onShowFeedback} onHideFeedback={config.onHideFeedback} />, inline: true } : undefined;\n };\n}\n",
64
+ "type": "registry:component",
65
+ "target": "components/feedback/feedback-card.tsx"
66
+ },
67
+ {
68
+ "path": "registry/feedback/voice-input.tsx",
69
+ "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 { cn } from '../utils/utils';\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\nconst ROUND = 'inline-flex size-11 shrink-0 cursor-pointer items-center justify-center rounded-full bg-muted text-foreground transition-colors outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring/60 disabled:pointer-events-none disabled:opacity-45 motion-reduce:transition-none';\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 type=\"button\" data-testid=\"composer-voice-cancel\" aria-label={labels.voiceCancel} title={labels.voiceCancel} onClick={cancel} className={ROUND}>\n <XIcon className=\"size-4\" aria-hidden=\"true\" /></button>}\n <button type=\"button\" data-testid=\"composer-voice\" data-state={phase} aria-pressed={recording} aria-label={recording ? labels.voiceStop : labels.voiceRecord}\n title={recording ? labels.voiceStop : labels.voiceRecord} disabled={phase === 'starting' || busy} onClick={onClick}\n className={cn(ROUND, recording && 'bg-destructive text-white hover:bg-destructive/90')}>\n {busy ? <Loader2Icon role=\"status\" aria-label={labels.voiceTranscribing} className=\"size-4 animate-spin motion-reduce:animate-none\" />\n : recording ? <SquareIcon className=\"size-4 fill-current\" aria-hidden=\"true\" />\n : <MicIcon className=\"size-5\" aria-hidden=\"true\" />}\n </button>\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
+ "type": "registry:component",
71
+ "target": "components/feedback/voice-input.tsx"
72
+ },
73
+ {
74
+ "path": "registry/feedback/feedback-labels.ts",
75
+ "content": "// The words of the feedback controls that cancel a selection and ask a general question, as data. The defaults are English, like the\n// rest of the copied components; `feedbackLabelsFr` is the French set (pass it as `labels` to AnnotateSheet, FeedbackBar and\n// NoteBubble). A host may pass any subset: what it leaves out keeps the default.\nexport interface FeedbackLabels {\n /** The annotate sheet's title for a selected zone, and the bubble's accessible name prefix is `note`. */\n readonly title: string;\n readonly close: string;\n /** The button that drops the selected zone and keeps what was typed. */\n readonly cancelSelection: string;\n /** The entry point (bar button, sheet button, general sheet title): a note with no element attached. */\n readonly askGeneral: string;\n readonly generalPlaceholder: string;\n readonly generalHint: string;\n /** The sheet's field label for a selected zone / for a general question. */\n readonly changeField: string;\n readonly generalField: string;\n readonly copyReport: string;\n readonly save: string;\n readonly saveAgain: string;\n readonly saving: string;\n readonly saved: string;\n readonly bubblePlaceholder: string;\n readonly bubbleHint: string;\n /** Announced when the selection was cancelled, so a screen reader hears that the zone is gone. */\n readonly selectionCancelled: string;\n readonly generalOpened: string;\n}\n\nexport const feedbackLabels: FeedbackLabels = {\n title: 'Feedback',\n close: 'Close',\n cancelSelection: 'Cancel selection',\n askGeneral: 'Ask a general question',\n generalPlaceholder: 'Your question (no element attached)',\n generalHint: 'Esc to close · Done to send',\n changeField: 'What should change?',\n generalField: 'Your question',\n copyReport: 'Copy report',\n save: 'Save',\n saveAgain: 'Save again',\n saving: 'Saving…',\n saved: 'Saved',\n bubblePlaceholder: \"What's wrong here?\",\n bubbleHint: 'Enter to save · Esc to discard',\n selectionCancelled: 'Selection cancelled.',\n generalOpened: 'General question: no element attached.',\n};\n\nexport const feedbackLabelsFr: FeedbackLabels = {\n title: 'Retour',\n close: 'Fermer',\n cancelSelection: 'Annuler la sélection',\n askGeneral: 'Poser une question générale',\n generalPlaceholder: 'Votre question (sans zone)',\n generalHint: 'Échap pour fermer · Terminé pour envoyer',\n changeField: 'Que faut-il changer ?',\n generalField: 'Votre question',\n copyReport: 'Copier le rapport',\n save: 'Enregistrer',\n saveAgain: 'Enregistrer à nouveau',\n saving: 'Enregistrement…',\n saved: 'Enregistré',\n bubblePlaceholder: \"Qu'est-ce qui ne va pas ici ?\",\n bubbleHint: 'Entrée pour valider · Échap pour annuler',\n selectionCancelled: 'Sélection annulée.',\n generalOpened: 'Question générale : aucune zone sélectionnée.',\n};\n\n/** The default labels with the host's overrides on top. */\nexport const withLabels = (labels?: Partial<FeedbackLabels>): FeedbackLabels => (labels ? { ...feedbackLabels, ...labels } : feedbackLabels);\n",
76
+ "type": "registry:lib",
77
+ "target": "components/feedback/feedback-labels.ts"
78
+ }
79
+ ],
80
+ "css": {
81
+ "[data-boring=\"feedback\"]": {
82
+ "--boring-feedback-accent": "var(--primary, #2563eb)",
83
+ "--boring-feedback-reveal": "#d97706",
84
+ "--boring-feedback-surface": "var(--popover, var(--background, #ffffff))",
85
+ "--boring-feedback-text": "var(--popover-foreground, var(--foreground, #111827))",
86
+ "--boring-feedback-muted": "var(--muted-foreground, #6b7280)",
87
+ "--boring-feedback-border": "var(--border, #e5e7eb)",
88
+ "--boring-feedback-radius": "var(--radius, 0.625rem)",
89
+ "--boring-feedback-label-background": "#111827",
90
+ "--boring-feedback-label-foreground": "#f9fafb",
91
+ "font": "14px/1.45 ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", sans-serif",
92
+ "color": "var(--boring-feedback-text)"
93
+ },
94
+ "[data-boring=\"feedback\"] *, [data-boring=\"feedback\"] *::before, [data-boring=\"feedback\"] *::after": {
95
+ "box-sizing": "border-box"
96
+ },
97
+ "[data-boring=\"feedback\"] .boring-feedback-button": {
98
+ "display": "inline-flex",
99
+ "align-items": "center",
100
+ "justify-content": "center",
101
+ "gap": "0.375rem",
102
+ "min-height": "2.25rem",
103
+ "padding": "0 0.875rem",
104
+ "border": "1px solid var(--boring-feedback-border)",
105
+ "border-radius": "calc(var(--boring-feedback-radius) - 2px)",
106
+ "background": "var(--boring-feedback-surface)",
107
+ "color": "var(--boring-feedback-text)",
108
+ "font": "inherit",
109
+ "font-weight": "600",
110
+ "cursor": "pointer"
111
+ },
112
+ "[data-boring=\"feedback\"] .boring-feedback-button:hover": {
113
+ "background": "var(--accent, #f3f4f6)"
114
+ },
115
+ "[data-boring=\"feedback\"] .boring-feedback-button:focus-visible, [data-boring=\"feedback\"] button:focus-visible, [data-boring=\"feedback\"] textarea:focus-visible": {
116
+ "outline": "2px solid var(--ring, var(--boring-feedback-accent))",
117
+ "outline-offset": "2px"
118
+ },
119
+ "[data-boring=\"feedback\"] .boring-feedback-button[data-variant=\"primary\"]": {
120
+ "border-color": "transparent",
121
+ "background": "var(--boring-feedback-accent)",
122
+ "color": "var(--primary-foreground, #ffffff)"
123
+ },
124
+ "[data-boring=\"feedback\"] .boring-feedback-button:disabled": {
125
+ "opacity": "0.6",
126
+ "cursor": "default"
127
+ },
128
+ "[data-boring=\"feedback\"] .boring-feedback-button[aria-pressed=\"true\"]": {
129
+ "background": "var(--boring-feedback-accent)",
130
+ "color": "var(--primary-foreground, #ffffff)"
131
+ },
132
+ "[data-boring=\"feedback\"] .boring-feedback-icon": {
133
+ "display": "inline-flex",
134
+ "align-items": "center",
135
+ "justify-content": "center",
136
+ "width": "2rem",
137
+ "height": "2rem",
138
+ "border": "0",
139
+ "border-radius": "0.5rem",
140
+ "background": "transparent",
141
+ "color": "var(--boring-feedback-muted)",
142
+ "font-size": "1.25rem",
143
+ "line-height": "1",
144
+ "cursor": "pointer"
145
+ },
146
+ "[data-boring=\"feedback\"].boring-feedback-point": {
147
+ "display": "inline-flex",
148
+ "align-items": "center",
149
+ "gap": "0.625rem"
150
+ },
151
+ "[data-boring=\"feedback\"] .boring-feedback-hint": {
152
+ "max-width": "32rem",
153
+ "overflow": "hidden",
154
+ "text-overflow": "ellipsis",
155
+ "white-space": "nowrap",
156
+ "color": "var(--boring-feedback-muted)",
157
+ "font-size": "0.8125rem"
158
+ },
159
+ "[data-boring=\"feedback\"] .boring-feedback-keys": {
160
+ "opacity": "0.8"
161
+ },
162
+ "[data-boring=\"feedback\"].boring-feedback-sheet, [data-boring=\"feedback\"].boring-feedback-report, [data-boring=\"feedback\"].boring-feedback-list": {
163
+ "display": "flex",
164
+ "flex-direction": "column",
165
+ "gap": "0.75rem",
166
+ "padding": "1rem",
167
+ "border": "1px solid var(--boring-feedback-border)",
168
+ "border-radius": "var(--boring-feedback-radius)",
169
+ "background": "var(--boring-feedback-surface)"
170
+ },
171
+ "[data-boring=\"feedback\"].boring-feedback-sheet": {
172
+ "z-index": "2147483000",
173
+ "box-shadow": "0 16px 48px rgb(0 0 0 / 18%)",
174
+ "max-height": "calc(100dvh - 24px)",
175
+ "overflow": "auto"
176
+ },
177
+ "[data-boring=\"feedback\"].boring-feedback-sheet[data-docked=\"bottom\"]": {
178
+ "position": "fixed",
179
+ "left": "12px",
180
+ "right": "12px",
181
+ "bottom": "12px",
182
+ "max-width": "32rem",
183
+ "margin": "0 auto"
184
+ },
185
+ "[data-boring=\"feedback\"] .boring-feedback-sheet-header": {
186
+ "display": "flex",
187
+ "align-items": "center",
188
+ "justify-content": "space-between",
189
+ "gap": "0.5rem"
190
+ },
191
+ "[data-boring=\"feedback\"] h2": {
192
+ "margin": "0",
193
+ "font-size": "1rem",
194
+ "font-weight": "650"
195
+ },
196
+ "[data-boring=\"feedback\"] h3": {
197
+ "margin": "0 0 0.25rem",
198
+ "font-size": "0.875rem"
199
+ },
200
+ "[data-boring=\"feedback\"] .boring-feedback-targets": {
201
+ "margin": "0",
202
+ "padding": "0",
203
+ "list-style": "none",
204
+ "color": "var(--boring-feedback-muted)",
205
+ "font-size": "0.8125rem"
206
+ },
207
+ "[data-boring=\"feedback\"] .boring-feedback-field": {
208
+ "display": "grid",
209
+ "gap": "0.375rem",
210
+ "font-weight": "600",
211
+ "font-size": "0.8125rem"
212
+ },
213
+ "[data-boring=\"feedback\"] textarea": {
214
+ "width": "100%",
215
+ "padding": "0.5rem 0.625rem",
216
+ "border": "1px solid var(--input, var(--boring-feedback-border))",
217
+ "border-radius": "calc(var(--boring-feedback-radius) - 2px)",
218
+ "background": "var(--background, #ffffff)",
219
+ "color": "inherit",
220
+ "font": "inherit",
221
+ "font-size": "16px",
222
+ "font-weight": "400",
223
+ "resize": "vertical"
224
+ },
225
+ "[data-boring=\"feedback\"] .boring-feedback-actions": {
226
+ "display": "flex",
227
+ "flex-wrap": "wrap",
228
+ "gap": "0.5rem"
229
+ },
230
+ "[data-boring=\"feedback\"] .boring-feedback-status": {
231
+ "margin": "0",
232
+ "min-height": "1.25em",
233
+ "color": "var(--boring-feedback-muted)",
234
+ "font-size": "0.8125rem"
235
+ },
236
+ "[data-boring=\"feedback\"] .boring-feedback-warning": {
237
+ "margin": "0",
238
+ "color": "var(--destructive, #b91c1c)",
239
+ "font-size": "0.8125rem"
240
+ },
241
+ "[data-boring=\"feedback\"] .boring-feedback-notice": {
242
+ "margin": "0",
243
+ "padding": "0.5rem 0.625rem",
244
+ "border-radius": "calc(var(--boring-feedback-radius) - 2px)",
245
+ "background": "color-mix(in srgb, var(--boring-feedback-reveal) 14%, transparent)",
246
+ "font-size": "0.8125rem"
247
+ },
248
+ "[data-boring=\"feedback\"] .boring-feedback-copied pre, [data-boring=\"feedback\"] .boring-feedback-manual": {
249
+ "max-height": "12rem",
250
+ "overflow": "auto",
251
+ "margin": "0.375rem 0 0",
252
+ "font": "12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace",
253
+ "white-space": "pre-wrap"
254
+ },
255
+ "[data-boring=\"feedback\"] .boring-feedback-copied summary": {
256
+ "cursor": "pointer",
257
+ "color": "var(--boring-feedback-muted)",
258
+ "font-size": "0.8125rem"
259
+ },
260
+ "[data-boring=\"feedback\"].boring-feedback-list ul": {
261
+ "display": "flex",
262
+ "flex-direction": "column",
263
+ "gap": "0.25rem",
264
+ "margin": "0",
265
+ "padding": "0",
266
+ "list-style": "none"
267
+ },
268
+ "[data-boring=\"feedback\"].boring-feedback-list li button": {
269
+ "display": "grid",
270
+ "gap": "0.125rem",
271
+ "width": "100%",
272
+ "padding": "0.5rem 0.625rem",
273
+ "border": "0",
274
+ "border-radius": "calc(var(--boring-feedback-radius) - 2px)",
275
+ "background": "transparent",
276
+ "color": "inherit",
277
+ "font": "inherit",
278
+ "text-align": "left",
279
+ "cursor": "pointer"
280
+ },
281
+ "[data-boring=\"feedback\"].boring-feedback-list li button:hover, [data-boring=\"feedback\"].boring-feedback-list li button[aria-current=\"true\"]": {
282
+ "background": "var(--accent, #f3f4f6)"
283
+ },
284
+ "[data-boring=\"feedback\"] .boring-feedback-title": {
285
+ "font-weight": "600"
286
+ },
287
+ "[data-boring=\"feedback\"] .boring-feedback-meta": {
288
+ "margin": "0",
289
+ "color": "var(--boring-feedback-muted)",
290
+ "font-size": "0.8125rem"
291
+ },
292
+ "[data-boring=\"feedback\"] [data-status=\"addressed\"]": {
293
+ "color": "#15803d"
294
+ },
295
+ "[data-boring=\"feedback\"] .boring-feedback-empty": {
296
+ "margin": "0",
297
+ "color": "var(--boring-feedback-muted)"
298
+ },
299
+ "[data-boring=\"feedback\"] .boring-feedback-said": {
300
+ "margin": "0",
301
+ "white-space": "pre-wrap"
302
+ },
303
+ "[data-boring=\"feedback\"] .boring-feedback-anchors": {
304
+ "display": "flex",
305
+ "flex-direction": "column",
306
+ "gap": "0.5rem",
307
+ "margin": "0",
308
+ "padding-left": "1.25rem"
309
+ },
310
+ "[data-boring=\"feedback\"] .boring-feedback-anchors > li": {
311
+ "display": "flex",
312
+ "flex-wrap": "wrap",
313
+ "align-items": "center",
314
+ "gap": "0.5rem"
315
+ },
316
+ "[data-boring=\"feedback\"] .boring-feedback-anchors code": {
317
+ "font-size": "0.75rem",
318
+ "color": "var(--boring-feedback-muted)"
319
+ },
320
+ "[data-boring=\"feedback\"] .boring-feedback-candidates": {
321
+ "display": "flex",
322
+ "flex-wrap": "wrap",
323
+ "gap": "0.375rem",
324
+ "margin": "0.375rem 0 0",
325
+ "padding": "0",
326
+ "list-style": "none"
327
+ },
328
+ "[data-boring=\"feedback\"].boring-feedback-bar": {
329
+ "position": "fixed",
330
+ "top": "12px",
331
+ "left": "0",
332
+ "right": "0",
333
+ "z-index": "2147483000",
334
+ "display": "flex",
335
+ "flex-wrap": "nowrap",
336
+ "align-items": "center",
337
+ "gap": "0.5rem",
338
+ "width": "max-content",
339
+ "max-width": "calc(100vw - 24px)",
340
+ "margin": "0 auto",
341
+ "padding": "0.375rem 0.5rem 0.375rem 0.875rem",
342
+ "border": "1px solid var(--boring-feedback-border)",
343
+ "border-radius": "999px",
344
+ "background": "var(--boring-feedback-surface)",
345
+ "box-shadow": "0 10px 32px rgb(0 0 0 / 16%)"
346
+ },
347
+ "[data-boring=\"feedback\"].boring-feedback-bar[data-using=\"true\"]": {
348
+ "border-color": "var(--boring-feedback-reveal)"
349
+ },
350
+ "[data-boring=\"feedback\"].boring-feedback-bar[data-docked=\"true\"]": {
351
+ "position": "static",
352
+ "width": "auto",
353
+ "max-width": "none",
354
+ "margin": "0 0.625rem 0.625rem",
355
+ "flex-wrap": "wrap",
356
+ "border-radius": "0.75rem",
357
+ "box-shadow": "none"
358
+ },
359
+ "[data-boring=\"feedback\"].boring-feedback-bar > *": {
360
+ "flex": "none"
361
+ },
362
+ "[data-boring=\"feedback\"].boring-feedback-bar > .boring-feedback-hint": {
363
+ "flex": "0 1 auto",
364
+ "min-width": "0",
365
+ "max-width": "24rem"
366
+ },
367
+ "[data-boring=\"feedback\"] .boring-feedback-bar-title": {
368
+ "display": "inline-flex",
369
+ "align-items": "center",
370
+ "gap": "0.375rem",
371
+ "font-weight": "650"
372
+ },
373
+ "[data-boring=\"feedback\"] .boring-feedback-dot": {
374
+ "width": "0.5rem",
375
+ "height": "0.5rem",
376
+ "border-radius": "50%",
377
+ "background": "var(--boring-feedback-accent)"
378
+ },
379
+ "[data-boring=\"feedback\"].boring-feedback-bar[data-using=\"true\"] .boring-feedback-dot": {
380
+ "background": "var(--boring-feedback-reveal)"
381
+ },
382
+ "[data-boring=\"feedback\"] .boring-feedback-bar-meta": {
383
+ "color": "var(--boring-feedback-muted)",
384
+ "font-size": "0.8125rem"
385
+ },
386
+ "[data-boring=\"feedback\"] .boring-feedback-alt": {
387
+ "color": "var(--boring-feedback-muted)",
388
+ "font-size": "0.75rem"
389
+ },
390
+ "[data-boring=\"feedback\"] .boring-feedback-icon[aria-pressed=\"true\"]": {
391
+ "background": "color-mix(in srgb, #dc2626 16%, transparent)",
392
+ "color": "#dc2626"
393
+ },
394
+ "[data-boring=\"feedback\"] .boring-feedback-icon:disabled": {
395
+ "opacity": "0.45",
396
+ "cursor": "default"
397
+ },
398
+ "[data-boring=\"feedback\"] .boring-feedback-recording": {
399
+ "display": "inline-flex",
400
+ "align-items": "center",
401
+ "gap": "0.375rem",
402
+ "padding": "0.125rem 0.5rem",
403
+ "border-radius": "999px",
404
+ "background": "color-mix(in srgb, #dc2626 12%, transparent)",
405
+ "color": "#b91c1c",
406
+ "font-size": "0.75rem",
407
+ "font-weight": "650"
408
+ },
409
+ "[data-boring=\"feedback\"] .boring-feedback-rec": {
410
+ "width": "0.5rem",
411
+ "height": "0.5rem",
412
+ "border-radius": "50%",
413
+ "background": "#dc2626"
414
+ },
415
+ "[data-boring=\"feedback\"].boring-feedback-bar[data-docked=\"true\"] > .boring-feedback-hint": {
416
+ "flex": "1 1 10rem",
417
+ "max-width": "none",
418
+ "white-space": "normal"
419
+ },
420
+ "[data-boring=\"feedback\"] .boring-feedback-record": {
421
+ "min-height": "2rem",
422
+ "padding": "0 0.625rem",
423
+ "gap": "0.3125rem",
424
+ "font-size": "0.8125rem"
425
+ },
426
+ "[data-boring=\"feedback\"] .boring-feedback-record[aria-pressed=\"true\"]": {
427
+ "border-color": "transparent",
428
+ "background": "#dc2626",
429
+ "color": "#ffffff"
430
+ },
431
+ "[data-boring=\"feedback\"] .boring-feedback-stop": {
432
+ "width": "0.625rem",
433
+ "height": "0.625rem",
434
+ "border-radius": "2px",
435
+ "background": "currentColor"
436
+ },
437
+ "[data-boring=\"feedback\"] .boring-feedback-timer": {
438
+ "font-variant-numeric": "tabular-nums"
439
+ },
440
+ "[data-boring=\"feedback\"].boring-feedback-bar[data-recording=\"true\"]": {
441
+ "border-color": "color-mix(in srgb, #dc2626 55%, var(--boring-feedback-border))"
442
+ },
443
+ "[data-boring=\"feedback\"] .boring-feedback-voice-problem": {
444
+ "flex": "1 1 100%",
445
+ "margin": "0",
446
+ "color": "var(--destructive, #b91c1c)",
447
+ "font-size": "0.8125rem",
448
+ "white-space": "normal"
449
+ },
450
+ "[data-boring=\"feedback\"] .boring-feedback-element": {
451
+ "display": "inline-flex",
452
+ "flex": "1",
453
+ "align-items": "baseline",
454
+ "gap": "0.375rem",
455
+ "min-width": "0",
456
+ "overflow": "hidden",
457
+ "white-space": "nowrap"
458
+ },
459
+ "[data-boring=\"feedback\"] .boring-feedback-element-name": {
460
+ "flex": "0 1 auto",
461
+ "min-width": "2rem",
462
+ "overflow": "hidden",
463
+ "text-overflow": "ellipsis",
464
+ "font-size": "0.8125rem",
465
+ "font-weight": "600"
466
+ },
467
+ "[data-boring=\"feedback\"] .boring-feedback-element-file": {
468
+ "flex": "0 100 auto",
469
+ "min-width": "0",
470
+ "overflow": "hidden",
471
+ "text-overflow": "ellipsis",
472
+ "color": "var(--boring-feedback-muted)",
473
+ "font": "0.6875rem/1.4 ui-monospace, SFMono-Regular, Menlo, monospace"
474
+ },
475
+ "[data-boring=\"feedback\"] .boring-feedback-badge": {
476
+ "flex": "none",
477
+ "padding": "0 0.375rem",
478
+ "border-radius": "999px",
479
+ "background": "var(--muted, #f3f4f6)",
480
+ "color": "var(--boring-feedback-muted)",
481
+ "font-size": "0.6875rem",
482
+ "line-height": "1.25rem"
483
+ },
484
+ "[data-boring=\"feedback\"].boring-feedback-bubble": {
485
+ "z-index": "2147483001",
486
+ "display": "grid",
487
+ "gap": "0.375rem",
488
+ "padding": "0.5rem",
489
+ "border": "1px solid var(--boring-feedback-border)",
490
+ "border-radius": "var(--boring-feedback-radius)",
491
+ "background": "var(--boring-feedback-surface)",
492
+ "box-shadow": "0 12px 32px rgb(0 0 0 / 18%)"
493
+ },
494
+ "[data-boring=\"feedback\"] .boring-feedback-bubble-head, [data-boring=\"feedback\"] .boring-feedback-note-head": {
495
+ "display": "flex",
496
+ "align-items": "center",
497
+ "gap": "0.375rem",
498
+ "min-width": "0"
499
+ },
500
+ "[data-boring=\"feedback\"] .boring-feedback-bubble-label, [data-boring=\"feedback\"] .boring-feedback-note-label": {
501
+ "flex": "1",
502
+ "min-width": "0",
503
+ "overflow": "hidden",
504
+ "text-overflow": "ellipsis",
505
+ "white-space": "nowrap",
506
+ "color": "var(--boring-feedback-muted)",
507
+ "font-size": "0.75rem"
508
+ },
509
+ "[data-boring=\"feedback\"] .boring-feedback-pin": {
510
+ "color": "var(--boring-feedback-accent)",
511
+ "font-weight": "700"
512
+ },
513
+ "[data-boring=\"feedback\"].boring-feedback-chip": {
514
+ "position": "relative",
515
+ "display": "inline-flex",
516
+ "flex-wrap": "wrap",
517
+ "align-items": "center",
518
+ "gap": "0.125rem",
519
+ "max-width": "100%",
520
+ "padding": "0.125rem 0.125rem 0.125rem 0.625rem",
521
+ "border": "1px solid var(--boring-feedback-border)",
522
+ "border-radius": "999px",
523
+ "background": "color-mix(in srgb, var(--boring-feedback-accent) 8%, var(--boring-feedback-surface))",
524
+ "font-size": "0.8125rem"
525
+ },
526
+ "[data-boring=\"feedback\"] .boring-feedback-chip-open": {
527
+ "min-width": "0",
528
+ "padding": "0.25rem 0.25rem",
529
+ "border": "0",
530
+ "background": "transparent",
531
+ "color": "inherit",
532
+ "font": "inherit",
533
+ "font-weight": "600",
534
+ "text-align": "left",
535
+ "cursor": "pointer"
536
+ },
537
+ "[data-boring=\"feedback\"] .boring-feedback-chip-route": {
538
+ "color": "var(--boring-feedback-muted)",
539
+ "font-family": "ui-monospace, SFMono-Regular, Menlo, monospace",
540
+ "font-weight": "500"
541
+ },
542
+ "[data-boring=\"feedback\"] .boring-feedback-chip-remove": {
543
+ "width": "1.5rem",
544
+ "height": "1.5rem",
545
+ "font-size": "1rem"
546
+ },
547
+ "[data-boring=\"feedback\"].boring-feedback-review": {
548
+ "z-index": "2147483000",
549
+ "display": "flex",
550
+ "flex-direction": "column",
551
+ "gap": "0.625rem",
552
+ "max-height": "min(28rem, calc(100dvh - 24px))",
553
+ "overflow": "auto",
554
+ "padding": "0.875rem",
555
+ "border": "1px solid var(--boring-feedback-border)",
556
+ "border-radius": "var(--boring-feedback-radius)",
557
+ "background": "var(--boring-feedback-surface)",
558
+ "box-shadow": "0 16px 48px rgb(0 0 0 / 18%)",
559
+ "text-align": "left"
560
+ },
561
+ "[data-boring=\"feedback\"] .boring-feedback-notes": {
562
+ "display": "flex",
563
+ "flex-direction": "column",
564
+ "gap": "0.5rem",
565
+ "margin": "0",
566
+ "padding": "0",
567
+ "list-style": "none"
568
+ },
569
+ "[data-boring=\"feedback\"] .boring-feedback-notes > li": {
570
+ "display": "grid",
571
+ "gap": "0.25rem",
572
+ "padding": "0.375rem",
573
+ "border-radius": "calc(var(--boring-feedback-radius) - 2px)"
574
+ },
575
+ "[data-boring=\"feedback\"] .boring-feedback-notes > li:hover, [data-boring=\"feedback\"] .boring-feedback-notes > li:focus-within": {
576
+ "background": "var(--accent, #f3f4f6)"
577
+ },
578
+ "@media (max-width: 640px), (pointer: coarse)": {
579
+ "[data-boring=\"feedback\"] .boring-feedback-button": {
580
+ "min-height": "2.75rem"
581
+ },
582
+ "[data-boring=\"feedback\"] .boring-feedback-icon": {
583
+ "width": "2.75rem",
584
+ "height": "2.75rem"
585
+ },
586
+ "[data-boring=\"feedback\"] .boring-feedback-hint": {
587
+ "display": "none"
588
+ },
589
+ "[data-boring=\"feedback\"] .boring-feedback-alt": {
590
+ "display": "none"
591
+ },
592
+ "[data-boring=\"feedback\"].boring-feedback-bar": {
593
+ "top": "auto",
594
+ "bottom": "12px",
595
+ "flex-wrap": "wrap",
596
+ "border-radius": "var(--boring-feedback-radius)"
597
+ }
598
+ },
599
+ "[data-boring=\"feedback\"] .boring-feedback-mic": {
600
+ "flex": "none",
601
+ "display": "block"
602
+ },
603
+ "[data-boring=\"feedback\"].boring-feedback-preview": {
604
+ "position": "fixed",
605
+ "top": "12px",
606
+ "left": "0",
607
+ "right": "0",
608
+ "z-index": "2147483001",
609
+ "display": "flex",
610
+ "flex-direction": "column",
611
+ "gap": "0.5rem",
612
+ "width": "min(40rem, calc(100vw - 24px))",
613
+ "margin": "0 auto",
614
+ "padding": "0.75rem 0.875rem",
615
+ "border": "2px solid var(--boring-feedback-reveal)",
616
+ "border-radius": "var(--boring-feedback-radius)",
617
+ "background": "var(--boring-feedback-surface)",
618
+ "box-shadow": "0 12px 36px rgb(0 0 0 / 18%)"
619
+ },
620
+ "[data-boring=\"feedback\"] .boring-feedback-preview-head": {
621
+ "display": "flex",
622
+ "flex-wrap": "wrap",
623
+ "align-items": "baseline",
624
+ "gap": "0.5rem"
625
+ },
626
+ "[data-boring=\"feedback\"] .boring-feedback-preview-title": {
627
+ "padding": "0.125rem 0.5rem",
628
+ "border-radius": "999px",
629
+ "background": "var(--boring-feedback-reveal)",
630
+ "color": "#ffffff",
631
+ "font-size": "0.8125rem",
632
+ "letter-spacing": "0.02em",
633
+ "text-transform": "uppercase"
634
+ },
635
+ "[data-boring=\"feedback\"] .boring-feedback-preview-notice": {
636
+ "font-weight": "600"
637
+ },
638
+ "[data-boring=\"feedback\"] .boring-feedback-preview-status": {
639
+ "margin-left": "auto",
640
+ "color": "var(--boring-feedback-muted)",
641
+ "font-size": "0.8125rem"
642
+ },
643
+ "[data-boring=\"feedback\"] .boring-feedback-preview-summary": {
644
+ "margin": "0"
645
+ },
646
+ "[data-boring=\"feedback\"] .boring-feedback-preview-changes": {
647
+ "margin": "0",
648
+ "padding-left": "1.125rem",
649
+ "color": "var(--boring-feedback-muted)",
650
+ "font-size": "0.8125rem"
651
+ },
652
+ "[data-boring=\"feedback\"] .boring-feedback-preview-changes code": {
653
+ "font-size": "0.75rem"
654
+ },
655
+ "[data-boring=\"feedback\"] .boring-feedback-preview-error": {
656
+ "margin": "0",
657
+ "color": "#b91c1c",
658
+ "font-size": "0.8125rem"
659
+ },
660
+ "[data-boring=\"feedback\"] .boring-feedback-preview-actions": {
661
+ "display": "flex",
662
+ "flex-wrap": "wrap",
663
+ "gap": "0.5rem"
664
+ },
665
+ "[data-boring=\"feedback\"] .boring-feedback-preview-input": {
666
+ "flex": "1 1 12rem",
667
+ "min-width": "0",
668
+ "min-height": "2.25rem",
669
+ "padding": "0 0.75rem",
670
+ "border": "1px solid var(--boring-feedback-border)",
671
+ "border-radius": "calc(var(--boring-feedback-radius) - 2px)",
672
+ "background": "var(--boring-feedback-surface)",
673
+ "color": "var(--boring-feedback-text)",
674
+ "font": "inherit"
675
+ },
676
+ "[data-boring=\"feedback\"] .boring-feedback-bubble-actions": {
677
+ "display": "flex",
678
+ "flex-wrap": "wrap",
679
+ "gap": "0.375rem"
680
+ },
681
+ "[data-boring=\"feedback\"] .boring-feedback-bubble-actions .boring-feedback-button": {
682
+ "min-height": "2rem",
683
+ "padding": "0 0.625rem",
684
+ "font-size": "0.8125rem"
685
+ }
686
+ },
687
+ "type": "registry:component"
688
+ }