@hachej/boring-ui-kit 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/r/feedback.json CHANGED
@@ -4,21 +4,21 @@
4
4
  "title": "Feedback",
5
5
  "description": "Feedback from the chat composer: one Feedback button starts feedback mode on the application page (hover, click to pin a numbered note, hold ⌥ to use the app, optional voice), and one chip carries the notes to the next message. FeedbackBar, NoteBubble, FeedbackChip with its review, useComposerFeedback for the pi-chat composer, and PickerOverlay styles. Optional, not mounted by default: PointButton, AnnotateSheet, FeedbackList and FeedbackReport. Picking, masking, anchoring and placement stay in @hachej/boring-feedback/page and /ui, so restyling cannot weaken them.",
6
6
  "dependencies": [
7
- "@hachej/boring-feedback@^0.2.2",
8
- "@hachej/boring-ui-kit@^0.2.2",
9
- "@hachej/boring-files@^0.2.2",
7
+ "@hachej/boring-feedback@^0.2.4",
8
+ "@hachej/boring-ui-kit@^0.2.4",
9
+ "@hachej/boring-files@^0.2.4",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0",
12
12
  "lucide-react@^1.51.0"
13
13
  ],
14
14
  "registryDependencies": [
15
- "@boring-ui/pi-chat",
16
- "@boring-ui/utils"
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.2.4/r/utils.json"
17
17
  ],
18
18
  "files": [
19
19
  {
20
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",
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;\n/** Pixels at the bottom of the viewport taken by point mode's bar (PointBar, `data-feedback-point-bar`), plus a gap; 0 without it. */\nexport function pointBarInset(document: Document): number {\n const bar = document.querySelector('[data-feedback-point-bar]');\n const height = document.defaultView?.innerHeight ?? 768;\n return bar ? Math.max(0, height - bar.getBoundingClientRect().top + 8) : 0;\n}\n\n/**\n * Beside the element: below it when the bubble (`size`, its measured height once drawn) fits above point mode's bar (if any), else\n * above it, else as low as it fits. Every branch is clamped to the space above the bar, so the bubble never covers it; a bubble taller\n * than that space scrolls inside it.\n */\nfunction bubblePlace(element: Element, size = 120): CSSProperties {\n const view = element.ownerDocument.defaultView;\n const width = view?.innerWidth ?? 1024, height = (view?.innerHeight ?? 768) - pointBarInset(element.ownerDocument);\n const rect = element.getBoundingClientRect();\n const left = Math.min(Math.max(8, rect.left), Math.max(8, width - BUBBLE_WIDTH - 8));\n const below = rect.bottom + 8;\n const above = rect.top - size - 8;\n const lowest = Math.max(8, height - size);\n const top = Math.min(below + size <= height ? below : above >= 8 ? above : lowest, lowest);\n return { position: 'fixed', top, left, width: Math.min(BUBBLE_WIDTH, width - 16), maxHeight: Math.max(0, height - top), overflowY: 'auto', boxSizing: 'border-box' };\n}\n\n/** 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. In point mode\n * it keeps clear of the PointBar. One NoteBubble serves both modes.\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 const box = useRef<HTMLDivElement>(null);\n // The bubble's natural height (its content plus borders), not the height it is clamped to, so it grows back when there is room.\n const size = (): number | undefined => (box.current ? box.current.scrollHeight + box.current.offsetHeight - box.current.clientHeight : 0) || undefined;\n useLayoutEffect(() => { if (bubble) setPlace(bubblePlace(bubble.element, size())); }, [bubble?.noteId, bubble?.element]);\n // Once drawn, place it again with its real height (taller with 44px touch targets and wrapped buttons).\n useLayoutEffect(() => {\n if (!bubble || !place || !box.current) return;\n const next = bubblePlace(bubble.element, size());\n if (next.top !== place.top || next.left !== place.left) setPlace(next);\n });\n useEffect(() => { if (bubble) field.current?.focus({ preventScroll: true }); }, [bubble?.noteId]);\n useEffect(() => { 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, size())); redraw(value => value + 1); };\n view?.addEventListener('scroll', follow, { capture: true, passive: true });\n view?.addEventListener('resize', follow);\n return () => { view?.removeEventListener('scroll', follow, { capture: true }); view?.removeEventListener('resize', follow); };\n }, [bubble?.noteId, bubble?.element]);\n 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: Math.max(72, typeof document === 'undefined' ? 0 : pointBarInset(document)), 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 ref={box} className=\"boring-feedback-bubble\" style={place} onKeyDown={onKeyDown}>\n <span className=\"boring-feedback-bubble-head\"><span className=\"boring-feedback-pin\" aria-hidden=\"true\">{pinMark(bubble.number)}</span>\n <span className=\"boring-feedback-bubble-label\" data-testid=\"feedback-bubble-label\">{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 point?: () => 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\nexport interface ComposerFeedbackOptions {\n /**\n * Which entry points the composer offers: `feedback` (default) the Feedback button and feedback mode docked in the composer; `point`\n * the « Show in app » action for phones (point mode: the host hides its panel while `usePointing` is true and mounts `PointMode`);\n * `both` offers the two.\n */\n readonly entry?: 'feedback' | 'point' | 'both';\n}\n\n/**\n * The composer's `feedback` prop for a session: the Feedback button (or « Show in app ») starts it, the chip shows the draft, and Send\n * attaches the report: a saved report as its `@` mention (the host's mention resolver inlines the stored report with the person's\n * access), otherwise the rendered report inline in a fenced block.\n */\nexport function useComposerFeedback(session: FeedbackSession | undefined, { entry = 'feedback' }: ComposerFeedbackOptions = {}): ComposerFeedbackProp | undefined {\n const state = useSyncState(session);\n if (!session || !state) return undefined;\n const on = state.phase === 'active' || state.phase === 'processing';\n return {\n ...(entry !== 'point' ? { start: () => session.start() } : {}),\n ...(entry !== 'feedback' ? { point: () => session.start({ mode: 'point' }) } : {}),\n active: on,\n pending: state.phase === 'review',\n // Point mode's bar is PointMode, mounted by the host outside its (hidden) panel.\n ...(on && state.mode === 'feedback' ? { 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
22
  "type": "registry:component",
23
23
  "target": "components/feedback/feedback-session.tsx"
24
24
  },
@@ -30,7 +30,7 @@
30
30
  },
31
31
  {
32
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",
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, over its base styles: fonts, sizes, weights and corners read the app's tokens (with the\n * shadcn defaults as fallbacks). 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: var(--radius-sm, calc(var(--radius, 0.625rem) * 0.6)); }\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, var(--primary)) 25%, transparent); }\n.box[data-tone=\"reveal\"] { animation: boring-feedback-pulse 1.2s var(--ease-standard, cubic-bezier(0.4, 0, 0.2, 1)) 2; }\n.label { border-radius: var(--radius-sm, calc(var(--radius, 0.625rem) * 0.6)); box-shadow: var(--shadow-sm, 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)); font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); font-size: var(--text-xs, 0.75rem); font-weight: var(--font-weight-medium, 500); line-height: var(--leading-normal, 1.5); }\n.label[data-tone=\"candidate\"] { font-weight: var(--font-weight-bold, 700); }\n.toolbar { border-radius: var(--radius-xl, calc(var(--radius, 0.625rem) * 1.4)); box-shadow: var(--shadow-lg, 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)); }\n.toolbar button { border-radius: var(--radius-md, calc(var(--radius, 0.625rem) * 0.8)); font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); font-size: var(--text-sm, 0.875rem); font-weight: var(--font-weight-semibold, 600); line-height: var(--leading-tight, 1.25); }\n@keyframes boring-feedback-pulse { from { box-shadow: 0 0 0 0 color-mix(in srgb, var(--boring-feedback-reveal, var(--ring)) 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
34
  "type": "registry:lib",
35
35
  "target": "components/feedback/picker-overlay.ts"
36
36
  },
@@ -60,35 +60,45 @@
60
60
  },
