@hachej/boring-ui-kit 0.3.2 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -18
- package/THIRD_PARTY_NOTICES.md +35 -0
- package/bin/boring-ui-blocks.mjs +286 -0
- package/dist/canvas-editor-native.d.ts +4 -1
- package/dist/canvas-editor-native.js +8 -6
- package/dist/canvas-editor.js +1 -1
- package/dist/canvas-proposals.d.ts +3 -1
- package/dist/canvas-proposals.js +4 -3
- package/dist/experience-document-viewer.d.ts +3 -0
- package/dist/experience-document-viewer.js +6 -5
- package/dist/experience.d.ts +3 -0
- package/dist/experience.js +2 -1
- package/dist/file-tree-view.d.ts +76 -3
- package/dist/file-tree-view.js +172 -39
- package/dist/file-tree.d.ts +3 -0
- package/dist/file-tree.js +17 -2
- package/dist/html-viewer.d.ts +4 -0
- package/dist/html-viewer.js +5 -3
- package/dist/markdown-autosave.d.ts +13 -0
- package/dist/markdown-autosave.js +94 -0
- package/dist/markdown-editor.d.ts +16 -0
- package/dist/markdown-editor.js +81 -17
- package/dist/markdown-extensions.js +9 -1
- package/dist/markdown-proposal-diff.d.ts +2 -1
- package/dist/markdown-proposal-diff.js +3 -2
- package/dist/markdown-safety.js +3 -1
- package/dist/native-chat.js +1 -1
- package/dist/remote-chat.d.ts +18 -0
- package/dist/remote-chat.js +149 -7
- package/dist/text-buffer.js +3 -2
- package/dist/text-draft-controls.d.ts +3 -1
- package/dist/text-draft-controls.js +3 -2
- package/dist/viewer-components.d.ts +14 -0
- package/dist/viewer-components.js +1 -0
- package/package.json +8 -4
- package/r/app-shell.json +39 -20
- package/r/button.json +19 -11
- package/r/dock.json +2 -2
- package/r/documents.json +17 -14
- package/r/feedback-preview.json +8 -2
- package/r/feedback.json +46 -325
- package/r/file-tree.json +14 -6
- package/r/html-viewer.json +8 -42
- package/r/markdown-editor.json +38 -87
- package/r/pi-ambient.json +15 -8
- package/r/pi-app.json +30 -24
- package/r/pi-chat.json +101 -70
- package/r/pi-workspace.json +7 -5
- package/r/provider-setup.json +9 -4
- package/r/registry.json +471 -893
- package/r/task-list-viewer.json +11 -21
- package/r/theme.json +23 -4
- package/r/version-history.json +24 -0
- package/r/viewers.json +139 -320
- package/styles/file-tree.css +41 -42
- package/dist/file-tree-shadcn.d.ts +0 -8
- package/dist/file-tree-shadcn.js +0 -14
package/r/feedback.json
CHANGED
|
@@ -4,51 +4,57 @@
|
|
|
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.
|
|
8
|
-
"@hachej/boring-ui-kit@^0.
|
|
9
|
-
"@hachej/boring-files@^0.
|
|
7
|
+
"@hachej/boring-feedback@^0.4.1",
|
|
8
|
+
"@hachej/boring-ui-kit@^0.4.1",
|
|
9
|
+
"@hachej/boring-files@^0.4.1",
|
|
10
10
|
"react@^19.0.0",
|
|
11
11
|
"react-dom@^19.0.0",
|
|
12
12
|
"lucide-react@^1.51.0"
|
|
13
13
|
],
|
|
14
14
|
"registryDependencies": [
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/pi-chat.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/utils.json",
|
|
17
|
+
"button",
|
|
18
|
+
"toggle",
|
|
19
|
+
"badge",
|
|
20
|
+
"item",
|
|
21
|
+
"card",
|
|
22
|
+
"textarea"
|
|
17
23
|
],
|
|
18
24
|
"files": [
|
|
19
25
|
{
|
|
20
26
|
"path": "registry/feedback/feedback-session.tsx",
|
|
21
|
-
"content": "// The feedback session's look (FEEDBACK.md, \"UX\"): the note pin beside a picked element (NoteBubble: a text field with a mic inside it),\n// the slim bar and the chip with its review (optional, for hosts with no chat). Everything they show and do is the headless\n// `createFeedbackSession` from `@hachej/boring-feedback/ui` (hover labels, pins, steps, the draft and Send come from there, through the\n// privacy policy); this file is how it looks. `useComposerFeedback` adapts a session to the chat composer's optional `feedback` prop\n// (registry/pi-chat): ONE annotate toggle and a count chip, structurally, so neither item imports the other.\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';\nimport { createPortal } from 'react-dom';\nimport { readableElement } from '@hachej/boring-feedback/format';\nimport { createVoiceCapture, type VoiceCapture } from '@hachej/boring-feedback/page';\nimport { pinMark, type FeedbackSession, type FeedbackSessionSnapshot, type SessionNote } from '@hachej/boring-feedback/ui';\nimport { 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}\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 }: FeedbackBarProps) {\n const state = useExternal(session);\n const recording = state.voice.kind === 'recording';\n const time = useRecordingTime(recording && (state.phase === 'active' || state.phase === 'processing'));\n if (state.phase !== 'active' && state.phase !== 'processing') return null;\n const processing = state.phase === 'processing';\n const pins = emptyPinCount(state);\n const voiceProblem = state.voice.kind === 'refused' ? state.voice : undefined;\n return <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) { event.stopPropagation(); session.cancelBubble(); } }}>\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 && <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\ntype PinVoice = 'idle' | 'starting' | 'recording' | 'transcribing';\n\n/**\n * The mic inside the pin's field: record, stop, and the transcript is appended to what is typed. A refused microphone, nothing heard or a\n * rejected transcription is said under the field and never touches the typed text. One capture (`createVoiceCapture`) per pin.\n */\nfunction PinMic({ session, transcribe, words }: { readonly session: FeedbackSession; readonly transcribe: PinTranscribe; readonly words: FeedbackLabels }) {\n const [phase, setPhase] = useState<PinVoice>('idle');\n const [problem, setProblem] = useState<string | undefined>(undefined);\n const capture = useRef<VoiceCapture | null>(null);\n const run = useRef(0);\n const work = useRef<AbortController | null>(null);\n capture.current ??= createVoiceCapture();\n // A closed pin releases the microphone and drops a transcription still in flight.\n useEffect(() => () => { run.current += 1; capture.current?.cancel(); work.current?.abort(); }, []);\n const start = async (): Promise<void> => {\n const attempt = ++run.current;\n setProblem(undefined); setPhase('starting');\n // Called straight 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') { setPhase('idle'); setProblem(result.reason); return; }\n setPhase('recording');\n };\n const finish = async (): Promise<void> => {\n const attempt = run.current;\n setPhase('transcribing');\n const recording = await capture.current!.stop().catch(() => null);\n if (attempt !== run.current) return;\n if (!recording) { setPhase('idle'); setProblem(words.voiceNothing); return; }\n const abort = new AbortController();\n work.current = abort;\n try {\n const spoken = (await transcribe(recording.audio, abort.signal)).trim();\n if (attempt !== run.current) return;\n if (!spoken) { setProblem(words.voiceNothing); return; }\n // Appended to what is typed NOW (the person may have kept typing), never replacing it.\n const typed = session.getSnapshot().bubble?.text ?? '';\n session.setBubbleText(`${typed}${typed === '' || /\\s$/.test(typed) ? '' : ' '}${spoken}`);\n } catch { if (attempt === run.current) setProblem(words.voiceFailed); }\n finally { if (work.current === abort) work.current = null; if (attempt === run.current) setPhase('idle'); }\n };\n const recording = phase === 'recording';\n return <>\n <button type=\"button\" className=\"boring-feedback-pin-mic\" data-testid=\"feedback-bubble-mic\" data-state={phase} aria-pressed={recording}\n aria-label={recording ? words.voiceStop : words.voiceRecord} title={recording ? words.voiceStop : words.voiceRecord} disabled={phase === 'starting' || phase === 'transcribing'}\n onClick={() => { if (phase === 'idle') void start(); else if (recording) void finish(); }}>\n {phase === 'transcribing' ? <span role=\"status\" aria-label={words.voiceTranscribing}>…</span>\n : recording ? <span className=\"boring-feedback-stop\" aria-hidden=\"true\" /> : <MicIcon />}\n </button>\n {problem && <span className=\"boring-feedback-voice-problem\" role=\"alert\" data-testid=\"feedback-bubble-voice-problem\">{problem}</span>}\n </>;\n}\n\n/** Turns a recording into text (`signal` aborts when the pin closes); a rejection keeps what was typed. */\nexport type PinTranscribe = (audio: Blob, signal: AbortSignal) => Promise<string>;\n\nconst touchScreen = (): boolean => globalThis.matchMedia?.('(pointer: coarse)').matches === true;\n\n/**\n * The pin beside a picked element: a text field with a mic inside it, \"Add note\" (Enter) and Cancel (Esc). Enter saves, Shift-Enter for a\n * new line, Esc discards the pin. On desktop one line says that holding Alt passes clicks to the app. In point mode it keeps clear of\n * the PointBar. `transcribe` turns the mic's recording into text; without it there is no mic.\n */\nexport function NoteBubble({ session, labels, transcribe }: { readonly session: FeedbackSession; readonly labels?: Partial<FeedbackLabels>; readonly transcribe?: PinTranscribe }) {\n const state = useExternal(session);\n const words = withLabels(labels);\n const bubble = state.bubble;\n const field = useRef<HTMLTextAreaElement>(null);\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n const [, redraw] = useState(0);\n const [announce, setAnnounce] = useState('');\n const box = useRef<HTMLDivElement>(null);\n // The bubble's natural height (its content plus borders), not the height it is clamped to, so it grows back when there is room.\n const size = (): number | undefined => (box.current ? box.current.scrollHeight + box.current.offsetHeight - box.current.clientHeight : 0) || undefined;\n useLayoutEffect(() => { if (bubble) setPlace(bubblePlace(bubble.element, size())); }, [bubble?.noteId, bubble?.element]);\n // Once drawn, place it again with its real height (taller with 44px touch targets and wrapped buttons).\n useLayoutEffect(() => {\n if (!bubble || !place || !box.current) return;\n const next = bubblePlace(bubble.element, size());\n if (next.top !== place.top || next.left !== place.left) setPlace(next);\n });\n useEffect(() => { if (bubble) field.current?.focus({ preventScroll: true }); }, [bubble?.noteId]);\n useEffect(() => {\n if (!bubble) return;\n const view = bubble.element.ownerDocument.defaultView;\n const follow = () => { setPlace(bubblePlace(bubble.element, size())); redraw(value => value + 1); };\n view?.addEventListener('scroll', follow, { capture: true, passive: true });\n view?.addEventListener('resize', follow);\n return () => { view?.removeEventListener('scroll', follow, { capture: true }); view?.removeEventListener('resize', follow); };\n }, [bubble?.noteId, bubble?.element]);\n // A polite live region outlives the bubble, so the cancellation is heard even though the dialog is gone.\n const cancel = (): void => { session.cancelSelection(); setAnnounce(words.selectionCancelled); };\n const live = <span data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"status\" aria-live=\"polite\" className=\"boring-feedback-sr\" data-testid=\"feedback-bubble-announce\"\n style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap' }}>{announce}</span>;\n if (!bubble || state.phase !== 'active') return state.phase === 'active' ? live : null;\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => {\n if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing && event.target === field.current) { event.preventDefault(); session.saveBubble(); }\n else if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); session.cancelBubble(); }\n };\n return <>{live}<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 <span className=\"boring-feedback-pin-field\">\n <textarea ref={field} data-testid=\"feedback-bubble-input\" rows={2} placeholder={words.bubblePlaceholder} aria-label={words.bubblePlaceholder} value={bubble.text}\n onChange={event => session.setBubbleText(event.target.value)} />\n {transcribe && <PinMic key={bubble.noteId} session={session} transcribe={transcribe} words={words} />}\n </span>\n <span className=\"boring-feedback-bubble-actions\">\n <button 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-variant=\"primary\" data-testid=\"feedback-bubble-save\" onClick={() => session.saveBubble()}>{words.add}</button>\n </span>\n {!touchScreen() && <span className=\"boring-feedback-keys\" data-testid=\"feedback-bubble-hint\">{words.altHint}</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 annotate: () => void;\n readonly active: boolean;\n readonly count: number;\n readonly clear: () => void;\n readonly attach: (text: string) => Promise<{ readonly kind: 'ok'; readonly text: string } | { readonly kind: 'refused'; readonly reason: string }>;\n readonly sent: () => void;\n}\n\n/** A Markdown fence longer than any backtick run in the text. */\nfunction fenced(text: string): string {\n const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map(match => match[0].length));\n const fence = '`'.repeat(Math.max(3, longest + 1));\n return `${fence}markdown\\n${text.endsWith('\\n') ? text : `${text}\\n`}${fence}`;\n}\n\n/** Touch or a compact screen: the panel covers the app and there is no hover, so annotating is point mode (use the app, then point). */\nfunction pointsInstead(): boolean {\n return globalThis.matchMedia?.('(pointer: coarse)').matches === true || (globalThis.innerWidth ?? 1024) < 768;\n}\n\n/**\n * Starts (or resumes) annotating in either mode, with Esc as a way out. The session swallows Esc on the page (it only closes a pin), so\n * this listens on the window's capture phase BEFORE the session starts, and ends the mode (Done: the notes wait for Send) when no pin is\n * open. From the review, `resume` keeps the notes, anchors and save guards: a second round adds to the same draft.\n */\nfunction startAnnotating(session: FeedbackSession): void {\n const view = globalThis.window;\n const onKey = (event: globalThis.KeyboardEvent): void => {\n // Esc that ends annotating is spent: a host layer around the composer (a panel that closes on Esc) must not also react to it.\n if (event.key === 'Escape' && !session.getSnapshot().bubble) { event.preventDefault(); event.stopPropagation(); void session.done(); }\n };\n view?.addEventListener('keydown', onKey, { capture: true });\n session.resume(pointsInstead() ? { mode: 'point' } : {});\n const stop = session.subscribe(() => {\n if (session.getSnapshot().phase === 'active') return;\n view?.removeEventListener('keydown', onKey, { capture: true });\n stop();\n });\n}\n\n/**\n * The composer's `feedback` prop for a session: ONE annotate toggle (targeting with a hover highlight; point mode on touch and compact\n * screens, and again after Esc to add more), a count of the notes taken, and Send attaching them: a saved report as its `@` mention (the host's mention resolver inlines\n * the stored report with the person's access), otherwise the rendered report inline in a fenced block. Pair it with `NoteBubble`.\n */\nexport function useComposerFeedback(session: FeedbackSession | undefined): ComposerFeedbackProp | undefined {\n const state = useSyncState(session);\n if (!session || !state) return undefined;\n const open = state.phase === 'active' || state.phase === 'processing';\n return {\n annotate: () => {\n const phase = session.getSnapshot().phase;\n if (phase === 'active') void session.done();\n else if (phase === 'idle' || phase === 'review') startAnnotating(session);\n },\n active: open,\n // A note being typed in the pin counts: Send saves it.\n count: noteCount(state) + (state.bubble?.fresh && state.bubble.text.trim() ? 1 : 0),\n clear: () => { session.highlight(undefined); session.discard(); },\n attach: async text => {\n // Send while annotating ends it first: the pin being typed is kept.\n if (session.getSnapshot().phase === 'active') await session.done();\n const attached = await session.attach();\n if (attached.kind === 'refused') return attached;\n const body = attached.kind === 'mention' ? `@${attached.path}` : fenced(attached.text);\n return { kind: 'ok', text: text.trim() ? `${text.trimEnd()}\\n\\n${body}` : body };\n },\n sent: () => session.sent(),\n };\n}\n\nconst NO_STATE = { getSnapshot: () => undefined, subscribe: () => () => {} };\nfunction useSyncState(session: FeedbackSession | undefined): FeedbackSessionSnapshot | undefined {\n return useExternal<FeedbackSessionSnapshot | undefined>(session ?? NO_STATE);\n}\n",
|
|
27
|
+
"content": "// The feedback session's look (FEEDBACK.md, \"UX\"): the note pin beside a picked element (NoteBubble: a text field with a mic inside it),\n// the slim bar and the chip with its review (optional, for hosts with no chat). Everything they show and do is the headless\n// `createFeedbackSession` from `@hachej/boring-feedback/ui` (hover labels, pins, steps, the draft and Send come from there, through the\n// privacy policy); this file is how it looks. `useComposerFeedback` adapts a session to the chat composer's optional `feedback` prop\n// (registry/pi-chat): ONE annotate toggle and a count chip, structurally, so neither item imports the other.\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';\nimport { createPortal } from 'react-dom';\nimport { MicIcon, SquareIcon, XIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Badge } from '@/components/ui/badge';\nimport { Card } from '@/components/ui/card';\nimport { Textarea } from '@/components/ui/textarea';\nimport { readableElement } from '@hachej/boring-feedback/format';\nimport { createVoiceCapture, type VoiceCapture } from '@hachej/boring-feedback/page';\nimport { pinMark, type FeedbackSession, type FeedbackSessionSnapshot, type SessionNote } from '@hachej/boring-feedback/ui';\nimport { useExternal } from './use-external';\nimport { withLabels, type FeedbackLabels } from './feedback-labels';\n\nconst plural = (count: number, one: string, many: string): string => `${count} ${count === 1 ? one : many}`;\n/** Notes the chip and the bar count: every note that says something (not a pin still being typed), plus the general note. */\nexport function noteCount(state: FeedbackSessionSnapshot): number {\n return state.notes.filter(note => note.text.trim()).length + (state.general.trim() ? 1 : 0);\n}\n\nexport interface FeedbackBarProps {\n readonly session: FeedbackSession;\n readonly className?: string;\n /** Docked under the chat composer (the composer's `feedback.bar`) instead of floating at the top of the page. */\n readonly docked?: boolean;\n}\n\n/** Pins dropped while recording that have no note yet (nothing transcribed for them, or transcription failed). */\nexport function emptyPinCount(state: FeedbackSessionSnapshot): number {\n return state.notes.filter(note => note.recorded && !note.text.trim()).length;\n}\n\n/** `mm:ss` since `since` (ms), for the recording timer. */\nexport function elapsed(ms: number): string {\n const seconds = Math.max(0, Math.floor(ms / 1000));\n return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;\n}\n\n/** The running time of a recording, from when the bar saw it start; 00:00 when not recording. */\nfunction useRecordingTime(recording: boolean): string {\n const [since, setSince] = useState<number | undefined>(undefined);\n const [, tick] = useState(0);\n useEffect(() => {\n if (!recording) { setSince(undefined); return; }\n setSince(Date.now());\n const timer = setInterval(() => tick(value => value + 1), 500);\n return () => clearInterval(timer);\n }, [recording]);\n return elapsed(since === undefined ? 0 : Date.now() - since);\n}\n\nfunction barHint(state: FeedbackSessionSnapshot): string {\n if (state.phase === 'processing') return state.transcribing ? 'Transcribing…' : 'Preparing your feedback…';\n if (state.problem) return state.problem;\n if (state.voice.kind === 'starting') return 'Allow the microphone if your browser asks…';\n if (state.voice.kind === 'recording') return state.using ? 'Recording — using the app' : 'Recording — point and talk';\n return state.using ? 'Using the app: your clicks are recorded as steps' : 'Click what you want to comment on';\n}\n\n/**\n * Feedback mode's bar: `● Feedback · N notes · 🎙 Record · 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 }: FeedbackBarProps) {\n const state = useExternal(session);\n const recording = state.voice.kind === 'recording';\n const time = useRecordingTime(recording && (state.phase === 'active' || state.phase === 'processing'));\n if (state.phase !== 'active' && state.phase !== 'processing') return null;\n const processing = state.phase === 'processing';\n const pins = emptyPinCount(state);\n const voiceProblem = state.voice.kind === 'refused' ? state.voice : undefined;\n return <Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"toolbar\" aria-label=\"Feedback mode\" data-testid=\"feedback-bar\" data-docked={docked ? 'true' : 'false'}\n data-using={state.using ? 'true' : 'false'} data-recording={recording ? 'true' : 'false'} data-transcribing={state.transcribing ? 'true' : 'false'}\n className={['boring-feedback-bar', className].filter(Boolean).join(' ')}\n onKeyDown={event => { if (event.key === 'Escape' && state.bubble) { event.stopPropagation(); session.cancelBubble(); } }}>\n <span className=\"boring-feedback-bar-title\">Feedback</span>\n <span className=\"boring-feedback-bar-meta\" data-testid=\"feedback-bar-count\">{plural(noteCount(state), 'note', 'notes')}{pins ? ` · ${plural(pins, 'pin', 'pins')}` : ''}</span>\n {state.voiceAvailable && <Toggle data-testid=\"feedback-voice\" pressed={recording}\n disabled={processing || state.voice.kind === 'starting' || state.voice.kind === 'stopping'} title={recording ? 'Stop recording' : 'Record your voice while you point: each click drops a pin, what you say becomes its note'}\n onPressedChange={() => { void session.toggleVoice(); }}>\n {recording ? <><SquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" />Stop</> : <><MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{state.voice.kind === 'starting' ? 'Starting…' : state.voice.kind === 'stopping' ? 'Stopping…' : 'Record'}</>}\n </Toggle>}\n {recording && <Badge variant=\"destructive\" role=\"status\" data-testid=\"feedback-recording\">\n <span className=\"boring-feedback-timer\" data-testid=\"feedback-recording-time\">{time}</span></Badge>}\n <span className=\"boring-feedback-hint\" role=\"status\" aria-live=\"polite\" data-testid=\"feedback-bar-hint\">{barHint(state)}</span>\n {voiceProblem && <span className=\"boring-feedback-voice-problem\" role=\"alert\" data-testid=\"feedback-voice-problem\" data-code={voiceProblem.code}>{voiceProblem.reason}</span>}\n {!processing && <span className=\"boring-feedback-keys boring-feedback-alt\">Hold ⌥ to use the app</span>}\n {!processing && <Toggle data-testid=\"feedback-use-app\" pressed={state.useToggle} onPressedChange={pressed => session.setUseToggle(pressed)}>Use app</Toggle>}\n <Button variant=\"default\" data-testid=\"feedback-done\" disabled={processing} onClick={() => { void session.done(); }}>Done</Button>\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"feedback-discard\" aria-label=\"Discard feedback\" title=\"Discard feedback\" disabled={processing} onClick={() => session.discard()}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </Card>;\n}\n\nconst BUBBLE_WIDTH = 280;\n/** Pixels at the bottom of the viewport taken by 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\ntype PinVoice = 'idle' | 'starting' | 'recording' | 'transcribing';\n\n/**\n * The mic inside the pin's field: record, stop, and the transcript is appended to what is typed. A refused microphone, nothing heard or a\n * rejected transcription is said under the field and never touches the typed text. One capture (`createVoiceCapture`) per pin.\n */\nfunction PinMic({ session, transcribe, words }: { readonly session: FeedbackSession; readonly transcribe: PinTranscribe; readonly words: FeedbackLabels }) {\n const [phase, setPhase] = useState<PinVoice>('idle');\n const [problem, setProblem] = useState<string | undefined>(undefined);\n const capture = useRef<VoiceCapture | null>(null);\n const run = useRef(0);\n const work = useRef<AbortController | null>(null);\n capture.current ??= createVoiceCapture();\n // A closed pin releases the microphone and drops a transcription still in flight.\n useEffect(() => () => { run.current += 1; capture.current?.cancel(); work.current?.abort(); }, []);\n const start = async (): Promise<void> => {\n const attempt = ++run.current;\n setProblem(undefined); setPhase('starting');\n // Called straight 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') { setPhase('idle'); setProblem(result.reason); return; }\n setPhase('recording');\n };\n const finish = async (): Promise<void> => {\n const attempt = run.current;\n setPhase('transcribing');\n const recording = await capture.current!.stop().catch(() => null);\n if (attempt !== run.current) return;\n if (!recording) { setPhase('idle'); setProblem(words.voiceNothing); return; }\n const abort = new AbortController();\n work.current = abort;\n try {\n const spoken = (await transcribe(recording.audio, abort.signal)).trim();\n if (attempt !== run.current) return;\n if (!spoken) { setProblem(words.voiceNothing); return; }\n // Appended to what is typed NOW (the person may have kept typing), never replacing it.\n const typed = session.getSnapshot().bubble?.text ?? '';\n session.setBubbleText(`${typed}${typed === '' || /\\s$/.test(typed) ? '' : ' '}${spoken}`);\n } catch { if (attempt === run.current) setProblem(words.voiceFailed); }\n finally { if (work.current === abort) work.current = null; if (attempt === run.current) setPhase('idle'); }\n };\n const recording = phase === 'recording';\n return <>\n <Toggle className=\"boring-feedback-pin-mic\" data-testid=\"feedback-bubble-mic\" data-state={phase} pressed={recording}\n aria-label={recording ? words.voiceStop : words.voiceRecord} title={recording ? words.voiceStop : words.voiceRecord} disabled={phase === 'starting' || phase === 'transcribing'}\n onPressedChange={() => { if (phase === 'idle') void start(); else if (recording) void finish(); }}>\n {phase === 'transcribing' ? <span role=\"status\" aria-label={words.voiceTranscribing}>…</span>\n : recording ? <SquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Toggle>\n {problem && <span className=\"boring-feedback-voice-problem\" role=\"alert\" data-testid=\"feedback-bubble-voice-problem\">{problem}</span>}\n </>;\n}\n\n/** Turns a recording into text (`signal` aborts when the pin closes); a rejection keeps what was typed. */\nexport type PinTranscribe = (audio: Blob, signal: AbortSignal) => Promise<string>;\n\nconst touchScreen = (): boolean => globalThis.matchMedia?.('(pointer: coarse)').matches === true;\n\n/**\n * The pin beside a picked element: a text field with a mic inside it, \"Add note\" (Enter) and Cancel (Esc). Enter saves, Shift-Enter for a\n * new line, Esc discards the pin. On desktop one line says that holding Alt passes clicks to the app. In point mode it keeps clear of\n * the PointBar. `transcribe` turns the mic's recording into text; without it there is no mic.\n */\nexport function NoteBubble({ session, labels, transcribe }: { readonly session: FeedbackSession; readonly labels?: Partial<FeedbackLabels>; readonly transcribe?: PinTranscribe }) {\n const state = useExternal(session);\n const words = withLabels(labels);\n const bubble = state.bubble;\n const field = useRef<HTMLTextAreaElement>(null);\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n const [, redraw] = useState(0);\n const [announce, setAnnounce] = useState('');\n const box = useRef<HTMLDivElement>(null);\n // The bubble's natural height (its content plus borders), not the height it is clamped to, so it grows back when there is room.\n const size = (): number | undefined => (box.current ? box.current.scrollHeight + box.current.offsetHeight - box.current.clientHeight : 0) || undefined;\n useLayoutEffect(() => { if (bubble) setPlace(bubblePlace(bubble.element, size())); }, [bubble?.noteId, bubble?.element]);\n // Once drawn, place it again with its real height (taller with 44px touch targets and wrapped buttons).\n useLayoutEffect(() => {\n if (!bubble || !place || !box.current) return;\n const next = bubblePlace(bubble.element, size());\n if (next.top !== place.top || next.left !== place.left) setPlace(next);\n });\n useEffect(() => { if (bubble) field.current?.focus({ preventScroll: true }); }, [bubble?.noteId]);\n useEffect(() => {\n if (!bubble) return;\n const view = bubble.element.ownerDocument.defaultView;\n const follow = () => { setPlace(bubblePlace(bubble.element, size())); redraw(value => value + 1); };\n view?.addEventListener('scroll', follow, { capture: true, passive: true });\n view?.addEventListener('resize', follow);\n return () => { view?.removeEventListener('scroll', follow, { capture: true }); view?.removeEventListener('resize', follow); };\n }, [bubble?.noteId, bubble?.element]);\n // A polite live region outlives the bubble, so the cancellation is heard even though the dialog is gone.\n const cancel = (): void => { session.cancelSelection(); setAnnounce(words.selectionCancelled); };\n const live = <span data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"status\" aria-live=\"polite\" className=\"boring-feedback-sr\" data-testid=\"feedback-bubble-announce\"\n style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap' }}>{announce}</span>;\n if (!bubble || state.phase !== 'active') return state.phase === 'active' ? live : null;\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => {\n if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing && event.target === field.current) { event.preventDefault(); session.saveBubble(); }\n else if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); session.cancelBubble(); }\n };\n return <>{live}<Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label={`Note ${bubble.number}`} data-testid=\"feedback-bubble\" data-note={bubble.number}\n ref={box} className=\"boring-feedback-bubble\" style={place} onKeyDown={onKeyDown}>\n <span className=\"boring-feedback-bubble-head\"><span className=\"boring-feedback-pin\" aria-hidden=\"true\">{pinMark(bubble.number)}</span>\n <span className=\"boring-feedback-bubble-label\" data-testid=\"feedback-bubble-label\">{bubble.label}</span></span>\n <span className=\"boring-feedback-pin-field\">\n <Textarea ref={field} data-testid=\"feedback-bubble-input\" rows={2} placeholder={words.bubblePlaceholder} aria-label={words.bubblePlaceholder} value={bubble.text}\n onChange={event => session.setBubbleText(event.target.value)} />\n {transcribe && <PinMic key={bubble.noteId} session={session} transcribe={transcribe} words={words} />}\n </span>\n <span className=\"boring-feedback-bubble-actions\">\n <Button variant=\"ghost\" data-testid=\"feedback-cancel-selection\" onClick={cancel}>{words.cancelSelection}</Button>\n <Button variant=\"default\" data-testid=\"feedback-bubble-save\" onClick={() => session.saveBubble()}>{words.add}</Button>\n </span>\n {!touchScreen() && <span className=\"boring-feedback-keys\" data-testid=\"feedback-bubble-hint\">{words.altHint}</span>}\n </Card></>;\n}\n\nexport interface FeedbackChipProps {\n readonly session: FeedbackSession;\n /** Opens the review on first render (journeys). */\n readonly defaultOpen?: boolean;\n /** Offer Copy report in the review: the way to use feedback with no chat and no store. */\n readonly copy?: boolean;\n}\n\n/** The one composer chip after Done: `💬 Feedback · 3 notes · /settings/:section`. Click opens the review; × discards. */\nexport function FeedbackChip({ session, defaultOpen = false, copy = false }: FeedbackChipProps) {\n const state = useExternal(session);\n const [open, setOpen] = useState(defaultOpen);\n const chip = useRef<HTMLSpanElement>(null);\n if (state.phase !== 'review') return null;\n const count = noteCount(state);\n const pins = emptyPinCount(state);\n const refusal = state.send.kind !== 'idle' && state.send.kind !== 'sending' && state.send.kind !== 'saved' ? state.send.reason : undefined;\n return <span ref={chip} data-boring=\"feedback\" data-feedback-ignore=\"\" data-testid=\"feedback-chip\" data-send={state.send.kind} className=\"boring-feedback-chip\">\n <Button variant=\"outline\" className=\"min-w-0 max-w-full\" data-testid=\"feedback-chip-open\" aria-expanded={open} onClick={() => setOpen(value => !value)}\n title={refusal ?? 'Review your feedback before sending'}>\n <span aria-hidden=\"true\">💬</span> Feedback · {plural(count, 'note', 'notes')}{pins ? ` · ${plural(pins, 'pin', 'pins')} to describe` : ''} · <span className=\"boring-feedback-chip-route\">{state.route}</span>\n </Button>\n <Button variant=\"ghost\" size=\"icon-sm\" data-testid=\"feedback-chip-discard\" aria-label=\"Discard feedback\" title=\"Discard feedback\" onClick={() => { session.highlight(undefined); session.discard(); }}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n {state.problem && <span className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-chip-voice\">{state.problem}</span>}\n {refusal && <span className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-chip-problem\">{refusal}</span>}\n {/* In the page's body: the chip may sit in a transformed chat bar, where a fixed panel would not be placed against the viewport. */}\n {open && createPortal(<FeedbackReview session={session} anchor={chip.current} copy={copy} onClose={() => setOpen(false)} />, chip.current?.ownerDocument.body ?? document.body)}\n </span>;\n}\n\nfunction reviewPlace(anchor: Element | null): CSSProperties | undefined {\n if (!anchor) return undefined;\n const view = anchor.ownerDocument.defaultView;\n const width = view?.innerWidth ?? 1024, height = view?.innerHeight ?? 768;\n const rect = anchor.getBoundingClientRect();\n const panel = Math.min(416, width - 24);\n return { position: 'fixed', left: Math.min(Math.max(12, rect.left), width - panel - 12), bottom: Math.max(12, height - rect.top + 8), width: panel };\n}\n\n/** The compact review: notes in order, editable and removable; hovering a note highlights its element in the page, or says it is not here. */\nexport function FeedbackReview({ session, anchor = null, copy = false, onClose }: { readonly session: FeedbackSession; readonly anchor?: Element | null; readonly copy?: boolean; readonly onClose: () => void }) {\n const state = useExternal(session);\n const [where, setWhere] = useState<Readonly<Record<number, 'shown' | 'not-on-page'>>>({});\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n useLayoutEffect(() => { setPlace(reviewPlace(anchor)); }, [anchor]);\n useEffect(() => () => { session.highlight(undefined); }, [session]);\n const hover = (id: number | undefined): void => {\n const result = session.highlight(id);\n if (id !== undefined && result !== 'none') setWhere(value => ({ ...value, [id]: result }));\n };\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => { if (event.key === 'Escape') { event.stopPropagation(); onClose(); } };\n return <Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label=\"Review feedback\" data-testid=\"feedback-review\" className=\"boring-feedback-review\"\n style={place} onKeyDown={onKeyDown} onMouseLeave={() => hover(undefined)}>\n <header className=\"boring-feedback-sheet-header\"><h2>Feedback · {state.route}</h2>\n <Button variant=\"ghost\" size=\"icon\" aria-label=\"Close review\" title=\"Close review\" data-testid=\"feedback-review-close\" onClick={onClose}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button></header>\n {state.problem && <p className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-review-problem\">{state.problem}</p>}\n <ol className=\"boring-feedback-notes\">\n {state.notes.map(note => <li key={note.id} data-testid=\"feedback-review-note\" data-note={note.number} data-where={where[note.id]}\n onMouseEnter={() => hover(note.id)} onFocus={() => hover(note.id)}>\n <span className=\"boring-feedback-note-head\"><span className=\"boring-feedback-pin\" aria-hidden=\"true\">{pinMark(note.number)}</span>\n <ElementName note={note} />\n {note.from === 'voice' && <Badge variant=\"secondary\" data-testid=\"feedback-review-voice\">voice</Badge>}\n {where[note.id] === 'not-on-page' && <Badge variant=\"secondary\" data-testid=\"feedback-review-elsewhere\">not on this page</Badge>}\n <Button variant=\"ghost\" size=\"icon\" aria-label={`Remove note ${note.number}`} title={`Remove note ${note.number}`} data-testid=\"feedback-review-remove\" onClick={() => session.removeNote(note.id)}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button></span>\n <Textarea rows={2} aria-label={`Note ${note.number}`} data-testid=\"feedback-review-text\" value={note.text} placeholder={note.recorded ? 'Nothing was said here: type a note, or remove the pin' : undefined}\n onChange={event => session.setNoteText(note.id, event.target.value)} />\n </li>)}\n </ol>\n <label className=\"boring-feedback-field\"><span>General note</span>\n <Textarea rows={2} data-testid=\"feedback-review-general\" placeholder=\"Anything else? (optional)\" value={state.general} onChange={event => session.setGeneral(event.target.value)} /></label>\n <p className=\"boring-feedback-meta\">{state.stepCount > 1 ? `${plural(state.stepCount, 'step', 'steps')} recorded between the notes. ` : ''}{copy ? 'Copy the report and paste it anywhere.' : 'Sent with your next message.'}</p>\n {copy && <div className=\"boring-feedback-actions\">\n <Button variant=\"default\" data-testid=\"feedback-copy\" onClick={() => { void session.copy(); }}>Copy report</Button></div>}\n {copy && <p role=\"status\" aria-live=\"polite\" className=\"boring-feedback-status\" data-testid=\"feedback-status\" data-copy={state.copy.kind}>\n {state.copy.kind === 'copied' ? 'Report copied. Paste it anywhere.' : state.copy.kind === 'manual' ? 'The clipboard is not available here: select the report below and copy it.' : state.copy.kind === 'refused' ? state.copy.reason : ''}</p>}\n {copy && state.copy.kind === 'manual' && <Textarea readOnly className=\"boring-feedback-manual\" data-testid=\"feedback-manual-copy\" value={state.copy.text} rows={6} onFocus={event => event.currentTarget.select()} />}\n {copy && state.copy.kind === 'copied' && <details className=\"boring-feedback-copied\"><summary>Copied report</summary><pre data-testid=\"feedback-copy-text\">{state.copy.text}</pre></details>}\n </Card>;\n}\n\n/**\n * A note's element as one calm line: the readable name (semibold) and the file basename (muted, monospace), the full path in the\n * tooltip. The same reading as the pi-chat feedback card's element line (`readableElement` from `@hachej/boring-feedback/format`).\n */\nexport function ElementName({ note }: { readonly note: SessionNote }) {\n const readable = note.fallback ? readableElement(note.fallback, note.source) : { name: note.label };\n return <span className=\"boring-feedback-element\" data-testid=\"feedback-review-element\" title={'path' in readable && readable.path ? readable.path : note.label}>\n <span className=\"boring-feedback-element-name\" data-testid=\"feedback-review-name\">{readable.name}</span>\n {'file' in readable && readable.file && <span className=\"boring-feedback-element-file\" data-testid=\"feedback-review-file\">{readable.file}</span>}\n </span>;\n}\n\n/** What `useComposerFeedback` returns: the structural shape of pi-chat's `ComposerFeedback`. */\nexport interface ComposerFeedbackProp {\n readonly annotate: () => void;\n readonly active: boolean;\n readonly count: number;\n readonly clear: () => void;\n readonly attach: (text: string) => Promise<{ readonly kind: 'ok'; readonly text: string } | { readonly kind: 'refused'; readonly reason: string }>;\n readonly sent: () => void;\n}\n\n/** A Markdown fence longer than any backtick run in the text. */\nfunction fenced(text: string): string {\n const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map(match => match[0].length));\n const fence = '`'.repeat(Math.max(3, longest + 1));\n return `${fence}markdown\\n${text.endsWith('\\n') ? text : `${text}\\n`}${fence}`;\n}\n\n/** Touch or a compact screen: the panel covers the app and there is no hover, so annotating is point mode (use the app, then point). */\nfunction pointsInstead(): boolean {\n return globalThis.matchMedia?.('(pointer: coarse)').matches === true || (globalThis.innerWidth ?? 1024) < 768;\n}\n\n/**\n * Starts (or resumes) annotating in either mode, with Esc as a way out. The session swallows Esc on the page (it only closes a pin), so\n * this listens on the window's capture phase BEFORE the session starts, and ends the mode (Done: the notes wait for Send) when no pin is\n * open. From the review, `resume` keeps the notes, anchors and save guards: a second round adds to the same draft.\n */\nfunction startAnnotating(session: FeedbackSession): void {\n const view = globalThis.window;\n const onKey = (event: globalThis.KeyboardEvent): void => {\n // Esc that ends annotating is spent: a host layer around the composer (a panel that closes on Esc) must not also react to it.\n if (event.key === 'Escape' && !session.getSnapshot().bubble) { event.preventDefault(); event.stopPropagation(); void session.done(); }\n };\n view?.addEventListener('keydown', onKey, { capture: true });\n session.resume(pointsInstead() ? { mode: 'point' } : {});\n const stop = session.subscribe(() => {\n if (session.getSnapshot().phase === 'active') return;\n view?.removeEventListener('keydown', onKey, { capture: true });\n stop();\n });\n}\n\n/**\n * The composer's `feedback` prop for a session: ONE annotate toggle (targeting with a hover highlight; point mode on touch and compact\n * screens, and again after Esc to add more), a count of the notes taken, and Send attaching them: a saved report as its `@` mention (the host's mention resolver inlines\n * the stored report with the person's access), otherwise the rendered report inline in a fenced block. Pair it with `NoteBubble`.\n */\nexport function useComposerFeedback(session: FeedbackSession | undefined): ComposerFeedbackProp | undefined {\n const state = useSyncState(session);\n if (!session || !state) return undefined;\n const open = state.phase === 'active' || state.phase === 'processing';\n return {\n annotate: () => {\n const phase = session.getSnapshot().phase;\n if (phase === 'active') void session.done();\n else if (phase === 'idle' || phase === 'review') startAnnotating(session);\n },\n active: open,\n // A note being typed in the pin counts: Send saves it.\n count: noteCount(state) + (state.bubble?.fresh && state.bubble.text.trim() ? 1 : 0),\n clear: () => { session.highlight(undefined); session.discard(); },\n attach: async text => {\n // Send while annotating ends it first: the pin being typed is kept.\n if (session.getSnapshot().phase === 'active') await session.done();\n const attached = await session.attach();\n if (attached.kind === 'refused') return attached;\n const body = attached.kind === 'mention' ? `@${attached.path}` : fenced(attached.text);\n return { kind: 'ok', text: text.trim() ? `${text.trimEnd()}\\n\\n${body}` : body };\n },\n sent: () => session.sent(),\n };\n}\n\nconst NO_STATE = { getSnapshot: () => undefined, subscribe: () => () => {} };\nfunction useSyncState(session: FeedbackSession | undefined): FeedbackSessionSnapshot | undefined {\n return useExternal<FeedbackSessionSnapshot | undefined>(session ?? NO_STATE);\n}\n",
|
|
22
28
|
"type": "registry:component",
|
|
23
29
|
"target": "components/feedback/feedback-session.tsx"
|
|
24
30
|
},
|
|
25
31
|
{
|
|
26
32
|
"path": "registry/feedback/point-button.tsx",
|
|
27
|
-
"content": "// PointButton: enters pick mode on the application root. Picking itself (events captured and stopped, keys, touch, labels) is\n// `createPicker` from `@hachej/boring-feedback/page`; this file is the button and the hint people see while they point.\nimport { useEffect, useRef, useState, type ReactNode } from 'react';\nimport { createPicker, type Picker, type PickerOverlay, type PickerState, type PrivacyPolicy } from '@hachej/boring-feedback/page';\n\nexport interface PointButtonProps {\n /** The application root; only elements inside it can be picked. */\n readonly root: () => Element | null;\n readonly policy: PrivacyPolicy;\n /** The page's shared overlay (`usePickerOverlay`). */\n readonly overlay: PickerOverlay | undefined;\n /** Called with the pinned elements; the overlay still shows them. */\n readonly onPinned: (elements: readonly Element[]) => void;\n readonly onCancel?: () => void;\n /** Shift-click pins several elements. Default true. */\n readonly multiple?: boolean;\n readonly children?: ReactNode;\n readonly className?: string;\n}\n\nexport function PointButton({ root, policy, overlay, onPinned, onCancel, multiple = true, children = 'Point', className }: PointButtonProps) {\n const picker = useRef<Picker | null>(null);\n const [state, setState] = useState<PickerState | null>(null);\n const callbacks = useRef({ onPinned, onCancel });\n callbacks.current = { onPinned, onCancel };\n useEffect(() => () => { picker.current?.dispose(); picker.current = null; }, []);\n\n const picking = state?.mode === 'picking';\n const toggle = (): void => {\n if (picking) { picker.current?.cancel(); return; }\n const element = root();\n if (!element || !overlay) return;\n picker.current?.dispose();\n const created = createPicker({ root: element, policy, overlay, multiple, onChange: setState });\n picker.current = created;\n void created.start().then(result => {\n if (result.kind === 'pinned') callbacks.current.onPinned(result.elements);\n else callbacks.current.onCancel?.();\n });\n };\n\n return <span data-boring=\"feedback\" data-feedback-ignore=\"\" className={['boring-feedback-point', className].filter(Boolean).join(' ')}>\n <
|
|
33
|
+
"content": "// PointButton: enters pick mode on the application root. Picking itself (events captured and stopped, keys, touch, labels) is\n// `createPicker` from `@hachej/boring-feedback/page`; this file is the button and the hint people see while they point.\nimport { useEffect, useRef, useState, type ReactNode } from 'react';\nimport { createPicker, type Picker, type PickerOverlay, type PickerState, type PrivacyPolicy } from '@hachej/boring-feedback/page';\nimport { Toggle } from '@/components/ui/toggle';\n\nexport interface PointButtonProps {\n /** The application root; only elements inside it can be picked. */\n readonly root: () => Element | null;\n readonly policy: PrivacyPolicy;\n /** The page's shared overlay (`usePickerOverlay`). */\n readonly overlay: PickerOverlay | undefined;\n /** Called with the pinned elements; the overlay still shows them. */\n readonly onPinned: (elements: readonly Element[]) => void;\n readonly onCancel?: () => void;\n /** Shift-click pins several elements. Default true. */\n readonly multiple?: boolean;\n readonly children?: ReactNode;\n readonly className?: string;\n}\n\nexport function PointButton({ root, policy, overlay, onPinned, onCancel, multiple = true, children = 'Point', className }: PointButtonProps) {\n const picker = useRef<Picker | null>(null);\n const [state, setState] = useState<PickerState | null>(null);\n const callbacks = useRef({ onPinned, onCancel });\n callbacks.current = { onPinned, onCancel };\n useEffect(() => () => { picker.current?.dispose(); picker.current = null; }, []);\n\n const picking = state?.mode === 'picking';\n const toggle = (): void => {\n if (picking) { picker.current?.cancel(); return; }\n const element = root();\n if (!element || !overlay) return;\n picker.current?.dispose();\n const created = createPicker({ root: element, policy, overlay, multiple, onChange: setState });\n picker.current = created;\n void created.start().then(result => {\n if (result.kind === 'pinned') callbacks.current.onPinned(result.elements);\n else callbacks.current.onCancel?.();\n });\n };\n\n return <span data-boring=\"feedback\" data-feedback-ignore=\"\" className={['boring-feedback-point', className].filter(Boolean).join(' ')}>\n <Toggle data-testid=\"feedback-point\" pressed={picking} disabled={!overlay} onPressedChange={toggle}>\n {picking ? 'Cancel' : children}\n </Toggle>\n {picking && <span role=\"status\" aria-live=\"polite\" data-testid=\"feedback-point-hint\" className=\"boring-feedback-hint\">\n {state?.label ?? state?.refusal ?? 'Point at an element'}\n <span className=\"boring-feedback-keys\"> · Click to pin{multiple ? ', Shift-click for several' : ''} · ↑ parent ↓ child · Esc</span>\n </span>}\n </span>;\n}\n",
|
|
28
34
|
"type": "registry:component",
|
|
29
35
|
"target": "components/feedback/point-button.tsx"
|
|
30
36
|
},
|
|
31
37
|
{
|
|
32
38
|
"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, over its base styles: fonts, sizes, weights and
|
|
39
|
+
"content": "// PickerOverlay: styles only. The overlay itself (its shadow root, pointer-events: none, and what it draws) is `createOverlay` from\n// `@hachej/boring-feedback/page`; this file is the editable look, passed into that shadow root. Labels come from the privacy policy there, so\n// restyling cannot change what they say. The host keeps one overlay per page and shares it between PointButton and FeedbackReport.\nimport { useEffect, useState } from 'react';\nimport { createOverlay, type PickerOverlay } from '@hachej/boring-feedback/page';\n\n/** CSS added to the overlay's shadow root, over its base styles: fonts, sizes, weights, corners and elevation read the app's\n * tokens only; tones (hover, pinned, reveal, candidate) keep the base styles' borders. Parts: `box`, `label`, `toolbar` (and `box-<tone>`, `label-<tone>` for hover, pinned,\n * reveal and candidate). The colors read the host's `--boring-feedback-*` variables, which inherit through the shadow boundary. */\nexport const pickerOverlayStyles = `\n.box { border-radius: var(--radius-sm, calc(var(--radius, 0.625rem) * 0.6)); }\n.label { border-radius: var(--radius-sm, calc(var(--radius, 0.625rem) * 0.6)); box-shadow: var(--shadow-sm, none); font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); font-size: var(--text-xs, 0.75rem); font-weight: var(--font-weight-medium, 500); line-height: var(--leading-normal, 1.5); }\n.label[data-tone=\"candidate\"] { font-weight: var(--font-weight-bold, 700); }\n.toolbar { border-radius: var(--radius-xl, calc(var(--radius, 0.625rem) * 1.4)); box-shadow: var(--shadow-lg, none); }\n.toolbar button { border-radius: var(--radius-md, calc(var(--radius, 0.625rem) * 0.8)); font-family: var(--font-sans, ui-sans-serif, system-ui, sans-serif); font-size: var(--text-sm, 0.875rem); font-weight: var(--font-weight-semibold, 600); line-height: var(--leading-tight, 1.25); }\n`;\n\n/** One overlay for the page, created after mount and removed on unmount. */\nexport function usePickerOverlay(styles: string = pickerOverlayStyles): PickerOverlay | undefined {\n const [overlay, setOverlay] = useState<PickerOverlay>();\n useEffect(() => {\n const created = createOverlay({ document, styles });\n setOverlay(created);\n return () => { created.dispose(); };\n }, [styles]);\n return overlay;\n}\n",
|
|
34
40
|
"type": "registry:lib",
|
|
35
41
|
"target": "components/feedback/picker-overlay.ts"
|
|
36
42
|
},
|
|
37
43
|
{
|
|
38
44
|
"path": "registry/feedback/annotate-sheet.tsx",
|
|
39
|
-
"content": "// AnnotateSheet: the note, Cancel selection (back to a general note, nothing attached), Copy report and, when the host has a save endpoint, Save. The draft, its anchors and the Save operation id are\n// the headless `createAnnotation` from `@hachej/boring-feedback/ui`; this file is how the sheet looks and what it says.\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';\nimport { protectionNotice, type Annotation, type CopyState, type FeedbackProtection, type SaveState } from '@hachej/boring-feedback/ui';\nimport { useExternal } from './use-external';\nimport { withLabels, type FeedbackLabels } from './feedback-labels';\n\nexport interface AnnotateSheetProps {\n readonly annotation: Annotation;\n readonly onClose: () => void;\n /** The pinned element the sheet opens beside. Without it the sheet docks at the bottom. */\n readonly near?: Element;\n /** The store's declared protection, when the host saves. An unprotected store is stated. */\n readonly protection?: FeedbackProtection;\n readonly title?: string;\n /** Words of the controls (`feedbackLabelsFr` for French); what is left out keeps the English default. */\n readonly labels?: Partial<FeedbackLabels>;\n /**\n * Called after the selection was cancelled (Esc, the Cancel selection button, or clicking the selected element again). The host\n * returns to the picker or to no zone here, typically by closing the sheet. Without it the sheet stays open as a general note, with\n * what was typed kept.\n */\n readonly onCancelSelection?: () => void;\n}\n\nconst copyMessage = (copy: CopyState): string => {\n switch (copy.kind) {\n case 'copied': return 'Report copied. Paste it anywhere.';\n case 'manual': return 'The clipboard is not available here: select the report below and copy it.';\n case 'refused': return copy.reason;\n default: return '';\n }\n};\nconst saveMessage = (save: SaveState): string => {\n switch (save.kind) {\n case 'saving': return 'Saving…';\n case 'saved': return 'Saved.';\n case 'unknown': return `${save.reason} Nothing is lost: Save again uses the same operation, so it cannot be stored twice.`;\n case 'denied': return `Not saved: ${save.reason}`;\n case 'unavailable': return `Not saved: ${save.reason}`;\n case 'conflict': return `Not saved: ${save.reason}`;\n case 'invalid': return save.reason;\n default: return '';\n }\n};\n\nconst SHEET_WIDTH = 360;\nfunction placeNear(element: Element | undefined): CSSProperties | undefined {\n if (!element || !element.isConnected) return undefined;\n const view = element.ownerDocument.defaultView;\n if (!view || view.innerWidth < 640) return undefined;\n const rect = element.getBoundingClientRect();\n const right = rect.right + 12;\n const left = right + SHEET_WIDTH <= view.innerWidth - 12 ? right : Math.max(12, rect.left - SHEET_WIDTH - 12);\n const top = Math.min(Math.max(12, rect.top), Math.max(12, view.innerHeight - 420));\n return { position: 'fixed', top, left, width: SHEET_WIDTH };\n}\n\nexport function AnnotateSheet({ annotation, onClose, near, protection, title, labels, onCancelSelection }: AnnotateSheetProps) {\n const state = useExternal(annotation);\n const words = withLabels(labels);\n const heading = state.selected ? (title ?? words.title) : words.askGeneral;\n const note = useRef<HTMLTextAreaElement>(null);\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n useLayoutEffect(() => { setPlace(placeNear(near)); }, [near]);\n const [cancelled, setCancelled] = useState(false);\n const callbacks = useRef({ onCancelSelection });\n callbacks.current = { onCancelSelection };\n useEffect(() => { note.current?.focus({ preventScroll: true }); }, []);\n // Focus goes back to where it was when the sheet opened (the Point button, after the picker restored it), not to the top of the page.\n useEffect(() => {\n const opener = note.current?.ownerDocument.activeElement;\n const before = opener && opener !== note.current ? opener : undefined;\n return () => { if (before && before.isConnected && typeof (before as HTMLElement).focus === 'function') (before as HTMLElement).focus({ preventScroll: true }); };\n }, []);\n const cancelSelection = (): void => {\n if (!annotation.clearSelection()) return;\n setCancelled(true);\n note.current?.focus({ preventScroll: true });\n callbacks.current.onCancelSelection?.();\n };\n // Clicking the selected element again cancels the selection instead of activating it.\n const selected = state.selected;\n useEffect(() => {\n const view = near?.ownerDocument.defaultView;\n if (!near || !view || !selected) return;\n const onClick = (event: Event): void => {\n const path = event.composedPath();\n if (path.some(item => (item as Element).hasAttribute?.('data-feedback-ignore')) || !path.includes(near)) return;\n event.preventDefault(); event.stopPropagation();\n if (annotation.clearSelection()) { setCancelled(true); note.current?.focus({ preventScroll: true }); callbacks.current.onCancelSelection?.(); }\n };\n view.addEventListener('click', onClick, { capture: true });\n return () => view.removeEventListener('click', onClick, { capture: true });\n }, [near, selected, annotation]);\n const notice = protection ? protectionNotice(protection) : undefined;\n // Esc cancels the selection first (one step back: to no zone); on a general note it closes the sheet.\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => {\n if (event.key !== 'Escape') return;\n event.stopPropagation();\n if (state.selected && state.save.kind !== 'saved' && state.save.kind !== 'saving') cancelSelection(); else onClose();\n };\n const copyText = state.copy.kind === 'copied' || state.copy.kind === 'manual' ? state.copy.text : undefined;\n const saved = state.save.kind === 'saved';\n\n return <
|
|
45
|
+
"content": "// AnnotateSheet: the note, Cancel selection (back to a general note, nothing attached), Copy report and, when the host has a save endpoint, Save. The draft, its anchors and the Save operation id are\n// the headless `createAnnotation` from `@hachej/boring-feedback/ui`; this file is how the sheet looks and what it says.\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';\nimport { protectionNotice, type Annotation, type CopyState, type FeedbackProtection, type SaveState } from '@hachej/boring-feedback/ui';\nimport { Button } from '@/components/ui/button';\nimport { Card } from '@/components/ui/card';\nimport { Textarea } from '@/components/ui/textarea';\nimport { XIcon } from 'lucide-react';\nimport { useExternal } from './use-external';\nimport { withLabels, type FeedbackLabels } from './feedback-labels';\n\nexport interface AnnotateSheetProps {\n readonly annotation: Annotation;\n readonly onClose: () => void;\n /** The pinned element the sheet opens beside. Without it the sheet docks at the bottom. */\n readonly near?: Element;\n /** The store's declared protection, when the host saves. An unprotected store is stated. */\n readonly protection?: FeedbackProtection;\n readonly title?: string;\n /** Words of the controls (`feedbackLabelsFr` for French); what is left out keeps the English default. */\n readonly labels?: Partial<FeedbackLabels>;\n /**\n * Called after the selection was cancelled (Esc, the Cancel selection button, or clicking the selected element again). The host\n * returns to the picker or to no zone here, typically by closing the sheet. Without it the sheet stays open as a general note, with\n * what was typed kept.\n */\n readonly onCancelSelection?: () => void;\n}\n\nconst copyMessage = (copy: CopyState): string => {\n switch (copy.kind) {\n case 'copied': return 'Report copied. Paste it anywhere.';\n case 'manual': return 'The clipboard is not available here: select the report below and copy it.';\n case 'refused': return copy.reason;\n default: return '';\n }\n};\nconst saveMessage = (save: SaveState): string => {\n switch (save.kind) {\n case 'saving': return 'Saving…';\n case 'saved': return 'Saved.';\n case 'unknown': return `${save.reason} Nothing is lost: Save again uses the same operation, so it cannot be stored twice.`;\n case 'denied': return `Not saved: ${save.reason}`;\n case 'unavailable': return `Not saved: ${save.reason}`;\n case 'conflict': return `Not saved: ${save.reason}`;\n case 'invalid': return save.reason;\n default: return '';\n }\n};\n\nconst SHEET_WIDTH = 360;\nfunction placeNear(element: Element | undefined): CSSProperties | undefined {\n if (!element || !element.isConnected) return undefined;\n const view = element.ownerDocument.defaultView;\n if (!view || view.innerWidth < 640) return undefined;\n const rect = element.getBoundingClientRect();\n const right = rect.right + 12;\n const left = right + SHEET_WIDTH <= view.innerWidth - 12 ? right : Math.max(12, rect.left - SHEET_WIDTH - 12);\n const top = Math.min(Math.max(12, rect.top), Math.max(12, view.innerHeight - 420));\n return { position: 'fixed', top, left, width: SHEET_WIDTH };\n}\n\nexport function AnnotateSheet({ annotation, onClose, near, protection, title, labels, onCancelSelection }: AnnotateSheetProps) {\n const state = useExternal(annotation);\n const words = withLabels(labels);\n const heading = state.selected ? (title ?? words.title) : words.askGeneral;\n const note = useRef<HTMLTextAreaElement>(null);\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n useLayoutEffect(() => { setPlace(placeNear(near)); }, [near]);\n const [cancelled, setCancelled] = useState(false);\n const callbacks = useRef({ onCancelSelection });\n callbacks.current = { onCancelSelection };\n useEffect(() => { note.current?.focus({ preventScroll: true }); }, []);\n // Focus goes back to where it was when the sheet opened (the Point button, after the picker restored it), not to the top of the page.\n useEffect(() => {\n const opener = note.current?.ownerDocument.activeElement;\n const before = opener && opener !== note.current ? opener : undefined;\n return () => { if (before && before.isConnected && typeof (before as HTMLElement).focus === 'function') (before as HTMLElement).focus({ preventScroll: true }); };\n }, []);\n const cancelSelection = (): void => {\n if (!annotation.clearSelection()) return;\n setCancelled(true);\n note.current?.focus({ preventScroll: true });\n callbacks.current.onCancelSelection?.();\n };\n // Clicking the selected element again cancels the selection instead of activating it.\n const selected = state.selected;\n useEffect(() => {\n const view = near?.ownerDocument.defaultView;\n if (!near || !view || !selected) return;\n const onClick = (event: Event): void => {\n const path = event.composedPath();\n if (path.some(item => (item as Element).hasAttribute?.('data-feedback-ignore')) || !path.includes(near)) return;\n event.preventDefault(); event.stopPropagation();\n if (annotation.clearSelection()) { setCancelled(true); note.current?.focus({ preventScroll: true }); callbacks.current.onCancelSelection?.(); }\n };\n view.addEventListener('click', onClick, { capture: true });\n return () => view.removeEventListener('click', onClick, { capture: true });\n }, [near, selected, annotation]);\n const notice = protection ? protectionNotice(protection) : undefined;\n // Esc cancels the selection first (one step back: to no zone); on a general note it closes the sheet.\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => {\n if (event.key !== 'Escape') return;\n event.stopPropagation();\n if (state.selected && state.save.kind !== 'saved' && state.save.kind !== 'saving') cancelSelection(); else onClose();\n };\n const copyText = state.copy.kind === 'copied' || state.copy.kind === 'manual' ? state.copy.text : undefined;\n const saved = state.save.kind === 'saved';\n\n return <Card data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label={heading} data-testid=\"feedback-sheet\" data-selected={state.selected ? 'true' : 'false'}\n className=\"boring-feedback-sheet\" data-docked={place ? undefined : 'bottom'} style={place} onKeyDown={onKeyDown}>\n <header className=\"boring-feedback-sheet-header\">\n <h2>{heading}</h2>\n <Button variant=\"ghost\" size=\"icon\" aria-label={words.close} title={words.close} data-testid=\"feedback-close\" onClick={onClose}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </header>\n {state.selected && <ul className=\"boring-feedback-targets\" data-testid=\"feedback-targets\">\n {state.targets.map((target, index) => <li key={index}>{target}</li>)}\n </ul>}\n {state.refused.length > 0 && <p role=\"alert\" className=\"boring-feedback-warning\" data-testid=\"feedback-refused\">\n {state.refused.length === 1 ? 'One element could not be pointed at' : `${state.refused.length} elements could not be pointed at`}: {state.refused.map(item => item.reason).join('; ')}.\n </p>}\n <label className=\"boring-feedback-field\">\n <span>{state.selected ? words.changeField : words.generalField}</span>\n <Textarea ref={note} data-testid=\"feedback-note\" rows={4} value={state.said} readOnly={saved} onChange={event => annotation.setSaid(event.target.value)} />\n </label>\n <div className=\"boring-feedback-actions\">\n {state.selected && state.save.kind !== 'saved' && <Button variant=\"ghost\" data-testid=\"feedback-cancel-selection\" disabled={state.save.kind === 'saving'}\n onClick={cancelSelection}>{words.cancelSelection}</Button>}\n <Button variant=\"ghost\" data-testid=\"feedback-copy\" onClick={() => { void annotation.copy(); }}>{words.copyReport}</Button>\n {state.canSave && <Button variant=\"default\" data-testid=\"feedback-save\" disabled={state.save.kind === 'saving' || saved}\n onClick={() => { void annotation.save(); }}>{state.save.kind === 'unknown' ? words.saveAgain : saved ? words.saved : words.save}</Button>}\n </div>\n <p role=\"status\" aria-live=\"polite\" className=\"boring-feedback-status\" data-testid=\"feedback-status\" data-copy={state.copy.kind} data-save={state.save.kind}>\n {[copyMessage(state.copy), saveMessage(state.save), cancelled && !state.selected ? words.selectionCancelled : ''].filter(Boolean).join(' ')}\n </p>\n {state.copy.kind === 'manual' && copyText !== undefined && <Textarea readOnly className=\"boring-feedback-manual\" data-testid=\"feedback-manual-copy\" value={copyText}\n onFocus={event => event.currentTarget.select()} rows={6} />}\n {state.copy.kind === 'copied' && copyText !== undefined && <details className=\"boring-feedback-copied\"><summary>Copied report</summary>\n <pre data-testid=\"feedback-copy-text\">{copyText}</pre></details>}\n {notice && <p className=\"boring-feedback-notice\" data-testid=\"feedback-protection\">{notice}</p>}\n </Card>;\n}\n",
|
|
40
46
|
"type": "registry:component",
|
|
41
47
|
"target": "components/feedback/annotate-sheet.tsx"
|
|
42
48
|
},
|
|
43
49
|
{
|
|
44
50
|
"path": "registry/feedback/feedback-list.tsx",
|
|
45
|
-
"content": "// FeedbackList: stored feedback, newest first, as the host's authorized list route returns it. Rows are `listRows` from\n// `@hachej/boring-feedback/ui`; an unprotected store is stated above them.\nimport type { FeedbackListItem } from '@hachej/boring-feedback/format';\nimport { listRows, protectionNotice, type FeedbackProtection } from '@hachej/boring-feedback/ui';\n\nexport interface FeedbackListProps {\n readonly items: readonly FeedbackListItem[];\n readonly protection: FeedbackProtection;\n readonly onOpen: (id: string) => void;\n /** The open item, highlighted. */\n readonly activeId?: string;\n /** Why the list could not be loaded (denied, unavailable). */\n readonly problem?: string;\n readonly now?: Date;\n}\n\nexport function FeedbackList({ items, protection, onOpen, activeId, problem, now }: FeedbackListProps) {\n const notice = protectionNotice(protection);\n const rows = listRows(items, now);\n return <
|
|
51
|
+
"content": "// FeedbackList: stored feedback, newest first, as the host's authorized list route returns it. Rows are `listRows` from\n// `@hachej/boring-feedback/ui`; an unprotected store is stated above them.\nimport type { FeedbackListItem } from '@hachej/boring-feedback/format';\nimport { listRows, protectionNotice, type FeedbackProtection } from '@hachej/boring-feedback/ui';\nimport { Button } from '@/components/ui/button';\nimport { Card } from '@/components/ui/card';\n\nexport interface FeedbackListProps {\n readonly items: readonly FeedbackListItem[];\n readonly protection: FeedbackProtection;\n readonly onOpen: (id: string) => void;\n /** The open item, highlighted. */\n readonly activeId?: string;\n /** Why the list could not be loaded (denied, unavailable). */\n readonly problem?: string;\n readonly now?: Date;\n}\n\nexport function FeedbackList({ items, protection, onOpen, activeId, problem, now }: FeedbackListProps) {\n const notice = protectionNotice(protection);\n const rows = listRows(items, now);\n return <Card size=\"sm\" role=\"region\" data-boring=\"feedback\" data-feedback-ignore=\"\" className=\"boring-feedback-list\" aria-label=\"Feedback\" data-testid=\"feedback-list\">\n {notice && <p className=\"boring-feedback-notice\" data-testid=\"feedback-protection\">{notice}</p>}\n {problem !== undefined && <p role=\"alert\" className=\"boring-feedback-warning\">{problem}</p>}\n {problem === undefined && rows.length === 0 && <p className=\"boring-feedback-empty\">No feedback yet. Point at something to leave some.</p>}\n <ul>\n {rows.map(row => <li key={row.id}>\n <Button variant=\"ghost\" className=\"h-auto w-full flex-col items-start\" data-testid=\"feedback-item\" data-id={row.id} aria-current={row.id === activeId ? 'true' : undefined} onClick={() => onOpen(row.id)}>\n <span className=\"boring-feedback-title\">{row.title}</span>\n <span className=\"boring-feedback-meta\"><span data-status={row.status}>{row.status}</span> · {row.where} · {row.age}</span>\n </Button>\n </li>)}\n </ul>\n </Card>;\n}\n",
|
|
46
52
|
"type": "registry:component",
|
|
47
53
|
"target": "components/feedback/feedback-list.tsx"
|
|
48
54
|
},
|
|
49
55
|
{
|
|
50
56
|
"path": "registry/feedback/feedback-report.tsx",
|
|
51
|
-
"content": "// FeedbackReport: one stored report and Show. Show resolves each anchor against the live page now (`showAnchor` from\n// `@hachej/boring-feedback/ui`): exact and moved placements are revealed on the overlay, ambiguous ones are numbered for the person to choose,\n// and missing, stale or unsupported results are said as they are. Nothing uncertain is revealed without that choice.\nimport { useEffect, useState } from 'react';\nimport type { FeedbackReport as Report } from '@hachej/boring-feedback/format';\nimport type { PickerOverlay, PrivacyPolicy } from '@hachej/boring-feedback/page';\nimport { reportView, showAnchor, type ShowOutcome } from '@hachej/boring-feedback/ui';\n\nexport interface FeedbackReportProps {\n readonly report: Report;\n /** Show works only in the application page itself: pass its root, policy and overlay. Without them Show is not offered. */\n readonly page?: { readonly root: () => Element | null; readonly policy: PrivacyPolicy; readonly overlay: PickerOverlay | undefined };\n readonly onClose?: () => void;\n}\n\ntype Shown = { readonly anchor: number; readonly outcome: ShowOutcome | 'working' };\n\nconst outcomeText = (outcome: ShowOutcome): string => {\n switch (outcome.kind) {\n case 'revealed': return outcome.placement === 'chosen' ? 'Shown: the element you chose.' : outcome.placement === 'moved' ? 'Shown. It has moved since the feedback was left.' : 'Shown.';\n case 'choose': return `${outcome.candidates.length} elements could be the one meant. Choose the right one:`;\n case 'stale': return `Not shown: ${outcome.reason}`;\n case 'missing': return `Not shown: ${outcome.reason}`;\n default: return `Not shown: ${outcome.reason}`;\n }\n};\n\nexport function FeedbackReport({ report, page, onClose }: FeedbackReportProps) {\n const view = reportView(report);\n const [shown, setShown] = useState<Shown | null>(null);\n useEffect(() => () => { page?.overlay?.clear(); }, [page?.overlay]);\n useEffect(() => { setShown(null); page?.overlay?.clear(); }, [report.id]);\n\n const show = async (index: number): Promise<void> => {\n const root = page?.root();\n if (!page || !root) { setShown({ anchor: index, outcome: { kind: 'unsupported', reason: 'Open the application page to see it.' } }); return; }\n setShown({ anchor: index, outcome: 'working' });\n const outcome = await showAnchor({ anchor: report.anchors[index], root, policy: page.policy, ...(page.overlay ? { overlay: page.overlay } : {}), note: view.title });\n setShown({ anchor: index, outcome });\n };\n const choose = (outcome: Extract<ShowOutcome, { kind: 'choose' }>, number: number, index: number): void => setShown({ anchor: index, outcome: outcome.choose(number) });\n\n return <article data-boring=\"feedback\" data-feedback-ignore=\"\" className=\"boring-feedback-report\" data-testid=\"feedback-report\" data-id={view.id}>\n <header className=\"boring-feedback-sheet-header\">\n <h2>{view.title}</h2>\n {onClose && <
|
|
57
|
+
"content": "// FeedbackReport: one stored report and Show. Show resolves each anchor against the live page now (`showAnchor` from\n// `@hachej/boring-feedback/ui`): exact and moved placements are revealed on the overlay, ambiguous ones are numbered for the person to choose,\n// and missing, stale or unsupported results are said as they are. Nothing uncertain is revealed without that choice.\nimport { useEffect, useState } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Card } from '@/components/ui/card';\nimport { XIcon } from 'lucide-react';\nimport type { FeedbackReport as Report } from '@hachej/boring-feedback/format';\nimport type { PickerOverlay, PrivacyPolicy } from '@hachej/boring-feedback/page';\nimport { reportView, showAnchor, type ShowOutcome } from '@hachej/boring-feedback/ui';\n\nexport interface FeedbackReportProps {\n readonly report: Report;\n /** Show works only in the application page itself: pass its root, policy and overlay. Without them Show is not offered. */\n readonly page?: { readonly root: () => Element | null; readonly policy: PrivacyPolicy; readonly overlay: PickerOverlay | undefined };\n readonly onClose?: () => void;\n}\n\ntype Shown = { readonly anchor: number; readonly outcome: ShowOutcome | 'working' };\n\nconst outcomeText = (outcome: ShowOutcome): string => {\n switch (outcome.kind) {\n case 'revealed': return outcome.placement === 'chosen' ? 'Shown: the element you chose.' : outcome.placement === 'moved' ? 'Shown. It has moved since the feedback was left.' : 'Shown.';\n case 'choose': return `${outcome.candidates.length} elements could be the one meant. Choose the right one:`;\n case 'stale': return `Not shown: ${outcome.reason}`;\n case 'missing': return `Not shown: ${outcome.reason}`;\n default: return `Not shown: ${outcome.reason}`;\n }\n};\n\nexport function FeedbackReport({ report, page, onClose }: FeedbackReportProps) {\n const view = reportView(report);\n const [shown, setShown] = useState<Shown | null>(null);\n useEffect(() => () => { page?.overlay?.clear(); }, [page?.overlay]);\n useEffect(() => { setShown(null); page?.overlay?.clear(); }, [report.id]);\n\n const show = async (index: number): Promise<void> => {\n const root = page?.root();\n if (!page || !root) { setShown({ anchor: index, outcome: { kind: 'unsupported', reason: 'Open the application page to see it.' } }); return; }\n setShown({ anchor: index, outcome: 'working' });\n const outcome = await showAnchor({ anchor: report.anchors[index], root, policy: page.policy, ...(page.overlay ? { overlay: page.overlay } : {}), note: view.title });\n setShown({ anchor: index, outcome });\n };\n const choose = (outcome: Extract<ShowOutcome, { kind: 'choose' }>, number: number, index: number): void => setShown({ anchor: index, outcome: outcome.choose(number) });\n\n return <Card size=\"sm\" role=\"article\" data-boring=\"feedback\" data-feedback-ignore=\"\" className=\"boring-feedback-report\" data-testid=\"feedback-report\" data-id={view.id}>\n <header className=\"boring-feedback-sheet-header\">\n <h2>{view.title}</h2>\n {onClose && <Button variant=\"ghost\" size=\"icon\" aria-label=\"Close\" title=\"Close\" onClick={onClose}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n </header>\n <p className=\"boring-feedback-meta\">\n <span data-status={view.status}>{view.status}</span>\n {view.author !== undefined && <> · {view.author}</>}\n {view.app !== undefined && <> · {view.app} {view.route}</>}\n {view.build !== undefined && <> · build {view.build}</>}\n </p>\n <p className=\"boring-feedback-said\" data-testid=\"feedback-said\">{view.said}</p>\n <ol className=\"boring-feedback-anchors\">\n {view.anchors.map(anchor => {\n const current = shown?.anchor === anchor.index ? shown.outcome : undefined;\n return <li key={anchor.index} data-testid=\"feedback-anchor\">\n <span>{anchor.fallback}</span>\n {anchor.source !== undefined && <code>{anchor.source}</code>}\n {page && anchor.placeable && <Button variant=\"ghost\" data-testid=\"feedback-show\" disabled={current === 'working'} onClick={() => { void show(anchor.index); }}>Show</Button>}\n {!anchor.placeable && <span className=\"boring-feedback-meta\">This page cannot place {anchor.kind}.</span>}\n {current !== undefined && current !== 'working' && <div role=\"status\" className=\"boring-feedback-status\" data-testid=\"feedback-show-result\" data-kind={current.kind}>\n {outcomeText(current)}\n {current.kind === 'choose' && <ol className=\"boring-feedback-candidates\">\n {current.candidates.map(candidate => <li key={candidate.number}>\n <Button variant=\"outline\" data-testid=\"feedback-candidate\" data-number={candidate.number}\n onClick={() => choose(current, candidate.number, anchor.index)}>{candidate.number} · {candidate.label}</Button>\n </li>)}\n </ol>}\n </div>}\n </li>;\n })}\n </ol>\n {view.widened.length > 0 && <p className=\"boring-feedback-meta\" data-testid=\"feedback-widened\">Privacy policy widened by the app: {view.widened.join(', ')}</p>}\n {view.resolutions.length > 0 && <section className=\"boring-feedback-resolutions\"><h3>Resolution</h3>\n {view.resolutions.map((entry, index) => <p key={index}><strong>{entry.by}</strong> · {entry.note}</p>)}</section>}\n </Card>;\n}\n",
|
|
52
58
|
"type": "registry:component",
|
|
53
59
|
"target": "components/feedback/feedback-report.tsx"
|
|
54
60
|
},
|
|
@@ -60,13 +66,13 @@
|
|
|
60
66
|
},
|
|
61
67
|
{
|
|
62
68
|
"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-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",
|
|
69
|
+
"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 { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Item, ItemContent, ItemDescription, ItemHeader, ItemMedia, ItemTitle } from '@/components/ui/item';\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 ? <Badge data-testid=\"feedback-outcome\" role=\"status\" title={outcome.reason ?? 'Shown in the page.'} data-outcome={outcome.kind} variant={outcome.kind === 'applied' ? 'secondary' : 'outline'}>\n {outcome.kind === 'applied' ? <><CheckIcon aria-hidden=\"true\" />{outcomeBadge(outcome).slice(1).trim() || <span className=\"sr-only\">shown in the page</span>}</> : outcomeBadge(outcome)}</Badge>\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 variant=\"ghost\" 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=\"h-auto w-full justify-start whitespace-normal text-left\">{text}</Button>\n : <span data-testid=\"feedback-element\" data-feedback-id={request.id} data-anchor={request.anchor} title={label} className=\"block\">{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 whitespace-pre-line [overflow-wrap:anywhere]', !open && 'line-clamp-2')}>{title}</span>\n {long && <Button variant=\"link\" size=\"xs\" data-testid=\"feedback-title-more\" aria-expanded={open} onClick={() => setOpen(value => !value)}>{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 <Item variant=\"outline\" size=\"sm\" render={<section />} data-testid=\"feedback-card\" data-kind=\"offer\" data-feedback-id={view.id} data-outcome={outcome?.kind} aria-label=\"Feedback offer\" className=\"my-2 max-w-md text-start\">\n <ItemMedia variant=\"icon\"><LocateFixedIcon aria-hidden=\"true\" /></ItemMedia>\n <ItemContent className=\"min-w-0 basis-0\">\n <ItemDescription>Feedback {view.id}</ItemDescription>\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 && <ItemDescription data-testid=\"feedback-note\" className=\"line-clamp-none [overflow-wrap:anywhere]\">{view.note}</ItemDescription>}\n </ItemContent>\n </Item>;\n}\n\nfunction List({ view, onShowFeedback, onHideFeedback }: { readonly view: Extract<FeedbackResultView, { kind: 'list' }>; readonly onShowFeedback: FeedbackCardConfig['onShowFeedback']; readonly onHideFeedback: FeedbackCardConfig['onHideFeedback'] }) {\n return <Item variant=\"outline\" size=\"sm\" render={<section />} data-testid=\"feedback-card\" data-kind=\"list\" aria-label=\"Feedback\" className=\"my-2 max-w-xl flex-col items-stretch text-start\">\n <ItemHeader>\n <ItemTitle><MessageSquareTextIcon className=\"size-4\" aria-hidden=\"true\" />Feedback · {view.items.length}{view.more ? '+' : ''}</ItemTitle>\n {view.unprotected && <Badge variant=\"secondary\" data-testid=\"feedback-unprotected\"><ShieldAlertIcon aria-hidden=\"true\" />Unprotected store</Badge>}\n </ItemHeader>\n {view.items.length === 0 && <ItemDescription>No feedback.</ItemDescription>}\n <ul className=\"m-0 flex list-none flex-col gap-1.5 p-0\">\n {view.items.map(item => <Item key={item.id} variant=\"muted\" size=\"xs\" render={<li />} data-testid=\"feedback-item\" data-feedback-id={item.id} data-status={item.status}>\n <ItemContent className=\"min-w-0 basis-0\">\n <ClampedTitle text={item.title} />\n <ItemDescription data-testid=\"feedback-meta\" className=\"line-clamp-none\">{[item.status, item.author, item.age, item.unavailable ? 'unavailable' : ''].filter(Boolean).join(' · ')}</ItemDescription>\n {item.anchors.length > 0 && <ul className=\"m-0 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 && <Badge variant=\"outline\" data-testid=\"feedback-placement\" title={anchor.placementDetail}>{anchor.placement}</Badge>}\n </ElementLine></li>)}\n </ul>}\n </ItemContent>\n </Item>)}\n </ul>\n </Item>;\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 <Item variant=\"muted\" size=\"sm\" render={<section />} data-testid=\"feedback-card\" data-kind=\"refused\" data-outcome={view.outcome} aria-label=\"Feedback\" className=\"my-2 max-w-md text-start\">\n <ItemContent className=\"min-w-0 basis-0\"><ItemDescription className=\"line-clamp-none\">Feedback not shown ({view.outcome}){view.reason ? `: ${view.reason}` : ''}</ItemDescription></ItemContent></Item>;\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 body = <><MessageSquareTextIcon aria-hidden=\"true\" />{id}</>;\n return onOpen\n ? <Button variant=\"outline\" size=\"xs\" data-testid=\"feedback-mention\" data-path={path} aria-label={`Open feedback ${id}`} onClick={() => onOpen(path)}>{body}</Button>\n : <Badge variant=\"outline\" data-testid=\"feedback-mention\" data-path={path} title={`Feedback ${id}`}>{body}</Badge>;\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
70
|
"type": "registry:component",
|
|
65
71
|
"target": "components/feedback/feedback-card.tsx"
|
|
66
72
|
},
|
|
67
73
|
{
|
|
68
74
|
"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 {
|
|
75
|
+
"content": "// A microphone button for the composer: records on the first click, stops on the second, transcribes the recording and appends the\n// text to the composer draft. It never sends: the person reads the transcript and presses Send. Pass it to `PiChat` as `tools`.\nimport { useEffect, useRef, useState } from 'react';\nimport { Loader2Icon, MicIcon, SquareIcon, XIcon } from 'lucide-react';\nimport { createVoiceCapture } from '@hachej/boring-feedback/page';\nimport type { VoiceCapture, VoiceRefusal } from '@hachej/boring-feedback/page';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { useChatText } from '../pi-chat/labels';\nimport type { ChatLabels } from '../pi-chat/labels';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\n\nexport interface VoiceInputProps {\n /** The chat whose composer draft receives the transcript. */\n readonly controller: NativeChatController;\n /** Turns the recording into text. `signal` aborts when the person cancels or leaves the page; a rejection shows an error. */\n readonly transcribe: (audio: Blob, signal: AbortSignal) => Promise<string>;\n}\n\ntype Phase = 'idle' | 'starting' | 'recording' | 'transcribing';\n\n/** The words for each reason the microphone could not be used (the package's own English refusals are not shown). */\nconst REFUSAL: Readonly<Record<VoiceRefusal, (labels: ChatLabels) => string>> = {\n denied: labels => labels.voiceDenied,\n 'no-microphone': labels => labels.voiceNoMicrophone,\n busy: labels => labels.voiceBusy,\n insecure: labels => labels.voiceInsecure,\n unsupported: labels => labels.voiceUnsupported,\n failed: labels => labels.voiceStartFailed,\n};\n\nexport function VoiceInput({ controller, transcribe }: VoiceInputProps) {\n const { labels } = useChatText();\n const [phase, setPhase] = useState<Phase>('idle');\n const [problem, setProblem] = useState<string | undefined>();\n // One capture per mounted button. Each recording gets a run number: a cancel or unmount bumps it, so late results are dropped.\n const capture = useRef<VoiceCapture | null>(null);\n const run = useRef(0);\n const work = useRef<AbortController | null>(null);\n capture.current ??= createVoiceCapture();\n\n // Leaving the page (or the chat) releases the microphone and abandons any transcription in flight.\n useEffect(() => () => {\n run.current += 1;\n capture.current?.cancel();\n work.current?.abort();\n }, []);\n\n const append = (spoken: string) => {\n const draft = controller.getSnapshot().draft.text;\n controller.setText(`${draft}${draft === '' || /\\s$/.test(draft) ? '' : ' '}${spoken}`);\n };\n\n const start = async () => {\n const attempt = ++run.current;\n setProblem(undefined);\n setPhase('starting');\n // Called synchronously from the click, so the browser ties the permission prompt to the person's gesture.\n const result = await capture.current!.start();\n if (attempt !== run.current) return;\n if (result.kind === 'refused') {\n setPhase('idle');\n setProblem(result.code ? REFUSAL[result.code](labels) : result.reason);\n return;\n }\n setPhase('recording');\n };\n\n const finish = async () => {\n const attempt = run.current;\n setPhase('transcribing');\n const recording = await capture.current!.stop().catch(() => null);\n if (attempt !== run.current) return;\n if (!recording) {\n setPhase('idle');\n setProblem(labels.voiceNothingHeard);\n return;\n }\n const abort = new AbortController();\n work.current = abort;\n try {\n const spoken = (await transcribe(recording.audio, abort.signal)).trim();\n if (attempt !== run.current) return;\n if (spoken) append(spoken);\n else setProblem(labels.voiceNothingHeard);\n } catch {\n if (attempt === run.current) setProblem(labels.voiceTranscribeFailed);\n } finally {\n if (work.current === abort) work.current = null;\n if (attempt === run.current) setPhase('idle');\n }\n };\n\n const cancel = () => {\n run.current += 1;\n capture.current?.cancel();\n work.current?.abort();\n work.current = null;\n setProblem(undefined);\n setPhase('idle');\n };\n\n const onClick = () => {\n if (phase === 'idle') void start();\n else if (phase === 'recording') void finish();\n };\n\n const recording = phase === 'recording';\n const busy = phase === 'transcribing';\n return (\n <div className=\"flex items-center gap-1.5\">\n {(recording || busy) && <Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-voice-cancel\" aria-label={labels.voiceCancel} title={labels.voiceCancel} onClick={cancel}>\n <XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n <Toggle data-testid=\"composer-voice\" data-state={phase} pressed={recording} aria-label={recording ? labels.voiceStop : labels.voiceRecord}\n title={recording ? labels.voiceStop : labels.voiceRecord} disabled={phase === 'starting' || busy} onPressedChange={onClick}>\n {busy ? <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" role=\"status\" aria-label={labels.voiceTranscribing} />\n : recording ? <SquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n : <MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Toggle>\n {problem && <span role=\"alert\" data-testid=\"composer-voice-problem\" className=\"max-w-56 text-xs leading-4 text-destructive\">{problem}</span>}\n </div>\n );\n}\n",
|
|
70
76
|
"type": "registry:component",
|
|
71
77
|
"target": "components/feedback/voice-input.tsx"
|
|
72
78
|
},
|
|
@@ -78,7 +84,7 @@
|
|
|
78
84
|
},
|
|
79
85
|
{
|
|
80
86
|
"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(<
|
|
87
|
+
"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 { Button } from '@/components/ui/button';\nimport { Card } from '@/components/ui/card';\nimport { Toggle } from '@/components/ui/toggle';\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(<Card size=\"sm\" 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-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 <Toggle data-testid=\"feedback-point-use-app\" pressed={state.useToggle} disabled={processing}\n onPressedChange={pressed => session.setUseToggle(pressed)}>{words.pointUseApp}</Toggle>\n <span className=\"boring-feedback-point-spacer\" />\n <Button variant=\"ghost\" data-testid=\"feedback-point-cancel\" disabled={processing} onClick={() => session.discard()}>{words.pointCancel}</Button>\n <Button variant=\"default\" data-testid=\"feedback-point-done\" disabled={processing}\n onClick={() => { void session.done(); }}>{words.pointDone}</Button>\n </span>\n </Card>, document.body);\n}\n",
|
|
82
88
|
"type": "registry:component",
|
|
83
89
|
"target": "components/feedback/point-mode.tsx"
|
|
84
90
|
}
|
|
@@ -91,9 +97,6 @@
|
|
|
91
97
|
"--boring-feedback-text": "var(--popover-foreground)",
|
|
92
98
|
"--boring-feedback-muted": "var(--muted-foreground)",
|
|
93
99
|
"--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
100
|
"--boring-feedback-label-background": "var(--foreground)",
|
|
98
101
|
"--boring-feedback-label-foreground": "var(--background)",
|
|
99
102
|
"font-family": "inherit",
|
|
@@ -101,58 +104,17 @@
|
|
|
101
104
|
"line-height": "var(--leading-normal, 1.5)",
|
|
102
105
|
"color": "var(--boring-feedback-text)"
|
|
103
106
|
},
|
|
107
|
+
"@layer base": {
|
|
108
|
+
"[data-boring=\"feedback\"] :where(button, [role=\"button\"])": {
|
|
109
|
+
"appearance": "none",
|
|
110
|
+
"background-color": "transparent",
|
|
111
|
+
"border": "0",
|
|
112
|
+
"padding": "0"
|
|
113
|
+
}
|
|
114
|
+
},
|
|
104
115
|
"[data-boring=\"feedback\"] *, [data-boring=\"feedback\"] *::before, [data-boring=\"feedback\"] *::after": {
|
|
105
116
|
"box-sizing": "border-box"
|
|
106
117
|
},
|
|
107
|
-
"[data-boring=\"feedback\"] .boring-feedback-button": {
|
|
108
|
-
"display": "inline-flex",
|
|
109
|
-
"align-items": "center",
|
|
110
|
-
"justify-content": "center",
|
|
111
|
-
"gap": "0.375rem",
|
|
112
|
-
"min-height": "2.25rem",
|
|
113
|
-
"padding": "0 0.875rem",
|
|
114
|
-
"border": "var(--border-width, 1px) solid var(--boring-feedback-border)",
|
|
115
|
-
"border-radius": "var(--boring-feedback-radius-md)",
|
|
116
|
-
"background": "var(--boring-feedback-surface)",
|
|
117
|
-
"color": "var(--boring-feedback-text)",
|
|
118
|
-
"font": "inherit",
|
|
119
|
-
"font-weight": "var(--font-weight-semibold, 600)",
|
|
120
|
-
"cursor": "pointer"
|
|
121
|
-
},
|
|
122
|
-
"[data-boring=\"feedback\"] .boring-feedback-button:hover": {
|
|
123
|
-
"background": "var(--accent)"
|
|
124
|
-
},
|
|
125
|
-
"[data-boring=\"feedback\"] .boring-feedback-button:focus-visible, [data-boring=\"feedback\"] button:focus-visible, [data-boring=\"feedback\"] textarea:focus-visible": {
|
|
126
|
-
"outline": "2px solid var(--ring)",
|
|
127
|
-
"outline-offset": "2px"
|
|
128
|
-
},
|
|
129
|
-
"[data-boring=\"feedback\"] .boring-feedback-button[data-variant=\"primary\"]": {
|
|
130
|
-
"border-color": "transparent",
|
|
131
|
-
"background": "var(--boring-feedback-accent)",
|
|
132
|
-
"color": "var(--primary-foreground)"
|
|
133
|
-
},
|
|
134
|
-
"[data-boring=\"feedback\"] .boring-feedback-button:disabled": {
|
|
135
|
-
"opacity": "0.6",
|
|
136
|
-
"cursor": "default"
|
|
137
|
-
},
|
|
138
|
-
"[data-boring=\"feedback\"] .boring-feedback-button[aria-pressed=\"true\"]": {
|
|
139
|
-
"background": "var(--boring-feedback-accent)",
|
|
140
|
-
"color": "var(--primary-foreground)"
|
|
141
|
-
},
|
|
142
|
-
"[data-boring=\"feedback\"] .boring-feedback-icon": {
|
|
143
|
-
"display": "inline-flex",
|
|
144
|
-
"align-items": "center",
|
|
145
|
-
"justify-content": "center",
|
|
146
|
-
"width": "2rem",
|
|
147
|
-
"height": "2rem",
|
|
148
|
-
"border": "0",
|
|
149
|
-
"border-radius": "var(--boring-feedback-radius-md)",
|
|
150
|
-
"background": "transparent",
|
|
151
|
-
"color": "var(--boring-feedback-muted)",
|
|
152
|
-
"font-size": "var(--text-xl, 1.25rem)",
|
|
153
|
-
"line-height": "1",
|
|
154
|
-
"cursor": "pointer"
|
|
155
|
-
},
|
|
156
118
|
"[data-boring=\"feedback\"].boring-feedback-point": {
|
|
157
119
|
"display": "inline-flex",
|
|
158
120
|
"align-items": "center",
|
|
@@ -169,18 +131,11 @@
|
|
|
169
131
|
"[data-boring=\"feedback\"] .boring-feedback-keys": {
|
|
170
132
|
"opacity": "0.8"
|
|
171
133
|
},
|
|
172
|
-
"[data-boring=\"feedback\"].boring-feedback-sheet,
|
|
173
|
-
"
|
|
174
|
-
"flex-direction": "column",
|
|
175
|
-
"gap": "0.75rem",
|
|
176
|
-
"padding": "1rem",
|
|
177
|
-
"border": "var(--border-width, 1px) solid var(--boring-feedback-border)",
|
|
178
|
-
"border-radius": "var(--boring-feedback-radius)",
|
|
179
|
-
"background": "var(--boring-feedback-surface)"
|
|
134
|
+
"[data-boring=\"feedback\"]:is(.boring-feedback-sheet, .boring-feedback-report, .boring-feedback-list, .boring-feedback-bubble, .boring-feedback-review, .boring-feedback-pointbar, .boring-feedback-preview, .boring-feedback-bar)": {
|
|
135
|
+
"padding-inline": "var(--card-spacing)"
|
|
180
136
|
},
|
|
181
137
|
"[data-boring=\"feedback\"].boring-feedback-sheet": {
|
|
182
138
|
"z-index": "2147483000",
|
|
183
|
-
"box-shadow": "var(--boring-feedback-shadow)",
|
|
184
139
|
"max-height": "calc(100dvh - 24px)",
|
|
185
140
|
"overflow": "auto"
|
|
186
141
|
},
|
|
@@ -221,18 +176,6 @@
|
|
|
221
176
|
"font-weight": "var(--font-weight-semibold, 600)",
|
|
222
177
|
"font-size": "var(--text-sm, 0.875rem)"
|
|
223
178
|
},
|
|
224
|
-
"[data-boring=\"feedback\"] textarea": {
|
|
225
|
-
"width": "100%",
|
|
226
|
-
"padding": "0.5rem 0.625rem",
|
|
227
|
-
"border": "var(--border-width, 1px) solid var(--input)",
|
|
228
|
-
"border-radius": "var(--boring-feedback-radius-md)",
|
|
229
|
-
"background": "var(--background)",
|
|
230
|
-
"color": "inherit",
|
|
231
|
-
"font": "inherit",
|
|
232
|
-
"font-size": "var(--text-base, 1rem)",
|
|
233
|
-
"font-weight": "var(--font-weight-normal, 400)",
|
|
234
|
-
"resize": "vertical"
|
|
235
|
-
},
|
|
236
179
|
"[data-boring=\"feedback\"] .boring-feedback-actions": {
|
|
237
180
|
"display": "flex",
|
|
238
181
|
"flex-wrap": "wrap",
|
|
@@ -251,9 +194,7 @@
|
|
|
251
194
|
},
|
|
252
195
|
"[data-boring=\"feedback\"] .boring-feedback-notice": {
|
|
253
196
|
"margin": "0",
|
|
254
|
-
"
|
|
255
|
-
"border-radius": "var(--boring-feedback-radius-md)",
|
|
256
|
-
"background": "color-mix(in srgb, var(--boring-feedback-reveal) 14%, transparent)",
|
|
197
|
+
"color": "var(--boring-feedback-muted)",
|
|
257
198
|
"font-size": "var(--text-sm, 0.875rem)"
|
|
258
199
|
},
|
|
259
200
|
"[data-boring=\"feedback\"] .boring-feedback-copied pre, [data-boring=\"feedback\"] .boring-feedback-manual": {
|
|
@@ -278,22 +219,6 @@
|
|
|
278
219
|
"padding": "0",
|
|
279
220
|
"list-style": "none"
|
|
280
221
|
},
|
|
281
|
-
"[data-boring=\"feedback\"].boring-feedback-list li button": {
|
|
282
|
-
"display": "grid",
|
|
283
|
-
"gap": "0.125rem",
|
|
284
|
-
"width": "100%",
|
|
285
|
-
"padding": "0.5rem 0.625rem",
|
|
286
|
-
"border": "0",
|
|
287
|
-
"border-radius": "var(--boring-feedback-radius-md)",
|
|
288
|
-
"background": "transparent",
|
|
289
|
-
"color": "inherit",
|
|
290
|
-
"font": "inherit",
|
|
291
|
-
"text-align": "left",
|
|
292
|
-
"cursor": "pointer"
|
|
293
|
-
},
|
|
294
|
-
"[data-boring=\"feedback\"].boring-feedback-list li button:hover, [data-boring=\"feedback\"].boring-feedback-list li button[aria-current=\"true\"]": {
|
|
295
|
-
"background": "var(--accent)"
|
|
296
|
-
},
|
|
297
222
|
"[data-boring=\"feedback\"] .boring-feedback-title": {
|
|
298
223
|
"font-weight": "var(--font-weight-semibold, 600)"
|
|
299
224
|
},
|
|
@@ -344,30 +269,20 @@
|
|
|
344
269
|
"left": "0",
|
|
345
270
|
"right": "0",
|
|
346
271
|
"z-index": "2147483000",
|
|
347
|
-
"
|
|
272
|
+
"flex-direction": "row",
|
|
348
273
|
"flex-wrap": "nowrap",
|
|
349
274
|
"align-items": "center",
|
|
350
275
|
"gap": "0.5rem",
|
|
351
276
|
"width": "max-content",
|
|
352
277
|
"max-width": "calc(100vw - 24px)",
|
|
353
|
-
"margin": "0 auto"
|
|
354
|
-
"padding": "0.375rem 0.5rem 0.375rem 0.875rem",
|
|
355
|
-
"border": "var(--border-width, 1px) solid var(--boring-feedback-border)",
|
|
356
|
-
"border-radius": "999px",
|
|
357
|
-
"background": "var(--boring-feedback-surface)",
|
|
358
|
-
"box-shadow": "var(--boring-feedback-shadow)"
|
|
359
|
-
},
|
|
360
|
-
"[data-boring=\"feedback\"].boring-feedback-bar[data-using=\"true\"]": {
|
|
361
|
-
"border-color": "var(--boring-feedback-reveal)"
|
|
278
|
+
"margin": "0 auto"
|
|
362
279
|
},
|
|
363
280
|
"[data-boring=\"feedback\"].boring-feedback-bar[data-docked=\"true\"]": {
|
|
364
281
|
"position": "static",
|
|
365
282
|
"width": "auto",
|
|
366
283
|
"max-width": "none",
|
|
367
284
|
"margin": "0 0.625rem 0.625rem",
|
|
368
|
-
"flex-wrap": "wrap"
|
|
369
|
-
"border-radius": "var(--radius-xl, calc(var(--radius, 0.625rem) * 1.4))",
|
|
370
|
-
"box-shadow": "none"
|
|
285
|
+
"flex-wrap": "wrap"
|
|
371
286
|
},
|
|
372
287
|
"[data-boring=\"feedback\"].boring-feedback-bar > *": {
|
|
373
288
|
"flex": "none"
|
|
@@ -384,15 +299,6 @@
|
|
|
384
299
|
"gap": "0.375rem",
|
|
385
300
|
"font-weight": "var(--font-weight-semibold, 600)"
|
|
386
301
|
},
|
|
387
|
-
"[data-boring=\"feedback\"] .boring-feedback-dot": {
|
|
388
|
-
"width": "0.5rem",
|
|
389
|
-
"height": "0.5rem",
|
|
390
|
-
"border-radius": "50%",
|
|
391
|
-
"background": "var(--boring-feedback-accent)"
|
|
392
|
-
},
|
|
393
|
-
"[data-boring=\"feedback\"].boring-feedback-bar[data-using=\"true\"] .boring-feedback-dot": {
|
|
394
|
-
"background": "var(--boring-feedback-reveal)"
|
|
395
|
-
},
|
|
396
302
|
"[data-boring=\"feedback\"] .boring-feedback-bar-meta": {
|
|
397
303
|
"color": "var(--boring-feedback-muted)",
|
|
398
304
|
"font-size": "var(--text-sm, 0.875rem)"
|
|
@@ -401,59 +307,14 @@
|
|
|
401
307
|
"color": "var(--boring-feedback-muted)",
|
|
402
308
|
"font-size": "var(--text-xs, 0.75rem)"
|
|
403
309
|
},
|
|
404
|
-
"[data-boring=\"feedback\"] .boring-feedback-icon[aria-pressed=\"true\"]": {
|
|
405
|
-
"background": "color-mix(in srgb, var(--destructive) 16%, transparent)",
|
|
406
|
-
"color": "var(--destructive)"
|
|
407
|
-
},
|
|
408
|
-
"[data-boring=\"feedback\"] .boring-feedback-icon:disabled": {
|
|
409
|
-
"opacity": "0.45",
|
|
410
|
-
"cursor": "default"
|
|
411
|
-
},
|
|
412
|
-
"[data-boring=\"feedback\"] .boring-feedback-recording": {
|
|
413
|
-
"display": "inline-flex",
|
|
414
|
-
"align-items": "center",
|
|
415
|
-
"gap": "0.375rem",
|
|
416
|
-
"padding": "0.125rem 0.5rem",
|
|
417
|
-
"border-radius": "999px",
|
|
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)"
|
|
422
|
-
},
|
|
423
|
-
"[data-boring=\"feedback\"] .boring-feedback-rec": {
|
|
424
|
-
"width": "0.5rem",
|
|
425
|
-
"height": "0.5rem",
|
|
426
|
-
"border-radius": "50%",
|
|
427
|
-
"background": "var(--destructive)"
|
|
428
|
-
},
|
|
429
310
|
"[data-boring=\"feedback\"].boring-feedback-bar[data-docked=\"true\"] > .boring-feedback-hint": {
|
|
430
311
|
"flex": "1 1 10rem",
|
|
431
312
|
"max-width": "none",
|
|
432
313
|
"white-space": "normal"
|
|
433
314
|
},
|
|
434
|
-
"[data-boring=\"feedback\"] .boring-feedback-record": {
|
|
435
|
-
"min-height": "2rem",
|
|
436
|
-
"padding": "0 0.625rem",
|
|
437
|
-
"gap": "0.3125rem",
|
|
438
|
-
"font-size": "var(--text-sm, 0.875rem)"
|
|
439
|
-
},
|
|
440
|
-
"[data-boring=\"feedback\"] .boring-feedback-record[aria-pressed=\"true\"]": {
|
|
441
|
-
"border-color": "transparent",
|
|
442
|
-
"background": "var(--destructive)",
|
|
443
|
-
"color": "white"
|
|
444
|
-
},
|
|
445
|
-
"[data-boring=\"feedback\"] .boring-feedback-stop": {
|
|
446
|
-
"width": "0.625rem",
|
|
447
|
-
"height": "0.625rem",
|
|
448
|
-
"border-radius": "2px",
|
|
449
|
-
"background": "currentColor"
|
|
450
|
-
},
|
|
451
315
|
"[data-boring=\"feedback\"] .boring-feedback-timer": {
|
|
452
316
|
"font-variant-numeric": "tabular-nums"
|
|
453
317
|
},
|
|
454
|
-
"[data-boring=\"feedback\"].boring-feedback-bar[data-recording=\"true\"]": {
|
|
455
|
-
"border-color": "color-mix(in srgb, var(--destructive) 55%, var(--boring-feedback-border))"
|
|
456
|
-
},
|
|
457
318
|
"[data-boring=\"feedback\"] .boring-feedback-voice-problem": {
|
|
458
319
|
"flex": "1 1 100%",
|
|
459
320
|
"margin": "0",
|
|
@@ -488,24 +349,8 @@
|
|
|
488
349
|
"font-size": "var(--text-xs, 0.75rem)",
|
|
489
350
|
"line-height": "var(--leading-snug, 1.375)"
|
|
490
351
|
},
|
|
491
|
-
"[data-boring=\"feedback\"] .boring-feedback-badge": {
|
|
492
|
-
"flex": "none",
|
|
493
|
-
"padding": "0 0.375rem",
|
|
494
|
-
"border-radius": "999px",
|
|
495
|
-
"background": "var(--muted)",
|
|
496
|
-
"color": "var(--boring-feedback-muted)",
|
|
497
|
-
"font-size": "var(--text-xs, 0.75rem)",
|
|
498
|
-
"line-height": "calc(var(--spacing, 0.25rem) * 5)"
|
|
499
|
-
},
|
|
500
352
|
"[data-boring=\"feedback\"].boring-feedback-bubble": {
|
|
501
|
-
"z-index": "2147483001"
|
|
502
|
-
"display": "grid",
|
|
503
|
-
"gap": "0.375rem",
|
|
504
|
-
"padding": "0.5rem",
|
|
505
|
-
"border": "var(--border-width, 1px) solid var(--boring-feedback-border)",
|
|
506
|
-
"border-radius": "var(--boring-feedback-radius)",
|
|
507
|
-
"background": "var(--boring-feedback-surface)",
|
|
508
|
-
"box-shadow": "var(--boring-feedback-shadow)"
|
|
353
|
+
"z-index": "2147483001"
|
|
509
354
|
},
|
|
510
355
|
"[data-boring=\"feedback\"] .boring-feedback-bubble-head, [data-boring=\"feedback\"] .boring-feedback-note-head": {
|
|
511
356
|
"display": "flex",
|
|
@@ -532,46 +377,17 @@
|
|
|
532
377
|
"flex-wrap": "wrap",
|
|
533
378
|
"align-items": "center",
|
|
534
379
|
"gap": "0.125rem",
|
|
535
|
-
"max-width": "100%"
|
|
536
|
-
"padding": "0.125rem 0.125rem 0.125rem 0.625rem",
|
|
537
|
-
"border": "var(--border-width, 1px) solid var(--boring-feedback-border)",
|
|
538
|
-
"border-radius": "999px",
|
|
539
|
-
"background": "color-mix(in srgb, var(--boring-feedback-accent) 8%, var(--boring-feedback-surface))",
|
|
540
|
-
"font-size": "var(--text-sm, 0.875rem)"
|
|
541
|
-
},
|
|
542
|
-
"[data-boring=\"feedback\"] .boring-feedback-chip-open": {
|
|
543
|
-
"min-width": "0",
|
|
544
|
-
"padding": "0.25rem 0.25rem",
|
|
545
|
-
"border": "0",
|
|
546
|
-
"background": "transparent",
|
|
547
|
-
"color": "inherit",
|
|
548
|
-
"font": "inherit",
|
|
549
|
-
"font-weight": "var(--font-weight-semibold, 600)",
|
|
550
|
-
"text-align": "left",
|
|
551
|
-
"cursor": "pointer"
|
|
380
|
+
"max-width": "100%"
|
|
552
381
|
},
|
|
553
382
|
"[data-boring=\"feedback\"] .boring-feedback-chip-route": {
|
|
554
383
|
"color": "var(--boring-feedback-muted)",
|
|
555
384
|
"font-family": "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace)",
|
|
556
385
|
"font-weight": "var(--font-weight-medium, 500)"
|
|
557
386
|
},
|
|
558
|
-
"[data-boring=\"feedback\"] .boring-feedback-chip-remove": {
|
|
559
|
-
"width": "1.5rem",
|
|
560
|
-
"height": "1.5rem",
|
|
561
|
-
"font-size": "var(--text-base, 1rem)"
|
|
562
|
-
},
|
|
563
387
|
"[data-boring=\"feedback\"].boring-feedback-review": {
|
|
564
388
|
"z-index": "2147483000",
|
|
565
|
-
"display": "flex",
|
|
566
|
-
"flex-direction": "column",
|
|
567
|
-
"gap": "0.625rem",
|
|
568
389
|
"max-height": "min(28rem, calc(100dvh - 24px))",
|
|
569
390
|
"overflow": "auto",
|
|
570
|
-
"padding": "0.875rem",
|
|
571
|
-
"border": "var(--border-width, 1px) solid var(--boring-feedback-border)",
|
|
572
|
-
"border-radius": "var(--boring-feedback-radius)",
|
|
573
|
-
"background": "var(--boring-feedback-surface)",
|
|
574
|
-
"box-shadow": "var(--boring-feedback-shadow)",
|
|
575
391
|
"text-align": "left"
|
|
576
392
|
},
|
|
577
393
|
"[data-boring=\"feedback\"] .boring-feedback-notes": {
|
|
@@ -584,34 +400,15 @@
|
|
|
584
400
|
},
|
|
585
401
|
"[data-boring=\"feedback\"] .boring-feedback-notes > li": {
|
|
586
402
|
"display": "grid",
|
|
587
|
-
"gap": "0.25rem"
|
|
588
|
-
"padding": "0.375rem",
|
|
589
|
-
"border-radius": "var(--boring-feedback-radius-md)"
|
|
590
|
-
},
|
|
591
|
-
"[data-boring=\"feedback\"] .boring-feedback-notes > li:hover, [data-boring=\"feedback\"] .boring-feedback-notes > li:focus-within": {
|
|
592
|
-
"background": "var(--accent)"
|
|
403
|
+
"gap": "0.25rem"
|
|
593
404
|
},
|
|
594
405
|
"[data-boring=\"feedback\"].boring-feedback-pointbar": {
|
|
595
406
|
"position": "fixed",
|
|
596
407
|
"left": "8px",
|
|
597
408
|
"right": "8px",
|
|
598
409
|
"z-index": "2147483000",
|
|
599
|
-
"display": "flex",
|
|
600
|
-
"flex-direction": "column",
|
|
601
|
-
"gap": "0.375rem",
|
|
602
410
|
"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)"
|
|
411
|
+
"margin": "0 auto"
|
|
615
412
|
},
|
|
616
413
|
"[data-boring=\"feedback\"] .boring-feedback-point-line": {
|
|
617
414
|
"display": "flex",
|
|
@@ -620,9 +417,6 @@
|
|
|
620
417
|
"min-width": "0",
|
|
621
418
|
"padding": "0 0.25rem"
|
|
622
419
|
},
|
|
623
|
-
"[data-boring=\"feedback\"] .boring-feedback-point-line .boring-feedback-dot": {
|
|
624
|
-
"flex": "none"
|
|
625
|
-
},
|
|
626
420
|
"[data-boring=\"feedback\"] .boring-feedback-point-hint": {
|
|
627
421
|
"flex": "1",
|
|
628
422
|
"min-width": "0",
|
|
@@ -639,21 +433,10 @@
|
|
|
639
433
|
"align-items": "center",
|
|
640
434
|
"gap": "0.375rem"
|
|
641
435
|
},
|
|
642
|
-
"[data-boring=\"feedback\"] .boring-feedback-point-actions .boring-feedback-button": {
|
|
643
|
-
"min-height": "2.75rem",
|
|
644
|
-
"min-width": "2.75rem"
|
|
645
|
-
},
|
|
646
436
|
"[data-boring=\"feedback\"] .boring-feedback-point-spacer": {
|
|
647
437
|
"flex": "1"
|
|
648
438
|
},
|
|
649
439
|
"@media (max-width: 640px), (pointer: coarse)": {
|
|
650
|
-
"[data-boring=\"feedback\"] .boring-feedback-button": {
|
|
651
|
-
"min-height": "2.75rem"
|
|
652
|
-
},
|
|
653
|
-
"[data-boring=\"feedback\"] .boring-feedback-icon": {
|
|
654
|
-
"width": "2.75rem",
|
|
655
|
-
"height": "2.75rem"
|
|
656
|
-
},
|
|
657
440
|
"[data-boring=\"feedback\"] .boring-feedback-hint": {
|
|
658
441
|
"display": "none"
|
|
659
442
|
},
|
|
@@ -663,30 +446,21 @@
|
|
|
663
446
|
"[data-boring=\"feedback\"].boring-feedback-bar": {
|
|
664
447
|
"top": "auto",
|
|
665
448
|
"bottom": "12px",
|
|
666
|
-
"flex-wrap": "wrap"
|
|
667
|
-
|
|
449
|
+
"flex-wrap": "wrap"
|
|
450
|
+
},
|
|
451
|
+
"[data-boring=\"feedback\"] :is([data-slot=\"button\"], [data-slot=\"toggle\"], [class~=\"group/button\"], [class~=\"group/toggle\"])": {
|
|
452
|
+
"min-height": "2.75rem",
|
|
453
|
+
"min-width": "2.75rem"
|
|
668
454
|
}
|
|
669
455
|
},
|
|
670
|
-
"[data-boring=\"feedback\"] .boring-feedback-mic": {
|
|
671
|
-
"flex": "none",
|
|
672
|
-
"display": "block"
|
|
673
|
-
},
|
|
674
456
|
"[data-boring=\"feedback\"].boring-feedback-preview": {
|
|
675
457
|
"position": "fixed",
|
|
676
458
|
"top": "12px",
|
|
677
459
|
"left": "0",
|
|
678
460
|
"right": "0",
|
|
679
461
|
"z-index": "2147483001",
|
|
680
|
-
"display": "flex",
|
|
681
|
-
"flex-direction": "column",
|
|
682
|
-
"gap": "0.5rem",
|
|
683
462
|
"width": "min(40rem, calc(100vw - 24px))",
|
|
684
|
-
"margin": "0 auto"
|
|
685
|
-
"padding": "0.75rem 0.875rem",
|
|
686
|
-
"border": "calc(var(--border-width, 1px) * 2) solid var(--boring-feedback-reveal)",
|
|
687
|
-
"border-radius": "var(--boring-feedback-radius)",
|
|
688
|
-
"background": "var(--boring-feedback-surface)",
|
|
689
|
-
"box-shadow": "var(--boring-feedback-shadow)"
|
|
463
|
+
"margin": "0 auto"
|
|
690
464
|
},
|
|
691
465
|
"[data-boring=\"feedback\"] .boring-feedback-preview-head": {
|
|
692
466
|
"display": "flex",
|
|
@@ -694,15 +468,6 @@
|
|
|
694
468
|
"align-items": "baseline",
|
|
695
469
|
"gap": "0.5rem"
|
|
696
470
|
},
|
|
697
|
-
"[data-boring=\"feedback\"] .boring-feedback-preview-title": {
|
|
698
|
-
"padding": "0.125rem 0.5rem",
|
|
699
|
-
"border-radius": "999px",
|
|
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)",
|
|
704
|
-
"text-transform": "uppercase"
|
|
705
|
-
},
|
|
706
471
|
"[data-boring=\"feedback\"] .boring-feedback-preview-notice": {
|
|
707
472
|
"font-weight": "var(--font-weight-semibold, 600)"
|
|
708
473
|
},
|
|
@@ -733,17 +498,6 @@
|
|
|
733
498
|
"flex-wrap": "wrap",
|
|
734
499
|
"gap": "0.5rem"
|
|
735
500
|
},
|
|
736
|
-
"[data-boring=\"feedback\"] .boring-feedback-preview-input": {
|
|
737
|
-
"flex": "1 1 12rem",
|
|
738
|
-
"min-width": "0",
|
|
739
|
-
"min-height": "2.25rem",
|
|
740
|
-
"padding": "0 0.75rem",
|
|
741
|
-
"border": "var(--border-width, 1px) solid var(--boring-feedback-border)",
|
|
742
|
-
"border-radius": "var(--boring-feedback-radius-md)",
|
|
743
|
-
"background": "var(--boring-feedback-surface)",
|
|
744
|
-
"color": "var(--boring-feedback-text)",
|
|
745
|
-
"font": "inherit"
|
|
746
|
-
},
|
|
747
501
|
"[data-boring=\"feedback\"] .boring-feedback-pin-field": {
|
|
748
502
|
"display": "grid"
|
|
749
503
|
},
|
|
@@ -757,50 +511,17 @@
|
|
|
757
511
|
"grid-row": "2"
|
|
758
512
|
},
|
|
759
513
|
"[data-boring=\"feedback\"] .boring-feedback-pin-field textarea": {
|
|
760
|
-
"display": "block",
|
|
761
|
-
"box-sizing": "border-box",
|
|
762
514
|
"width": "100%",
|
|
763
|
-
"padding-
|
|
515
|
+
"padding-inline-end": "3.25rem"
|
|
764
516
|
},
|
|
765
517
|
"[data-boring=\"feedback\"] .boring-feedback-pin-mic": {
|
|
766
518
|
"justify-self": "end",
|
|
767
|
-
"align-self": "end"
|
|
768
|
-
"display": "inline-flex",
|
|
769
|
-
"align-items": "center",
|
|
770
|
-
"justify-content": "center",
|
|
771
|
-
"width": "2.75rem",
|
|
772
|
-
"height": "2.75rem",
|
|
773
|
-
"padding": "0",
|
|
774
|
-
"border": "0",
|
|
775
|
-
"border-radius": "var(--boring-feedback-radius-md)",
|
|
776
|
-
"background": "transparent",
|
|
777
|
-
"color": "var(--boring-feedback-muted)",
|
|
778
|
-
"cursor": "pointer"
|
|
779
|
-
},
|
|
780
|
-
"[data-boring=\"feedback\"] .boring-feedback-pin-mic:hover": {
|
|
781
|
-
"color": "var(--boring-feedback-text, inherit)"
|
|
782
|
-
},
|
|
783
|
-
"[data-boring=\"feedback\"] .boring-feedback-pin-mic[aria-pressed=\"true\"]": {
|
|
784
|
-
"color": "var(--boring-feedback-accent)"
|
|
785
|
-
},
|
|
786
|
-
"[data-boring=\"feedback\"] .boring-feedback-pin-mic:disabled": {
|
|
787
|
-
"opacity": "0.6",
|
|
788
|
-
"cursor": "default"
|
|
519
|
+
"align-self": "end"
|
|
789
520
|
},
|
|
790
521
|
"[data-boring=\"feedback\"] .boring-feedback-bubble-actions": {
|
|
791
522
|
"display": "flex",
|
|
792
523
|
"flex-wrap": "wrap",
|
|
793
524
|
"gap": "0.375rem"
|
|
794
|
-
},
|
|
795
|
-
"[data-boring=\"feedback\"] .boring-feedback-bubble-actions .boring-feedback-button": {
|
|
796
|
-
"min-height": "2.75rem",
|
|
797
|
-
"padding": "0 0.625rem",
|
|
798
|
-
"font-size": "var(--text-sm, 0.875rem)"
|
|
799
|
-
},
|
|
800
|
-
"@media (pointer: coarse)": {
|
|
801
|
-
"[data-boring=\"feedback\"] .boring-feedback-bubble-actions .boring-feedback-button": {
|
|
802
|
-
"min-height": "2.75rem"
|
|
803
|
-
}
|
|
804
525
|
}
|
|
805
526
|
},
|
|
806
527
|
"type": "registry:component"
|