@hachej/boring-ui-kit 0.4.57 → 0.4.59
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/bin/boring-ui-blocks.mjs +49 -4
- package/dist/chat-react-conversations.d.ts +7 -1
- package/dist/chat-react-conversations.js +58 -18
- package/package.json +6 -6
- package/r/_shared.json +19 -0
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +17 -17
- package/r/board-viewer.json +10 -10
- package/r/button.json +4 -4
- package/r/data-table.json +21 -0
- package/r/diagram-viewer.json +6 -4
- package/r/dock.json +1 -1
- package/r/documents.json +13 -14
- package/r/drawing-viewer.json +6 -4
- package/r/empty-state.json +22 -0
- package/r/feedback.json +14 -14
- package/r/file-tree.json +4 -4
- package/r/highlights.json +3 -3
- package/r/html-viewer.json +3 -3
- package/r/kpi-stat.json +22 -0
- package/r/list-row.json +21 -0
- package/r/looks-viewer.json +6 -6
- package/r/maquette-viewer.json +7 -6
- package/r/markdown-editor.json +10 -9
- package/r/mockup-viewer.json +5 -5
- package/r/page-header.json +22 -0
- package/r/pi-ambient.json +12 -12
- package/r/pi-app-kinds.json +10 -10
- package/r/pi-app.json +17 -17
- package/r/pi-artifacts.json +4 -4
- package/r/pi-chat-approval.json +4 -4
- package/r/pi-chat-attachments.json +4 -4
- package/r/pi-chat-markdown.json +3 -3
- package/r/pi-chat-mentions.json +4 -4
- package/r/pi-chat-pickers.json +4 -4
- package/r/pi-chat-plugins.json +11 -11
- package/r/pi-chat-question.json +3 -3
- package/r/pi-chat-slash.json +5 -5
- package/r/pi-chat-subagent.json +4 -4
- package/r/pi-chat-voice.json +7 -7
- package/r/pi-chat.json +20 -20
- package/r/pi-workspace.json +3 -3
- package/r/placed-agent.json +7 -7
- package/r/provider-setup.json +4 -4
- package/r/record-viewer.json +5 -3
- package/r/registry.json +494 -280
- package/r/shared-chat.json +11 -11
- package/r/spaces.json +7 -5
- package/r/task-list-viewer.json +28 -3
- package/r/theme.json +115 -50
- package/r/version-history.json +2 -2
- package/r/viewer-code.json +5 -5
- package/r/viewer-frame.json +9 -9
- package/r/viewer-html.json +7 -7
- package/r/viewer-image.json +5 -5
- package/r/viewer-markdown.json +7 -7
- package/r/viewer-pdf.json +6 -6
- package/r/viewers.json +6 -6
- package/r/windows.json +4 -4
package/r/feedback.json
CHANGED
|
@@ -4,22 +4,22 @@
|
|
|
4
4
|
"title": "Feedback",
|
|
5
5
|
"description": "Feedback from the chat composer: one Feedback button starts feedback mode on the application page (hover, click to pin a numbered note, hold ⌥ to use the app, optional voice), and one chip carries the notes to the next message. FeedbackBar, NoteBubble, FeedbackChip with its review, useComposerFeedback for the pi-chat composer, and PickerOverlay styles. Optional, not mounted by default: PointButton, AnnotateSheet, FeedbackList and FeedbackReport. Picking, masking, anchoring and placement stay in @hachej/boring-feedback/page and /ui, so restyling cannot weaken them.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-feedback@^0.4.
|
|
8
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
9
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-feedback@^0.4.59",
|
|
8
|
+
"@hachej/boring-ui-kit@^0.4.59",
|
|
9
|
+
"@hachej/boring-files@^0.4.59",
|
|
10
10
|
"react@^19.0.0",
|
|
11
|
-
"react-dom@^19.0.0"
|
|
12
|
-
"lucide-react@^1.51.0"
|
|
11
|
+
"react-dom@^19.0.0"
|
|
13
12
|
],
|
|
14
13
|
"registryDependencies": [
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
17
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat-voice.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/utils.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
|
|
18
18
|
],
|
|
19
19
|
"files": [
|
|
20
20
|
{
|
|
21
21
|
"path": "registry/feedback/feedback-session.tsx",
|
|
22
|
-
"content": "// The feedback session's look (FEEDBACK.md, \"UX\"): the note pin beside a picked element (NoteBubble: a text field with a mic inside it),\n// the slim bar and the chip with its review (optional, for hosts with no chat). Everything they show and do is the headless\n// `createFeedbackSession` from `@hachej/boring-feedback/ui` (hover labels, pins, steps, the draft and Send come from there, through the\n// privacy policy); this file is how it looks. `useComposerFeedback` adapts a session to the chat composer's optional `feedback` prop\n// (registry/pi-chat): ONE annotate toggle and a count chip, structurally, so neither item imports the other.\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';\nimport { createPortal } from 'react-dom';\nimport { MicIcon, SquareIcon, XIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Badge } from '@/components/ui/badge';\nimport { Card } from '@/components/ui/card';\nimport { Textarea } from '@/components/ui/textarea';\nimport { readableElement } from '@hachej/boring-feedback/format';\nimport { createVoiceCapture, type VoiceCapture } from '@hachej/boring-feedback/page';\nimport { pinMark, type FeedbackSession, type FeedbackSessionSnapshot, type SessionNote } from '@hachej/boring-feedback/ui';\nimport { useHoldToRecord } from '../pi-chat-voice/use-hold-to-record';\nimport { useExternal } from './use-external';\nimport { plainElement, withLabels, type FeedbackLabels } from './feedback-labels';\n\nconst plural = (count: number, one: string, many: string): string => `${count} ${count === 1 ? one : many}`;\n/** Notes the chip and the bar count: every note that says something (not a pin still being typed), plus the general note. */\nexport function noteCount(state: FeedbackSessionSnapshot): number {\n return state.notes.filter(note => note.text.trim()).length + (state.general.trim() ? 1 : 0);\n}\n\nexport interface FeedbackBarProps {\n readonly session: FeedbackSession;\n readonly className?: string;\n /** Docked under the chat composer (the composer's `feedback.bar`) instead of floating at the top of the page. */\n readonly docked?: boolean;\n}\n\n/** Pins dropped while recording that have no note yet (nothing transcribed for them, or transcription failed). */\nexport function emptyPinCount(state: FeedbackSessionSnapshot): number {\n return state.notes.filter(note => note.recorded && !note.text.trim()).length;\n}\n\n/** `mm:ss` since `since` (ms), for the recording timer. */\nexport function elapsed(ms: number): string {\n const seconds = Math.max(0, Math.floor(ms / 1000));\n return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;\n}\n\n/** The running time of a recording, from when the bar saw it start; 00:00 when not recording. */\nfunction useRecordingTime(recording: boolean): string {\n const [since, setSince] = useState<number | undefined>(undefined);\n const [, tick] = useState(0);\n useEffect(() => {\n // eslint-disable-next-line react-hooks/set-state-in-effect -- the start time is taken when the bar sees the recording start (after commit), once per change of recording\n if (!recording) { setSince(undefined); return; }\n setSince(Date.now());\n const timer = setInterval(() => tick(value => value + 1), 500);\n return () => clearInterval(timer);\n }, [recording]);\n // eslint-disable-next-line react-hooks/purity -- a running clock: each tick re-renders to read the current time\n return elapsed(since === undefined ? 0 : Date.now() - since);\n}\n\nfunction barHint(state: FeedbackSessionSnapshot): string {\n if (state.phase === 'processing') return state.transcribing ? 'Transcribing…' : 'Preparing your feedback…';\n if (state.problem) return state.problem;\n if (state.voice.kind === 'starting') return 'Allow the microphone if your browser asks…';\n if (state.voice.kind === 'recording') return state.using ? 'Recording — using the app' : 'Recording — point and talk';\n return state.using ? 'Using the app: your clicks are recorded as steps' : 'Click what you want to comment on';\n}\n\n/**\n * Feedback mode's bar: `● Feedback · N notes · 🎙 Record · Use app · Done · ✕`. While recording: a red dot, the\n * running time, \"Recording — point and talk\" and Stop. A refused microphone is said in the bar itself, never only in a tooltip.\n */\nexport function FeedbackBar({ session, className, docked = false }: FeedbackBarProps) {\n const state = useExternal(session);\n const recording = state.voice.kind === 'recording';\n const time = useRecordingTime(recording && (state.phase === 'active' || state.phase === 'processing'));\n if (state.phase !== 'active' && state.phase !== 'processing') return null;\n const processing = state.phase === 'processing';\n const pins = emptyPinCount(state);\n const voiceProblem = state.voice.kind === 'refused' ? state.voice : undefined;\n return <Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"toolbar\" aria-label=\"Feedback mode\" data-testid=\"feedback-bar\" data-docked={docked ? 'true' : 'false'}\n data-using={state.using ? 'true' : 'false'} data-recording={recording ? 'true' : 'false'} data-transcribing={state.transcribing ? 'true' : 'false'}\n className={['boring-feedback-bar', className].filter(Boolean).join(' ')}\n onKeyDown={event => { if (event.key === 'Escape' && state.bubble) { event.stopPropagation(); session.cancelBubble(); } }}>\n <span className=\"boring-feedback-bar-title\">Feedback</span>\n <span className=\"boring-feedback-bar-meta\" data-testid=\"feedback-bar-count\">{plural(noteCount(state), 'note', 'notes')}{pins ? ` · ${plural(pins, 'pin', 'pins')}` : ''}</span>\n {state.voiceAvailable && <Toggle data-testid=\"feedback-voice\" pressed={recording}\n disabled={processing || state.voice.kind === 'starting' || state.voice.kind === 'stopping'} title={recording ? 'Stop recording' : 'Record your voice while you point: each click drops a pin, what you say becomes its note'}\n onPressedChange={() => { void session.toggleVoice(); }}>\n {recording ? <><SquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" />Stop</> : <><MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{state.voice.kind === 'starting' ? 'Starting…' : state.voice.kind === 'stopping' ? 'Stopping…' : 'Record'}</>}\n </Toggle>}\n {recording && <Badge variant=\"destructive\" role=\"status\" data-testid=\"feedback-recording\">\n <span className=\"boring-feedback-timer\" data-testid=\"feedback-recording-time\">{time}</span></Badge>}\n <span className=\"boring-feedback-hint\" role=\"status\" aria-live=\"polite\" data-testid=\"feedback-bar-hint\">{barHint(state)}</span>\n {voiceProblem && <span className=\"boring-feedback-voice-problem\" role=\"alert\" data-testid=\"feedback-voice-problem\" data-code={voiceProblem.code}>{voiceProblem.reason}</span>}\n {!processing && <Toggle data-testid=\"feedback-use-app\" pressed={state.useToggle} onPressedChange={pressed => session.setUseToggle(pressed)}>Use app</Toggle>}\n <Button variant=\"default\" data-testid=\"feedback-done\" disabled={processing} onClick={() => { void session.done(); }}>Done</Button>\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"feedback-discard\" aria-label=\"Discard feedback\" title=\"Discard feedback\" disabled={processing} onClick={() => session.discard()}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </Card>;\n}\n\nconst BUBBLE_WIDTH = 280;\n/** Pixels at the bottom of the viewport taken by comment mode's banner on a phone (CommentBanner, `data-feedback-point-bar=\"bottom\"`), plus a gap; 0 without it. */\nexport function pointBarInset(document: Document): number {\n const bar = document.querySelector('[data-feedback-point-bar=\"bottom\"]');\n const height = document.defaultView?.innerHeight ?? 768;\n return bar ? Math.max(0, height - bar.getBoundingClientRect().top + 8) : 0;\n}\n\n/**\n * Beside the element: below it when the bubble (`size`, its measured height once drawn) fits above point mode's bar (if any), else\n * above it, else as low as it fits. Every branch is clamped to the space above the bar, so the bubble never covers it; a bubble taller\n * than that space scrolls inside it.\n */\nfunction bubblePlace(element: Element, size = 120): CSSProperties {\n const view = element.ownerDocument.defaultView;\n const width = view?.innerWidth ?? 1024, height = (view?.innerHeight ?? 768) - pointBarInset(element.ownerDocument);\n const rect = element.getBoundingClientRect();\n const left = Math.min(Math.max(8, rect.left), Math.max(8, width - BUBBLE_WIDTH - 8));\n const below = rect.bottom + 8;\n const above = rect.top - size - 8;\n const lowest = Math.max(8, height - size);\n const top = Math.min(below + size <= height ? below : above >= 8 ? above : lowest, lowest);\n return { position: 'fixed', top, left, width: Math.min(BUBBLE_WIDTH, width - 16), maxHeight: Math.max(0, height - top), overflowY: 'auto', boxSizing: 'border-box' };\n}\n\n/** The bubble's natural height (its content plus borders), not the height it is clamped to, so it grows back when there is room. */\nconst naturalHeight = (node: HTMLElement | null): number | undefined => (node ? node.scrollHeight + node.offsetHeight - node.clientHeight : 0) || undefined;\n\ntype PinVoice = 'idle' | 'starting' | 'recording' | 'transcribing';\n\n/**\n * The mic inside the pin's field, held like the chat's voice message (`useHoldToRecord`, the same gesture policy as the composer and the\n * shell): press and hold to talk (Space or Enter from the keyboard), release and the transcript is added to the note and the note is kept\n * (with `useSendEachNote`, sent at once). A slide off the button, Escape or a hidden page drops the recording. A refused microphone, nothing\n * heard or a rejected transcription is said under the field and never touches the typed text. One capture (`createVoiceCapture`) per pin.\n */\nfunction PinMic({ session, transcribe, words }: { readonly session: FeedbackSession; readonly transcribe: PinTranscribe; readonly words: FeedbackLabels }) {\n const [phase, setPhase] = useState<PinVoice>('idle');\n const [problem, setProblem] = useState<string | undefined>(undefined);\n const capture = useRef<VoiceCapture | null>(null);\n const run = useRef(0);\n const work = useRef<AbortController | null>(null);\n // Let go before the microphone was ready: the recording ends as soon as it starts.\n const released = useRef(false);\n capture.current ??= createVoiceCapture();\n // A closed pin releases the microphone and drops a transcription still in flight.\n useEffect(() => () => { run.current += 1; capture.current?.cancel(); work.current?.abort(); }, []);\n const finish = async (): Promise<void> => {\n const attempt = run.current;\n setPhase('transcribing');\n const recording = await capture.current!.stop().catch(() => null);\n if (attempt !== run.current) return;\n if (!recording) { setPhase('idle'); setProblem(words.voiceNothing); return; }\n const abort = new AbortController();\n work.current = abort;\n try {\n const spoken = (await transcribe(recording.audio, abort.signal)).trim();\n if (attempt !== run.current) return;\n if (!spoken) { setProblem(words.voiceNothing); return; }\n // Appended to what is typed NOW (the person may have kept typing), never replacing it.\n const typed = session.getSnapshot().bubble?.text ?? '';\n session.setBubbleText(`${typed}${typed === '' || /\\s$/.test(typed) ? '' : ' '}${spoken}`);\n session.saveBubble();\n } catch { if (attempt === run.current) setProblem(words.voiceFailed); }\n finally { if (work.current === abort) work.current = null; if (attempt === run.current) setPhase('idle'); }\n };\n const hold = useHoldToRecord({\n // Called straight from the press, so the browser ties the permission prompt to the person's gesture.\n start: () => {\n const attempt = ++run.current;\n released.current = false;\n setProblem(undefined); setPhase('starting');\n void capture.current!.start().then(result => {\n if (attempt !== run.current) return;\n if (result.kind === 'refused') { setPhase('idle'); setProblem(result.reason); return; }\n setPhase('recording');\n if (released.current) void finish();\n });\n },\n finish: () => { if (phase === 'recording') void finish(); else released.current = true; },\n cancel: () => { run.current += 1; capture.current?.cancel(); setPhase('idle'); },\n }, phase === 'transcribing');\n const recording = phase === 'recording';\n return <>\n <Button {...hold} variant=\"ghost\" size=\"icon\" className=\"boring-feedback-pin-mic min-h-11 min-w-11 touch-none select-none\" data-testid=\"feedback-bubble-mic\" data-state={phase}\n aria-pressed={recording || phase === 'starting'} aria-label={recording ? words.voiceStop : words.voiceRecord} title={recording ? words.voiceStop : words.voiceRecord} disabled={phase === 'transcribing'}>\n {phase === 'transcribing' ? <span role=\"status\" aria-label={words.voiceTranscribing}>…</span>\n : recording ? <SquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Button>\n {problem && <span className=\"boring-feedback-voice-problem\" role=\"alert\" data-testid=\"feedback-bubble-voice-problem\">{problem}</span>}\n </>;\n}\n\n/** Turns a recording into text (`signal` aborts when the pin closes); a rejection keeps what was typed. */\nexport type PinTranscribe = (audio: Blob, signal: AbortSignal) => Promise<string>;\n\n/**\n * The pin beside a picked element: a text field with a mic inside it, \"Add note\" (Enter) and Cancel (Esc). Enter saves, Shift-Enter for a\n * new line, Esc discards the pin. The element is named in plain words (`plainElement`). On a phone it keeps clear of the comment banner. `transcribe` turns the mic's recording into text; without it there is no mic.\n */\nexport function NoteBubble({ session, labels, transcribe }: { readonly session: FeedbackSession; readonly labels?: Partial<FeedbackLabels>; readonly transcribe?: PinTranscribe }) {\n const state = useExternal(session);\n const words = withLabels(labels);\n const bubble = state.bubble;\n const field = useRef<HTMLTextAreaElement>(null);\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n const [, redraw] = useState(0);\n const [announce, setAnnounce] = useState('');\n const box = useRef<HTMLDivElement>(null);\n const noteId = bubble?.noteId, element = bubble?.element;\n useLayoutEffect(() => { if (element) setPlace(bubblePlace(element, naturalHeight(box.current))); }, [noteId, element]);\n // Once drawn, place it again with its real height (taller with 44px touch targets and wrapped buttons).\n // eslint-disable-next-line react-hooks/exhaustive-deps -- measures after every commit on purpose (content, labels and voice state change the height); it sets state only when the place moved, so it settles\n useLayoutEffect(() => {\n if (!bubble || !place || !box.current) return;\n const next = bubblePlace(bubble.element, naturalHeight(box.current));\n if (next.top !== place.top || next.left !== place.left) setPlace(next);\n });\n useEffect(() => { if (noteId !== undefined) field.current?.focus({ preventScroll: true }); }, [noteId]);\n useEffect(() => {\n if (!element) return;\n const view = element.ownerDocument.defaultView;\n const follow = () => { setPlace(bubblePlace(element, naturalHeight(box.current))); redraw(value => value + 1); };\n view?.addEventListener('scroll', follow, { capture: true, passive: true });\n view?.addEventListener('resize', follow);\n return () => { view?.removeEventListener('scroll', follow, { capture: true }); view?.removeEventListener('resize', follow); };\n }, [noteId, element]);\n // A polite live region outlives the bubble, so the cancellation is heard even though the dialog is gone.\n const cancel = (): void => { session.cancelSelection(); setAnnounce(words.selectionCancelled); };\n const live = <span data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"status\" aria-live=\"polite\" className=\"boring-feedback-sr\" data-testid=\"feedback-bubble-announce\"\n style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap' }}>{announce}</span>;\n if (!bubble || state.phase !== 'active') return state.phase === 'active' ? live : null;\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => {\n if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing && event.target === field.current) { event.preventDefault(); session.saveBubble(); }\n else if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); session.cancelBubble(); }\n };\n return <>{live}<Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label={`Note ${bubble.number}`} data-testid=\"feedback-bubble\" data-note={bubble.number}\n ref={box} className=\"boring-feedback-bubble\" style={place} onKeyDown={onKeyDown}>\n <span className=\"boring-feedback-bubble-head\"><span className=\"boring-feedback-pin\" aria-hidden=\"true\">{pinMark(bubble.number)}</span>\n <span className=\"boring-feedback-bubble-label\" data-testid=\"feedback-bubble-label\">{plainElement(bubble.label, words)}</span></span>\n <span className=\"boring-feedback-pin-field\">\n <Textarea ref={field} data-testid=\"feedback-bubble-input\" rows={2} placeholder={words.bubblePlaceholder} aria-label={words.bubblePlaceholder} value={bubble.text}\n onChange={event => session.setBubbleText(event.target.value)} />\n {transcribe && <PinMic key={bubble.noteId} session={session} transcribe={transcribe} words={words} />}\n </span>\n <span className=\"boring-feedback-bubble-actions\">\n <Button variant=\"ghost\" className=\"min-h-11 min-w-11\" data-testid=\"feedback-cancel-selection\" onClick={cancel}>{words.cancelSelection}</Button>\n <Button variant=\"default\" className=\"min-h-11 min-w-11\" data-testid=\"feedback-bubble-save\" onClick={() => session.saveBubble()}>{words.add}</Button>\n </span>\n </Card></>;\n}\n\nexport interface FeedbackChipProps {\n readonly session: FeedbackSession;\n /** Opens the review on first render (journeys). */\n readonly defaultOpen?: boolean;\n /** Offer Copy report in the review: the way to use feedback with no chat and no store. */\n readonly copy?: boolean;\n}\n\n/** The one composer chip after Done: `💬 Feedback · 3 notes · /settings/:section`. Click opens the review; × discards. */\nexport function FeedbackChip({ session, defaultOpen = false, copy = false }: FeedbackChipProps) {\n const state = useExternal(session);\n const [open, setOpen] = useState(defaultOpen);\n const chip = useRef<HTMLSpanElement>(null);\n if (state.phase !== 'review') return null;\n const count = noteCount(state);\n const pins = emptyPinCount(state);\n const refusal = state.send.kind !== 'idle' && state.send.kind !== 'sending' && state.send.kind !== 'saved' ? state.send.reason : undefined;\n return <span ref={chip} data-boring=\"feedback\" data-feedback-ignore=\"\" data-testid=\"feedback-chip\" data-send={state.send.kind} className=\"boring-feedback-chip\">\n <Button variant=\"outline\" className=\"min-w-0 max-w-full\" data-testid=\"feedback-chip-open\" aria-expanded={open} onClick={() => setOpen(value => !value)}\n title={refusal ?? 'Review your feedback before sending'}>\n <span aria-hidden=\"true\">💬</span> Feedback · {plural(count, 'note', 'notes')}{pins ? ` · ${plural(pins, 'pin', 'pins')} to describe` : ''} · <span className=\"boring-feedback-chip-route\">{state.route}</span>\n </Button>\n <Button variant=\"ghost\" size=\"icon-sm\" data-testid=\"feedback-chip-discard\" aria-label=\"Discard feedback\" title=\"Discard feedback\" onClick={() => { session.highlight(undefined); session.discard(); }}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n {state.problem && <span className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-chip-voice\">{state.problem}</span>}\n {refusal && <span className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-chip-problem\">{refusal}</span>}\n {/* In the page's body: the chip may sit in a transformed chat bar, where a fixed panel would not be placed against the viewport. */}\n {/* eslint-disable-next-line react-hooks/refs -- the chip element is read when the review opens (after a click, so it is attached); reading it does not drive a re-render */}\n {open && createPortal(<FeedbackReview session={session} anchor={chip.current} copy={copy} onClose={() => setOpen(false)} />, chip.current?.ownerDocument.body ?? document.body)}\n </span>;\n}\n\nfunction reviewPlace(anchor: Element | null): CSSProperties | undefined {\n if (!anchor) return undefined;\n const view = anchor.ownerDocument.defaultView;\n const width = view?.innerWidth ?? 1024, height = view?.innerHeight ?? 768;\n const rect = anchor.getBoundingClientRect();\n const panel = Math.min(416, width - 24);\n return { position: 'fixed', left: Math.min(Math.max(12, rect.left), width - panel - 12), bottom: Math.max(12, height - rect.top + 8), width: panel };\n}\n\n/** The compact review: notes in order, editable and removable; hovering a note highlights its element in the page, or says it is not here. */\nexport function FeedbackReview({ session, anchor = null, copy = false, onClose }: { readonly session: FeedbackSession; readonly anchor?: Element | null; readonly copy?: boolean; readonly onClose: () => void }) {\n const state = useExternal(session);\n const [where, setWhere] = useState<Readonly<Record<number, 'shown' | 'not-on-page'>>>({});\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- the place is measured from the anchor's layout, which exists only after commit; set before paint, once per anchor\n useLayoutEffect(() => { setPlace(reviewPlace(anchor)); }, [anchor]);\n useEffect(() => () => { session.highlight(undefined); }, [session]);\n const hover = (id: number | undefined): void => {\n const result = session.highlight(id);\n if (id !== undefined && result !== 'none') setWhere(value => ({ ...value, [id]: result }));\n };\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => { if (event.key === 'Escape') { event.stopPropagation(); onClose(); } };\n return <Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label=\"Review feedback\" data-testid=\"feedback-review\" className=\"boring-feedback-review\"\n style={place} onKeyDown={onKeyDown} onMouseLeave={() => hover(undefined)}>\n <header className=\"boring-feedback-sheet-header\"><h2>Feedback · {state.route}</h2>\n <Button variant=\"ghost\" size=\"icon\" aria-label=\"Close review\" title=\"Close review\" data-testid=\"feedback-review-close\" onClick={onClose}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button></header>\n {state.problem && <p className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-review-problem\">{state.problem}</p>}\n <ol className=\"boring-feedback-notes\">\n {state.notes.map(note => <li key={note.id} data-testid=\"feedback-review-note\" data-note={note.number} data-where={where[note.id]}\n onMouseEnter={() => hover(note.id)} onFocus={() => hover(note.id)}>\n <span className=\"boring-feedback-note-head\"><span className=\"boring-feedback-pin\" aria-hidden=\"true\">{pinMark(note.number)}</span>\n <ElementName note={note} />\n {note.from === 'voice' && <Badge variant=\"secondary\" data-testid=\"feedback-review-voice\">voice</Badge>}\n {where[note.id] === 'not-on-page' && <Badge variant=\"secondary\" data-testid=\"feedback-review-elsewhere\">not on this page</Badge>}\n <Button variant=\"ghost\" size=\"icon\" aria-label={`Remove note ${note.number}`} title={`Remove note ${note.number}`} data-testid=\"feedback-review-remove\" onClick={() => session.removeNote(note.id)}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button></span>\n <Textarea rows={2} aria-label={`Note ${note.number}`} data-testid=\"feedback-review-text\" value={note.text} placeholder={note.recorded ? 'Nothing was said here: type a note, or remove the pin' : undefined}\n onChange={event => session.setNoteText(note.id, event.target.value)} />\n </li>)}\n </ol>\n <label className=\"boring-feedback-field\"><span>General note</span>\n <Textarea rows={2} data-testid=\"feedback-review-general\" placeholder=\"Anything else? (optional)\" value={state.general} onChange={event => session.setGeneral(event.target.value)} /></label>\n <p className=\"boring-feedback-meta\">{state.stepCount > 1 ? `${plural(state.stepCount, 'step', 'steps')} recorded between the notes. ` : ''}{copy ? 'Copy the report and paste it anywhere.' : 'Sent with your next message.'}</p>\n {copy && <div className=\"boring-feedback-actions\">\n <Button variant=\"default\" data-testid=\"feedback-copy\" onClick={() => { void session.copy(); }}>Copy report</Button></div>}\n {copy && <p role=\"status\" aria-live=\"polite\" className=\"boring-feedback-status\" data-testid=\"feedback-status\" data-copy={state.copy.kind}>\n {state.copy.kind === 'copied' ? 'Report copied. Paste it anywhere.' : state.copy.kind === 'manual' ? 'The clipboard is not available here: select the report below and copy it.' : state.copy.kind === 'refused' ? state.copy.reason : ''}</p>}\n {copy && state.copy.kind === 'manual' && <Textarea readOnly className=\"boring-feedback-manual\" data-testid=\"feedback-manual-copy\" value={state.copy.text} rows={6} onFocus={event => event.currentTarget.select()} />}\n {copy && state.copy.kind === 'copied' && <details className=\"boring-feedback-copied\"><summary>Copied report</summary><pre data-testid=\"feedback-copy-text\">{state.copy.text}</pre></details>}\n </Card>;\n}\n\n/**\n * A note's element as one calm line: the readable name (semibold) and the file basename (muted, monospace), the full path in the\n * tooltip. The same reading as the pi-chat feedback card's element line (`readableElement` from `@hachej/boring-feedback/format`).\n */\nexport function ElementName({ note }: { readonly note: SessionNote }) {\n const readable = note.fallback ? readableElement(note.fallback, note.source) : { name: note.label };\n return <span className=\"boring-feedback-element\" data-testid=\"feedback-review-element\" title={'path' in readable && readable.path ? readable.path : note.label}>\n <span className=\"boring-feedback-element-name\" data-testid=\"feedback-review-name\">{readable.name}</span>\n {'file' in readable && readable.file && <span className=\"boring-feedback-element-file\" data-testid=\"feedback-review-file\">{readable.file}</span>}\n </span>;\n}\n\n/** What `useComposerFeedback` returns: the structural shape of pi-chat's `ComposerFeedback`. */\nexport interface ComposerFeedbackProp {\n readonly annotate: () => void;\n readonly active: boolean;\n readonly count: number;\n readonly clear: () => void;\n readonly attach: (text: string) => Promise<{ readonly kind: 'ok'; readonly text: string } | { readonly kind: 'refused'; readonly reason: string }>;\n readonly sent: () => void;\n}\n\n/** A Markdown fence longer than any backtick run in the text. */\nfunction fenced(text: string): string {\n const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map(match => match[0].length));\n const fence = '`'.repeat(Math.max(3, longest + 1));\n return `${fence}markdown\\n${text.endsWith('\\n') ? text : `${text}\\n`}${fence}`;\n}\n\n/** Touch or a compact screen: the panel covers the app and there is no hover, so annotating is point mode (use the app, then point). */\nfunction pointsInstead(): boolean {\n return globalThis.matchMedia?.('(pointer: coarse)').matches === true || (globalThis.innerWidth ?? 1024) < 768;\n}\n\n/**\n * Starts (or resumes) annotating in either mode, with Esc as a way out. The session swallows Esc on the page (it only closes a pin), so\n * this listens on the window's capture phase BEFORE the session starts, and ends the mode (Done: the notes wait for Send) when no pin is\n * open. From the review, `resume` keeps the notes, anchors and save guards: a second round adds to the same draft.\n */\nfunction startAnnotating(session: FeedbackSession, mode: 'point' | undefined = pointsInstead() ? 'point' : undefined, fresh = true): void {\n if (fresh) sentInRound.delete(session);\n const view = globalThis.window;\n const onKey = (event: globalThis.KeyboardEvent): void => {\n // Esc that ends annotating is spent: a host layer around the composer (a panel that closes on Esc) must not also react to it.\n if (event.key === 'Escape' && !session.getSnapshot().bubble) { event.preventDefault(); event.stopPropagation(); void session.done(); }\n };\n view?.addEventListener('keydown', onKey, { capture: true });\n session.resume(mode ? { mode } : {});\n const stop = session.subscribe(() => {\n if (session.getSnapshot().phase === 'active') return;\n view?.removeEventListener('keydown', onKey, { capture: true });\n stop();\n });\n}\n\n/**\n * The composer's `feedback` prop for a session: ONE annotate toggle (targeting with a hover highlight; point mode on touch and compact\n * screens, and again after Esc to add more), a count of the notes taken, and Send attaching them: a saved report as its `@` mention (the host's mention resolver inlines\n * the stored report with the person's access), otherwise the rendered report inline in a fenced block. Pair it with `NoteBubble`.\n */\nexport function useComposerFeedback(session: FeedbackSession | undefined): ComposerFeedbackProp | undefined {\n const state = useSyncState(session);\n if (!session || !state) return undefined;\n const open = state.phase === 'active' || state.phase === 'processing';\n return {\n annotate: () => {\n const phase = session.getSnapshot().phase;\n if (phase === 'active') void session.done();\n else if (phase === 'idle' || phase === 'review') startAnnotating(session);\n },\n active: open,\n // A note being typed in the pin counts: Send saves it.\n count: noteCount(state) + (state.bubble?.fresh && state.bubble.text.trim() ? 1 : 0),\n clear: () => { session.highlight(undefined); session.discard(); },\n attach: async text => {\n // Send while annotating ends it first: the pin being typed is kept.\n if (session.getSnapshot().phase === 'active') await session.done();\n const attached = await session.attach();\n if (attached.kind === 'refused') return attached;\n const body = attached.kind === 'mention' ? `@${attached.path}` : fenced(attached.text);\n return { kind: 'ok', text: text.trim() ? `${text.trimEnd()}\\n\\n${body}` : body };\n },\n sent: () => session.sent(),\n };\n}\n\n/** Notes sent one by one in the current round of comment mode (`useSendEachNote`), for the banner; a new round starts at zero. */\nconst sentInRound = new WeakMap<FeedbackSession, number>();\nexport const notesSent = (session: FeedbackSession): number => sentInRound.get(session) ?? 0;\n\n/**\n * Each note goes to the chat the moment it is added: its element and text as one message (`send`, the chat's follow-up send), and\n * comment mode stays on for the next note. Nothing waits in a hidden batch, and the agent's reply follows in the conversation. Without\n * `send`, the notes wait for the person's next message as before. A refused report or a failed send stays in the review, where the composer shows it.\n */\nexport function useSendEachNote(session: FeedbackSession | undefined, send: ((text: string) => Promise<unknown>) | undefined): void {\n const state = useSyncState(session);\n const busy = useRef(false);\n useEffect(() => {\n if (!session || !send || !state || busy.current) return;\n if (state.phase !== 'active' || state.bubble || !state.notes.some(note => note.text.trim())) return;\n busy.current = true;\n const mode = state.mode === 'point' ? 'point' : undefined;\n void (async () => {\n let again = false;\n try {\n await session.done();\n const attached = await session.attach();\n if (attached.kind === 'refused') return;\n const submission = await send(attached.kind === 'mention' ? `@${attached.path}` : fenced(attached.text)).catch(() => undefined);\n if (submission === false || submission === undefined || submission === null) return;\n sentInRound.set(session, notesSent(session) + noteCount(session.getSnapshot()));\n session.sent();\n again = true;\n } finally {\n busy.current = false;\n if (again) startAnnotating(session, mode, false);\n }\n })();\n }, [session, send, state]);\n}\n\nconst NO_STATE = { getSnapshot: () => undefined, subscribe: () => () => {} };\nfunction useSyncState(session: FeedbackSession | undefined): FeedbackSessionSnapshot | undefined {\n return useExternal<FeedbackSessionSnapshot | undefined>(session ?? NO_STATE);\n}\n\n/** What `usePointComments` returns: the structural shape of the maquette viewer's `MaquetteComments` (any viewer may take it). */\nexport interface PointCommentsProp {\n /** A host-owned proxy element for an opaque frame pick, checked by the session's root and privacy policy. */\n readonly pick: (element: Element) => void;\n readonly reposition: () => void;\n /** Clicks are currently captured, false during Use app or processing. */\n readonly picking: boolean;\n /** The session is annotating now (point mode or feedback mode). */\n readonly active: boolean;\n /** Point mode on the page, keeping the notes already taken (from the review too); Done when it is on. Esc ends it like the composer's toggle. */\n readonly point: () => void;\n /** Every pin with a marked element: its number and the `data-feedback-id` it sits in (`SessionNote.feedbackId`). */\n readonly pins: readonly { readonly number: number; readonly target: string }[];\n}\n\n/**\n * Comment pins for a viewer inside the page (the maquette viewer's `comments`): the same session, notes, bubble, review and Send as the\n * composer's toggle, entered in point mode from the viewer's own icon. A viewer marks its parts with `data-feedback-id` and matches the\n * pins on them. Undefined without a session, so the viewer shows no icon.\n */\nexport function usePointComments(session: FeedbackSession | undefined): PointCommentsProp | undefined {\n const state = useSyncState(session);\n // One object per session state, so a host that puts it in a memoized kind list re-renders only when the pins or the mode change.\n return useMemo(() => !session || !state ? undefined : {\n active: state.phase === 'active' || state.phase === 'processing',\n pick: session.pick,\n reposition: session.reposition,\n picking: state.phase === 'active' && !state.using,\n point: () => {\n const phase = session.getSnapshot().phase;\n if (phase === 'active') void session.done();\n else if (phase === 'idle' || phase === 'review') startAnnotating(session, 'point');\n },\n pins: state.notes.flatMap(note => note.feedbackId ? [{ number: note.number, target: note.feedbackId }] : []),\n }, [session, state]);\n}\n",
|
|
22
|
+
"content": "// The feedback session's look (FEEDBACK.md, \"UX\"): the note pin beside a picked element (NoteBubble: a text field with a mic inside it),\n// the slim bar and the chip with its review (optional, for hosts with no chat). Everything they show and do is the headless\n// `createFeedbackSession` from `@hachej/boring-feedback/ui` (hover labels, pins, steps, the draft and Send come from there, through the\n// privacy policy); this file is how it looks. `useComposerFeedback` adapts a session to the chat composer's optional `feedback` prop\n// (registry/pi-chat): ONE annotate toggle and a count chip, structurally, so neither item imports the other.\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';\nimport { createPortal } from 'react-dom';\nimport { MicIcon, SquareIcon, XIcon } from '../_shared/icons';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Badge } from '@/components/ui/badge';\nimport { Card } from '@/components/ui/card';\nimport { Textarea } from '@/components/ui/textarea';\nimport { readableElement } from '@hachej/boring-feedback/format';\nimport { createVoiceCapture, type VoiceCapture } from '@hachej/boring-feedback/page';\nimport { pinMark, type FeedbackSession, type FeedbackSessionSnapshot, type SessionNote } from '@hachej/boring-feedback/ui';\nimport { useHoldToRecord } from '../pi-chat-voice/use-hold-to-record';\nimport { useExternal } from './use-external';\nimport { plainElement, withLabels, type FeedbackLabels } from './feedback-labels';\n\nconst plural = (count: number, one: string, many: string): string => `${count} ${count === 1 ? one : many}`;\n/** Notes the chip and the bar count: every note that says something (not a pin still being typed), plus the general note. */\nexport function noteCount(state: FeedbackSessionSnapshot): number {\n return state.notes.filter(note => note.text.trim()).length + (state.general.trim() ? 1 : 0);\n}\n\nexport interface FeedbackBarProps {\n readonly session: FeedbackSession;\n readonly className?: string;\n /** Docked under the chat composer (the composer's `feedback.bar`) instead of floating at the top of the page. */\n readonly docked?: boolean;\n}\n\n/** Pins dropped while recording that have no note yet (nothing transcribed for them, or transcription failed). */\nexport function emptyPinCount(state: FeedbackSessionSnapshot): number {\n return state.notes.filter(note => note.recorded && !note.text.trim()).length;\n}\n\n/** `mm:ss` since `since` (ms), for the recording timer. */\nexport function elapsed(ms: number): string {\n const seconds = Math.max(0, Math.floor(ms / 1000));\n return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;\n}\n\n/** The running time of a recording, from when the bar saw it start; 00:00 when not recording. */\nfunction useRecordingTime(recording: boolean): string {\n const [since, setSince] = useState<number | undefined>(undefined);\n const [, tick] = useState(0);\n useEffect(() => {\n // eslint-disable-next-line react-hooks/set-state-in-effect -- the start time is taken when the bar sees the recording start (after commit), once per change of recording\n if (!recording) { setSince(undefined); return; }\n setSince(Date.now());\n const timer = setInterval(() => tick(value => value + 1), 500);\n return () => clearInterval(timer);\n }, [recording]);\n // eslint-disable-next-line react-hooks/purity -- a running clock: each tick re-renders to read the current time\n return elapsed(since === undefined ? 0 : Date.now() - since);\n}\n\nfunction barHint(state: FeedbackSessionSnapshot): string {\n if (state.phase === 'processing') return state.transcribing ? 'Transcribing…' : 'Preparing your feedback…';\n if (state.problem) return state.problem;\n if (state.voice.kind === 'starting') return 'Allow the microphone if your browser asks…';\n if (state.voice.kind === 'recording') return state.using ? 'Recording — using the app' : 'Recording — point and talk';\n return state.using ? 'Using the app: your clicks are recorded as steps' : 'Click what you want to comment on';\n}\n\n/**\n * Feedback mode's bar: `● Feedback · N notes · 🎙 Record · Use app · Done · ✕`. While recording: a red dot, the\n * running time, \"Recording — point and talk\" and Stop. A refused microphone is said in the bar itself, never only in a tooltip.\n */\nexport function FeedbackBar({ session, className, docked = false }: FeedbackBarProps) {\n const state = useExternal(session);\n const recording = state.voice.kind === 'recording';\n const time = useRecordingTime(recording && (state.phase === 'active' || state.phase === 'processing'));\n if (state.phase !== 'active' && state.phase !== 'processing') return null;\n const processing = state.phase === 'processing';\n const pins = emptyPinCount(state);\n const voiceProblem = state.voice.kind === 'refused' ? state.voice : undefined;\n return <Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"toolbar\" aria-label=\"Feedback mode\" data-testid=\"feedback-bar\" data-docked={docked ? 'true' : 'false'}\n data-using={state.using ? 'true' : 'false'} data-recording={recording ? 'true' : 'false'} data-transcribing={state.transcribing ? 'true' : 'false'}\n className={['boring-feedback-bar', className].filter(Boolean).join(' ')}\n onKeyDown={event => { if (event.key === 'Escape' && state.bubble) { event.stopPropagation(); session.cancelBubble(); } }}>\n <span className=\"boring-feedback-bar-title\">Feedback</span>\n <span className=\"boring-feedback-bar-meta\" data-testid=\"feedback-bar-count\">{plural(noteCount(state), 'note', 'notes')}{pins ? ` · ${plural(pins, 'pin', 'pins')}` : ''}</span>\n {state.voiceAvailable && <Toggle data-testid=\"feedback-voice\" pressed={recording}\n disabled={processing || state.voice.kind === 'starting' || state.voice.kind === 'stopping'} title={recording ? 'Stop recording' : 'Record your voice while you point: each click drops a pin, what you say becomes its note'}\n onPressedChange={() => { void session.toggleVoice(); }}>\n {recording ? <><SquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" />Stop</> : <><MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{state.voice.kind === 'starting' ? 'Starting…' : state.voice.kind === 'stopping' ? 'Stopping…' : 'Record'}</>}\n </Toggle>}\n {recording && <Badge variant=\"destructive\" role=\"status\" data-testid=\"feedback-recording\">\n <span className=\"boring-feedback-timer\" data-testid=\"feedback-recording-time\">{time}</span></Badge>}\n <span className=\"boring-feedback-hint\" role=\"status\" aria-live=\"polite\" data-testid=\"feedback-bar-hint\">{barHint(state)}</span>\n {voiceProblem && <span className=\"boring-feedback-voice-problem\" role=\"alert\" data-testid=\"feedback-voice-problem\" data-code={voiceProblem.code}>{voiceProblem.reason}</span>}\n {!processing && <Toggle data-testid=\"feedback-use-app\" pressed={state.useToggle} onPressedChange={pressed => session.setUseToggle(pressed)}>Use app</Toggle>}\n <Button variant=\"default\" data-testid=\"feedback-done\" disabled={processing} onClick={() => { void session.done(); }}>Done</Button>\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"feedback-discard\" aria-label=\"Discard feedback\" title=\"Discard feedback\" disabled={processing} onClick={() => session.discard()}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </Card>;\n}\n\nconst BUBBLE_WIDTH = 280;\n/** Pixels at the bottom of the viewport taken by comment mode's banner on a phone (CommentBanner, `data-feedback-point-bar=\"bottom\"`), plus a gap; 0 without it. */\nexport function pointBarInset(document: Document): number {\n const bar = document.querySelector('[data-feedback-point-bar=\"bottom\"]');\n const height = document.defaultView?.innerHeight ?? 768;\n return bar ? Math.max(0, height - bar.getBoundingClientRect().top + 8) : 0;\n}\n\n/**\n * Beside the element: below it when the bubble (`size`, its measured height once drawn) fits above point mode's bar (if any), else\n * above it, else as low as it fits. Every branch is clamped to the space above the bar, so the bubble never covers it; a bubble taller\n * than that space scrolls inside it.\n */\nfunction bubblePlace(element: Element, size = 120): CSSProperties {\n const view = element.ownerDocument.defaultView;\n const width = view?.innerWidth ?? 1024, height = (view?.innerHeight ?? 768) - pointBarInset(element.ownerDocument);\n const rect = element.getBoundingClientRect();\n const left = Math.min(Math.max(8, rect.left), Math.max(8, width - BUBBLE_WIDTH - 8));\n const below = rect.bottom + 8;\n const above = rect.top - size - 8;\n const lowest = Math.max(8, height - size);\n const top = Math.min(below + size <= height ? below : above >= 8 ? above : lowest, lowest);\n return { position: 'fixed', top, left, width: Math.min(BUBBLE_WIDTH, width - 16), maxHeight: Math.max(0, height - top), overflowY: 'auto', boxSizing: 'border-box' };\n}\n\n/** The bubble's natural height (its content plus borders), not the height it is clamped to, so it grows back when there is room. */\nconst naturalHeight = (node: HTMLElement | null): number | undefined => (node ? node.scrollHeight + node.offsetHeight - node.clientHeight : 0) || undefined;\n\ntype PinVoice = 'idle' | 'starting' | 'recording' | 'transcribing';\n\n/**\n * The mic inside the pin's field, held like the chat's voice message (`useHoldToRecord`, the same gesture policy as the composer and the\n * shell): press and hold to talk (Space or Enter from the keyboard), release and the transcript is added to the note and the note is kept\n * (with `useSendEachNote`, sent at once). A slide off the button, Escape or a hidden page drops the recording. A refused microphone, nothing\n * heard or a rejected transcription is said under the field and never touches the typed text. One capture (`createVoiceCapture`) per pin.\n */\nfunction PinMic({ session, transcribe, words }: { readonly session: FeedbackSession; readonly transcribe: PinTranscribe; readonly words: FeedbackLabels }) {\n const [phase, setPhase] = useState<PinVoice>('idle');\n const [problem, setProblem] = useState<string | undefined>(undefined);\n const capture = useRef<VoiceCapture | null>(null);\n const run = useRef(0);\n const work = useRef<AbortController | null>(null);\n // Let go before the microphone was ready: the recording ends as soon as it starts.\n const released = useRef(false);\n capture.current ??= createVoiceCapture();\n // A closed pin releases the microphone and drops a transcription still in flight.\n useEffect(() => () => { run.current += 1; capture.current?.cancel(); work.current?.abort(); }, []);\n const finish = async (): Promise<void> => {\n const attempt = run.current;\n setPhase('transcribing');\n const recording = await capture.current!.stop().catch(() => null);\n if (attempt !== run.current) return;\n if (!recording) { setPhase('idle'); setProblem(words.voiceNothing); return; }\n const abort = new AbortController();\n work.current = abort;\n try {\n const spoken = (await transcribe(recording.audio, abort.signal)).trim();\n if (attempt !== run.current) return;\n if (!spoken) { setProblem(words.voiceNothing); return; }\n // Appended to what is typed NOW (the person may have kept typing), never replacing it.\n const typed = session.getSnapshot().bubble?.text ?? '';\n session.setBubbleText(`${typed}${typed === '' || /\\s$/.test(typed) ? '' : ' '}${spoken}`);\n session.saveBubble();\n } catch { if (attempt === run.current) setProblem(words.voiceFailed); }\n finally { if (work.current === abort) work.current = null; if (attempt === run.current) setPhase('idle'); }\n };\n const hold = useHoldToRecord({\n // Called straight from the press, so the browser ties the permission prompt to the person's gesture.\n start: () => {\n const attempt = ++run.current;\n released.current = false;\n setProblem(undefined); setPhase('starting');\n void capture.current!.start().then(result => {\n if (attempt !== run.current) return;\n if (result.kind === 'refused') { setPhase('idle'); setProblem(result.reason); return; }\n setPhase('recording');\n if (released.current) void finish();\n });\n },\n finish: () => { if (phase === 'recording') void finish(); else released.current = true; },\n cancel: () => { run.current += 1; capture.current?.cancel(); setPhase('idle'); },\n }, phase === 'transcribing');\n const recording = phase === 'recording';\n return <>\n <Button {...hold} variant=\"ghost\" size=\"icon\" className=\"boring-feedback-pin-mic min-h-11 min-w-11 touch-none select-none\" data-testid=\"feedback-bubble-mic\" data-state={phase}\n aria-pressed={recording || phase === 'starting'} aria-label={recording ? words.voiceStop : words.voiceRecord} title={recording ? words.voiceStop : words.voiceRecord} disabled={phase === 'transcribing'}>\n {phase === 'transcribing' ? <span role=\"status\" aria-label={words.voiceTranscribing}>…</span>\n : recording ? <SquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Button>\n {problem && <span className=\"boring-feedback-voice-problem\" role=\"alert\" data-testid=\"feedback-bubble-voice-problem\">{problem}</span>}\n </>;\n}\n\n/** Turns a recording into text (`signal` aborts when the pin closes); a rejection keeps what was typed. */\nexport type PinTranscribe = (audio: Blob, signal: AbortSignal) => Promise<string>;\n\n/**\n * The pin beside a picked element: a text field with a mic inside it, \"Add note\" (Enter) and Cancel (Esc). Enter saves, Shift-Enter for a\n * new line, Esc discards the pin. The element is named in plain words (`plainElement`). On a phone it keeps clear of the comment banner. `transcribe` turns the mic's recording into text; without it there is no mic.\n */\nexport function NoteBubble({ session, labels, transcribe }: { readonly session: FeedbackSession; readonly labels?: Partial<FeedbackLabels>; readonly transcribe?: PinTranscribe }) {\n const state = useExternal(session);\n const words = withLabels(labels);\n const bubble = state.bubble;\n const field = useRef<HTMLTextAreaElement>(null);\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n const [, redraw] = useState(0);\n const [announce, setAnnounce] = useState('');\n const box = useRef<HTMLDivElement>(null);\n const noteId = bubble?.noteId, element = bubble?.element;\n useLayoutEffect(() => { if (element) setPlace(bubblePlace(element, naturalHeight(box.current))); }, [noteId, element]);\n // Once drawn, place it again with its real height (taller with 44px touch targets and wrapped buttons).\n // eslint-disable-next-line react-hooks/exhaustive-deps -- measures after every commit on purpose (content, labels and voice state change the height); it sets state only when the place moved, so it settles\n useLayoutEffect(() => {\n if (!bubble || !place || !box.current) return;\n const next = bubblePlace(bubble.element, naturalHeight(box.current));\n if (next.top !== place.top || next.left !== place.left) setPlace(next);\n });\n useEffect(() => { if (noteId !== undefined) field.current?.focus({ preventScroll: true }); }, [noteId]);\n useEffect(() => {\n if (!element) return;\n const view = element.ownerDocument.defaultView;\n const follow = () => { setPlace(bubblePlace(element, naturalHeight(box.current))); redraw(value => value + 1); };\n view?.addEventListener('scroll', follow, { capture: true, passive: true });\n view?.addEventListener('resize', follow);\n return () => { view?.removeEventListener('scroll', follow, { capture: true }); view?.removeEventListener('resize', follow); };\n }, [noteId, element]);\n // A polite live region outlives the bubble, so the cancellation is heard even though the dialog is gone.\n const cancel = (): void => { session.cancelSelection(); setAnnounce(words.selectionCancelled); };\n const live = <span data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"status\" aria-live=\"polite\" className=\"boring-feedback-sr\" data-testid=\"feedback-bubble-announce\"\n style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap' }}>{announce}</span>;\n if (!bubble || state.phase !== 'active') return state.phase === 'active' ? live : null;\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => {\n if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing && event.target === field.current) { event.preventDefault(); session.saveBubble(); }\n else if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); session.cancelBubble(); }\n };\n return <>{live}<Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label={`Note ${bubble.number}`} data-testid=\"feedback-bubble\" data-note={bubble.number}\n ref={box} className=\"boring-feedback-bubble\" style={place} onKeyDown={onKeyDown}>\n <span className=\"boring-feedback-bubble-head\"><span className=\"boring-feedback-pin\" aria-hidden=\"true\">{pinMark(bubble.number)}</span>\n <span className=\"boring-feedback-bubble-label\" data-testid=\"feedback-bubble-label\">{plainElement(bubble.label, words)}</span></span>\n <span className=\"boring-feedback-pin-field\">\n <Textarea ref={field} data-testid=\"feedback-bubble-input\" rows={2} placeholder={words.bubblePlaceholder} aria-label={words.bubblePlaceholder} value={bubble.text}\n onChange={event => session.setBubbleText(event.target.value)} />\n {transcribe && <PinMic key={bubble.noteId} session={session} transcribe={transcribe} words={words} />}\n </span>\n <span className=\"boring-feedback-bubble-actions\">\n <Button variant=\"ghost\" className=\"min-h-11 min-w-11\" data-testid=\"feedback-cancel-selection\" onClick={cancel}>{words.cancelSelection}</Button>\n <Button variant=\"default\" className=\"min-h-11 min-w-11\" data-testid=\"feedback-bubble-save\" onClick={() => session.saveBubble()}>{words.add}</Button>\n </span>\n </Card></>;\n}\n\nexport interface FeedbackChipProps {\n readonly session: FeedbackSession;\n /** Opens the review on first render (journeys). */\n readonly defaultOpen?: boolean;\n /** Offer Copy report in the review: the way to use feedback with no chat and no store. */\n readonly copy?: boolean;\n}\n\n/** The one composer chip after Done: `💬 Feedback · 3 notes · /settings/:section`. Click opens the review; × discards. */\nexport function FeedbackChip({ session, defaultOpen = false, copy = false }: FeedbackChipProps) {\n const state = useExternal(session);\n const [open, setOpen] = useState(defaultOpen);\n const chip = useRef<HTMLSpanElement>(null);\n if (state.phase !== 'review') return null;\n const count = noteCount(state);\n const pins = emptyPinCount(state);\n const refusal = state.send.kind !== 'idle' && state.send.kind !== 'sending' && state.send.kind !== 'saved' ? state.send.reason : undefined;\n return <span ref={chip} data-boring=\"feedback\" data-feedback-ignore=\"\" data-testid=\"feedback-chip\" data-send={state.send.kind} className=\"boring-feedback-chip\">\n <Button variant=\"outline\" className=\"min-w-0 max-w-full\" data-testid=\"feedback-chip-open\" aria-expanded={open} onClick={() => setOpen(value => !value)}\n title={refusal ?? 'Review your feedback before sending'}>\n <span aria-hidden=\"true\">💬</span> Feedback · {plural(count, 'note', 'notes')}{pins ? ` · ${plural(pins, 'pin', 'pins')} to describe` : ''} · <span className=\"boring-feedback-chip-route\">{state.route}</span>\n </Button>\n <Button variant=\"ghost\" size=\"icon-sm\" data-testid=\"feedback-chip-discard\" aria-label=\"Discard feedback\" title=\"Discard feedback\" onClick={() => { session.highlight(undefined); session.discard(); }}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n {state.problem && <span className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-chip-voice\">{state.problem}</span>}\n {refusal && <span className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-chip-problem\">{refusal}</span>}\n {/* In the page's body: the chip may sit in a transformed chat bar, where a fixed panel would not be placed against the viewport. */}\n {/* eslint-disable-next-line react-hooks/refs -- the chip element is read when the review opens (after a click, so it is attached); reading it does not drive a re-render */}\n {open && createPortal(<FeedbackReview session={session} anchor={chip.current} copy={copy} onClose={() => setOpen(false)} />, chip.current?.ownerDocument.body ?? document.body)}\n </span>;\n}\n\nfunction reviewPlace(anchor: Element | null): CSSProperties | undefined {\n if (!anchor) return undefined;\n const view = anchor.ownerDocument.defaultView;\n const width = view?.innerWidth ?? 1024, height = view?.innerHeight ?? 768;\n const rect = anchor.getBoundingClientRect();\n const panel = Math.min(416, width - 24);\n return { position: 'fixed', left: Math.min(Math.max(12, rect.left), width - panel - 12), bottom: Math.max(12, height - rect.top + 8), width: panel };\n}\n\n/** The compact review: notes in order, editable and removable; hovering a note highlights its element in the page, or says it is not here. */\nexport function FeedbackReview({ session, anchor = null, copy = false, onClose }: { readonly session: FeedbackSession; readonly anchor?: Element | null; readonly copy?: boolean; readonly onClose: () => void }) {\n const state = useExternal(session);\n const [where, setWhere] = useState<Readonly<Record<number, 'shown' | 'not-on-page'>>>({});\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- the place is measured from the anchor's layout, which exists only after commit; set before paint, once per anchor\n useLayoutEffect(() => { setPlace(reviewPlace(anchor)); }, [anchor]);\n useEffect(() => () => { session.highlight(undefined); }, [session]);\n const hover = (id: number | undefined): void => {\n const result = session.highlight(id);\n if (id !== undefined && result !== 'none') setWhere(value => ({ ...value, [id]: result }));\n };\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => { if (event.key === 'Escape') { event.stopPropagation(); onClose(); } };\n return <Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label=\"Review feedback\" data-testid=\"feedback-review\" className=\"boring-feedback-review\"\n style={place} onKeyDown={onKeyDown} onMouseLeave={() => hover(undefined)}>\n <header className=\"boring-feedback-sheet-header\"><h2>Feedback · {state.route}</h2>\n <Button variant=\"ghost\" size=\"icon\" aria-label=\"Close review\" title=\"Close review\" data-testid=\"feedback-review-close\" onClick={onClose}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button></header>\n {state.problem && <p className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-review-problem\">{state.problem}</p>}\n <ol className=\"boring-feedback-notes\">\n {state.notes.map(note => <li key={note.id} data-testid=\"feedback-review-note\" data-note={note.number} data-where={where[note.id]}\n onMouseEnter={() => hover(note.id)} onFocus={() => hover(note.id)}>\n <span className=\"boring-feedback-note-head\"><span className=\"boring-feedback-pin\" aria-hidden=\"true\">{pinMark(note.number)}</span>\n <ElementName note={note} />\n {note.from === 'voice' && <Badge variant=\"secondary\" data-testid=\"feedback-review-voice\">voice</Badge>}\n {where[note.id] === 'not-on-page' && <Badge variant=\"secondary\" data-testid=\"feedback-review-elsewhere\">not on this page</Badge>}\n <Button variant=\"ghost\" size=\"icon\" aria-label={`Remove note ${note.number}`} title={`Remove note ${note.number}`} data-testid=\"feedback-review-remove\" onClick={() => session.removeNote(note.id)}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button></span>\n <Textarea rows={2} aria-label={`Note ${note.number}`} data-testid=\"feedback-review-text\" value={note.text} placeholder={note.recorded ? 'Nothing was said here: type a note, or remove the pin' : undefined}\n onChange={event => session.setNoteText(note.id, event.target.value)} />\n </li>)}\n </ol>\n <label className=\"boring-feedback-field\"><span>General note</span>\n <Textarea rows={2} data-testid=\"feedback-review-general\" placeholder=\"Anything else? (optional)\" value={state.general} onChange={event => session.setGeneral(event.target.value)} /></label>\n <p className=\"boring-feedback-meta\">{state.stepCount > 1 ? `${plural(state.stepCount, 'step', 'steps')} recorded between the notes. ` : ''}{copy ? 'Copy the report and paste it anywhere.' : 'Sent with your next message.'}</p>\n {copy && <div className=\"boring-feedback-actions\">\n <Button variant=\"default\" data-testid=\"feedback-copy\" onClick={() => { void session.copy(); }}>Copy report</Button></div>}\n {copy && <p role=\"status\" aria-live=\"polite\" className=\"boring-feedback-status\" data-testid=\"feedback-status\" data-copy={state.copy.kind}>\n {state.copy.kind === 'copied' ? 'Report copied. Paste it anywhere.' : state.copy.kind === 'manual' ? 'The clipboard is not available here: select the report below and copy it.' : state.copy.kind === 'refused' ? state.copy.reason : ''}</p>}\n {copy && state.copy.kind === 'manual' && <Textarea readOnly className=\"boring-feedback-manual\" data-testid=\"feedback-manual-copy\" value={state.copy.text} rows={6} onFocus={event => event.currentTarget.select()} />}\n {copy && state.copy.kind === 'copied' && <details className=\"boring-feedback-copied\"><summary>Copied report</summary><pre data-testid=\"feedback-copy-text\">{state.copy.text}</pre></details>}\n </Card>;\n}\n\n/**\n * A note's element as one calm line: the readable name (semibold) and the file basename (muted, monospace), the full path in the\n * tooltip. The same reading as the pi-chat feedback card's element line (`readableElement` from `@hachej/boring-feedback/format`).\n */\nexport function ElementName({ note }: { readonly note: SessionNote }) {\n const readable = note.fallback ? readableElement(note.fallback, note.source) : { name: note.label };\n return <span className=\"boring-feedback-element\" data-testid=\"feedback-review-element\" title={'path' in readable && readable.path ? readable.path : note.label}>\n <span className=\"boring-feedback-element-name\" data-testid=\"feedback-review-name\">{readable.name}</span>\n {'file' in readable && readable.file && <span className=\"boring-feedback-element-file\" data-testid=\"feedback-review-file\">{readable.file}</span>}\n </span>;\n}\n\n/** What `useComposerFeedback` returns: the structural shape of pi-chat's `ComposerFeedback`. */\nexport interface ComposerFeedbackProp {\n readonly annotate: () => void;\n readonly active: boolean;\n readonly count: number;\n readonly clear: () => void;\n readonly attach: (text: string) => Promise<{ readonly kind: 'ok'; readonly text: string } | { readonly kind: 'refused'; readonly reason: string }>;\n readonly sent: () => void;\n}\n\n/** A Markdown fence longer than any backtick run in the text. */\nfunction fenced(text: string): string {\n const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map(match => match[0].length));\n const fence = '`'.repeat(Math.max(3, longest + 1));\n return `${fence}markdown\\n${text.endsWith('\\n') ? text : `${text}\\n`}${fence}`;\n}\n\n/** Touch or a compact screen: the panel covers the app and there is no hover, so annotating is point mode (use the app, then point). */\nfunction pointsInstead(): boolean {\n return globalThis.matchMedia?.('(pointer: coarse)').matches === true || (globalThis.innerWidth ?? 1024) < 768;\n}\n\n/**\n * Starts (or resumes) annotating in either mode, with Esc as a way out. The session swallows Esc on the page (it only closes a pin), so\n * this listens on the window's capture phase BEFORE the session starts, and ends the mode (Done: the notes wait for Send) when no pin is\n * open. From the review, `resume` keeps the notes, anchors and save guards: a second round adds to the same draft.\n */\nfunction startAnnotating(session: FeedbackSession, mode: 'point' | undefined = pointsInstead() ? 'point' : undefined, fresh = true): void {\n if (fresh) sentInRound.delete(session);\n const view = globalThis.window;\n const onKey = (event: globalThis.KeyboardEvent): void => {\n // Esc that ends annotating is spent: a host layer around the composer (a panel that closes on Esc) must not also react to it.\n if (event.key === 'Escape' && !session.getSnapshot().bubble) { event.preventDefault(); event.stopPropagation(); void session.done(); }\n };\n view?.addEventListener('keydown', onKey, { capture: true });\n session.resume(mode ? { mode } : {});\n const stop = session.subscribe(() => {\n if (session.getSnapshot().phase === 'active') return;\n view?.removeEventListener('keydown', onKey, { capture: true });\n stop();\n });\n}\n\n/**\n * The composer's `feedback` prop for a session: ONE annotate toggle (targeting with a hover highlight; point mode on touch and compact\n * screens, and again after Esc to add more), a count of the notes taken, and Send attaching them: a saved report as its `@` mention (the host's mention resolver inlines\n * the stored report with the person's access), otherwise the rendered report inline in a fenced block. Pair it with `NoteBubble`.\n */\nexport function useComposerFeedback(session: FeedbackSession | undefined): ComposerFeedbackProp | undefined {\n const state = useSyncState(session);\n if (!session || !state) return undefined;\n const open = state.phase === 'active' || state.phase === 'processing';\n return {\n annotate: () => {\n const phase = session.getSnapshot().phase;\n if (phase === 'active') void session.done();\n else if (phase === 'idle' || phase === 'review') startAnnotating(session);\n },\n active: open,\n // A note being typed in the pin counts: Send saves it.\n count: noteCount(state) + (state.bubble?.fresh && state.bubble.text.trim() ? 1 : 0),\n clear: () => { session.highlight(undefined); session.discard(); },\n attach: async text => {\n // Send while annotating ends it first: the pin being typed is kept.\n if (session.getSnapshot().phase === 'active') await session.done();\n const attached = await session.attach();\n if (attached.kind === 'refused') return attached;\n const body = attached.kind === 'mention' ? `@${attached.path}` : fenced(attached.text);\n return { kind: 'ok', text: text.trim() ? `${text.trimEnd()}\\n\\n${body}` : body };\n },\n sent: () => session.sent(),\n };\n}\n\n/** Notes sent one by one in the current round of comment mode (`useSendEachNote`), for the banner; a new round starts at zero. */\nconst sentInRound = new WeakMap<FeedbackSession, number>();\nexport const notesSent = (session: FeedbackSession): number => sentInRound.get(session) ?? 0;\n\n/**\n * Each note goes to the chat the moment it is added: its element and text as one message (`send`, the chat's follow-up send), and\n * comment mode stays on for the next note. Nothing waits in a hidden batch, and the agent's reply follows in the conversation. Without\n * `send`, the notes wait for the person's next message as before. A refused report or a failed send stays in the review, where the composer shows it.\n */\nexport function useSendEachNote(session: FeedbackSession | undefined, send: ((text: string) => Promise<unknown>) | undefined): void {\n const state = useSyncState(session);\n const busy = useRef(false);\n useEffect(() => {\n if (!session || !send || !state || busy.current) return;\n if (state.phase !== 'active' || state.bubble || !state.notes.some(note => note.text.trim())) return;\n busy.current = true;\n const mode = state.mode === 'point' ? 'point' : undefined;\n void (async () => {\n let again = false;\n try {\n await session.done();\n const attached = await session.attach();\n if (attached.kind === 'refused') return;\n const submission = await send(attached.kind === 'mention' ? `@${attached.path}` : fenced(attached.text)).catch(() => undefined);\n if (submission === false || submission === undefined || submission === null) return;\n sentInRound.set(session, notesSent(session) + noteCount(session.getSnapshot()));\n session.sent();\n again = true;\n } finally {\n busy.current = false;\n if (again) startAnnotating(session, mode, false);\n }\n })();\n }, [session, send, state]);\n}\n\nconst NO_STATE = { getSnapshot: () => undefined, subscribe: () => () => {} };\nfunction useSyncState(session: FeedbackSession | undefined): FeedbackSessionSnapshot | undefined {\n return useExternal<FeedbackSessionSnapshot | undefined>(session ?? NO_STATE);\n}\n\n/** What `usePointComments` returns: the structural shape of the maquette viewer's `MaquetteComments` (any viewer may take it). */\nexport interface PointCommentsProp {\n /** A host-owned proxy element for an opaque frame pick, checked by the session's root and privacy policy. */\n readonly pick: (element: Element) => void;\n readonly reposition: () => void;\n /** Clicks are currently captured, false during Use app or processing. */\n readonly picking: boolean;\n /** The session is annotating now (point mode or feedback mode). */\n readonly active: boolean;\n /** Point mode on the page, keeping the notes already taken (from the review too); Done when it is on. Esc ends it like the composer's toggle. */\n readonly point: () => void;\n /** Every pin with a marked element: its number and the `data-feedback-id` it sits in (`SessionNote.feedbackId`). */\n readonly pins: readonly { readonly number: number; readonly target: string }[];\n}\n\n/**\n * Comment pins for a viewer inside the page (the maquette viewer's `comments`): the same session, notes, bubble, review and Send as the\n * composer's toggle, entered in point mode from the viewer's own icon. A viewer marks its parts with `data-feedback-id` and matches the\n * pins on them. Undefined without a session, so the viewer shows no icon.\n */\nexport function usePointComments(session: FeedbackSession | undefined): PointCommentsProp | undefined {\n const state = useSyncState(session);\n // One object per session state, so a host that puts it in a memoized kind list re-renders only when the pins or the mode change.\n return useMemo(() => !session || !state ? undefined : {\n active: state.phase === 'active' || state.phase === 'processing',\n pick: session.pick,\n reposition: session.reposition,\n picking: state.phase === 'active' && !state.using,\n point: () => {\n const phase = session.getSnapshot().phase;\n if (phase === 'active') void session.done();\n else if (phase === 'idle' || phase === 'review') startAnnotating(session, 'point');\n },\n pins: state.notes.flatMap(note => note.feedbackId ? [{ number: note.number, target: note.feedbackId }] : []),\n }, [session, state]);\n}\n",
|
|
23
23
|
"type": "registry:component",
|
|
24
24
|
"target": "components/feedback/feedback-session.tsx"
|
|
25
25
|
},
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
"path": "registry/feedback/annotate-sheet.tsx",
|
|
40
|
-
"content": "// AnnotateSheet: the note, Cancel selection (back to a general note, nothing attached), Copy report and, when the host has a save endpoint, Save. The draft, its anchors and the Save operation id are\n// the headless `createAnnotation` from `@hachej/boring-feedback/ui`; this file is how the sheet looks and what it says.\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';\nimport { protectionNotice, type Annotation, type CopyState, type FeedbackProtection, type SaveState } from '@hachej/boring-feedback/ui';\nimport { Button } from '@/components/ui/button';\nimport { Card } from '@/components/ui/card';\nimport { Textarea } from '@/components/ui/textarea';\nimport { XIcon } from '
|
|
40
|
+
"content": "// AnnotateSheet: the note, Cancel selection (back to a general note, nothing attached), Copy report and, when the host has a save endpoint, Save. The draft, its anchors and the Save operation id are\n// the headless `createAnnotation` from `@hachej/boring-feedback/ui`; this file is how the sheet looks and what it says.\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';\nimport { protectionNotice, type Annotation, type CopyState, type FeedbackProtection, type SaveState } from '@hachej/boring-feedback/ui';\nimport { Button } from '@/components/ui/button';\nimport { Card } from '@/components/ui/card';\nimport { Textarea } from '@/components/ui/textarea';\nimport { XIcon } from '../_shared/icons';\nimport { useExternal } from './use-external';\nimport { withLabels, type FeedbackLabels } from './feedback-labels';\n\nexport interface AnnotateSheetProps {\n readonly annotation: Annotation;\n readonly onClose: () => void;\n /** The pinned element the sheet opens beside. Without it the sheet docks at the bottom. */\n readonly near?: Element;\n /** The store's declared protection, when the host saves. An unprotected store is stated. */\n readonly protection?: FeedbackProtection;\n readonly title?: string;\n /** Words of the controls (`feedbackLabelsFr` for French); what is left out keeps the English default. */\n readonly labels?: Partial<FeedbackLabels>;\n /**\n * Called after the selection was cancelled (Esc, the Cancel selection button, or clicking the selected element again). The host\n * returns to the picker or to no zone here, typically by closing the sheet. Without it the sheet stays open as a general note, with\n * what was typed kept.\n */\n readonly onCancelSelection?: () => void;\n}\n\nconst copyMessage = (copy: CopyState): string => {\n switch (copy.kind) {\n case 'copied': return 'Report copied. Paste it anywhere.';\n case 'manual': return 'The clipboard is not available here: select the report below and copy it.';\n case 'refused': return copy.reason;\n default: return '';\n }\n};\nconst saveMessage = (save: SaveState): string => {\n switch (save.kind) {\n case 'saving': return 'Saving…';\n case 'saved': return 'Saved.';\n case 'unknown': return `${save.reason} Nothing is lost: Save again uses the same operation, so it cannot be stored twice.`;\n case 'denied': return `Not saved: ${save.reason}`;\n case 'unavailable': return `Not saved: ${save.reason}`;\n case 'conflict': return `Not saved: ${save.reason}`;\n case 'invalid': return save.reason;\n default: return '';\n }\n};\n\nconst SHEET_WIDTH = 360;\nfunction placeNear(element: Element | undefined): CSSProperties | undefined {\n if (!element || !element.isConnected) return undefined;\n const view = element.ownerDocument.defaultView;\n if (!view || view.innerWidth < 640) return undefined;\n const rect = element.getBoundingClientRect();\n const right = rect.right + 12;\n const left = right + SHEET_WIDTH <= view.innerWidth - 12 ? right : Math.max(12, rect.left - SHEET_WIDTH - 12);\n const top = Math.min(Math.max(12, rect.top), Math.max(12, view.innerHeight - 420));\n return { position: 'fixed', top, left, width: SHEET_WIDTH };\n}\n\nexport function AnnotateSheet({ annotation, onClose, near, protection, title, labels, onCancelSelection }: AnnotateSheetProps) {\n const state = useExternal(annotation);\n const words = withLabels(labels);\n const heading = state.selected ? (title ?? words.title) : words.askGeneral;\n const note = useRef<HTMLTextAreaElement>(null);\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- placed from the page's layout after commit and before paint, once per anchor\n useLayoutEffect(() => { setPlace(placeNear(near)); }, [near]);\n const [cancelled, setCancelled] = useState(false);\n const callbacks = useRef({ onCancelSelection });\n useLayoutEffect(() => { callbacks.current = { onCancelSelection }; });\n useEffect(() => { note.current?.focus({ preventScroll: true }); }, []);\n // Focus goes back to where it was when the sheet opened (the Point button, after the picker restored it), not to the top of the page.\n useEffect(() => {\n const opener = note.current?.ownerDocument.activeElement;\n const before = opener && opener !== note.current ? opener : undefined;\n return () => { if (before && before.isConnected && typeof (before as HTMLElement).focus === 'function') (before as HTMLElement).focus({ preventScroll: true }); };\n }, []);\n const cancelSelection = (): void => {\n if (!annotation.clearSelection()) return;\n setCancelled(true);\n note.current?.focus({ preventScroll: true });\n callbacks.current.onCancelSelection?.();\n };\n // Clicking the selected element again cancels the selection instead of activating it.\n const selected = state.selected;\n useEffect(() => {\n const view = near?.ownerDocument.defaultView;\n if (!near || !view || !selected) return;\n const onClick = (event: Event): void => {\n const path = event.composedPath();\n if (path.some(item => (item as Element).hasAttribute?.('data-feedback-ignore')) || !path.includes(near)) return;\n event.preventDefault(); event.stopPropagation();\n if (annotation.clearSelection()) { setCancelled(true); note.current?.focus({ preventScroll: true }); callbacks.current.onCancelSelection?.(); }\n };\n view.addEventListener('click', onClick, { capture: true });\n return () => view.removeEventListener('click', onClick, { capture: true });\n }, [near, selected, annotation]);\n const notice = protection ? protectionNotice(protection) : undefined;\n // Esc cancels the selection first (one step back: to no zone); on a general note it closes the sheet.\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => {\n if (event.key !== 'Escape') return;\n event.stopPropagation();\n if (state.selected && state.save.kind !== 'saved' && state.save.kind !== 'saving') cancelSelection(); else onClose();\n };\n const copyText = state.copy.kind === 'copied' || state.copy.kind === 'manual' ? state.copy.text : undefined;\n const saved = state.save.kind === 'saved';\n\n return <Card data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label={heading} data-testid=\"feedback-sheet\" data-selected={state.selected ? 'true' : 'false'}\n className=\"boring-feedback-sheet\" data-docked={place ? undefined : 'bottom'} style={place} onKeyDown={onKeyDown}>\n <header className=\"boring-feedback-sheet-header\">\n <h2>{heading}</h2>\n <Button variant=\"ghost\" size=\"icon\" aria-label={words.close} title={words.close} data-testid=\"feedback-close\" onClick={onClose}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </header>\n {state.selected && <ul className=\"boring-feedback-targets\" data-testid=\"feedback-targets\">\n {state.targets.map((target, index) => <li key={index}>{target}</li>)}\n </ul>}\n {state.refused.length > 0 && <p role=\"alert\" className=\"boring-feedback-warning\" data-testid=\"feedback-refused\">\n {state.refused.length === 1 ? 'One element could not be pointed at' : `${state.refused.length} elements could not be pointed at`}: {state.refused.map(item => item.reason).join('; ')}.\n </p>}\n <label className=\"boring-feedback-field\">\n <span>{state.selected ? words.changeField : words.generalField}</span>\n <Textarea ref={note} data-testid=\"feedback-note\" rows={4} value={state.said} readOnly={saved} onChange={event => annotation.setSaid(event.target.value)} />\n </label>\n <div className=\"boring-feedback-actions\">\n {state.selected && state.save.kind !== 'saved' && <Button variant=\"ghost\" data-testid=\"feedback-cancel-selection\" disabled={state.save.kind === 'saving'}\n onClick={cancelSelection}>{words.cancelSelection}</Button>}\n <Button variant=\"ghost\" data-testid=\"feedback-copy\" onClick={() => { void annotation.copy(); }}>{words.copyReport}</Button>\n {state.canSave && <Button variant=\"default\" data-testid=\"feedback-save\" disabled={state.save.kind === 'saving' || saved}\n onClick={() => { void annotation.save(); }}>{state.save.kind === 'unknown' ? words.saveAgain : saved ? words.saved : words.save}</Button>}\n </div>\n <p role=\"status\" aria-live=\"polite\" className=\"boring-feedback-status\" data-testid=\"feedback-status\" data-copy={state.copy.kind} data-save={state.save.kind}>\n {[copyMessage(state.copy), saveMessage(state.save), cancelled && !state.selected ? words.selectionCancelled : ''].filter(Boolean).join(' ')}\n </p>\n {state.copy.kind === 'manual' && copyText !== undefined && <Textarea readOnly className=\"boring-feedback-manual\" data-testid=\"feedback-manual-copy\" value={copyText}\n onFocus={event => event.currentTarget.select()} rows={6} />}\n {state.copy.kind === 'copied' && copyText !== undefined && <details className=\"boring-feedback-copied\"><summary>Copied report</summary>\n <pre data-testid=\"feedback-copy-text\">{copyText}</pre></details>}\n {notice && <p className=\"boring-feedback-notice\" data-testid=\"feedback-protection\">{notice}</p>}\n </Card>;\n}\n",
|
|
41
41
|
"type": "registry:component",
|
|
42
42
|
"target": "components/feedback/annotate-sheet.tsx"
|
|
43
43
|
},
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"path": "registry/feedback/feedback-report.tsx",
|
|
52
|
-
"content": "// FeedbackReport: one stored report and Show. Show resolves each anchor against the live page now (`showAnchor` from\n// `@hachej/boring-feedback/ui`): exact and moved placements are revealed on the overlay, ambiguous ones are numbered for the person to choose,\n// and missing, stale or unsupported results are said as they are. Nothing uncertain is revealed without that choice.\nimport { useEffect, useState } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Card } from '@/components/ui/card';\nimport { XIcon } from '
|
|
52
|
+
"content": "// FeedbackReport: one stored report and Show. Show resolves each anchor against the live page now (`showAnchor` from\n// `@hachej/boring-feedback/ui`): exact and moved placements are revealed on the overlay, ambiguous ones are numbered for the person to choose,\n// and missing, stale or unsupported results are said as they are. Nothing uncertain is revealed without that choice.\nimport { useEffect, useState } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Card } from '@/components/ui/card';\nimport { XIcon } from '../_shared/icons';\nimport type { FeedbackReport as Report } from '@hachej/boring-feedback/format';\nimport type { PickerOverlay, PrivacyPolicy } from '@hachej/boring-feedback/page';\nimport { reportView, showAnchor, type ShowOutcome } from '@hachej/boring-feedback/ui';\n\nexport interface FeedbackReportProps {\n readonly report: Report;\n /** Show works only in the application page itself: pass its root, policy and overlay. Without them Show is not offered. */\n readonly page?: { readonly root: () => Element | null; readonly policy: PrivacyPolicy; readonly overlay: PickerOverlay | undefined };\n readonly onClose?: () => void;\n}\n\ntype Shown = { readonly anchor: number; readonly outcome: ShowOutcome | 'working' };\n\nconst outcomeText = (outcome: ShowOutcome): string => {\n switch (outcome.kind) {\n case 'revealed': return outcome.placement === 'chosen' ? 'Shown: the element you chose.' : outcome.placement === 'moved' ? 'Shown. It has moved since the feedback was left.' : 'Shown.';\n case 'choose': return `${outcome.candidates.length} elements could be the one meant. Choose the right one:`;\n case 'stale': return `Not shown: ${outcome.reason}`;\n case 'missing': return `Not shown: ${outcome.reason}`;\n default: return `Not shown: ${outcome.reason}`;\n }\n};\n\nexport function FeedbackReport({ report, page, onClose }: FeedbackReportProps) {\n const view = reportView(report);\n const [shown, setShown] = useState<Shown | null>(null);\n const overlay = page?.overlay;\n useEffect(() => () => { overlay?.clear(); }, [overlay]);\n // eslint-disable-next-line react-hooks/set-state-in-effect -- another report starts with nothing shown; runs once per report id\n useEffect(() => { setShown(null); overlay?.clear(); }, [report.id]); // eslint-disable-line react-hooks/exhaustive-deps -- clears on a new report only; a new overlay is cleared by the effect above\n\n const show = async (index: number): Promise<void> => {\n const root = page?.root();\n if (!page || !root) { setShown({ anchor: index, outcome: { kind: 'unsupported', reason: 'Open the application page to see it.' } }); return; }\n setShown({ anchor: index, outcome: 'working' });\n const outcome = await showAnchor({ anchor: report.anchors[index], root, policy: page.policy, ...(page.overlay ? { overlay: page.overlay } : {}), note: view.title });\n setShown({ anchor: index, outcome });\n };\n const choose = (outcome: Extract<ShowOutcome, { kind: 'choose' }>, number: number, index: number): void => setShown({ anchor: index, outcome: outcome.choose(number) });\n\n return <Card size=\"sm\" role=\"article\" data-boring=\"feedback\" data-feedback-ignore=\"\" className=\"boring-feedback-report\" data-testid=\"feedback-report\" data-id={view.id}>\n <header className=\"boring-feedback-sheet-header\">\n <h2>{view.title}</h2>\n {onClose && <Button variant=\"ghost\" size=\"icon\" aria-label=\"Close\" title=\"Close\" onClick={onClose}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n </header>\n <p className=\"boring-feedback-meta\">\n <span data-status={view.status}>{view.status}</span>\n {view.author !== undefined && <> · {view.author}</>}\n {view.app !== undefined && <> · {view.app} {view.route}</>}\n {view.build !== undefined && <> · build {view.build}</>}\n </p>\n <p className=\"boring-feedback-said\" data-testid=\"feedback-said\">{view.said}</p>\n <ol className=\"boring-feedback-anchors\">\n {view.anchors.map(anchor => {\n const current = shown?.anchor === anchor.index ? shown.outcome : undefined;\n return <li key={anchor.index} data-testid=\"feedback-anchor\">\n <span>{anchor.fallback}</span>\n {anchor.source !== undefined && <code>{anchor.source}</code>}\n {page && anchor.placeable && <Button variant=\"ghost\" data-testid=\"feedback-show\" disabled={current === 'working'} onClick={() => { void show(anchor.index); }}>Show</Button>}\n {!anchor.placeable && <span className=\"boring-feedback-meta\">This page cannot place {anchor.kind}.</span>}\n {current !== undefined && current !== 'working' && <div role=\"status\" className=\"boring-feedback-status\" data-testid=\"feedback-show-result\" data-kind={current.kind}>\n {outcomeText(current)}\n {current.kind === 'choose' && <ol className=\"boring-feedback-candidates\">\n {current.candidates.map(candidate => <li key={candidate.number}>\n <Button variant=\"outline\" data-testid=\"feedback-candidate\" data-number={candidate.number}\n onClick={() => choose(current, candidate.number, anchor.index)}>{candidate.number} · {candidate.label}</Button>\n </li>)}\n </ol>}\n </div>}\n </li>;\n })}\n </ol>\n {view.widened.length > 0 && <p className=\"boring-feedback-meta\" data-testid=\"feedback-widened\">Privacy policy widened by the app: {view.widened.join(', ')}</p>}\n {view.resolutions.length > 0 && <section className=\"boring-feedback-resolutions\"><h3>Resolution</h3>\n {view.resolutions.map((entry, index) => <p key={index}><strong>{entry.by}</strong> · {entry.note}</p>)}</section>}\n </Card>;\n}\n",
|
|
53
53
|
"type": "registry:component",
|
|
54
54
|
"target": "components/feedback/feedback-report.tsx"
|
|
55
55
|
},
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
"path": "registry/feedback/feedback-card.tsx",
|
|
64
|
-
"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
|
+
"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 '../_shared/icons';\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",
|
|
65
65
|
"type": "registry:component",
|
|
66
66
|
"target": "components/feedback/feedback-card.tsx"
|
|
67
67
|
},
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
},
|
|
74
74
|
{
|
|
75
75
|
"path": "registry/feedback/point-mode.tsx",
|
|
76
|
-
"content": "// Comment mode's banner (FEEDBACK.md, \"Point mode\"). While a session annotates, CommentBanner says so: « Comment mode · 2 notes », what to\n// do now, an on/off switch (off, the app works as usual and the notes stay) and Done. On a phone the assistant's panel covers the app, so\n// « Show in app » in the composer (`useComposerFeedback(session)`) starts the session in point mode: the host binds `usePointing(session)`\n// to hide its own panel (a Sheet's `open`, AmbientChat's `state`) and the banner sits at the bottom of the screen; each tap picks an\n// element with the same picker, pins, NoteBubble and draft as feedback mode. With a mouse the banner sits at the top (`useCommenting`).\n// Done leaves the draft as the composer's chip (or, with `useSendEachNote`, each note already went to the chat) and the panel comes back.\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 CommentBanner (and the one NoteBubble) outside the panel, because the\n// panel may unmount while it is hidden.\nimport { createPortal } from 'react-dom';\nimport { CircleIcon } from '
|
|
76
|
+
"content": "// Comment mode's banner (FEEDBACK.md, \"Point mode\"). While a session annotates, CommentBanner says so: « Comment mode · 2 notes », what to\n// do now, an on/off switch (off, the app works as usual and the notes stay) and Done. On a phone the assistant's panel covers the app, so\n// « Show in app » in the composer (`useComposerFeedback(session)`) starts the session in point mode: the host binds `usePointing(session)`\n// to hide its own panel (a Sheet's `open`, AmbientChat's `state`) and the banner sits at the bottom of the screen; each tap picks an\n// element with the same picker, pins, NoteBubble and draft as feedback mode. With a mouse the banner sits at the top (`useCommenting`).\n// Done leaves the draft as the composer's chip (or, with `useSendEachNote`, each note already went to the chat) and the panel comes back.\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 CommentBanner (and the one NoteBubble) outside the panel, because the\n// panel may unmount while it is hidden.\nimport { createPortal } from 'react-dom';\nimport { CircleIcon } from '../_shared/icons';\nimport { Button } from '@/components/ui/button';\nimport { Card } from '@/components/ui/card';\nimport { Switch } from '@/components/ui/switch';\nimport { isPointing, type FeedbackSession, type FeedbackSessionSnapshot } from '@hachej/boring-feedback/ui';\nimport { cn } from '../utils/utils';\nimport { useExternal } from './use-external';\nimport { withLabels, type FeedbackLabels } from './feedback-labels';\nimport { noteCount, notesSent } from './feedback-session';\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\n/** True while the session is in comment mode, either way (point mode on a phone, the hover targeting with a mouse). */\nexport function useCommenting(session: FeedbackSession | undefined): boolean {\n const state = useExternal<FeedbackSessionSnapshot | undefined>(session ?? NO_STATE);\n return state !== undefined && (state.phase === 'active' || state.phase === 'processing');\n}\n\nexport interface CommentBannerProps {\n readonly session: FeedbackSession;\n /** The height of the app's own bottom chrome (a tab bar) as a CSS length; on a phone the banner sits above it and above the safe area. */\n readonly bottomOffset?: string;\n /** Words of the banner (`feedbackLabelsFr` for French); what is left out keeps the English default. */\n readonly labels?: Partial<FeedbackLabels>;\n /**\n * Rendered where it is mounted instead of over the whole window (the default, portalled to the body). For comments on one surface, such\n * as a version open in a side panel: mounted in a positioned layer over that surface (a `transform` makes it the containing block), the\n * banner sits over the surface and keeps clear of the panel's own bar and the rest of the app.\n */\n readonly inline?: boolean;\n readonly className?: string;\n}\n\n/**\n * Comment mode's banner, while the session annotates: « Comment mode · 2 notes », what to do now, an on/off switch and Done.\n * On, a tap (point mode, at the bottom of a phone above its tab bar) or a click (the hover targeting, at the top of a wide screen) picks\n * an element and opens its note. Off, the app works as usual (menus, links, another screen) and the notes stay; on again, the person\n * comments further. Done ends comment mode: with `useSendEachNote` each note already went to the agent (the hint says so, and Done shows the\n * conversation with its reply); otherwise the notes wait for the next message. There is no Cancel here: a note is removed on its own.\n * It is `data-feedback-ignore`, so it is never picked, and the note bubble keeps clear of it (`data-feedback-point-bar`).\n */\nexport function CommentBanner({ session, bottomOffset = '0px', labels, inline = false, className }: CommentBannerProps) {\n const state = useExternal(session);\n const words = withLabels(labels);\n if ((state.phase !== 'active' && state.phase !== 'processing') || typeof document === 'undefined') return null;\n const processing = state.phase === 'processing';\n const on = !state.useToggle;\n const phone = isPointing(state);\n const sent = notesSent(session);\n const hint = state.problem ?? (!on ? words.commentPaused : sent > 0 && !state.bubble ? words.commentSent(sent) : phone ? words.commentTap : words.commentClick);\n // Notes that say something: a pin still being typed is not one yet.\n const notes = noteCount(state);\n const banner = <Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" data-feedback-point-bar={phone ? 'bottom' : 'top'} role=\"region\" aria-label={words.commentMode}\n data-testid=\"feedback-point-bar\" data-commenting={on ? 'true' : 'false'} className={cn('boring-feedback-banner', className)}\n style={phone ? { bottom: `calc(${bottomOffset} + env(safe-area-inset-bottom, 0px) + 8px)` } : { top: '8px' }}>\n <CircleIcon className={cn('size-2 shrink-0 fill-current', on ? 'text-primary' : 'text-muted-foreground')} aria-hidden=\"true\" />\n <span className=\"flex min-w-0 flex-1 flex-col\">\n <span className=\"truncate text-sm font-semibold\">{words.commentMode}{notes > 0 && <span className=\"font-normal text-muted-foreground tabular-nums\" data-testid=\"feedback-point-count\"> · {words.pointCount(notes)}</span>}</span>\n <span className=\"text-xs text-pretty text-muted-foreground\" role=\"status\" aria-live=\"polite\" data-testid=\"feedback-point-hint\">{hint}</span>\n </span>\n <span className=\"flex min-h-11 shrink-0 items-center px-2\">\n <Switch data-testid=\"feedback-comment-switch\" checked={on} disabled={processing} aria-label={words.commentSwitch}\n onCheckedChange={checked => session.setUseToggle(!checked)} />\n </span>\n <Button className=\"min-h-11 shrink-0 sm:min-h-9\" data-testid=\"feedback-point-done\" disabled={processing} onClick={() => { void session.done(); }}>{words.pointDone}</Button>\n </Card>;\n return inline ? banner : createPortal(banner, document.body);\n}\n",
|
|
77
77
|
"type": "registry:component",
|
|
78
78
|
"target": "components/feedback/point-mode.tsx"
|
|
79
79
|
},
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
},
|
|
86
86
|
{
|
|
87
87
|
"path": "registry/feedback/sent-feedback.tsx",
|
|
88
|
-
"content": "'use client';\n\nimport { ChevronDownIcon } from '
|
|
88
|
+
"content": "'use client';\n\nimport { ChevronDownIcon } from '../_shared/icons';\nimport { readableElement } from '@hachej/boring-feedback/format';\nimport type { FeedbackReport } from '@hachej/boring-feedback/format';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';\nimport { cn } from '../utils/utils';\nimport { useChatText } from '../pi-chat/labels';\n\n/** Sent page comments are inert text, not live page targets or evidence that a change was accepted. */\nexport function SentFeedback({ report, align = 'end' }: { readonly report: FeedbackReport; readonly align?: 'start' | 'end' }) {\n const { labels } = useChatText();\n const notes = (report.notes ?? []).filter(note => note.text.trim());\n if (!notes.length) return null;\n const title = labels.feedbackSent(notes.length);\n const long = notes.length > 3 || report.said.length + notes.reduce((length, note) => length + note.text.length, 0) > 400;\n const content = <CardContent className=\"flex flex-col gap-3\">\n {report.said && <p className=\"m-0 whitespace-pre-wrap [overflow-wrap:anywhere]\">{report.said}</p>}\n <ol className=\"m-0 flex list-none flex-col gap-3 p-0\">\n {notes.map((note, at) => {\n const anchor = note.anchor === undefined ? undefined : report.anchors[note.anchor];\n const target = typeof anchor?.fallback === 'string' ? readableElement(anchor.fallback).name : undefined;\n return <li key={at} data-testid=\"message-feedback-note\" className=\"flex min-w-0 flex-col gap-1 [overflow-wrap:anywhere]\">\n {target && <CardDescription>{target}</CardDescription>}<p className=\"m-0 whitespace-pre-wrap\">{note.text}</p>\n </li>;\n })}\n </ol>\n </CardContent>;\n // CardHeader is an inline-size container; a collapsed card cannot size itself from its title.\n const card = { size: 'sm' as const, 'data-testid': 'message-feedback', role: 'group', 'aria-label': title, className: cn('w-80 max-w-[85%]', align === 'end' && 'ml-auto') };\n if (!long) return <Card {...card}><CardHeader><CardTitle>{title}</CardTitle></CardHeader>{content}</Card>;\n return <Collapsible data-slot=\"card\" render={<Card {...card} />}>\n <CardHeader><CardTitle><CollapsibleTrigger data-slot=\"button\" render={<Button variant=\"ghost\" className=\"group w-full justify-between whitespace-normal text-left\" />}>\n {title}<ChevronDownIcon data-icon=\"inline-end\" aria-hidden=\"true\" className=\"group-aria-expanded:rotate-180\" />\n </CollapsibleTrigger></CardTitle></CardHeader>\n <CollapsibleContent>{content}</CollapsibleContent>\n </Collapsible>;\n}\n",
|
|
89
89
|
"type": "registry:component",
|
|
90
90
|
"target": "components/feedback/sent-feedback.tsx"
|
|
91
91
|
},
|
package/r/file-tree.json
CHANGED
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
"title": "File tree",
|
|
5
5
|
"description": "Browse, search, upload, rename, move, delete and inspect retained versions through one revisionProvider binding. Uses the consumer's shadcn Button, dropdown-menu and dialog with shared version-history rows; Base UI style only.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.59",
|
|
8
|
+
"@hachej/boring-files@^0.4.59",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"lucide-react@^1.51.0"
|
|
11
11
|
],
|
|
12
12
|
"registryDependencies": [
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/utils.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/version-history.json"
|
|
15
15
|
],
|
|
16
16
|
"files": [
|
|
17
17
|
{
|
package/r/highlights.json
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
"title": "Anchored highlights",
|
|
5
5
|
"description": "Exact Markdown highlights and comments in a conditional workspace sidecar.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.59",
|
|
8
|
+
"@hachej/boring-files@^0.4.59",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0"
|
|
11
11
|
],
|
|
12
12
|
"registryDependencies": [
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/markdown-editor.json"
|
|
14
14
|
],
|
|
15
15
|
"files": [
|
|
16
16
|
{
|
package/r/html-viewer.json
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
"title": "HTML source and passive preview",
|
|
5
5
|
"description": "Exact HTML source editing with a reconstructed passive preview and borrowed controller. Browser security qualification remains separate.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.59",
|
|
8
|
+
"@hachej/boring-files@^0.4.59",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0"
|
|
11
11
|
],
|
|
12
12
|
"registryDependencies": [
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/button.json"
|
|
14
14
|
],
|
|
15
15
|
"files": [
|
|
16
16
|
{
|
package/r/kpi-stat.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "kpi-stat",
|
|
4
|
+
"title": "KPI stat and row",
|
|
5
|
+
"description": "A muted label, a title-size value in tabular mono numerals and an optional delta (up in success, down in destructive; an unchanged value shows none). `KpiRow` is the one surface that holds a row of stats.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"react@^19.0.0"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [
|
|
10
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/utils.json",
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "registry/kpi-stat/kpi-stat.tsx",
|
|
16
|
+
"content": "import type { ReactNode } from 'react';\nimport { cn } from '../utils/utils';\nimport { ArrowDown, ArrowUp } from '../_shared/icons';\n\n/** A change between two readings. `null` says nothing: equal values, no base to compare with, or a change that rounds to 0%. */\nexport type KpiChange = { direction: 'up' | 'down'; percent: number };\n\nexport function kpiChange(current: number, previous: number): KpiChange | null {\n if (!Number.isFinite(current) || !Number.isFinite(previous) || previous === 0 || current === previous) return null;\n const percent = Math.round(((current - previous) / Math.abs(previous)) * 100);\n if (percent === 0) return null;\n return { direction: current > previous ? 'up' : 'down', percent };\n}\n\nexport type KpiStatProps = {\n label: string;\n /** Already formatted by the caller (currency, locale, unit). */\n value: string;\n /** The delta shows only when the two readings differ. */\n compare?: { current: number; previous: number };\n className?: string;\n};\n\nexport function KpiStat({ label, value, compare, className }: KpiStatProps) {\n const change = compare ? kpiChange(compare.current, compare.previous) : null;\n return (\n <div className={cn('flex min-w-0 flex-col gap-0.5', className)}>\n <span className=\"truncate text-xs text-muted-foreground\">{label}</span>\n <span className=\"truncate font-mono text-sm font-semibold tabular-nums\">{value}</span>\n {change ? <KpiDelta change={change} /> : null}\n </div>\n );\n}\n\nfunction KpiDelta({ change }: { change: KpiChange }) {\n const up = change.direction === 'up';\n const Arrow = up ? ArrowUp : ArrowDown;\n return (\n <span className={cn('inline-flex items-center gap-0.5 font-mono text-xs tabular-nums', up ? 'text-success' : 'text-destructive')}>\n <Arrow aria-hidden className=\"size-3\" />\n <span className=\"sr-only\">{up ? 'Up' : 'Down'} </span>\n {up ? '+' : '−'}\n {Math.abs(change.percent)}%\n </span>\n );\n}\n\nexport type KpiRowProps = { children: ReactNode; className?: string };\n\n/** One surface holds the whole row of stats; never one card per stat. */\nexport function KpiRow({ children, className }: KpiRowProps) {\n return (\n <div className={cn('grid grid-cols-[repeat(auto-fit,minmax(6rem,1fr))] gap-x-6 gap-y-4 rounded-(--radius) bg-card p-4', className)}>\n {children}\n </div>\n );\n}\n",
|
|
17
|
+
"type": "registry:component",
|
|
18
|
+
"target": "components/kpi-stat/kpi-stat.tsx"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"type": "registry:component"
|
|
22
|
+
}
|