61
61
  {
62
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",
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-xs 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-xs leading-normal font-normal', outcome.kind === 'applied' ? 'text-(--chat-success)' : '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-xs 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-sm 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-sm 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-xs 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-xs 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-xs 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-xs leading-normal 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-sm 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
64
  "type": "registry:component",
65
65
  "target": "components/feedback/feedback-card.tsx"
66
66
  },
67
67
  {
68
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",
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-lg 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
70
  "type": "registry:component",
71
71
  "target": "components/feedback/voice-input.tsx"
72
72
  },
73
73
  {
74
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",
75
+ "content": "// The words of the feedback controls that cancel a selection, ask a general question and drive point mode, 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,\n// NoteBubble and PointMode). 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 /** Point mode's bar (phones): the hint, the count of picks, Done, Cancel and the \"use the app\" toggle with its hint. */\n readonly pointHint: string;\n readonly pointCount: (count: number) => string;\n readonly pointDone: string;\n readonly pointCancel: string;\n readonly pointUseApp: string;\n readonly pointUsing: string;\n readonly pointBar: 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 pointHint: 'Tap an element to comment',\n pointCount: count => `${count} ${count === 1 ? 'selection' : 'selections'}`,\n pointDone: 'Done',\n pointCancel: 'Cancel',\n pointUseApp: 'Use the app',\n pointUsing: 'Use the app, then come back to comment',\n pointBar: 'Point mode',\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 pointHint: 'Touchez un élément à commenter',\n pointCount: count => `${count} ${count <= 1 ? 'sélection' : 'sélections'}`,\n pointDone: 'Terminé',\n pointCancel: 'Annuler',\n pointUseApp: 'Utiliser l’app',\n pointUsing: 'Utilisez l’app, puis revenez commenter',\n pointBar: 'Mode pointage',\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
76
  "type": "registry:lib",
77
77
  "target": "components/feedback/feedback-labels.ts"
78
+ },
79
+ {
80
+ "path": "registry/feedback/point-mode.tsx",
81
+ "content": "// Point mode for phones (FEEDBACK.md, \"Point mode\"). On a phone the assistant's panel covers the app, so the person cannot point at it.\n// « Show in app » in the composer (`useComposerFeedback(session, { entry: 'point' })`) starts the session in point mode. The host binds\n// `usePointing(session)` to hide its own panel (a Sheet's `open`, AmbientChat's `state`), and this bar takes its place at the bottom of\n// the screen. Each tap picks an element with the same picker, pins, NoteBubble and draft as feedback mode. Done leaves the draft as the\n// composer's chip, the panel comes back, and Send attaches the report as it always does. Cancel discards everything.\n//\n// The kit never owns the host's panel. The host keeps its own open state; while `usePointing` is true it shows the panel as closed or\n// minimised, and the panel returns as it was when pointing ends. Mount PointBar (and the one NoteBubble) outside the panel, because the\n// panel may unmount while it is hidden.\nimport { createPortal } from 'react-dom';\nimport { isPointing, type FeedbackSession, type FeedbackSessionSnapshot } from '@hachej/boring-feedback/ui';\nimport { useExternal } from './use-external';\nimport { withLabels, type FeedbackLabels } from './feedback-labels';\n\nconst NO_STATE = { getSnapshot: () => undefined, subscribe: () => () => {} };\n\n/** True while the session is in point mode: the host hides its panel then, and shows it again when this turns false. */\nexport function usePointing(session: FeedbackSession | undefined): boolean {\n const state = useExternal<FeedbackSessionSnapshot | undefined>(session ?? NO_STATE);\n return state !== undefined && isPointing(state);\n}\n\nexport interface PointBarProps {\n readonly session: FeedbackSession;\n /** The height of the app's own bottom chrome (a tab bar) as a CSS length; the bar sits above it and above the safe area. */\n readonly bottomOffset?: string;\n /** Words of the bar (`feedbackLabelsFr` for French); what is left out keeps the English default. */\n readonly labels?: Partial<FeedbackLabels>;\n readonly className?: string;\n}\n\n/**\n * Point mode's compact bottom bar: « Tap an element to comment », the number of picks, « Use the app » (taps reach the page, for menus\n * and links), Cancel and Done. 44px targets, the host's shadcn tokens, the safe area and the app's tab bar (`bottomOffset`) respected.\n * It is `data-feedback-ignore`, so it is never picked, and the note bubble keeps clear of it.\n */\nexport function PointBar({ session, bottomOffset = '0px', labels, className }: PointBarProps) {\n const state = useExternal(session);\n const words = withLabels(labels);\n if (!isPointing(state) || typeof document === 'undefined') return null;\n const processing = state.phase === 'processing';\n const hint = state.problem ?? (state.using ? words.pointUsing : words.pointHint);\n return createPortal(<div data-boring=\"feedback\" data-feedback-ignore=\"\" data-feedback-point-bar=\"\" role=\"toolbar\" aria-label={words.pointBar} data-testid=\"feedback-point-bar\"\n data-using={state.using ? 'true' : 'false'} className={['boring-feedback-pointbar', className].filter(Boolean).join(' ')}\n style={{ bottom: `calc(${bottomOffset} + env(safe-area-inset-bottom, 0px) + 8px)` }}>\n <span className=\"boring-feedback-point-line\">\n <span className=\"boring-feedback-dot\" aria-hidden=\"true\" />\n <span className=\"boring-feedback-point-hint\" role=\"status\" aria-live=\"polite\" data-testid=\"feedback-point-hint\">{hint}</span>\n <span className=\"boring-feedback-point-count\" data-testid=\"feedback-point-count\">{words.pointCount(state.notes.length)}</span>\n </span>\n <span className=\"boring-feedback-point-actions\">\n <button type=\"button\" className=\"boring-feedback-button\" data-testid=\"feedback-point-use-app\" aria-pressed={state.useToggle} disabled={processing}\n onClick={() => session.setUseToggle(!state.useToggle)}>{words.pointUseApp}</button>\n <span className=\"boring-feedback-point-spacer\" />\n <button type=\"button\" className=\"boring-feedback-button\" data-testid=\"feedback-point-cancel\" disabled={processing} onClick={() => session.discard()}>{words.pointCancel}</button>\n <button type=\"button\" className=\"boring-feedback-button\" data-variant=\"primary\" data-testid=\"feedback-point-done\" disabled={processing}\n onClick={() => { void session.done(); }}>{words.pointDone}</button>\n </span>\n </div>, document.body);\n}\n",
82
+ "type": "registry:component",
83
+ "target": "components/feedback/point-mode.tsx"
78
84
  }
79
85
  ],
80
86
  "css": {
81
87
  "[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",
88
+ "--boring-feedback-accent": "var(--primary)",
89
+ "--boring-feedback-reveal": "var(--ring)",
90
+ "--boring-feedback-surface": "var(--popover)",
91
+ "--boring-feedback-text": "var(--popover-foreground)",
92
+ "--boring-feedback-muted": "var(--muted-foreground)",
93
+ "--boring-feedback-border": "var(--border)",
94
+ "--boring-feedback-radius": "var(--radius-lg, var(--radius, 0.625rem))",
95
+ "--boring-feedback-radius-md": "var(--radius-md, calc(var(--radius, 0.625rem) * 0.8))",
96
+ "--boring-feedback-shadow": "var(--shadow-lg, 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1))",
97
+ "--boring-feedback-label-background": "var(--foreground)",
98
+ "--boring-feedback-label-foreground": "var(--background)",
99
+ "font-family": "inherit",
100
+ "font-size": "var(--text-sm, 0.875rem)",
101
+ "line-height": "var(--leading-normal, 1.5)",
92
102
  "color": "var(--boring-feedback-text)"
93
103
  },
94
104
  "[data-boring=\"feedback\"] *, [data-boring=\"feedback\"] *::before, [data-boring=\"feedback\"] *::after": {
@@ -101,25 +111,25 @@
101
111
  "gap": "0.375rem",
102
112
  "min-height": "2.25rem",
103
113
  "padding": "0 0.875rem",
104
- "border": "1px solid var(--boring-feedback-border)",
105
- "border-radius": "calc(var(--boring-feedback-radius) - 2px)",
114
+ "border": "var(--border-width, 1px) solid var(--boring-feedback-border)",
115
+ "border-radius": "var(--boring-feedback-radius-md)",
106
116
  "background": "var(--boring-feedback-surface)",
107
117
  "color": "var(--boring-feedback-text)",
108
118
  "font": "inherit",
109
- "font-weight": "600",
119
+ "font-weight": "var(--font-weight-semibold, 600)",
110
120
  "cursor": "pointer"
111
121
  },
112
122
  "[data-boring=\"feedback\"] .boring-feedback-button:hover": {
113
- "background": "var(--accent, #f3f4f6)"
123
+ "background": "var(--accent)"
114
124
  },
115
125
  "[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))",
126
+ "outline": "2px solid var(--ring)",
117
127
  "outline-offset": "2px"
118
128
  },
119
129
  "[data-boring=\"feedback\"] .boring-feedback-button[data-variant=\"primary\"]": {
120
130
  "border-color": "transparent",
121
131
  "background": "var(--boring-feedback-accent)",
122
- "color": "var(--primary-foreground, #ffffff)"
132
+ "color": "var(--primary-foreground)"
123
133
  },
124
134
  "[data-boring=\"feedback\"] .boring-feedback-button:disabled": {
125
135
  "opacity": "0.6",
@@ -127,7 +137,7 @@
127
137
  },
128
138
  "[data-boring=\"feedback\"] .boring-feedback-button[aria-pressed=\"true\"]": {
129
139
  "background": "var(--boring-feedback-accent)",
130
- "color": "var(--primary-foreground, #ffffff)"
140
+ "color": "var(--primary-foreground)"
131
141
  },
132
142
  "[data-boring=\"feedback\"] .boring-feedback-icon": {
133
143
  "display": "inline-flex",
@@ -136,10 +146,10 @@
136
146
  "width": "2rem",
137
147
  "height": "2rem",
138
148
  "border": "0",
139
- "border-radius": "0.5rem",
149
+ "border-radius": "var(--boring-feedback-radius-md)",
140
150
  "background": "transparent",
141
151
  "color": "var(--boring-feedback-muted)",
142
- "font-size": "1.25rem",
152
+ "font-size": "var(--text-xl, 1.25rem)",
143
153
  "line-height": "1",
144
154
  "cursor": "pointer"
145
155
  },
@@ -154,7 +164,7 @@
154
164
  "text-overflow": "ellipsis",
155
165
  "white-space": "nowrap",
156
166
  "color": "var(--boring-feedback-muted)",
157
- "font-size": "0.8125rem"
167
+ "font-size": "var(--text-sm, 0.875rem)"
158
168
  },
159
169
  "[data-boring=\"feedback\"] .boring-feedback-keys": {
160
170
  "opacity": "0.8"
@@ -164,13 +174,13 @@
164
174
  "flex-direction": "column",
165
175
  "gap": "0.75rem",
166
176
  "padding": "1rem",
167
- "border": "1px solid var(--boring-feedback-border)",
177
+ "border": "var(--border-width, 1px) solid var(--boring-feedback-border)",
168
178
  "border-radius": "var(--boring-feedback-radius)",
169
179
  "background": "var(--boring-feedback-surface)"
170
180
  },
171
181
  "[data-boring=\"feedback\"].boring-feedback-sheet": {
172
182
  "z-index": "2147483000",
173
- "box-shadow": "0 16px 48px rgb(0 0 0 / 18%)",
183
+ "box-shadow": "var(--boring-feedback-shadow)",
174
184
  "max-height": "calc(100dvh - 24px)",
175
185
  "overflow": "auto"
176
186
  },
@@ -190,36 +200,37 @@
190
200
  },
191
201
  "[data-boring=\"feedback\"] h2": {
192
202
  "margin": "0",
193
- "font-size": "1rem",
194
- "font-weight": "650"
203
+ "font-family": "var(--font-display, inherit)",
204
+ "font-size": "var(--text-base, 1rem)",
205
+ "font-weight": "var(--font-weight-semibold, 600)"
195
206
  },
196
207
  "[data-boring=\"feedback\"] h3": {
197
208
  "margin": "0 0 0.25rem",
198
- "font-size": "0.875rem"
209
+ "font-size": "var(--text-sm, 0.875rem)"
199
210
  },
200
211
  "[data-boring=\"feedback\"] .boring-feedback-targets": {
201
212
  "margin": "0",
202
213
  "padding": "0",
203
214
  "list-style": "none",
204
215
  "color": "var(--boring-feedback-muted)",
205
- "font-size": "0.8125rem"
216
+ "font-size": "var(--text-sm, 0.875rem)"
206
217
  },
207
218
  "[data-boring=\"feedback\"] .boring-feedback-field": {
208
219
  "display": "grid",
209
220
  "gap": "0.375rem",
210
- "font-weight": "600",
211
- "font-size": "0.8125rem"
221
+ "font-weight": "var(--font-weight-semibold, 600)",
222
+ "font-size": "var(--text-sm, 0.875rem)"
212
223
  },
213
224
  "[data-boring=\"feedback\"] textarea": {
214
225
  "width": "100%",
215
226
  "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)",
227
+ "border": "var(--border-width, 1px) solid var(--input)",
228
+ "border-radius": "var(--boring-feedback-radius-md)",
229
+ "background": "var(--background)",
219
230
  "color": "inherit",
220
231
  "font": "inherit",
221
- "font-size": "16px",
222
- "font-weight": "400",
232
+ "font-size": "var(--text-base, 1rem)",
233
+ "font-weight": "var(--font-weight-normal, 400)",
223
234
  "resize": "vertical"
224
235
  },
225
236
  "[data-boring=\"feedback\"] .boring-feedback-actions": {
@@ -231,31 +242,33 @@
231
242
  "margin": "0",
232
243
  "min-height": "1.25em",
233
244
  "color": "var(--boring-feedback-muted)",
234
- "font-size": "0.8125rem"
245
+ "font-size": "var(--text-sm, 0.875rem)"
235
246
  },
236
247
  "[data-boring=\"feedback\"] .boring-feedback-warning": {
237
248
  "margin": "0",
238
- "color": "var(--destructive, #b91c1c)",
239
- "font-size": "0.8125rem"
249
+ "color": "var(--destructive)",
250
+ "font-size": "var(--text-sm, 0.875rem)"
240
251
  },
241
252
  "[data-boring=\"feedback\"] .boring-feedback-notice": {
242
253
  "margin": "0",
243
254
  "padding": "0.5rem 0.625rem",
244
- "border-radius": "calc(var(--boring-feedback-radius) - 2px)",
255
+ "border-radius": "var(--boring-feedback-radius-md)",
245
256
  "background": "color-mix(in srgb, var(--boring-feedback-reveal) 14%, transparent)",
246
- "font-size": "0.8125rem"
257
+ "font-size": "var(--text-sm, 0.875rem)"
247
258
  },
248
259
  "[data-boring=\"feedback\"] .boring-feedback-copied pre, [data-boring=\"feedback\"] .boring-feedback-manual": {
249
260
  "max-height": "12rem",
250
261
  "overflow": "auto",
251
262
  "margin": "0.375rem 0 0",
252
- "font": "12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace",
263
+ "font-family": "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace)",
264
+ "font-size": "var(--text-xs, 0.75rem)",
265
+ "line-height": "var(--leading-snug, 1.375)",
253
266
  "white-space": "pre-wrap"
254
267
  },
255
268
  "[data-boring=\"feedback\"] .boring-feedback-copied summary": {
256
269
  "cursor": "pointer",
257
270
  "color": "var(--boring-feedback-muted)",
258
- "font-size": "0.8125rem"
271
+ "font-size": "var(--text-sm, 0.875rem)"
259
272
  },
260
273
  "[data-boring=\"feedback\"].boring-feedback-list ul": {
261
274
  "display": "flex",
@@ -271,7 +284,7 @@
271
284
  "width": "100%",
272
285
  "padding": "0.5rem 0.625rem",
273
286
  "border": "0",
274
- "border-radius": "calc(var(--boring-feedback-radius) - 2px)",
287
+ "border-radius": "var(--boring-feedback-radius-md)",
275
288
  "background": "transparent",
276
289
  "color": "inherit",
277
290
  "font": "inherit",
@@ -279,18 +292,18 @@
279
292
  "cursor": "pointer"
280
293
  },
281
294
  "[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)"
295
+ "background": "var(--accent)"
283
296
  },
284
297
  "[data-boring=\"feedback\"] .boring-feedback-title": {
285
- "font-weight": "600"
298
+ "font-weight": "var(--font-weight-semibold, 600)"
286
299
  },
287
300
  "[data-boring=\"feedback\"] .boring-feedback-meta": {
288
301
  "margin": "0",
289
302
  "color": "var(--boring-feedback-muted)",
290
- "font-size": "0.8125rem"
303
+ "font-size": "var(--text-sm, 0.875rem)"
291
304
  },
292
305
  "[data-boring=\"feedback\"] [data-status=\"addressed\"]": {
293
- "color": "#15803d"
306
+ "color": "var(--success, var(--primary))"
294
307
  },
295
308
  "[data-boring=\"feedback\"] .boring-feedback-empty": {
296
309
  "margin": "0",
@@ -314,7 +327,7 @@
314
327
  "gap": "0.5rem"
315
328
  },
316
329
  "[data-boring=\"feedback\"] .boring-feedback-anchors code": {
317
- "font-size": "0.75rem",
330
+ "font-size": "var(--text-xs, 0.75rem)",
318
331
  "color": "var(--boring-feedback-muted)"
319
332
  },
320
333
  "[data-boring=\"feedback\"] .boring-feedback-candidates": {
@@ -339,10 +352,10 @@
339
352
  "max-width": "calc(100vw - 24px)",
340
353
  "margin": "0 auto",
341
354
  "padding": "0.375rem 0.5rem 0.375rem 0.875rem",
342
- "border": "1px solid var(--boring-feedback-border)",
355
+ "border": "var(--border-width, 1px) solid var(--boring-feedback-border)",
343
356
  "border-radius": "999px",
344
357
  "background": "var(--boring-feedback-surface)",
345
- "box-shadow": "0 10px 32px rgb(0 0 0 / 16%)"
358
+ "box-shadow": "var(--boring-feedback-shadow)"
346
359
  },
347
360
  "[data-boring=\"feedback\"].boring-feedback-bar[data-using=\"true\"]": {
348
361
  "border-color": "var(--boring-feedback-reveal)"
@@ -353,7 +366,7 @@
353
366
  "max-width": "none",
354
367
  "margin": "0 0.625rem 0.625rem",
355
368
  "flex-wrap": "wrap",
356
- "border-radius": "0.75rem",
369
+ "border-radius": "var(--radius-xl, calc(var(--radius, 0.625rem) * 1.4))",
357
370
  "box-shadow": "none"
358
371
  },
359
372
  "[data-boring=\"feedback\"].boring-feedback-bar > *": {
@@ -366,9 +379,10 @@
366
379
  },
367
380
  "[data-boring=\"feedback\"] .boring-feedback-bar-title": {
368
381
  "display": "inline-flex",
382
+ "font-family": "var(--font-display, inherit)",
369
383
  "align-items": "center",
370
384
  "gap": "0.375rem",
371
- "font-weight": "650"
385
+ "font-weight": "var(--font-weight-semibold, 600)"
372
386
  },
373
387
  "[data-boring=\"feedback\"] .boring-feedback-dot": {
374
388
  "width": "0.5rem",
@@ -381,15 +395,15 @@
381
395
  },
382
396
  "[data-boring=\"feedback\"] .boring-feedback-bar-meta": {
383
397
  "color": "var(--boring-feedback-muted)",
384
- "font-size": "0.8125rem"
398
+ "font-size": "var(--text-sm, 0.875rem)"
385
399
  },
386
400
  "[data-boring=\"feedback\"] .boring-feedback-alt": {
387
401
  "color": "var(--boring-feedback-muted)",
388
- "font-size": "0.75rem"
402
+ "font-size": "var(--text-xs, 0.75rem)"
389
403
  },
390
404
  "[data-boring=\"feedback\"] .boring-feedback-icon[aria-pressed=\"true\"]": {
391
- "background": "color-mix(in srgb, #dc2626 16%, transparent)",
392
- "color": "#dc2626"
405
+ "background": "color-mix(in srgb, var(--destructive) 16%, transparent)",
406
+ "color": "var(--destructive)"
393
407
  },
394
408
  "[data-boring=\"feedback\"] .boring-feedback-icon:disabled": {
395
409
  "opacity": "0.45",
@@ -401,16 +415,16 @@
401
415
  "gap": "0.375rem",
402
416
  "padding": "0.125rem 0.5rem",
403
417
  "border-radius": "999px",
404
- "background": "color-mix(in srgb, #dc2626 12%, transparent)",
405
- "color": "#b91c1c",
406
- "font-size": "0.75rem",
407
- "font-weight": "650"
418
+ "background": "color-mix(in srgb, var(--destructive) 12%, transparent)",
419
+ "color": "var(--destructive)",
420
+ "font-size": "var(--text-xs, 0.75rem)",
421
+ "font-weight": "var(--font-weight-semibold, 600)"
408
422
  },
409
423
  "[data-boring=\"feedback\"] .boring-feedback-rec": {
410
424
  "width": "0.5rem",
411
425
  "height": "0.5rem",
412
426
  "border-radius": "50%",
413
- "background": "#dc2626"
427
+ "background": "var(--destructive)"
414
428
  },
415
429
  "[data-boring=\"feedback\"].boring-feedback-bar[data-docked=\"true\"] > .boring-feedback-hint": {
416
430
  "flex": "1 1 10rem",
@@ -421,12 +435,12 @@
421
435
  "min-height": "2rem",
422
436
  "padding": "0 0.625rem",
423
437
  "gap": "0.3125rem",
424
- "font-size": "0.8125rem"
438
+ "font-size": "var(--text-sm, 0.875rem)"
425
439
  },
426
440
  "[data-boring=\"feedback\"] .boring-feedback-record[aria-pressed=\"true\"]": {
427
441
  "border-color": "transparent",
428
- "background": "#dc2626",
429
- "color": "#ffffff"
442
+ "background": "var(--destructive)",
443
+ "color": "white"
430
444
  },
431
445
  "[data-boring=\"feedback\"] .boring-feedback-stop": {
432
446
  "width": "0.625rem",
@@ -438,13 +452,13 @@
438
452
  "font-variant-numeric": "tabular-nums"
439
453
  },
440
454
  "[data-boring=\"feedback\"].boring-feedback-bar[data-recording=\"true\"]": {
441
- "border-color": "color-mix(in srgb, #dc2626 55%, var(--boring-feedback-border))"
455
+ "border-color": "color-mix(in srgb, var(--destructive) 55%, var(--boring-feedback-border))"
442
456
  },
443
457
  "[data-boring=\"feedback\"] .boring-feedback-voice-problem": {
444
458
  "flex": "1 1 100%",
445
459
  "margin": "0",
446
- "color": "var(--destructive, #b91c1c)",
447
- "font-size": "0.8125rem",
460
+ "color": "var(--destructive)",
461
+ "font-size": "var(--text-sm, 0.875rem)",
448
462
  "white-space": "normal"
449
463
  },
450
464
  "[data-boring=\"feedback\"] .boring-feedback-element": {
@@ -461,8 +475,8 @@
461
475
  "min-width": "2rem",
462
476
  "overflow": "hidden",
463
477
  "text-overflow": "ellipsis",
464
- "font-size": "0.8125rem",
465
- "font-weight": "600"
478
+ "font-size": "var(--text-sm, 0.875rem)",
479
+ "font-weight": "var(--font-weight-semibold, 600)"
466
480
  },
467
481
  "[data-boring=\"feedback\"] .boring-feedback-element-file": {
468
482
  "flex": "0 100 auto",
@@ -470,26 +484,28 @@
470
484
  "overflow": "hidden",
471
485
  "text-overflow": "ellipsis",
472
486
  "color": "var(--boring-feedback-muted)",
473
- "font": "0.6875rem/1.4 ui-monospace, SFMono-Regular, Menlo, monospace"
487
+ "font-family": "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace)",
488
+ "font-size": "var(--text-xs, 0.75rem)",
489
+ "line-height": "var(--leading-snug, 1.375)"
474
490
  },
475
491
  "[data-boring=\"feedback\"] .boring-feedback-badge": {
476
492
  "flex": "none",
477
493
  "padding": "0 0.375rem",
478
494
  "border-radius": "999px",
479
- "background": "var(--muted, #f3f4f6)",
495
+ "background": "var(--muted)",
480
496
  "color": "var(--boring-feedback-muted)",
481
- "font-size": "0.6875rem",
482
- "line-height": "1.25rem"
497
+ "font-size": "var(--text-xs, 0.75rem)",
498
+ "line-height": "calc(var(--spacing, 0.25rem) * 5)"
483
499
  },
484
500
  "[data-boring=\"feedback\"].boring-feedback-bubble": {
485
501
  "z-index": "2147483001",
486
502
  "display": "grid",
487
503
  "gap": "0.375rem",
488
504
  "padding": "0.5rem",
489
- "border": "1px solid var(--boring-feedback-border)",
505
+ "border": "var(--border-width, 1px) solid var(--boring-feedback-border)",
490
506
  "border-radius": "var(--boring-feedback-radius)",
491
507
  "background": "var(--boring-feedback-surface)",
492
- "box-shadow": "0 12px 32px rgb(0 0 0 / 18%)"
508
+ "box-shadow": "var(--boring-feedback-shadow)"
493
509
  },
494
510
  "[data-boring=\"feedback\"] .boring-feedback-bubble-head, [data-boring=\"feedback\"] .boring-feedback-note-head": {
495
511
  "display": "flex",
@@ -504,11 +520,11 @@
504
520
  "text-overflow": "ellipsis",
505
521
  "white-space": "nowrap",
506
522
  "color": "var(--boring-feedback-muted)",
507
- "font-size": "0.75rem"
523
+ "font-size": "var(--text-xs, 0.75rem)"
508
524
  },
509
525
  "[data-boring=\"feedback\"] .boring-feedback-pin": {
510
526
  "color": "var(--boring-feedback-accent)",
511
- "font-weight": "700"
527
+ "font-weight": "var(--font-weight-bold, 700)"
512
528
  },
513
529
  "[data-boring=\"feedback\"].boring-feedback-chip": {
514
530
  "position": "relative",
@@ -518,10 +534,10 @@
518
534
  "gap": "0.125rem",
519
535
  "max-width": "100%",
520
536
  "padding": "0.125rem 0.125rem 0.125rem 0.625rem",
521
- "border": "1px solid var(--boring-feedback-border)",
537
+ "border": "var(--border-width, 1px) solid var(--boring-feedback-border)",
522
538
  "border-radius": "999px",
523
539
  "background": "color-mix(in srgb, var(--boring-feedback-accent) 8%, var(--boring-feedback-surface))",
524
- "font-size": "0.8125rem"
540
+ "font-size": "var(--text-sm, 0.875rem)"
525
541
  },
526
542
  "[data-boring=\"feedback\"] .boring-feedback-chip-open": {
527
543
  "min-width": "0",
@@ -530,19 +546,19 @@
530
546
  "background": "transparent",
531
547
  "color": "inherit",
532
548
  "font": "inherit",
533
- "font-weight": "600",
549
+ "font-weight": "var(--font-weight-semibold, 600)",
534
550
  "text-align": "left",
535
551
  "cursor": "pointer"
536
552
  },
537
553
  "[data-boring=\"feedback\"] .boring-feedback-chip-route": {
538
554
  "color": "var(--boring-feedback-muted)",
539
- "font-family": "ui-monospace, SFMono-Regular, Menlo, monospace",
540
- "font-weight": "500"
555
+ "font-family": "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace)",
556
+ "font-weight": "var(--font-weight-medium, 500)"
541
557
  },
542
558
  "[data-boring=\"feedback\"] .boring-feedback-chip-remove": {
543
559
  "width": "1.5rem",
544
560
  "height": "1.5rem",
545
- "font-size": "1rem"
561
+ "font-size": "var(--text-base, 1rem)"
546
562
  },
547
563
  "[data-boring=\"feedback\"].boring-feedback-review": {
548
564
  "z-index": "2147483000",
@@ -552,10 +568,10 @@
552
568
  "max-height": "min(28rem, calc(100dvh - 24px))",
553
569
  "overflow": "auto",
554
570
  "padding": "0.875rem",
555
- "border": "1px solid var(--boring-feedback-border)",
571
+ "border": "var(--border-width, 1px) solid var(--boring-feedback-border)",
556
572
  "border-radius": "var(--boring-feedback-radius)",
557
573
  "background": "var(--boring-feedback-surface)",
558
- "box-shadow": "0 16px 48px rgb(0 0 0 / 18%)",
574
+ "box-shadow": "var(--boring-feedback-shadow)",
559
575
  "text-align": "left"
560
576
  },
561
577
  "[data-boring=\"feedback\"] .boring-feedback-notes": {
@@ -570,10 +586,65 @@
570
586
  "display": "grid",
571
587
  "gap": "0.25rem",
572
588
  "padding": "0.375rem",
573
- "border-radius": "calc(var(--boring-feedback-radius) - 2px)"
589
+ "border-radius": "var(--boring-feedback-radius-md)"
574
590
  },
575
591
  "[data-boring=\"feedback\"] .boring-feedback-notes > li:hover, [data-boring=\"feedback\"] .boring-feedback-notes > li:focus-within": {
576
- "background": "var(--accent, #f3f4f6)"
592
+ "background": "var(--accent)"
593
+ },
594
+ "[data-boring=\"feedback\"].boring-feedback-pointbar": {
595
+ "position": "fixed",
596
+ "left": "8px",
597
+ "right": "8px",
598
+ "z-index": "2147483000",
599
+ "display": "flex",
600
+ "flex-direction": "column",
601
+ "gap": "0.375rem",
602
+ "max-width": "32rem",
603
+ "margin": "0 auto",
604
+ "padding": "0.5rem",
605
+ "border": "var(--border-width, 1px) solid var(--boring-feedback-border)",
606
+ "border-radius": "var(--boring-feedback-radius)",
607
+ "background": "var(--boring-feedback-surface)",
608
+ "box-shadow": "var(--boring-feedback-shadow)"
609
+ },
610
+ "[data-boring=\"feedback\"].boring-feedback-pointbar[data-using=\"true\"]": {
611
+ "border-color": "var(--boring-feedback-reveal)"
612
+ },
613
+ "[data-boring=\"feedback\"].boring-feedback-pointbar[data-using=\"true\"] .boring-feedback-dot": {
614
+ "background": "var(--boring-feedback-reveal)"
615
+ },
616
+ "[data-boring=\"feedback\"] .boring-feedback-point-line": {
617
+ "display": "flex",
618
+ "align-items": "center",
619
+ "gap": "0.5rem",
620
+ "min-width": "0",
621
+ "padding": "0 0.25rem"
622
+ },
623
+ "[data-boring=\"feedback\"] .boring-feedback-point-line .boring-feedback-dot": {
624
+ "flex": "none"
625
+ },
626
+ "[data-boring=\"feedback\"] .boring-feedback-point-hint": {
627
+ "flex": "1",
628
+ "min-width": "0",
629
+ "font-weight": "var(--font-weight-semibold, 600)"
630
+ },
631
+ "[data-boring=\"feedback\"] .boring-feedback-point-count": {
632
+ "flex": "none",
633
+ "color": "var(--boring-feedback-muted)",
634
+ "font-size": "var(--text-sm, 0.875rem)",
635
+ "font-variant-numeric": "tabular-nums"
636
+ },
637
+ "[data-boring=\"feedback\"] .boring-feedback-point-actions": {
638
+ "display": "flex",
639
+ "align-items": "center",
640
+ "gap": "0.375rem"
641
+ },
642
+ "[data-boring=\"feedback\"] .boring-feedback-point-actions .boring-feedback-button": {
643
+ "min-height": "2.75rem",
644
+ "min-width": "2.75rem"
645
+ },
646
+ "[data-boring=\"feedback\"] .boring-feedback-point-spacer": {
647
+ "flex": "1"
577
648
  },
578
649
  "@media (max-width: 640px), (pointer: coarse)": {
579
650
  "[data-boring=\"feedback\"] .boring-feedback-button": {
@@ -612,10 +683,10 @@
612
683
  "width": "min(40rem, calc(100vw - 24px))",
613
684
  "margin": "0 auto",
614
685
  "padding": "0.75rem 0.875rem",
615
- "border": "2px solid var(--boring-feedback-reveal)",
686
+ "border": "calc(var(--border-width, 1px) * 2) solid var(--boring-feedback-reveal)",
616
687
  "border-radius": "var(--boring-feedback-radius)",
617
688
  "background": "var(--boring-feedback-surface)",
618
- "box-shadow": "0 12px 36px rgb(0 0 0 / 18%)"
689
+ "box-shadow": "var(--boring-feedback-shadow)"
619
690
  },
620
691
  "[data-boring=\"feedback\"] .boring-feedback-preview-head": {
621
692
  "display": "flex",
@@ -626,19 +697,19 @@
626
697
  "[data-boring=\"feedback\"] .boring-feedback-preview-title": {
627
698
  "padding": "0.125rem 0.5rem",
628
699
  "border-radius": "999px",
629
- "background": "var(--boring-feedback-reveal)",
630
- "color": "#ffffff",
631
- "font-size": "0.8125rem",
632
- "letter-spacing": "0.02em",
700
+ "background": "var(--primary)",
701
+ "color": "var(--primary-foreground)",
702
+ "font-size": "var(--text-sm, 0.875rem)",
703
+ "letter-spacing": "var(--tracking-wide, 0.025em)",
633
704
  "text-transform": "uppercase"
634
705
  },
635
706
  "[data-boring=\"feedback\"] .boring-feedback-preview-notice": {
636
- "font-weight": "600"
707
+ "font-weight": "var(--font-weight-semibold, 600)"
637
708
  },
638
709
  "[data-boring=\"feedback\"] .boring-feedback-preview-status": {
639
710
  "margin-left": "auto",
640
711
  "color": "var(--boring-feedback-muted)",
641
- "font-size": "0.8125rem"
712
+ "font-size": "var(--text-sm, 0.875rem)"
642
713
  },
643
714
  "[data-boring=\"feedback\"] .boring-feedback-preview-summary": {
644
715
  "margin": "0"
@@ -647,15 +718,15 @@
647
718
  "margin": "0",
648
719
  "padding-left": "1.125rem",
649
720
  "color": "var(--boring-feedback-muted)",
650
- "font-size": "0.8125rem"
721
+ "font-size": "var(--text-sm, 0.875rem)"
651
722
  },
652
723
  "[data-boring=\"feedback\"] .boring-feedback-preview-changes code": {
653
- "font-size": "0.75rem"
724
+ "font-size": "var(--text-xs, 0.75rem)"
654
725
  },
655
726
  "[data-boring=\"feedback\"] .boring-feedback-preview-error": {
656
727
  "margin": "0",
657
- "color": "#b91c1c",
658
- "font-size": "0.8125rem"
728
+ "color": "var(--destructive)",
729
+ "font-size": "var(--text-sm, 0.875rem)"
659
730
  },
660
731
  "[data-boring=\"feedback\"] .boring-feedback-preview-actions": {
661
732
  "display": "flex",
@@ -667,8 +738,8 @@
667
738
  "min-width": "0",
668
739
  "min-height": "2.25rem",
669
740
  "padding": "0 0.75rem",
670
- "border": "1px solid var(--boring-feedback-border)",
671
- "border-radius": "calc(var(--boring-feedback-radius) - 2px)",
741
+ "border": "var(--border-width, 1px) solid var(--boring-feedback-border)",
742
+ "border-radius": "var(--boring-feedback-radius-md)",
672
743
  "background": "var(--boring-feedback-surface)",
673
744
  "color": "var(--boring-feedback-text)",
674
745
  "font": "inherit"
@@ -681,7 +752,12 @@
681
752
  "[data-boring=\"feedback\"] .boring-feedback-bubble-actions .boring-feedback-button": {
682
753
  "min-height": "2rem",
683
754
  "padding": "0 0.625rem",
684
- "font-size": "0.8125rem"
755
+ "font-size": "var(--text-sm, 0.875rem)"
756
+ },
757
+ "@media (pointer: coarse)": {
758
+ "[data-boring=\"feedback\"] .boring-feedback-bubble-actions .boring-feedback-button": {
759
+ "min-height": "2.75rem"
760
+ }
685
761
  }
686
762
  },
687
763
  "type": "registry:component"