@hachej/boring-ui-kit 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "board-viewer",
4
+ "title": "Board viewer",
5
+ "description": "A board@1 artifact kind for *.board.json. Lazy native tldraw projection of maquette screens, sandboxed HTML mock-ups, goto arrows and notes. Conditional board-file autosave; phone viewing without accidental frame edits. Hosts supply their resource client, asset URLs and license key.",
6
+ "dependencies": [
7
+ "@hachej/boring-ui-kit@^0.4.3",
8
+ "@hachej/boring-files@^0.4.3",
9
+ "react@^19.0.0",
10
+ "lucide-react@1.51.0",
11
+ "tldraw@5.5.2"
12
+ ],
13
+ "registryDependencies": [
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/maquette-viewer.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/mockup-viewer.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/pi-chat.json",
17
+ "button",
18
+ "alert",
19
+ "card",
20
+ "tooltip"
21
+ ],
22
+ "files": [
23
+ {
24
+ "path": "registry/board-viewer/board.ts",
25
+ "content": "/*\n * The board format (`board@1`): a PM agent writes `<name>.board.json` to lay out maquette and HTML mock-ups left to right, with arrows for\n * the maquette's goto links and sticky notes beside them. It is plain data that names other workspace files (paths relative to the board's\n * folder); nothing it names is embedded or run here. The board file is the only authority: a canvas is a projection of it, and people's\n * frame moves and notes come back through `applyCanvasEdits`, never as a canvas store. The format is documented in registry/README.md\n * (\"Board format\"). Model output is untrusted: `parseBoard` is strict and reports every problem as an issue, and never throws.\n */\nimport { linksOf, parseMaquette } from '../maquette-viewer/maquette';\nimport type { Maquette } from '../maquette-viewer/maquette';\n\n/** The format this module reads and writes. A higher `board@N` is reported as `newer`, not read. */\nexport const BOARD_FORMAT = 'board@1';\nexport const BOARD_FORMAT_VERSION = 1;\n\nexport type BoardDevice = 'phone' | 'desktop';\n\n/** One thing on the board: a maquette (one frame per screen), an HTML mock-up (one frame) or a sticky note. */\nexport type BoardItem =\n | { readonly id: string; readonly maquette: string; readonly title?: string }\n | { readonly id: string; readonly html: string; readonly title?: string; readonly device: BoardDevice; readonly width?: number; readonly height?: number }\n | { readonly id: string; readonly note: string; readonly near?: string };\n\n/** An arrow between two frames (`<item>/<screen>` for a maquette screen, the item id for an HTML frame). */\nexport interface BoardLink { readonly from: string; readonly to: string; readonly label?: string }\nexport interface BoardPoint { readonly x: number; readonly y: number }\n\nexport interface Board {\n readonly format: typeof BOARD_FORMAT;\n readonly title: string;\n readonly items: readonly BoardItem[];\n readonly links: readonly BoardLink[];\n /** Where people put frames and notes: frame or note id to its top-left corner. An id absent here gets the automatic layout. */\n readonly layout: Readonly<Record<string, BoardPoint>>;\n}\n\n/** A problem at a JSON path (`items[2].html`), in words an agent can act on. */\nexport interface BoardIssue { readonly path: string; readonly message: string }\n\n/**\n * The outcome of reading a board. `newer` is a valid `board@N` with N above this module's version: the host should say so, not guess.\n * `invalid` covers bad JSON, a missing or unknown `format` (unversioned files are invalid), and every shape error.\n */\nexport type BoardParse =\n | { readonly kind: 'ok'; readonly board: Board }\n | { readonly kind: 'newer'; readonly format: string; readonly version: number }\n | { readonly kind: 'invalid'; readonly issues: readonly BoardIssue[] };\n\nconst MAX_ITEMS = 40, MAX_LINKS = 80, MAX_LAYOUT = 200, MAX_NOTE = 1000;\nconst ITEM_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,59}$/;\n/** Frame ids are `<item>/<screen>`, so layout keys may hold one slash. */\nconst FRAME_ID = /^[A-Za-z0-9][\\w./-]{0,119}$/;\nconst COORDINATE = 1e6;\nconst FORMAT_LIKE = /^board@(.*)$/;\n\ntype Obj = Record<string, unknown>;\nconst isObject = (value: unknown): value is Obj => value !== null && typeof value === 'object' && !Array.isArray(value);\n\n/** A workspace path relative to the board's folder, ending in `ending`: no scheme, no absolute path, no `..`. */\nexport function boardPathProblem(value: unknown, ending: '.maquette.json' | '.mockup.html'): string | undefined {\n if (typeof value !== 'string' || !value) return 'must be a non-empty path string';\n if (value.length > 200) return 'path is longer than 200 characters';\n if (/^[a-z]+:/i.test(value) || value.startsWith('/') || value.includes('\\\\')) return 'must be a relative path inside the board folder';\n if (value.split('/').includes('..')) return 'must not contain \"..\"';\n if (!value.toLowerCase().endsWith(ending)) return `must end with ${ending}`;\n return undefined;\n}\n\nfunction optionalText(value: unknown, path: string, max: number, issues: BoardIssue[]): string | undefined {\n if (value === undefined) return undefined;\n if (typeof value !== 'string') { issues.push({ path, message: 'must be a string' }); return undefined; }\n if (value.length > max) issues.push({ path, message: `is longer than ${max} characters` });\n return value.slice(0, max);\n}\n\nfunction optionalNumber(value: unknown, path: string, min: number, max: number, issues: BoardIssue[]): number | undefined {\n if (value === undefined) return undefined;\n if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) {\n issues.push({ path, message: `must be a number from ${min} to ${max}` });\n return undefined;\n }\n return value;\n}\n\nfunction unknownFields(value: Obj, allowed: readonly string[], path: string, issues: BoardIssue[]) {\n for (const key of Object.keys(value)) if (!allowed.includes(key)) issues.push({ path: path ? `${path}.${key}` : key, message: `unknown field (allowed: ${allowed.join(', ')})` });\n}\n\nfunction readItem(value: unknown, path: string, issues: BoardIssue[]): BoardItem | undefined {\n if (!isObject(value)) { issues.push({ path, message: 'must be an object' }); return undefined; }\n const id = value['id'];\n if (typeof id !== 'string' || !ITEM_ID.test(id)) { issues.push({ path: `${path}.id`, message: 'must be 1 to 60 letters, digits, \"-\" or \"_\", starting with a letter or digit' }); return undefined; }\n const kinds = ['maquette', 'html', 'note'].filter(kind => kind in value);\n if (kinds.length !== 1) { issues.push({ path, message: 'needs exactly one of \"maquette\", \"html\" or \"note\"' }); return undefined; }\n const kind = kinds[0];\n const title = optionalText(value['title'], `${path}.title`, 120, issues);\n if (kind === 'maquette') {\n unknownFields(value, ['id', 'maquette', 'title'], path, issues);\n const problem = boardPathProblem(value['maquette'], '.maquette.json');\n if (problem) { issues.push({ path: `${path}.maquette`, message: problem }); return undefined; }\n return { id, maquette: value['maquette'] as string, ...(title ? { title } : {}) };\n }\n if (kind === 'html') {\n unknownFields(value, ['id', 'html', 'title', 'device', 'width', 'height'], path, issues);\n const problem = boardPathProblem(value['html'], '.mockup.html');\n if (problem) { issues.push({ path: `${path}.html`, message: problem }); return undefined; }\n const device = value['device'] === undefined ? 'phone' : value['device'];\n if (device !== 'phone' && device !== 'desktop') { issues.push({ path: `${path}.device`, message: 'must be \"phone\" or \"desktop\"' }); return undefined; }\n const width = optionalNumber(value['width'], `${path}.width`, 240, 1600, issues);\n const height = optionalNumber(value['height'], `${path}.height`, 240, 2400, issues);\n return { id, html: value['html'] as string, device, ...(title ? { title } : {}), ...(width !== undefined ? { width } : {}), ...(height !== undefined ? { height } : {}) };\n }\n unknownFields(value, ['id', 'note', 'near'], path, issues);\n const note = value['note'];\n if (typeof note !== 'string' || !note.trim() || note.length > MAX_NOTE) { issues.push({ path: `${path}.note`, message: `must be 1 to ${MAX_NOTE} characters` }); return undefined; }\n const near = value['near'];\n if (near !== undefined && (typeof near !== 'string' || !FRAME_ID.test(near))) { issues.push({ path: `${path}.near`, message: 'must name a frame id, such as \"booking/sessions\"' }); return undefined; }\n return { id, note, ...(near !== undefined ? { near } : {}) };\n}\n\nfunction readLink(value: unknown, path: string, issues: BoardIssue[]): BoardLink | undefined {\n if (!isObject(value)) { issues.push({ path, message: 'must be an object' }); return undefined; }\n unknownFields(value, ['from', 'to', 'label'], path, issues);\n const from = value['from'], to = value['to'];\n for (const [field, end] of [['from', from], ['to', to]] as const) {\n if (typeof end !== 'string' || !FRAME_ID.test(end)) { issues.push({ path: `${path}.${field}`, message: 'must name a frame id, such as \"booking/confirmed\"' }); return undefined; }\n }\n const label = optionalText(value['label'], `${path}.label`, 80, issues);\n return { from: from as string, to: to as string, ...(label ? { label } : {}) };\n}\n\n/** Reads a board (its JSON text or an already parsed value). Never throws. */\nexport function parseBoard(input: unknown): BoardParse {\n let raw: unknown = input;\n if (typeof raw === 'string') {\n try { raw = JSON.parse(raw); } catch { return { kind: 'invalid', issues: [{ path: '', message: 'is not valid JSON' }] }; }\n }\n if (!isObject(raw)) return { kind: 'invalid', issues: [{ path: '', message: 'must be a JSON object' }] };\n const format = raw['format'];\n if (format === undefined) return { kind: 'invalid', issues: [{ path: 'format', message: `is missing: an unversioned board is invalid, write \"format\": \"${BOARD_FORMAT}\"` }] };\n if (typeof format !== 'string') return { kind: 'invalid', issues: [{ path: 'format', message: `must be a string such as \"${BOARD_FORMAT}\"` }] };\n const versioned = FORMAT_LIKE.exec(format)?.[1];\n const version = versioned !== undefined && /^[1-9]\\d{0,3}$/.test(versioned) ? Number(versioned) : undefined;\n if (version === undefined) return { kind: 'invalid', issues: [{ path: 'format', message: `is \"${format.slice(0, 40)}\", not a board format such as \"${BOARD_FORMAT}\"` }] };\n if (version > BOARD_FORMAT_VERSION) return { kind: 'newer', format, version };\n if (version !== BOARD_FORMAT_VERSION) return { kind: 'invalid', issues: [{ path: 'format', message: `\"${format}\" is not a known board version` }] };\n\n const issues: BoardIssue[] = [];\n unknownFields(raw, ['format', 'title', 'items', 'links', 'layout'], '', issues);\n const title = optionalText(raw['title'], 'title', 120, issues) ?? '';\n\n const items: BoardItem[] = [];\n const seen = new Set<string>();\n const list = raw['items'];\n if (!Array.isArray(list)) issues.push({ path: 'items', message: 'must be a list (use [] for an empty board)' });\n else if (list.length > MAX_ITEMS) issues.push({ path: 'items', message: `has ${list.length} items; at most ${MAX_ITEMS}` });\n else list.forEach((value, index) => {\n const item = readItem(value, `items[${index}]`, issues);\n if (!item) return;\n if (seen.has(item.id)) { issues.push({ path: `items[${index}].id`, message: `\"${item.id}\" is already used by another item` }); return; }\n seen.add(item.id);\n items.push(item);\n });\n\n const links: BoardLink[] = [];\n const linkList = raw['links'] ?? [];\n if (!Array.isArray(linkList)) issues.push({ path: 'links', message: 'must be a list' });\n else if (linkList.length > MAX_LINKS) issues.push({ path: 'links', message: `has ${linkList.length} links; at most ${MAX_LINKS}` });\n else linkList.forEach((value, index) => { const link = readLink(value, `links[${index}]`, issues); if (link) links.push(link); });\n\n const layout: Record<string, BoardPoint> = {};\n const layoutSpec = raw['layout'] ?? {};\n if (!isObject(layoutSpec)) issues.push({ path: 'layout', message: 'must be an object from frame or note id to {\"x\", \"y\"}' });\n else if (Object.keys(layoutSpec).length > MAX_LAYOUT) issues.push({ path: 'layout', message: `has more than ${MAX_LAYOUT} entries` });\n else for (const [id, value] of Object.entries(layoutSpec)) {\n const path = `layout.${id}`;\n if (!FRAME_ID.test(id)) { issues.push({ path, message: 'is not a frame or note id' }); continue; }\n if (!isObject(value)) { issues.push({ path, message: 'must be {\"x\": number, \"y\": number}' }); continue; }\n const x = optionalNumber(value['x'], `${path}.x`, -COORDINATE, COORDINATE, issues);\n const y = optionalNumber(value['y'], `${path}.y`, -COORDINATE, COORDINATE, issues);\n if (x !== undefined && y !== undefined) layout[id] = { x, y };\n }\n\n if (issues.length) return { kind: 'invalid', issues };\n return { kind: 'ok', board: { format: BOARD_FORMAT, title, items, links, layout } };\n}\n\n/** What a canvas draws. Ids are stable: `<item>/<screen>` for maquette frames, the item id for HTML frames and notes. */\nexport interface PlannedFrame { readonly id: string; readonly kind: 'maquette' | 'html'; readonly src: string; readonly screen: string; readonly title: string; readonly x: number; readonly y: number; readonly w: number; readonly h: number }\nexport interface PlannedArrow { readonly id: string; readonly from: string; readonly to: string; readonly label: string }\nexport interface PlannedNote { readonly id: string; readonly text: string; readonly x: number; readonly y: number }\nexport interface BoardPlan {\n readonly frames: readonly PlannedFrame[];\n readonly arrows: readonly PlannedArrow[];\n readonly notes: readonly PlannedNote[];\n /** Referenced files that are missing or unreadable, and links that name no frame. The plan still draws everything else. */\n readonly problems: readonly BoardIssue[];\n}\n\nconst SIZE = { phone: { w: 390, h: 780 }, desktop: { w: 1024, h: 720 } } as const;\nconst GAP = 160;\n\n/**\n * Lays the board out left to right. `files` maps each referenced path to its text: a maquette is needed for its screens and goto links.\n * A frame with a layout entry keeps that position; the automatic frames go to the right of everything placed before them.\n */\nexport function planBoard(board: Board, files: ReadonlyMap<string, string>): BoardPlan {\n const frames: PlannedFrame[] = [], notes: PlannedNote[] = [], problems: BoardIssue[] = [];\n const maquettes = new Map<string, Maquette>();\n const arrowLinks: { from: string; to: string; label: string; source: string }[] = [];\n let cursor = 0;\n // Automatic frames go to the right of everything placed so far, so a frame a person moved never overlaps one the layout did not place.\n const place = (id: string, w: number): BoardPoint => {\n const at = board.layout[id] ?? { x: cursor, y: 0 };\n cursor = Math.max(cursor, at.x + w + GAP);\n return at;\n };\n\n for (const item of board.items) {\n if ('maquette' in item) {\n const text = files.get(item.maquette);\n const parsed = text === undefined ? undefined : parseMaquette(text);\n if (!parsed || parsed.kind !== 'ok') {\n problems.push({ path: `items.${item.id}`, message: text === undefined ? `${item.maquette} is missing` : `${item.maquette} is not a readable maquette` });\n continue;\n }\n const maquette = parsed.maquette;\n maquettes.set(item.id, maquette);\n const size = SIZE[maquette.device === 'desktop' ? 'desktop' : 'phone'];\n for (const screen of maquette.screens) {\n const id = `${item.id}/${screen.id}`;\n const at = place(id, size.w);\n frames.push({ id, kind: 'maquette', src: item.maquette, screen: screen.id, title: screen.title, x: at.x, y: at.y, ...size });\n }\n for (const screen of maquette.screens) for (const target of linksOf(screen.blocks))\n arrowLinks.push({ from: `${item.id}/${screen.id}`, to: `${item.id}/${target}`, label: '', source: `${item.id}/${screen.id}` });\n } else if ('html' in item) {\n const device = SIZE[item.device];\n const size = { w: item.width ?? device.w, h: item.height ?? device.h };\n const at = place(item.id, size.w);\n frames.push({ id: item.id, kind: 'html', src: item.html, screen: '', title: item.title ?? item.html, x: at.x, y: at.y, ...size });\n if (!files.has(item.html)) problems.push({ path: `items.${item.id}`, message: `${item.html} is missing` });\n }\n }\n\n const frameIds = new Set(frames.map(frame => frame.id));\n for (const link of board.links) arrowLinks.push({ from: link.from, to: link.to, label: link.label ?? '', source: `links.${link.from}->${link.to}` });\n const arrows: PlannedArrow[] = [];\n for (const link of arrowLinks) {\n if (frameIds.has(link.from) && frameIds.has(link.to)) arrows.push({ id: `${link.from}->${link.to}`, from: link.from, to: link.to, label: link.label });\n else problems.push({ path: link.source, message: `${frameIds.has(link.from) ? link.to : link.from} is not a frame on this board` });\n }\n\n for (const item of board.items) if ('note' in item) {\n const near = item.near === undefined ? undefined : frames.find(frame => frame.id === item.near);\n if (item.near !== undefined && !near) problems.push({ path: `items.${item.id}.near`, message: `${item.near} is not a frame on this board` });\n const at = board.layout[item.id] ?? (near ? { x: near.x + near.w - 120, y: near.y - 260 } : { x: cursor, y: 0 });\n notes.push({ id: item.id, text: item.note, x: at.x, y: at.y });\n }\n return { frames, arrows, notes, problems };\n}\n\n/** Frame and note positions, and the notes after people edited them on the canvas. */\nexport interface CanvasEdits {\n readonly positions: Readonly<Record<string, BoardPoint>>;\n /** Every note that remains. A note missing here was deleted. Its `near` is kept from the board. */\n readonly notes: readonly { readonly id: string; readonly text: string }[];\n}\n\nexport type BoardEditResult = { readonly kind: 'ok'; readonly board: Board } | { readonly kind: 'invalid'; readonly issues: readonly BoardIssue[] };\n\n/**\n * Writes people's canvas edits back into a board, as `board@1`: `layout` takes the new positions and the note items take the notes.\n * Maquette, HTML and link entries are never changed. The result is valid by construction and is checked as if it were read from a file.\n */\nexport function applyCanvasEdits(board: Board, edits: CanvasEdits): BoardEditResult {\n const issues: BoardIssue[] = [];\n const kept = board.items.filter(item => !('note' in item));\n const keptIds = new Set(kept.map(item => item.id));\n const near = new Map(board.items.flatMap(item => 'note' in item ? [[item.id, item.near] as const] : []));\n const notes: BoardItem[] = [];\n edits.notes.forEach((note, index) => {\n if (!ITEM_ID.test(note.id) || keptIds.has(note.id)) { issues.push({ path: `notes[${index}].id`, message: `\"${note.id}\" is not a free note id` }); return; }\n const text = note.text.trim();\n if (!text) { issues.push({ path: `notes[${index}].text`, message: 'is empty' }); return; }\n const attached = near.get(note.id);\n notes.push({ id: note.id, note: text.slice(0, MAX_NOTE), ...(attached !== undefined ? { near: attached } : {}) });\n });\n const layout: Record<string, BoardPoint> = {};\n for (const [id, at] of Object.entries(edits.positions)) {\n if (!FRAME_ID.test(id)) { issues.push({ path: `positions.${id}`, message: 'is not a frame or note id' }); continue; }\n if (!Number.isFinite(at.x) || !Number.isFinite(at.y) || Math.abs(at.x) > COORDINATE || Math.abs(at.y) > COORDINATE) { issues.push({ path: `positions.${id}`, message: 'is not a finite position' }); continue; }\n layout[id] = { x: Math.round(at.x), y: Math.round(at.y) };\n }\n if (Object.keys(layout).length > MAX_LAYOUT) issues.push({ path: 'positions', message: `has more than ${MAX_LAYOUT} entries` });\n if (kept.length + notes.length > MAX_ITEMS) issues.push({ path: 'notes', message: `would make more than ${MAX_ITEMS} items` });\n if (issues.length) return { kind: 'invalid', issues };\n return { kind: 'ok', board: { ...board, items: [...kept, ...notes], layout } };\n}\n\n/** The board's file text: `board@1` JSON, two-space indented, with a trailing newline. */\nexport const serializeBoard = (board: Board): string => `${JSON.stringify(board, null, 2)}\\n`;\n",
26
+ "type": "registry:lib",
27
+ "target": "components/board-viewer/board.ts"
28
+ },
29
+ {
30
+ "path": "registry/board-viewer/board-viewer.tsx",
31
+ "content": "'use client';\n\nimport { lazy, Suspense, useEffect, useMemo, useState, useSyncExternalStore } from 'react';\nimport { LayoutDashboardIcon } from 'lucide-react';\nimport type { ResourceClient, ResourceLocator } from '@hachej/boring-files';\nimport { createTextBuffer } from '@hachej/boring-ui-kit/text-buffer';\nimport type { TextBuffer } from '@hachej/boring-ui-kit/text-buffer';\nimport { Button } from '@/components/ui/button';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport type { ArtifactKind } from '../pi-chat/artifact';\nimport type { BoardHostProps, BoardViewerProps } from './board-props';\nexport type { BoardHostProps, BoardViewerProps } from './board-props';\nimport { parseBoard } from './board';\n\nexport { parseBoard, planBoard, applyCanvasEdits, serializeBoard } from './board';\nexport type { Board, BoardPlan } from './board';\nconst Canvas = lazy(() => import('./board-canvas'));\n\nconst readText = (snapshot: Parameters<Parameters<typeof createTextBuffer>[0]['readText']>[0]) => {\n const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(snapshot.bytes);\n if (parseBoard(text).kind !== 'ok') throw new Error('Unreadable board');\n return text;\n};\n\n/** An opt-in artifact kind. Its client and authenticated receipt identity belong to the host's selected workspace. */\nexport function createBoardKind(host: BoardHostProps): ArtifactKind {\n return { match: target => /\\.board\\.json$/i.test(target.resource.path), label: 'Board', icon: LayoutDashboardIcon,\n renderer: ({ artifact, text, revision, pinned }) => <BoardViewer {...host} target={artifact.target} text={text} revision={revision} pinned={pinned} /> };\n}\n\nexport function BoardViewer(props: BoardViewerProps) {\n const parsed = useMemo(() => parseBoard(props.text), [props.text]);\n if (parsed.kind !== 'ok') return <Alert role=\"alert\"><AlertTitle>{parsed.kind === 'newer' ? 'Newer board format' : 'This board cannot be read'}</AlertTitle>\n <AlertDescription>{parsed.kind === 'newer' ? parsed.format : parsed.issues.map(issue => `${issue.path}: ${issue.message}`).join('; ')}</AlertDescription></Alert>;\n const key = JSON.stringify([props.target, props.identity, props.pinned]);\n return <BoardSession key={key} {...props} />;\n}\n\nfunction BoardSession({ text, revision, target, pinned, client, identity, ...host }: BoardViewerProps) {\n const [buffer, setBuffer] = useState<TextBuffer>();\n useEffect(() => {\n const next = createTextBuffer({ client, identity, instanceId: 'board', epoch: revision, readOnly: pinned || !client.publish,\n source: { kind: 'saved', snapshot: { ref: { ...target, revision }, bytes: new TextEncoder().encode(text), mediaType: 'application/json' } },\n mediaType: 'application/json', readText });\n setBuffer(next);\n return () => next.dispose();\n // A changed revision refreshes the existing buffer below; it must not replace a dirty canvas.\n }, [client, identity, target, pinned]); // eslint-disable-line react-hooks/exhaustive-deps\n useEffect(() => {\n if (!buffer || pinned) return;\n const state = buffer.getSnapshot();\n if (state.base.kind === 'revision' && state.base.target.revision !== revision) void buffer.refresh(false);\n }, [buffer, text, revision, pinned]);\n return buffer ? <BoardDocument {...host} buffer={buffer} client={client} target={target} pinned={pinned} /> : <p role=\"status\">Loading board…</p>;\n}\n\nfunction BoardDocument({ buffer, client, target, pinned, ...host }: Omit<BoardHostProps, 'identity'> & { readonly buffer: TextBuffer; readonly target: ResourceLocator; readonly pinned: boolean }) {\n const state = useSyncExternalStore(buffer.subscribe, buffer.getSnapshot, buffer.getSnapshot);\n const parsed = useMemo(() => parseBoard(state.text), [state.text]);\n // Layout-only edits must not fetch linked files or remount the projection.\n const sources = parsed.kind === 'ok' ? JSON.stringify([...new Set(parsed.board.items.flatMap(item => 'maquette' in item ? [item.maquette] : 'html' in item ? [item.html] : []))]) : '[]';\n const [loaded, setLoaded] = useState<{ readonly sources: string; readonly files: ReadonlyMap<string, string> }>();\n const [error, setError] = useState<string>();\n useEffect(() => {\n let live = true;\n setError(undefined);\n const paths: string[] = JSON.parse(sources);\n const folder = target.resource.path.slice(0, target.resource.path.lastIndexOf('/') + 1);\n void Promise.all(paths.map(async relative => {\n const result = await client.read({ target: { ...target, resource: { ...target.resource, path: folder + relative } }, revision: { kind: 'latest' } });\n if (result.kind !== 'available') throw new Error('Linked files are unavailable');\n return [relative, new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(result.snapshot.bytes)] as const;\n })).then(entries => { if (live) setLoaded({ sources, files: new Map(entries) }); }, () => { if (live) setError('Linked files are unavailable. Reload the board to retry.'); });\n return () => { live = false; };\n }, [client, target, sources]);\n const outcome = state.save.kind === 'settled' ? state.save.result : undefined;\n const conflict = state.remote !== null || outcome?.kind === 'conflict';\n const unknown = outcome?.kind === 'unknown';\n const pending = state.save.kind === 'pending';\n const run = (action: () => Promise<unknown>) => { void action().catch(() => setError('Board operation failed. Your local edits are retained.')); };\n return <section className=\"flex h-full min-h-0 flex-col\" data-boring=\"board-viewer\">\n <div role=\"status\" data-testid=\"board-save\" className=\"text-muted-foreground\">{pinned ? 'Read only' : pending ? 'Saving…' : unknown ? 'Save unconfirmed' : conflict ? 'Changed elsewhere' : outcome && outcome.kind !== 'saved' ? 'Save failed' : state.dirty ? 'Unsaved changes' : 'Saved'}</div>\n {conflict && <Alert role=\"alert\" data-testid=\"board-conflict\"><AlertTitle>Changed elsewhere</AlertTitle><AlertDescription>Your local edits have been kept.</AlertDescription>\n <Button disabled={pending || unknown} onClick={() => run(() => buffer.refresh(true))}>Discard local changes and reload</Button></Alert>}\n {unknown && <Alert role=\"alert\"><AlertTitle>Save unconfirmed</AlertTitle><AlertDescription>Your local edits have been kept. Do not repeat the save.</AlertDescription>\n <Button onClick={() => run(buffer.reconcile)}>Check save outcome</Button><Button onClick={() => run(buffer.abandon)}>Keep draft and refresh</Button></Alert>}\n {error && <Alert role=\"alert\"><AlertTitle>{error}</AlertTitle></Alert>}\n {!error && loaded?.sources === sources && parsed.kind === 'ok' && <Suspense fallback={<p role=\"status\">Loading board…</p>}>\n <Canvas {...host} buffer={buffer} files={loaded.files} />\n </Suspense>}\n </section>;\n}\n",
32
+ "type": "registry:component",
33
+ "target": "components/board-viewer/board-viewer.tsx"
34
+ },
35
+ {
36
+ "path": "registry/board-viewer/board-canvas.tsx",
37
+ "content": "'use client';\n\n// Ported from spike/canvas-board. Native shapes are a disposable projection of board@1, never a saved tldraw store.\nimport { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport { HTMLContainer, Rectangle2d, ShapeUtil, T, createShapeId, renderPlaintextFromRichText, toRichText, useValue } from 'tldraw';\nimport type { Editor, TLBaseShape } from 'tldraw';\nimport { CanvasProjection } from '@hachej/boring-ui-kit/canvas-editor';\nimport type { TextBuffer } from '@hachej/boring-ui-kit/text-buffer';\nimport { HandIcon, MousePointer2Icon, StickyNoteIcon, MaximizeIcon, Undo2Icon, Redo2Icon, MessageSquarePlusIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent } from '@/components/ui/card';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { MockupViewer } from '../mockup-viewer/mockup-viewer';\nimport { parseMaquette } from '../maquette-viewer/maquette';\nimport { MaquetteScreenView } from '../maquette-viewer/maquette-blocks';\nimport { defaultMaquetteLabels } from '../maquette-viewer/maquette-labels';\nimport { applyCanvasEdits, parseBoard, planBoard, serializeBoard } from './board';\nimport type { Board } from './board';\nimport type { BoardHostProps } from './board-props';\n\nconst FRAME = 'board-frame';\ntype FrameProps = { w: number; h: number; kind: string; src: string; screen: string; title: string; frameId: string };\ntype BoardFrame = TLBaseShape<typeof FRAME, FrameProps>;\ndeclare module 'tldraw' { interface TLGlobalShapePropsMap { 'board-frame': FrameProps } }\ninterface BoardContext {\n readonly files: ReadonlyMap<string, string>;\n readonly comments: BoardHostProps['comments'];\n readonly path: string;\n readonly go: (frameId: string, screen: string) => void;\n}\nconst Context = createContext<BoardContext>({ files: new Map(), comments: undefined, path: '', go: () => {} });\nconst frameId = (id: string) => createShapeId(`frame-${id}`);\n\nfunction FrameContent({ shape, editor }: { readonly shape: BoardFrame; readonly editor: Editor }) {\n const board = useContext(Context);\n const editing = useValue('board frame editing', () => editor.getEditingShapeId() === shape.id, [editor, shape.id]);\n const { src, kind, screen, frameId: id, title } = shape.props;\n const text = board.files.get(src) ?? '';\n const parsed = useMemo(() => kind === 'maquette' ? parseMaquette(text) : undefined, [kind, text]);\n const shown = parsed?.kind === 'ok' ? parsed.maquette.screens.find(item => item.id === screen) : undefined;\n return <HTMLContainer className=\"flex flex-col\" style={{ width: shape.props.w, height: shape.props.h }}>\n <div className=\"pointer-events-none absolute -top-8 left-0 whitespace-nowrap text-muted-foreground\" data-testid=\"board-frame-title\">{title}</div>\n <Card className=\"min-h-0 flex-1 overflow-hidden\" data-testid=\"board-frame\" data-frame={id}>\n <CardContent className=\"min-h-0 flex-1 overflow-hidden\" style={{ pointerEvents: editing || board.comments?.active ? 'auto' : 'none' }}>\n {kind === 'html' ? <MockupViewer text={text} path={src} comments={board.comments} />\n : shown && parsed?.kind === 'ok' ? <MaquetteScreenView maquette={parsed.maquette} screen={shown} labels={defaultMaquetteLabels}\n links={{ go: target => board.go(id, target), hint: target => `Opens ${target}`,\n target: block => `board:${board.path}#frame=${id}&block=${block.id}`,\n pins: block => board.comments?.pins.filter(pin => pin.target === `board:${board.path}#frame=${id}&block=${block.id}`).map(pin => pin.number) ?? [] }} /> : <p role=\"alert\">Unreadable maquette {src}</p>}\n </CardContent>\n </Card>\n </HTMLContainer>;\n}\n\nclass BoardFrameUtil extends ShapeUtil<BoardFrame> {\n static override type = FRAME;\n static override props = { w: T.number, h: T.number, kind: T.string, src: T.string, screen: T.string, title: T.string, frameId: T.string };\n override getDefaultProps(): FrameProps { return { w: 390, h: 780, kind: 'maquette', src: '', screen: '', title: '', frameId: '' }; }\n override canEdit() { return !this.editor.getIsReadonly(); }\n override canResize() { return false; }\n override canBind() { return true; }\n override hideRotateHandle() { return true; }\n override getGeometry(shape: BoardFrame) { return new Rectangle2d({ width: shape.props.w, height: shape.props.h, isFilled: true }); }\n override component(shape: BoardFrame) { return <FrameContent shape={shape} editor={this.editor} />; }\n override getIndicatorPath(shape: BoardFrame) { const path = new Path2D(); path.rect(0, 0, shape.props.w, shape.props.h); return path; }\n}\nconst shapeUtils = [BoardFrameUtil];\n\nfunction project(editor: Editor, board: Board, files: ReadonlyMap<string, string>) {\n const plan = planBoard(board, files);\n const readOnly = editor.getIsReadonly();\n editor.updateInstanceState({ isReadonly: false });\n try { editor.store.mergeRemoteChanges(() => editor.run(() => {\n editor.deleteShapes([...editor.getCurrentPageShapeIds()]);\n editor.createShapes<BoardFrame>(plan.frames.map(frame => ({ id: frameId(frame.id), type: FRAME, x: frame.x, y: frame.y,\n props: { w: frame.w, h: frame.h, kind: frame.kind, src: frame.src, screen: frame.screen, title: frame.title, frameId: frame.id } })));\n const at = new Map(plan.frames.map(frame => [frame.id, frame]));\n // Duplicate goto targets produce one arrow per pair.\n for (const arrow of new Map(plan.arrows.map(arrow => [arrow.id, arrow])).values()) {\n const id = createShapeId(`arrow-${arrow.id}`);\n const backward = (at.get(arrow.from)?.x ?? 0) > (at.get(arrow.to)?.x ?? 0);\n editor.createShape({ id, type: 'arrow', isLocked: true, props: { start: { x: 0, y: 0 }, end: { x: 1, y: 0 }, bend: backward ? -260 : 0,\n richText: toRichText(arrow.label), dash: backward ? 'dashed' : 'solid' } });\n editor.createBindings(([['start', arrow.from], ['end', arrow.to]] as const).map(([terminal, frame]) => ({ type: 'arrow', fromId: id, toId: frameId(frame),\n props: { terminal, normalizedAnchor: { x: 0.5, y: 0.5 }, isExact: false, isPrecise: false, snap: 'none' } })));\n }\n editor.createShapes(plan.notes.map(note => ({ id: createShapeId(`note-${note.id}`), type: 'note', x: note.x, y: note.y, props: { richText: toRichText(note.text) } })));\n }, { history: 'ignore', ignoreShapeLock: true })); }\n finally { editor.updateInstanceState({ isReadonly: readOnly }); }\n return plan;\n}\n\nfunction capture(editor: Editor, board: Board): Board | undefined {\n const positions: Record<string, { x: number; y: number }> = {};\n const notes: { id: string; text: string }[] = [];\n for (const shape of editor.getCurrentPageShapes()) {\n if (editor.isShapeOfType<BoardFrame>(shape, FRAME)) positions[shape.props.frameId] = { x: shape.x, y: shape.y };\n if (editor.isShapeOfType(shape, 'note')) {\n const id = shape.id.startsWith('shape:note-') ? shape.id.slice('shape:note-'.length) : `note-${shape.id.slice(6)}`;\n positions[id] = { x: shape.x, y: shape.y };\n notes.push({ id, text: renderPlaintextFromRichText(editor, shape.props.richText) });\n }\n }\n const result = applyCanvasEdits(board, { positions, notes });\n return result.kind === 'ok' ? result.board : undefined;\n}\n\nexport default function BoardCanvas({ buffer, files, assetUrls, licenseKey, onEditor, comments }: Omit<BoardHostProps, 'identity' | 'client'> & { readonly buffer: TextBuffer; readonly files: ReadonlyMap<string, string> }) {\n const state = useSyncExternalStore(buffer.subscribe, buffer.getSnapshot, buffer.getSnapshot);\n const [phone, setPhone] = useState(() => typeof matchMedia === 'function' && matchMedia('(max-width: 639px), (pointer: coarse)').matches);\n useEffect(() => {\n const media = matchMedia('(max-width: 639px), (pointer: coarse)');\n const changed = () => setPhone(media.matches);\n media.addEventListener('change', changed);\n return () => media.removeEventListener('change', changed);\n }, []);\n const editorRef = useRef<Editor | null>(null);\n const projected = useRef(state.text);\n const [editor, setEditor] = useState<Editor | null>(null);\n const blocked = state.readOnly || phone;\n const mode = useRef(blocked); mode.current = blocked;\n const [problem, setProblem] = useState<string>();\n const attach = useCallback((native: Editor) => {\n editorRef.current = native;\n const parsed = parseBoard(buffer.getSnapshot().text);\n if (parsed.kind !== 'ok') return;\n projected.current = buffer.getSnapshot().text;\n const plan = project(native, parsed.board, files);\n native.updateInstanceState({ isReadonly: mode.current });\n native.setCurrentTool(mode.current ? 'hand' : 'select');\n const fit = () => {\n native.updateViewportScreenBounds(native.getContainer());\n const first = plan.frames[0] && native.getShapePageBounds(frameId(plan.frames[0].id));\n if (mode.current && first) native.zoomToBounds(first, { animation: { duration: 0 }, inset: 24 });\n else native.zoomToFit({ animation: { duration: 0 } });\n };\n fit();\n const timer = setTimeout(fit, 400);\n // Frame identity/content and goto arrows come only from board@1, never native delete/paste/rotate operations.\n const stopDelete = native.sideEffects.registerBeforeDeleteHandler('shape', (shape, source) => { if (source === 'user' && (shape.type === FRAME || shape.type === 'arrow')) return false; });\n const stopUpdate = native.sideEffects.registerBeforeChangeHandler('shape', (previous, next, source) => source === 'user' && previous.type === FRAME ? { ...previous, x: next.x, y: next.y } : next);\n const stop = native.store.listen(() => {\n if (mode.current) return;\n const current = parseBoard(buffer.getSnapshot().text);\n if (current.kind !== 'ok') return;\n const next = capture(native, current.board);\n if (!next) { setProblem('Finish the note before saving.'); return; }\n setProblem(undefined);\n projected.current = serializeBoard(next);\n buffer.edit(projected.current);\n }, { source: 'user', scope: 'document' });\n setEditor(native); onEditor?.(native);\n return () => { clearTimeout(timer); stop(); stopDelete(); stopUpdate(); editorRef.current = null; setEditor(null); onEditor?.(null); };\n }, [buffer, files, onEditor]);\n useEffect(() => {\n if (!editor) return;\n editor.updateInstanceState({ isReadonly: blocked });\n if (blocked) editor.setCurrentTool('hand');\n }, [editor, blocked]);\n useEffect(() => {\n if (!editor || state.text === projected.current) return;\n const parsed = parseBoard(state.text);\n if (parsed.kind === 'ok') { project(editor, parsed.board, files); projected.current = state.text; }\n }, [editor, state.text, files]);\n useEffect(() => {\n if (blocked || problem || !state.dirty || state.remote || state.save.kind === 'pending' || state.save.kind === 'settled' && state.save.result.kind !== 'saved') return;\n const timer = setTimeout(() => { void buffer.flush(buffer.selection()); }, 600);\n return () => clearTimeout(timer);\n }, [buffer, state, blocked, problem]);\n const context = useMemo<BoardContext>(() => ({ files, comments, path: state.base.target.resource.path, go: (id, screen) => {\n const native = editorRef.current;\n const target = native?.getShape(frameId(`${id.split('/')[0]}/${screen}`));\n const bounds = target && native?.getShapePageBounds(target);\n if (native && bounds) native.zoomToBounds(bounds, { animation: { duration: 200 }, inset: 24 });\n } }), [files, comments, state.base.target.resource.path]);\n return <Context.Provider value={context}><div data-testid=\"board\" data-phone={phone} className=\"relative min-h-0 flex-1 overscroll-contain\" style={{ touchAction: 'none' }}>\n <CanvasProjection assetUrls={assetUrls} licenseKey={licenseKey} hideUi shapeUtils={shapeUtils} onMount={attach} />\n {editor && <BoardTools editor={editor} blocked={blocked} comments={comments} />}\n {problem && <p role=\"alert\" className=\"absolute bottom-2 left-2 text-muted-foreground\">{problem}</p>}\n </div></Context.Provider>;\n}\n\nfunction BoardTools({ editor, blocked, comments }: { readonly editor: Editor; readonly blocked: boolean; readonly comments: BoardHostProps['comments'] }) {\n const native = useValue('board toolbar', () => ({ undo: editor.getCanUndo(), redo: editor.getCanRedo() }), [editor]);\n const tools = [\n ...(!blocked ? [{ label: 'Select frames', icon: MousePointer2Icon, action: () => editor.setCurrentTool('select') }, { label: 'Pan board', icon: HandIcon, action: () => editor.setCurrentTool('hand') },\n { label: 'Add note', icon: StickyNoteIcon, action: () => editor.setCurrentTool('note') }, { label: 'Undo', icon: Undo2Icon, action: () => editor.undo(), disabled: !native.undo },\n { label: 'Redo', icon: Redo2Icon, action: () => editor.redo(), disabled: !native.redo }] : []),\n { label: 'Fit board', icon: MaximizeIcon, action: () => editor.zoomToFit() },\n ...(comments ? [{ label: 'Comment on board', icon: MessageSquarePlusIcon, action: comments.point }] : []),\n ];\n return <nav aria-label=\"Board tools\" data-feedback-ignore=\"\" className=\"absolute top-2 left-2 z-[300] flex flex-wrap gap-1\">\n {tools.map(tool => <Tooltip key={tool.label}><TooltipTrigger render={<Button size=\"icon\" aria-label={tool.label} disabled={'disabled' in tool && tool.disabled} onClick={tool.action} />}><tool.icon aria-hidden=\"true\" /></TooltipTrigger><TooltipContent>{tool.label}</TooltipContent></Tooltip>)}\n </nav>;\n}\n",
38
+ "type": "registry:component",
39
+ "target": "components/board-viewer/board-canvas.tsx"
40
+ },
41
+ {
42
+ "path": "registry/board-viewer/board-props.ts",
43
+ "content": "import type { ResourceClient, ResourceLocator } from '@hachej/boring-files';\nimport type { TextBufferState } from '@hachej/boring-ui-kit/text-buffer';\nimport type { CanvasAssetUrls } from '@hachej/boring-ui-kit/canvas-editor';\nimport type { Editor } from 'tldraw';\nimport type { MockupComments } from '../mockup-viewer/mockup-viewer';\n\nexport interface BoardHostProps {\n readonly client: ResourceClient;\n readonly identity: TextBufferState['identity'];\n readonly assetUrls: CanvasAssetUrls;\n readonly licenseKey?: string | undefined;\n /** Existing host point-comments binding; structured board pin delivery is installed separately. */\n readonly comments?: MockupComments | undefined;\n /** The editor and its store are a disposable projection, never the saved document. */\n readonly onEditor?: ((editor: Editor | null) => void) | undefined;\n}\nexport interface BoardViewerProps extends BoardHostProps {\n readonly target: ResourceLocator;\n readonly text: string;\n readonly revision: string;\n readonly pinned: boolean;\n}\n",
44
+ "type": "registry:lib",
45
+ "target": "components/board-viewer/board-props.ts"
46
+ }
47
+ ],
48
+ "type": "registry:block"
49
+ }
package/r/button.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/utils.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/utils.json",
13
13
  "dropdown-menu",
14
14
  "button",
15
15
  "toggle",
@@ -0,0 +1,42 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "diagram-viewer",
4
+ "title": "Diagram viewer",
5
+ "description": "DiagramViewer, DiagramView and diagramKind: an agent's *.diagram.json (format v1, versioned: nodes, edges, optional groups, highlighted path and note) as boxes and numbered arrows with a legend that says what happens and who acts. Layers run left to right while they fit and top to bottom on a phone, scrolling inside their own box; no layout library. A superset of a maquette's workflow, which the maquette viewer draws with DiagramView. The pi-app artifact panel shows .diagram.json files with it by default; app artifactKinds win.",
6
+ "dependencies": [
7
+ "react@^19.0.0",
8
+ "lucide-react@^1.51.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/theme.json",
12
+ "alert",
13
+ "badge"
14
+ ],
15
+ "files": [
16
+ {
17
+ "path": "registry/diagram-viewer/diagram.ts",
18
+ "content": "/*\n * The diagram format: boxes and arrows an agent writes as `<name>.diagram.json` (a workflow, a flow between screens, the states of a case).\n * Plain data drawn by `DiagramView`: nothing in it is built or runs. It is a superset of a maquette's `workflow` (`states` and `transitions`\n * are aliases of `nodes` and `edges`), so a maquette hands its workflow to the same reader. Documented in registry/README.md (\"Diagram viewer\").\n * Model output is untrusted: `parseDiagram` keeps what it understands, bounds every list and string, and never throws.\n */\n\n/** The format version this viewer writes and reads. A file without `version` is read as version 1. */\nexport const DIAGRAM_FORMAT_VERSION = 1;\n\n/** A diagram file as written in format v1. */\nexport interface DiagramFileV1 {\n /** Absent means 1. */\n readonly version?: 1;\n readonly title?: string;\n /** The boxes, in reading order; the first is where the flow starts. A bare string is a node whose label is its id. `states` is an alias. */\n readonly nodes: readonly ({ readonly id: string; readonly label?: string; readonly group?: string } | string)[];\n /** The arrows between nodes (`transitions` is an alias; `on` is an alias of `label`). `by`: who acts. */\n readonly edges?: readonly { readonly from: string; readonly to: string; readonly label?: string; readonly on?: string; readonly by?: string }[];\n /** Named groups a node can belong to (its `group`); a group named by a node but not listed here is named by its id. */\n readonly groups?: readonly ({ readonly id: string; readonly label?: string } | string)[];\n /** Node ids of one path through the diagram, drawn stronger with the arrows between consecutive nodes. */\n readonly path?: readonly string[];\n /** One short paragraph under the diagram. */\n readonly note?: string;\n}\n\nexport interface DiagramNode { readonly id: string; readonly label: string; readonly group?: string | undefined }\nexport interface DiagramEdge { readonly from: string; readonly to: string; readonly label: string; readonly by: string }\nexport interface DiagramGroup { readonly id: string; readonly label: string }\n/** A diagram ready to draw: every field read, bounded and defaulted; edges and the path only name known nodes. */\nexport interface Diagram {\n readonly title: string;\n readonly nodes: readonly DiagramNode[];\n readonly edges: readonly DiagramEdge[];\n readonly groups: readonly DiagramGroup[];\n readonly path: readonly string[];\n readonly note: string;\n}\n\n/**\n * The outcome of reading a file. `version` is the file's declared version (1 when absent); a version above `DIAGRAM_FORMAT_VERSION`\n * is read as v1, best effort. `invalid` is not JSON (`json`) or has no node to draw (`shape`).\n */\nexport type DiagramParse =\n | { readonly kind: 'ok'; readonly diagram: Diagram; readonly version: number }\n | { readonly kind: 'invalid'; readonly reason: 'json' | 'shape'; readonly version: number | undefined };\n\nconst str = (value: unknown, max = 400): string => typeof value === 'string' ? value.slice(0, max) : typeof value === 'number' && Number.isFinite(value) ? String(value) : '';\nconst arr = (value: unknown, max: number): readonly unknown[] => Array.isArray(value) ? value.slice(0, max) : [];\nconst obj = (value: unknown): Record<string, unknown> => value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};\n\n/** Reads a diagram (its JSON text, or an already parsed value such as a maquette's `workflow`). Never throws. */\nexport function parseDiagram(input: unknown): DiagramParse {\n let raw: unknown = input;\n if (typeof raw === 'string') {\n try { raw = JSON.parse(raw); } catch { return { kind: 'invalid', reason: 'json', version: undefined }; }\n }\n const spec = obj(raw);\n const declared = spec['version'];\n const version = declared === undefined ? 1 : typeof declared === 'number' && Number.isSafeInteger(declared) && declared >= 1 ? declared : undefined;\n if (version === undefined) return { kind: 'invalid', reason: 'shape', version: undefined };\n const seen = new Set<string>();\n const nodes = arr(spec['nodes'] ?? spec['states'], 40).map((value): DiagramNode | null => {\n const node = obj(value), id = str(node['id'], 60) || str(value, 60);\n if (!id || seen.has(id)) return null;\n seen.add(id);\n const group = str(node['group'], 60);\n return { id, label: str(node['label'], 80) || id, ...(group ? { group } : {}) };\n }).filter((node): node is DiagramNode => node !== null);\n if (!nodes.length) return { kind: 'invalid', reason: 'shape', version };\n const edges = arr(spec['edges'] ?? spec['transitions'], 80).map((value): DiagramEdge => {\n const edge = obj(value);\n return { from: str(edge['from'], 60), to: str(edge['to'], 60), label: str(edge['label'] ?? edge['on'], 80), by: str(edge['by'], 40) };\n }).filter(edge => seen.has(edge.from) && seen.has(edge.to));\n const listed = arr(spec['groups'], 12).map((value): DiagramGroup | null => {\n const group = obj(value), id = str(group['id'], 60) || str(value, 60);\n return id ? { id, label: str(group['label'], 80) || id } : null;\n }).filter((group): group is DiagramGroup => group !== null);\n const named = [...new Set(nodes.flatMap(node => node.group ? [node.group] : []))].filter(id => !listed.some(group => group.id === id)).map(id => ({ id, label: id }));\n return { kind: 'ok', version, diagram: {\n title: str(spec['title'], 120), nodes, edges, groups: [...listed, ...named],\n path: arr(spec['path'], 20).map(id => str(id, 60)).filter(id => seen.has(id)),\n note: str(spec['note'], 600),\n } };\n}\n\n/** True for a path the built-in diagram kind draws: a `.diagram.json` file. */\nexport const isDiagramPath = (path: string): boolean => /\\.diagram\\.json$/i.test(path);\n",
19
+ "type": "registry:lib",
20
+ "target": "components/diagram-viewer/diagram.ts"
21
+ },
22
+ {
23
+ "path": "registry/diagram-viewer/diagram-labels.ts",
24
+ "content": "/* The diagram viewer's words in English and French. `locale` picks the base set; a host's `labels` still override any of them. */\n\nexport type DiagramLocale = 'en' | 'fr';\n\nexport const defaultDiagramLabels = {\n /** The kind's name on the artifact card and in the panel bar. */\n kind: 'Diagram',\n transitionBy: (by: string) => `by ${by}`,\n newerTitle: 'Made with a newer version',\n newer: (version: number) => `This diagram uses format version ${version}. This viewer reads version 1: parts it does not know may be missing.`,\n errorTitle: 'This diagram cannot be shown',\n invalidJson: 'The file is not valid JSON. Ask the agent to present it again.',\n invalidShape: 'The file has no node to draw (a diagram needs \"nodes\": [{ \"id\", \"label\" }]).',\n};\nexport type DiagramLabels = typeof defaultDiagramLabels;\n\n/** Typed as `DiagramLabels`, so a key added in English must be added here too. */\nexport const frenchDiagramLabels: DiagramLabels = {\n kind: 'Diagramme',\n transitionBy: by => `par ${by}`,\n newerTitle: 'Fait avec une version plus récente',\n newer: version => `Ce diagramme utilise la version ${version} du format. Cet afficheur lit la version 1 : ce qu’il ne connaît pas peut manquer.`,\n errorTitle: 'Ce diagramme ne peut pas être affiché',\n invalidJson: 'Le fichier n’est pas du JSON valide. Demandez à l’agent de le présenter à nouveau.',\n invalidShape: 'Le fichier n’a aucun nœud à dessiner (un diagramme a besoin de « nodes » : [{ \"id\", \"label\" }]).',\n};\n\n/** The viewer's words for `locale` (English by default) with the host's overrides. */\nexport const diagramLabelsFor = (locale: DiagramLocale | undefined, labels?: Partial<DiagramLabels>): DiagramLabels =>\n ({ ...(locale === 'fr' ? frenchDiagramLabels : defaultDiagramLabels), ...labels });\n",
25
+ "type": "registry:lib",
26
+ "target": "components/diagram-viewer/diagram-labels.ts"
27
+ },
28
+ {
29
+ "path": "registry/diagram-viewer/diagram-view.tsx",
30
+ "content": "'use client';\n\n// A diagram drawn as an SVG and a legend: nodes in layers by distance from the first node, one arrow per edge with a numbered badge, and a\n// numbered legend under it that says what happens and who acts (labels of any length). Layers run left to right while they fit the box; in\n// a narrow box (a phone) they run top to bottom instead. A diagram still wider than its box scrolls inside it, never the page. A node's\n// group is a muted second line in its box. No layout library: the layered layout is a breadth-first pass over the edges.\nimport { useEffect, useId, useRef, useState } from 'react';\nimport { Badge } from '@/components/ui/badge';\nimport type { Diagram } from './diagram';\n\nconst H1 = 44, H2 = 54, W_WIDE = 148, GX = 72, GY = 36, VX = 16, VY = 56, LOOP = 36;\n\ntype Point = { readonly x: number; readonly y: number };\n\n/** Each node's layer: the shortest number of edges from the first node; nodes it never reaches come after, one layer each. */\nfunction layers(diagram: Diagram): Map<number, string[]> {\n const { nodes, edges } = diagram;\n const depth = new Map<string, number>([[nodes[0]!.id, 0]]);\n for (let changed = true, guard = 0; changed && guard < 50; guard++) {\n changed = false;\n for (const edge of edges) if (depth.has(edge.from) && !depth.has(edge.to)) { depth.set(edge.to, depth.get(edge.from)! + 1); changed = true; }\n }\n let extra = Math.max(0, ...depth.values()) + 1;\n for (const node of nodes) if (!depth.has(node.id)) depth.set(node.id, extra++);\n const columns = new Map<number, string[]>();\n for (const node of nodes) { const d = depth.get(node.id)!; columns.set(d, [...(columns.get(d) ?? []), node.id]); }\n return columns;\n}\n\n/** The SVG path of an edge and where its number sits. */\nfunction arrow(a: Point, b: Point, W: number, H: number, vertical: boolean): { d: string; x: number; y: number } {\n if (!vertical) {\n // Next layer: right edge to left edge. Same layer, below: straight down. Otherwise (back, or skipping a layer): a loop under both boxes.\n const kind = b.x > a.x && b.x - a.x <= W + GX ? 'forward' : b.x === a.x && b.y > a.y ? 'down' : 'back';\n const x1 = kind === 'forward' ? a.x + W : a.x + W / 2, y1 = kind === 'forward' ? a.y + H / 2 : a.y + H;\n const x2 = kind === 'forward' ? b.x : b.x + W / 2, y2 = kind === 'forward' ? b.y + H / 2 : kind === 'down' ? b.y : b.y + H;\n const d = kind === 'forward' ? `M${x1} ${y1} C${x1 + GX / 2} ${y1} ${x2 - GX / 2} ${y2} ${x2} ${y2}` : kind === 'down' ? `M${x1} ${y1} L${x2} ${y2}` : `M${x1} ${y1} C${x1} ${y1 + 30} ${x2} ${y2 + 30} ${x2} ${y2}`;\n return { d, x: kind === 'down' ? x1 + 12 : (x1 + x2) / 2, y: kind === 'back' ? Math.max(y1, y2) + 22 : (y1 + y2) / 2 };\n }\n // Next layer: bottom edge to top edge. Same row: side to side. Otherwise (back, or skipping a layer): a loop on the right.\n const kind = b.y > a.y && b.y - a.y <= H + VY ? 'forward' : b.y === a.y ? 'across' : 'back';\n const right = b.x > a.x;\n const x1 = kind === 'forward' ? a.x + W / 2 : kind === 'across' ? (right ? a.x + W : a.x) : a.x + W, y1 = kind === 'forward' ? a.y + H : a.y + H / 2;\n const x2 = kind === 'forward' ? b.x + W / 2 : kind === 'across' ? (right ? b.x : b.x + W) : b.x + W, y2 = kind === 'forward' ? b.y : b.y + H / 2;\n const out = Math.max(x1, x2) + LOOP - 8;\n const d = kind === 'forward' ? `M${x1} ${y1} C${x1} ${y1 + VY / 2} ${x2} ${y2 - VY / 2} ${x2} ${y2}` : kind === 'across' ? `M${x1} ${y1} L${x2} ${y2}` : `M${x1} ${y1} C${out} ${y1} ${out} ${y2} ${x2} ${y2}`;\n return { d, x: kind === 'back' ? out - 6 : (x1 + x2) / 2, y: (y1 + y2) / 2 };\n}\n\nconst clip = (text: string, fit: number) => text.length > fit ? `${text.slice(0, fit - 1)}…` : text;\n\n/** The words the view needs (a maquette's labels have them too). */\nexport interface DiagramViewLabels { readonly transitionBy: (by: string) => string }\n\n/**\n * The diagram and its legend. `highlight`: node ids of a path (the diagram's own `path` by default), drawn stronger with the arrows between\n * consecutive nodes of it.\n */\nexport function DiagramView({ diagram, highlight = diagram.path, labels }: { readonly diagram: Diagram; readonly highlight?: readonly string[]; readonly labels: DiagramViewLabels }) {\n const { nodes, edges, groups } = diagram;\n const box = useRef<HTMLDivElement>(null);\n const [room, setRoom] = useState(0);\n const marker = `diagram-arrow-${useId().replace(/:/g, '')}`;\n useEffect(() => {\n const element = box.current;\n if (!element) return undefined;\n const measure = () => setRoom(element.clientWidth);\n measure();\n if (typeof ResizeObserver === 'undefined') return undefined;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n const H = nodes.some(node => node.group) ? H2 : H1;\n const columns = layers(diagram);\n const count = Math.max(...columns.keys()) + 1, widest = Math.max(...[...columns.values()].map(ids => ids.length));\n const vertical = room > 0 && 16 + count * W_WIDE + (count - 1) * GX > room;\n const W = vertical ? Math.max(104, Math.min(W_WIDE, Math.floor((room - 16 - LOOP - (widest - 1) * VX) / widest))) : W_WIDE;\n const pos = new Map<string, Point>();\n for (const [d, ids] of columns) ids.forEach((id, i) => pos.set(id, vertical ? { x: 8 + i * (W + VX), y: 8 + d * (H + VY) } : { x: 8 + d * (W + GX), y: 8 + i * (H + GY) }));\n const points = [...pos.values()];\n const width = (vertical ? LOOP : 16) + Math.max(...points.map(p => p.x)) + W + 8, height = (vertical ? 16 : 40) + Math.max(...points.map(p => p.y)) + H;\n const on = new Set(highlight);\n const steps = new Set(highlight.slice(1).map((id, i) => `${highlight[i]}\\n${id}`));\n const lit = (from: string, to: string) => steps.has(`${from}\\n${to}`);\n const label = (id: string) => nodes.find(node => node.id === id)?.label ?? id;\n const groupLabel = (id: string | undefined) => id ? groups.find(group => group.id === id)?.label ?? id : '';\n const fit = Math.floor((W - 16) / 7);\n return <div data-testid=\"diagram-view\" className=\"flex min-w-0 flex-col gap-4\">\n <div ref={box} data-testid=\"diagram\" data-layout={vertical ? 'vertical' : 'horizontal'} className=\"min-w-0 overflow-x-auto\">\n <svg role=\"img\" aria-label={nodes.map(node => node.label).join(' → ')} width={width} height={height} viewBox={`0 0 ${width} ${height}`} className=\"block\">\n <defs><marker id={marker} viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"6\" markerHeight=\"6\" orient=\"auto-start-reverse\"><path d=\"M0 0L10 5L0 10z\" className=\"fill-muted-foreground\" /></marker></defs>\n {edges.map((edge, i) => {\n const path = arrow(pos.get(edge.from)!, pos.get(edge.to)!, W, H, vertical);\n const strong = lit(edge.from, edge.to);\n return <g key={i} data-edge={`${edge.from}→${edge.to}`} data-lit={strong || undefined}>\n <path d={path.d} fill=\"none\" className={strong ? 'stroke-primary' : 'stroke-muted-foreground/60'} strokeWidth={strong ? 2 : 1.25} markerEnd={`url(#${marker})`} />\n <circle cx={path.x} cy={path.y} r=\"9\" className={strong ? 'fill-primary stroke-primary' : 'fill-background stroke-border'} />\n <text x={path.x} y={path.y + 3.5} textAnchor=\"middle\" fontSize=\"10\" className={strong ? 'fill-primary-foreground' : 'fill-foreground'}>{i + 1}</text>\n </g>;\n })}\n {nodes.map(node => {\n const p = pos.get(node.id)!, strong = on.has(node.id), group = groupLabel(node.group);\n return <g key={node.id} data-node={node.id} data-group={node.group} data-lit={strong || undefined}>\n <rect x={p.x} y={p.y} width={W} height={H} rx=\"10\" className={strong ? 'fill-primary/10 stroke-primary' : 'fill-card stroke-border'} strokeWidth={strong ? 2 : 1} />\n <text x={p.x + W / 2} y={p.y + (group ? 22 : H / 2 + 4)} textAnchor=\"middle\" fontSize=\"12\" className=\"fill-foreground\">{clip(node.label, fit)}<title>{node.label}</title></text>\n {group && <text x={p.x + W / 2} y={p.y + 40} textAnchor=\"middle\" fontSize=\"10\" className=\"fill-muted-foreground\">{clip(group, fit + 2)}</text>}\n </g>;\n })}\n </svg>\n </div>\n {edges.length > 0 && <ol data-testid=\"diagram-legend\" className=\"m-0 grid list-none gap-x-6 gap-y-2 p-0 text-sm sm:grid-cols-2\">\n {edges.map((edge, i) => <li key={i} className=\"flex min-w-0 items-start gap-2\">\n <Badge variant={lit(edge.from, edge.to) ? 'default' : 'outline'} className=\"tabular-nums\">{i + 1}</Badge>\n <span className=\"min-w-0 text-pretty\"><span className=\"font-medium\">{label(edge.from)} → {label(edge.to)}</span>{edge.label && <>: {edge.label}</>}\n {edge.by && <span className=\"text-muted-foreground\"> ({labels.transitionBy(edge.by)})</span>}</span>\n </li>)}\n </ol>}\n {diagram.note && <p data-testid=\"diagram-note\" className=\"m-0 text-sm text-pretty text-muted-foreground\">{diagram.note}</p>}\n </div>;\n}\n",
31
+ "type": "registry:component",
32
+ "target": "components/diagram-viewer/diagram-view.tsx"
33
+ },
34
+ {
35
+ "path": "registry/diagram-viewer/diagram-viewer.tsx",
36
+ "content": "'use client';\n\n// The diagram viewer: an agent's `*.diagram.json` (format v1) drawn by `DiagramView`, with its highlighted path and note. `diagramKind` is\n// the kind the pi-app artifact panel uses by default for `.diagram.json` files; an app's own kind in `artifactKinds` wins. The maquette\n// viewer draws its workflow (\"How it works\") with the same `DiagramView`. It draws data only: nothing in the file runs.\nimport { useMemo } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { InfoIcon, TriangleAlertIcon, WorkflowIcon } from 'lucide-react';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport { DIAGRAM_FORMAT_VERSION, isDiagramPath, parseDiagram } from './diagram';\nimport { diagramLabelsFor } from './diagram-labels';\nimport type { DiagramLabels, DiagramLocale } from './diagram-labels';\nimport { DiagramView } from './diagram-view';\n\nexport { DIAGRAM_FORMAT_VERSION, isDiagramPath, parseDiagram } from './diagram';\nexport type { Diagram, DiagramEdge, DiagramFileV1, DiagramGroup, DiagramNode, DiagramParse } from './diagram';\nexport { DiagramView } from './diagram-view';\nexport type { DiagramViewLabels } from './diagram-view';\nexport { defaultDiagramLabels, diagramLabelsFor, frenchDiagramLabels } from './diagram-labels';\nexport type { DiagramLabels, DiagramLocale } from './diagram-labels';\n\nexport interface DiagramViewerProps {\n /** The file's text (JSON). */\n readonly text: string;\n /** The base words: English by default. */\n readonly locale?: DiagramLocale | undefined;\n /** Any word over the locale's. */\n readonly labels?: Partial<DiagramLabels> | undefined;\n}\n\n/** A diagram file; a newer format version shows a notice, an unreadable file an inline error. */\nexport function DiagramViewer({ text, locale, labels: given }: DiagramViewerProps) {\n const labels = useMemo(() => diagramLabelsFor(locale, given), [locale, given]);\n const parsed = useMemo(() => parseDiagram(text), [text]);\n const newer = parsed.version !== undefined && parsed.version > DIAGRAM_FORMAT_VERSION;\n return <section data-boring=\"diagram-viewer\" data-state={parsed.kind} aria-label={parsed.kind === 'ok' ? parsed.diagram.title || labels.kind : labels.kind}\n className=\"mx-auto flex w-full max-w-5xl min-w-0 flex-col gap-3 px-3 pt-2 pb-4 sm:px-6\">\n {newer && <Alert data-testid=\"diagram-newer\"><InfoIcon aria-hidden=\"true\" /><AlertTitle>{labels.newerTitle}</AlertTitle><AlertDescription>{labels.newer(parsed.version!)}</AlertDescription></Alert>}\n {parsed.kind === 'ok' ? <DiagramView diagram={parsed.diagram} labels={labels} />\n : <Alert variant=\"destructive\" data-testid=\"diagram-error\" data-reason={parsed.reason}><TriangleAlertIcon aria-hidden=\"true\" /><AlertTitle>{labels.errorTitle}</AlertTitle>\n <AlertDescription>{parsed.reason === 'json' ? labels.invalidJson : labels.invalidShape}</AlertDescription></Alert>}\n </section>;\n}\n\n/**\n * The artifact kind of a diagram file (structurally an `ArtifactKind` of pi-chat): it matches `*.diagram.json`, names it, gives its icon\n * and draws it with `DiagramViewer`. Pass it, or your own, in `artifactKinds`; kinds an app passes are consulted before the built-in one.\n */\nexport interface DiagramKind {\n readonly match: (target: { readonly resource: { readonly path: string } }) => boolean;\n readonly label: string;\n readonly icon: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }>;\n readonly renderer: (props: { readonly text: string }) => ReactNode;\n}\n\n/** A diagram kind in `locale` (\"Diagram\" in English, \"Diagramme\" in French) with any word overridden. */\nexport function createDiagramKind({ locale, labels }: { readonly locale?: DiagramLocale | undefined; readonly labels?: Partial<DiagramLabels> | undefined } = {}): DiagramKind {\n return {\n match: target => isDiagramPath(target.resource.path),\n label: diagramLabelsFor(locale, labels).kind,\n icon: WorkflowIcon,\n renderer: ({ text }) => <DiagramViewer text={text} locale={locale} labels={labels} />,\n };\n}\n\n/** The built-in diagram kind, in English. */\nexport const diagramKind: DiagramKind = createDiagramKind();\n",
37
+ "type": "registry:component",
38
+ "target": "components/diagram-viewer/diagram-viewer.tsx"
39
+ }
40
+ ],
41
+ "type": "registry:component"
42
+ }
package/r/dock.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "react@^19.0.0"
8
8
  ],
9
9
  "registryDependencies": [
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/utils.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/utils.json"
11
11
  ],
12
12
  "files": [
13
13
  {
package/r/documents.json CHANGED
@@ -4,20 +4,20 @@
4
4
  "title": "Documents: the Library, a file and its revisions",
5
5
  "description": "The workspace's Library as the list, the chosen file in its viewer and its retained revisions beside it (an older one opens read-only). DocumentSheet opens one document over the app for a host's « Voir la fiche », with an optional « Toutes les fiches ». Every read goes through one revisionProvider binding.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.1",
8
- "@hachej/boring-files@^0.4.1",
7
+ "@hachej/boring-ui-kit@^0.4.3",
8
+ "@hachej/boring-files@^0.4.3",
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.1/r/theme.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/utils.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/button.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/pi-chat.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/pi-app.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/app-shell.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/file-tree.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/viewers.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/theme.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/utils.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/button.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/pi-chat.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/pi-app.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/app-shell.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/file-tree.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/viewers.json",
21
21
  "drawer",
22
22
  "button",
23
23
  "scroll-area",
@@ -26,7 +26,7 @@
26
26
  "files": [
27
27
  {
28
28
  "path": "registry/documents/documents.tsx",
29
- "content": "'use client';\n\n// The documents screen: the workspace's Library is the list (`ListDetail`: beside the file on a wide screen, one pane at a time on a phone);\n// the chosen file opens in the same viewer the Library uses (editable where it edits); its Revisions sit beside it. Every revision row opens\n// that exact revision read-only, and « Back to editing » returns to the file. The editor stays mounted while a revision is inspected, so its\n// unsaved edits survive; leaving the file with unsaved edits asks first. Every read goes through the one `revisionProvider` the host binds.\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { createFileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport type { FileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport { FileTreeView } from '../file-tree/file-tree';\nimport type { RevisionProvider } from '@hachej/boring-files/revision';\nimport { mediaTypeOf, viewerKindOf } from '@hachej/boring-files/file-types';\nimport type { ViewerKind } from '@hachej/boring-files/file-types';\nimport { ListDetail } from '../app-shell/list-detail';\nimport { ArtifactPanel } from '../pi-app/artifact-panel';\nimport type { ArtifactDescriptor } from '../pi-chat/artifact';\nimport type { CustomViewers, ViewerOptions } from '../pi-app/artifact-panel';\nimport { FileViewer } from '../pi-app/file-viewer';\nimport { ImagePane } from '../viewers/image-pane';\nimport { PdfPane } from '../viewers/pdf-pane';\nimport { ViewerFrame } from '../viewers/viewer-frame';\nimport { useViewerText } from '../viewers/viewer-window';\nimport { cn } from '../utils/utils';\nimport { Revisions } from './revisions';\nimport type { DocumentsLabels } from './documents-labels';\nimport { documentsLabels, DocumentsWords } from './words';\nimport type { DocumentsLocale } from './words';\n\nexport interface DocumentsProps {\n /** The workspace's revision provider (the files guide's `connectRevisionProvider`, or a host's own binding). Borrowed: the host disposes it. */\n readonly provider: RevisionProvider;\n readonly locale?: DocumentsLocale | undefined;\n /** The block's own words, over `documentsLabels` (the viewers' words follow `locale`). */\n readonly labels?: Partial<DocumentsLabels> | undefined;\n /** Host viewers by kind, as for the Library (for example a canvas). */\n readonly viewers?: CustomViewers | undefined;\n readonly share?: ViewerOptions['share'];\n}\n\nconst nameOf = (path: string) => path.split('/').pop() || path;\n\n/** The workspace's change feed as a counter: it moves on every change, so the listings and the revisions are read again. */\nfunction useWorkspaceVersion(provider: RevisionProvider): number {\n const [version, setVersion] = useState(0);\n useEffect(() => {\n if (!provider.changes) return undefined;\n const abort = new AbortController();\n void (async () => {\n try { for await (const change of provider.changes!({ signal: abort.signal })) { void change; setVersion(current => current + 1); } } catch { /* the binding ended the feed: the listings stay as they are */ }\n })();\n return () => abort.abort();\n }, [provider]);\n return version;\n}\n\n/** The Library's tree over the provider: created and disposed with the binding, read again when the workspace changes. */\nfunction useFileTree(provider: RevisionProvider, version: number): FileTreeController | undefined {\n const [tree, setTree] = useState<FileTreeController>();\n useEffect(() => {\n const next = createFileTreeController({ revisionProvider: provider });\n setTree(next);\n void next.refresh();\n return () => next.dispose();\n }, [provider]);\n useEffect(() => {\n if (!tree || version === 0) return;\n const { query, expanded } = tree.getSnapshot();\n if (query) void tree.search(query);\n else for (const directory of ['', ...expanded]) void tree.refresh(directory);\n }, [tree, version]);\n return tree;\n}\n\n/**\n * The provider as it reads one revision: every read of the returned client is the exact `revision`, over the same provider (a reload in a\n * pinned view re-reads that revision, never the latest). It is for read-only views only.\n */\nfunction pinnedProviderOf(provider: RevisionProvider, revision: string): RevisionProvider {\n return { ...provider, read: (request, signal) => provider.read({ target: request.target, revision: { kind: 'exact', value: revision } }, signal) };\n}\n\n/** An image or PDF revision, read exactly and shown in the viewer's read-only pane. */\nfunction BinaryRevision({ provider, path, revision, kind, labels, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly revision: string;\n readonly kind: 'image' | 'pdf';\n readonly labels: DocumentsLabels;\n readonly onClose: () => void;\n}) {\n const [state, setState] = useState<{ readonly kind: 'loading' } | { readonly kind: 'failed' } | { readonly kind: 'ready'; readonly bytes: Uint8Array; readonly mediaType: string }>({ kind: 'loading' });\n const { labels: viewer } = useViewerText();\n useEffect(() => {\n const abort = new AbortController();\n setState({ kind: 'loading' });\n provider.read({ target: provider.locate(path), revision: { kind: 'exact', value: revision } }, abort.signal).then(result => {\n if (!abort.signal.aborted) setState(result.kind === 'available' ? { kind: 'ready', bytes: result.snapshot.bytes, mediaType: result.snapshot.mediaType } : { kind: 'failed' });\n }, () => { if (!abort.signal.aborted) setState({ kind: 'failed' }); });\n return () => abort.abort();\n }, [provider, path, revision]);\n const status = { label: viewer.readOnly, kind: 'read-only' as const };\n if (state.kind !== 'ready') return <ViewerFrame title={nameOf(path)} status={status} onClose={onClose}>\n <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{state.kind === 'loading' ? labels.revisionsLoading : labels.revisionLoadFailed}</p>\n </ViewerFrame>;\n return kind === 'image'\n ? <ImagePane name={nameOf(path)} mediaType={state.mediaType} bytes={state.bytes} status={status} onClose={onClose} />\n : <PdfPane name={nameOf(path)} bytes={state.bytes} status={status} onClose={onClose} />;\n}\n\n/** One revision of the file, read-only, with « Back to editing ». Text, Markdown and HTML use the artifact panel's pinned view. */\nfunction OlderVersion({ provider, path, revision, options, labels, onBack, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly revision: string;\n readonly options: ViewerOptions;\n readonly labels: DocumentsLabels;\n readonly onBack: () => void;\n readonly onClose: () => void;\n}) {\n const kind: ViewerKind = viewerKindOf(path);\n const pinned = useMemo(() => pinnedProviderOf(provider, revision), [provider, revision]);\n const type = kind === 'markdown' ? 'markdown' : kind === 'html' ? 'html' : kind === 'text' ? 'code' : undefined;\n const artifact: ArtifactDescriptor | undefined = type === undefined ? undefined\n : { schema: 'boring.artifact', version: 1, title: nameOf(path), type, mediaType: mediaTypeOf(path), target: provider.locate(path), revision };\n const bar = <>\n <div className=\"flex shrink-0 items-center gap-2 px-3 py-2\">\n <span role=\"status\" className=\"min-w-0 flex-1 truncate text-sm font-medium\">{labels.readOnlyRevision}</span>\n <Button variant=\"ghost\" data-testid=\"back-to-editing\" onClick={onBack}>{labels.backToEditing}</Button>\n </div>\n <Separator />\n </>;\n let body;\n if (artifact) body = <ArtifactPanel active={{ descriptor: artifact, follow: false }} versions={[]} options={{ ...options, client: pinned }} onSelect={onBack} onClose={onClose} />;\n else if (kind === 'image' || kind === 'pdf') body = <BinaryRevision provider={provider} path={path} revision={revision} kind={kind} labels={labels} onClose={onClose} />;\n else body = <ViewerFrame title={nameOf(path)} onClose={onClose}><p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.unsupportedPreview}</p></ViewerFrame>;\n return <div data-testid=\"revision-view\" data-kind={kind} data-revision={revision} className=\"flex h-full min-h-0 flex-1 flex-col\">\n {bar}\n <div className=\"flex min-h-0 flex-1 flex-col\">{body}</div>\n </div>;\n}\n\nfunction DocumentDetail({ provider, path, version, options, labels, locale, older, onDirty, onOpenRevision, onBack, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly version: number;\n readonly options: ViewerOptions;\n readonly labels: DocumentsLabels;\n readonly locale: DocumentsLocale;\n /** The older revision on show, `undefined` for the editable file. */\n readonly older: string | undefined;\n readonly onDirty: (unsaved: boolean) => void;\n readonly onOpenRevision: (revision: string) => void;\n readonly onBack: () => void;\n readonly onClose: () => void;\n}) {\n return <div data-testid=\"documents-detail\" className=\"flex h-full min-h-0 flex-col md:flex-row\">\n <div className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n {/* The editor stays mounted (hidden) while a revision is on show, so its unsaved edits are kept. */}\n <div className={cn('min-h-0 flex-1 flex-col', older === undefined ? 'flex' : 'hidden')}>\n <FileViewer path={path} locator={provider.locate(path)} options={options} onClose={onClose} onUnsavedChange={onDirty} />\n </div>\n {older !== undefined && <OlderVersion provider={provider} path={path} revision={older} options={options} labels={labels} onBack={onBack} onClose={onClose} />}\n </div>\n <Separator className=\"md:hidden\" />\n <Separator orientation=\"vertical\" className=\"max-md:hidden\" />\n <aside className=\"flex max-h-[40%] min-h-0 shrink-0 flex-col overflow-hidden md:max-h-none md:w-72\">\n <Revisions provider={provider} path={path} version={version} selected={older} locale={locale} labels={labels} onOpen={onOpenRevision} />\n </aside>\n </div>;\n}\n\nexport function Documents({ provider, locale, labels: given, viewers, share }: DocumentsProps) {\n const labels = documentsLabels(locale, given);\n const language: DocumentsLocale = locale === 'fr' ? 'fr' : 'en';\n const version = useWorkspaceVersion(provider);\n const tree = useFileTree(provider, version);\n const [path, setPath] = useState<string>();\n // An older revision of the file on show (`undefined`: the editable file).\n const [pinned, setPinned] = useState<{ readonly path: string; readonly revision: string }>();\n // The file's unsaved edits, as its viewer reports them. A change of file or a close that would drop them asks first.\n const dirty = useRef(false);\n const onDirty = useCallback((unsaved: boolean) => { dirty.current = unsaved; }, []);\n const mayLeave = () => !dirty.current || window.confirm(labels.discardChanges);\n const options = useMemo<ViewerOptions>(() => ({ client: provider, identity: provider.identity, ...(viewers ? { viewers } : {}), ...(share ? { share } : {}) }), [provider, viewers, share]);\n const choose = (next: string) => {\n if (next === path || !mayLeave()) return;\n setPath(next);\n setPinned(undefined);\n };\n const closeFile = () => { if (!mayLeave()) return; setPath(undefined); setPinned(undefined); };\n const list = tree ? <div className=\"min-h-0\">\n <FileTreeView controller={tree} onOpen={choose} selectedPath={path} locale={language} upload={false} history={false} />\n </div> : null;\n const older = path !== undefined && pinned?.path === path ? pinned.revision : undefined;\n // `null` (nothing chosen) is what lets a phone show the list alone: ListDetail stacks the detail only when there is one.\n const detail = path === undefined ? null\n : <DocumentDetail key={path} provider={provider} path={path} version={version} options={options} labels={labels} locale={language} older={older}\n onDirty={onDirty} onOpenRevision={revision => setPinned({ path, revision })} onBack={() => setPinned(undefined)} onClose={closeFile} />;\n return <DocumentsWords locale={locale}>\n <ListDetail list={list} detail={detail} onBack={closeFile} />\n </DocumentsWords>;\n}\n",
29
+ "content": "'use client';\n\n// The documents screen: the workspace's Library is the list (`ListDetail`: beside the file on a wide screen, one pane at a time on a phone);\n// the chosen file opens in the same viewer the Library uses (editable where it edits); its Revisions sit beside it. Every revision row opens\n// that exact revision read-only, and « Back to editing » returns to the file. The editor stays mounted while a revision is inspected, so its\n// unsaved edits survive; leaving the file with unsaved edits asks first. Every read goes through the one `revisionProvider` the host binds.\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { createFileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport type { FileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport { FileTreeView } from '../file-tree/file-tree';\nimport type { RevisionProvider } from '@hachej/boring-files/revision';\nimport { mediaTypeOf, viewerKindOf } from '@hachej/boring-files/file-types';\nimport { checkBlobBase, withBlobPointers } from '@hachej/boring-files/blob';\nimport type { ViewerKind } from '@hachej/boring-files/file-types';\nimport { ListDetail } from '../app-shell/list-detail';\nimport { ArtifactPanel } from '../pi-app/artifact-panel';\nimport type { ArtifactDescriptor } from '../pi-chat/artifact';\nimport type { CustomViewers, ViewerOptions } from '../pi-app/artifact-panel';\nimport { FileViewer } from '../pi-app/file-viewer';\nimport { ImagePane } from '../viewers/image-pane';\nimport { PdfPane } from '../viewers/pdf-pane';\nimport { ViewerFrame } from '../viewers/viewer-frame';\nimport { useViewerText } from '../viewers/viewer-window';\nimport { cn } from '../utils/utils';\nimport { Revisions } from './revisions';\nimport type { DocumentsLabels } from './documents-labels';\nimport { documentsLabels, DocumentsWords } from './words';\nimport type { DocumentsLocale } from './words';\n\nexport interface DocumentsProps {\n /** The workspace's revision provider (the files guide's `connectRevisionProvider`, or a host's own binding). Borrowed: the host disposes it. */\n readonly provider: RevisionProvider;\n readonly locale?: DocumentsLocale | undefined;\n /** The block's own words, over `documentsLabels` (the viewers' words follow `locale`). */\n readonly labels?: Partial<DocumentsLabels> | undefined;\n /** Host viewers by kind, as for the Library (for example a canvas). */\n readonly viewers?: CustomViewers | undefined;\n readonly share?: ViewerOptions['share'];\n /** Where the host serves the bytes of large files (blob pointers): a same-origin path starting with `/`, default `/_files/`. */\n readonly blobBase?: string | undefined;\n}\n\nconst nameOf = (path: string) => path.split('/').pop() || path;\n\n/** The workspace's change feed as a counter: it moves on every change, so the listings and the revisions are read again. */\nfunction useWorkspaceVersion(provider: RevisionProvider): number {\n const [version, setVersion] = useState(0);\n useEffect(() => {\n if (!provider.changes) return undefined;\n const abort = new AbortController();\n void (async () => {\n try { for await (const change of provider.changes!({ signal: abort.signal })) { void change; setVersion(current => current + 1); } } catch { /* the binding ended the feed: the listings stay as they are */ }\n })();\n return () => abort.abort();\n }, [provider]);\n return version;\n}\n\n/** The Library's tree over the provider: created and disposed with the binding, read again when the workspace changes. */\nfunction useFileTree(provider: RevisionProvider, version: number, blobBase: string): FileTreeController | undefined {\n const [tree, setTree] = useState<FileTreeController>();\n useEffect(() => {\n const next = createFileTreeController({ revisionProvider: withBlobPointers(provider, blobBase) });\n setTree(next);\n void next.refresh();\n return () => next.dispose();\n }, [provider, blobBase]);\n useEffect(() => {\n if (!tree || version === 0) return;\n const { query, expanded } = tree.getSnapshot();\n if (query) void tree.search(query);\n else for (const directory of ['', ...expanded]) void tree.refresh(directory);\n }, [tree, version]);\n return tree;\n}\n\n/**\n * The provider as it reads one revision: every read of the returned client is the exact `revision`, over the same provider (a reload in a\n * pinned view re-reads that revision, never the latest). It is for read-only views only.\n */\nfunction pinnedProviderOf(provider: RevisionProvider, revision: string): RevisionProvider {\n return { ...provider, read: (request, signal) => provider.read({ target: request.target, revision: { kind: 'exact', value: revision } }, signal) };\n}\n\n/** An image or PDF revision, read exactly and shown in the viewer's read-only pane. */\nfunction BinaryRevision({ provider, path, revision, kind, labels, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly revision: string;\n readonly kind: 'image' | 'pdf';\n readonly labels: DocumentsLabels;\n readonly onClose: () => void;\n}) {\n const [state, setState] = useState<{ readonly kind: 'loading' } | { readonly kind: 'failed' } | { readonly kind: 'ready'; readonly bytes: Uint8Array; readonly mediaType: string }>({ kind: 'loading' });\n const { labels: viewer } = useViewerText();\n useEffect(() => {\n const abort = new AbortController();\n setState({ kind: 'loading' });\n provider.read({ target: provider.locate(path), revision: { kind: 'exact', value: revision } }, abort.signal).then(result => {\n if (!abort.signal.aborted) setState(result.kind === 'available' ? { kind: 'ready', bytes: result.snapshot.bytes, mediaType: result.snapshot.mediaType } : { kind: 'failed' });\n }, () => { if (!abort.signal.aborted) setState({ kind: 'failed' }); });\n return () => abort.abort();\n }, [provider, path, revision]);\n const status = { label: viewer.readOnly, kind: 'read-only' as const };\n if (state.kind !== 'ready') return <ViewerFrame title={nameOf(path)} status={status} onClose={onClose}>\n <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{state.kind === 'loading' ? labels.revisionsLoading : labels.revisionLoadFailed}</p>\n </ViewerFrame>;\n return kind === 'image'\n ? <ImagePane name={nameOf(path)} mediaType={state.mediaType} bytes={state.bytes} status={status} onClose={onClose} />\n : <PdfPane name={nameOf(path)} bytes={state.bytes} status={status} onClose={onClose} />;\n}\n\n/** One revision of the file, read-only, with « Back to editing ». Text, Markdown and HTML use the artifact panel's pinned view. */\nfunction OlderVersion({ provider, path, revision, options, labels, onBack, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly revision: string;\n readonly options: ViewerOptions;\n readonly labels: DocumentsLabels;\n readonly onBack: () => void;\n readonly onClose: () => void;\n}) {\n const kind: ViewerKind = viewerKindOf(path);\n const pinned = useMemo(() => pinnedProviderOf(provider, revision), [provider, revision]);\n const type = kind === 'markdown' ? 'markdown' : kind === 'html' ? 'html' : kind === 'text' ? 'code' : undefined;\n const artifact: ArtifactDescriptor | undefined = type === undefined ? undefined\n : { schema: 'boring.artifact', version: 1, title: nameOf(path), type, mediaType: mediaTypeOf(path), target: provider.locate(path), revision };\n const bar = <>\n <div className=\"flex shrink-0 items-center gap-2 px-3 py-2\">\n <span role=\"status\" className=\"min-w-0 flex-1 truncate text-sm font-medium\">{labels.readOnlyRevision}</span>\n <Button variant=\"ghost\" data-testid=\"back-to-editing\" onClick={onBack}>{labels.backToEditing}</Button>\n </div>\n <Separator />\n </>;\n let body;\n if (artifact) body = <ArtifactPanel active={{ descriptor: artifact, follow: false }} versions={[]} options={{ ...options, client: pinned }} onSelect={onBack} onClose={onClose} />;\n else if (kind === 'image' || kind === 'pdf') body = <BinaryRevision provider={provider} path={path} revision={revision} kind={kind} labels={labels} onClose={onClose} />;\n else body = <ViewerFrame title={nameOf(path)} onClose={onClose}><p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.unsupportedPreview}</p></ViewerFrame>;\n return <div data-testid=\"revision-view\" data-kind={kind} data-revision={revision} className=\"flex h-full min-h-0 flex-1 flex-col\">\n {bar}\n <div className=\"flex min-h-0 flex-1 flex-col\">{body}</div>\n </div>;\n}\n\nfunction DocumentDetail({ provider, path, version, options, labels, locale, older, onDirty, onOpenRevision, onBack, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly version: number;\n readonly options: ViewerOptions;\n readonly labels: DocumentsLabels;\n readonly locale: DocumentsLocale;\n /** The older revision on show, `undefined` for the editable file. */\n readonly older: string | undefined;\n readonly onDirty: (unsaved: boolean) => void;\n readonly onOpenRevision: (revision: string) => void;\n readonly onBack: () => void;\n readonly onClose: () => void;\n}) {\n return <div data-testid=\"documents-detail\" className=\"flex h-full min-h-0 flex-col md:flex-row\">\n <div className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n {/* The editor stays mounted (hidden) while a revision is on show, so its unsaved edits are kept. */}\n <div className={cn('min-h-0 flex-1 flex-col', older === undefined ? 'flex' : 'hidden')}>\n <FileViewer path={path} locator={provider.locate(path)} options={options} onClose={onClose} onUnsavedChange={onDirty} />\n </div>\n {older !== undefined && <OlderVersion provider={provider} path={path} revision={older} options={options} labels={labels} onBack={onBack} onClose={onClose} />}\n </div>\n <Separator className=\"md:hidden\" />\n <Separator orientation=\"vertical\" className=\"max-md:hidden\" />\n <aside className=\"flex max-h-[40%] min-h-0 shrink-0 flex-col overflow-hidden md:max-h-none md:w-72\">\n <Revisions provider={provider} path={path} version={version} selected={older} locale={locale} labels={labels} onOpen={onOpenRevision} />\n </aside>\n </div>;\n}\n\nexport function Documents({ provider, locale, labels: given, viewers, share, blobBase: givenBase }: DocumentsProps) {\n const labels = documentsLabels(locale, given);\n const language: DocumentsLocale = locale === 'fr' ? 'fr' : 'en';\n const version = useWorkspaceVersion(provider);\n const blobBase = checkBlobBase(givenBase);\n const tree = useFileTree(provider, version, blobBase);\n const [path, setPath] = useState<string>();\n // An older revision of the file on show (`undefined`: the editable file).\n const [pinned, setPinned] = useState<{ readonly path: string; readonly revision: string }>();\n // The file's unsaved edits, as its viewer reports them. A change of file or a close that would drop them asks first.\n const dirty = useRef(false);\n const onDirty = useCallback((unsaved: boolean) => { dirty.current = unsaved; }, []);\n const mayLeave = () => !dirty.current || window.confirm(labels.discardChanges);\n const options = useMemo<ViewerOptions>(() => ({ client: provider, identity: provider.identity, blobBase, ...(viewers ? { viewers } : {}), ...(share ? { share } : {}) }), [provider, viewers, share, blobBase]);\n const choose = (next: string) => {\n if (next === path || !mayLeave()) return;\n setPath(next);\n setPinned(undefined);\n };\n const closeFile = () => { if (!mayLeave()) return; setPath(undefined); setPinned(undefined); };\n const list = tree ? <div className=\"min-h-0\">\n <FileTreeView controller={tree} onOpen={choose} selectedPath={path} locale={language} upload={false} history={false} />\n </div> : null;\n const older = path !== undefined && pinned?.path === path ? pinned.revision : undefined;\n // `null` (nothing chosen) is what lets a phone show the list alone: ListDetail stacks the detail only when there is one.\n const detail = path === undefined ? null\n : <DocumentDetail key={path} provider={provider} path={path} version={version} options={options} labels={labels} locale={language} older={older}\n onDirty={onDirty} onOpenRevision={revision => setPinned({ path, revision })} onBack={() => setPinned(undefined)} onClose={closeFile} />;\n return <DocumentsWords locale={locale}>\n <ListDetail list={list} detail={detail} onBack={closeFile} />\n </DocumentsWords>;\n}\n",
30
30
  "type": "registry:component",
31
31
  "target": "components/documents/documents.tsx"
32
32
  },
@@ -4,7 +4,7 @@
4
4
  "title": "Feedback preview",
5
5
  "description": "PreviewBanner: a live, reversible preview of a change in the page (Approve / Discard) over `@hachej/boring-feedback/preview`, whose subagent calls models through a host gateway. Separate from `feedback` so annotation-only apps do not install the model SDK; install `feedback` first (its styles cover the banner).",
6
6
  "dependencies": [
7
- "@hachej/boring-feedback@^0.4.1",
7
+ "@hachej/boring-feedback@^0.4.3",
8
8
  "@earendil-works/pi-ai@~1.2.0",
9
9
  "react@^19.0.0"
10
10
  ],
package/r/feedback.json CHANGED
@@ -4,16 +4,16 @@
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.1",
8
- "@hachej/boring-ui-kit@^0.4.1",
9
- "@hachej/boring-files@^0.4.1",
7
+ "@hachej/boring-feedback@^0.4.3",
8
+ "@hachej/boring-ui-kit@^0.4.3",
9
+ "@hachej/boring-files@^0.4.3",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0",
12
12
  "lucide-react@^1.51.0"
13
13
  ],
14
14
  "registryDependencies": [
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/utils.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/utils.json",
17
17
  "button",
18
18
  "toggle",
19
19
  "badge",
@@ -24,7 +24,7 @@
24
24
  "files": [
25
25
  {
26
26
  "path": "registry/feedback/feedback-session.tsx",
27
- "content": "// The feedback session's look (FEEDBACK.md, \"UX\"): the note pin beside a picked element (NoteBubble: a text field with a mic inside it),\n// the slim bar and the chip with its review (optional, for hosts with no chat). Everything they show and do is the headless\n// `createFeedbackSession` from `@hachej/boring-feedback/ui` (hover labels, pins, steps, the draft and Send come from there, through the\n// privacy policy); this file is how it looks. `useComposerFeedback` adapts a session to the chat composer's optional `feedback` prop\n// (registry/pi-chat): ONE annotate toggle and a count chip, structurally, so neither item imports the other.\nimport { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';\nimport { createPortal } from 'react-dom';\nimport { MicIcon, SquareIcon, XIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Badge } from '@/components/ui/badge';\nimport { Card } from '@/components/ui/card';\nimport { Textarea } from '@/components/ui/textarea';\nimport { readableElement } from '@hachej/boring-feedback/format';\nimport { createVoiceCapture, type VoiceCapture } from '@hachej/boring-feedback/page';\nimport { pinMark, type FeedbackSession, type FeedbackSessionSnapshot, type SessionNote } from '@hachej/boring-feedback/ui';\nimport { useExternal } from './use-external';\nimport { withLabels, type FeedbackLabels } from './feedback-labels';\n\nconst plural = (count: number, one: string, many: string): string => `${count} ${count === 1 ? one : many}`;\n/** Notes the chip and the bar count: every note that says something (not a pin still being typed), plus the general note. */\nexport function noteCount(state: FeedbackSessionSnapshot): number {\n return state.notes.filter(note => note.text.trim()).length + (state.general.trim() ? 1 : 0);\n}\n\nexport interface FeedbackBarProps {\n readonly session: FeedbackSession;\n readonly className?: string;\n /** Docked under the chat composer (the composer's `feedback.bar`) instead of floating at the top of the page. */\n readonly docked?: boolean;\n}\n\n/** Pins dropped while recording that have no note yet (nothing transcribed for them, or transcription failed). */\nexport function emptyPinCount(state: FeedbackSessionSnapshot): number {\n return state.notes.filter(note => note.recorded && !note.text.trim()).length;\n}\n\n/** `mm:ss` since `since` (ms), for the recording timer. */\nexport function elapsed(ms: number): string {\n const seconds = Math.max(0, Math.floor(ms / 1000));\n return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;\n}\n\n/** The running time of a recording, from when the bar saw it start; 00:00 when not recording. */\nfunction useRecordingTime(recording: boolean): string {\n const [since, setSince] = useState<number | undefined>(undefined);\n const [, tick] = useState(0);\n useEffect(() => {\n if (!recording) { setSince(undefined); return; }\n setSince(Date.now());\n const timer = setInterval(() => tick(value => value + 1), 500);\n return () => clearInterval(timer);\n }, [recording]);\n return elapsed(since === undefined ? 0 : Date.now() - since);\n}\n\nfunction barHint(state: FeedbackSessionSnapshot): string {\n if (state.phase === 'processing') return state.transcribing ? 'Transcribing…' : 'Preparing your feedback…';\n if (state.problem) return state.problem;\n if (state.voice.kind === 'starting') return 'Allow the microphone if your browser asks…';\n if (state.voice.kind === 'recording') return state.using ? 'Recording — using the app' : 'Recording — point and talk';\n return state.using ? 'Using the app: your clicks are recorded as steps' : 'Click what you want to comment on';\n}\n\n/**\n * Feedback mode's bar: `● Feedback · N notes · 🎙 Record · Hold ⌥ to use the app · Use app · Done · ✕`. While recording: a red dot, the\n * running time, \"Recording — point and talk\" and Stop. A refused microphone is said in the bar itself, never only in a tooltip.\n */\nexport function FeedbackBar({ session, className, docked = false }: FeedbackBarProps) {\n const state = useExternal(session);\n const recording = state.voice.kind === 'recording';\n const time = useRecordingTime(recording && (state.phase === 'active' || state.phase === 'processing'));\n if (state.phase !== 'active' && state.phase !== 'processing') return null;\n const processing = state.phase === 'processing';\n const pins = emptyPinCount(state);\n const voiceProblem = state.voice.kind === 'refused' ? state.voice : undefined;\n return <Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"toolbar\" aria-label=\"Feedback mode\" data-testid=\"feedback-bar\" data-docked={docked ? 'true' : 'false'}\n data-using={state.using ? 'true' : 'false'} data-recording={recording ? 'true' : 'false'} data-transcribing={state.transcribing ? 'true' : 'false'}\n className={['boring-feedback-bar', className].filter(Boolean).join(' ')}\n onKeyDown={event => { if (event.key === 'Escape' && state.bubble) { event.stopPropagation(); session.cancelBubble(); } }}>\n <span className=\"boring-feedback-bar-title\">Feedback</span>\n <span className=\"boring-feedback-bar-meta\" data-testid=\"feedback-bar-count\">{plural(noteCount(state), 'note', 'notes')}{pins ? ` · ${plural(pins, 'pin', 'pins')}` : ''}</span>\n {state.voiceAvailable && <Toggle data-testid=\"feedback-voice\" pressed={recording}\n disabled={processing || state.voice.kind === 'starting' || state.voice.kind === 'stopping'} title={recording ? 'Stop recording' : 'Record your voice while you point: each click drops a pin, what you say becomes its note'}\n onPressedChange={() => { void session.toggleVoice(); }}>\n {recording ? <><SquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" />Stop</> : <><MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{state.voice.kind === 'starting' ? 'Starting…' : state.voice.kind === 'stopping' ? 'Stopping…' : 'Record'}</>}\n </Toggle>}\n {recording && <Badge variant=\"destructive\" role=\"status\" data-testid=\"feedback-recording\">\n <span className=\"boring-feedback-timer\" data-testid=\"feedback-recording-time\">{time}</span></Badge>}\n <span className=\"boring-feedback-hint\" role=\"status\" aria-live=\"polite\" data-testid=\"feedback-bar-hint\">{barHint(state)}</span>\n {voiceProblem && <span className=\"boring-feedback-voice-problem\" role=\"alert\" data-testid=\"feedback-voice-problem\" data-code={voiceProblem.code}>{voiceProblem.reason}</span>}\n {!processing && <span className=\"boring-feedback-keys boring-feedback-alt\">Hold ⌥ to use the app</span>}\n {!processing && <Toggle data-testid=\"feedback-use-app\" pressed={state.useToggle} onPressedChange={pressed => session.setUseToggle(pressed)}>Use app</Toggle>}\n <Button variant=\"default\" data-testid=\"feedback-done\" disabled={processing} onClick={() => { void session.done(); }}>Done</Button>\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"feedback-discard\" aria-label=\"Discard feedback\" title=\"Discard feedback\" disabled={processing} onClick={() => session.discard()}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </Card>;\n}\n\nconst BUBBLE_WIDTH = 280;\n/** Pixels at the bottom of the viewport taken by point mode's bar (PointBar, `data-feedback-point-bar`), plus a gap; 0 without it. */\nexport function pointBarInset(document: Document): number {\n const bar = document.querySelector('[data-feedback-point-bar]');\n const height = document.defaultView?.innerHeight ?? 768;\n return bar ? Math.max(0, height - bar.getBoundingClientRect().top + 8) : 0;\n}\n\n/**\n * Beside the element: below it when the bubble (`size`, its measured height once drawn) fits above point mode's bar (if any), else\n * above it, else as low as it fits. Every branch is clamped to the space above the bar, so the bubble never covers it; a bubble taller\n * than that space scrolls inside it.\n */\nfunction bubblePlace(element: Element, size = 120): CSSProperties {\n const view = element.ownerDocument.defaultView;\n const width = view?.innerWidth ?? 1024, height = (view?.innerHeight ?? 768) - pointBarInset(element.ownerDocument);\n const rect = element.getBoundingClientRect();\n const left = Math.min(Math.max(8, rect.left), Math.max(8, width - BUBBLE_WIDTH - 8));\n const below = rect.bottom + 8;\n const above = rect.top - size - 8;\n const lowest = Math.max(8, height - size);\n const top = Math.min(below + size <= height ? below : above >= 8 ? above : lowest, lowest);\n return { position: 'fixed', top, left, width: Math.min(BUBBLE_WIDTH, width - 16), maxHeight: Math.max(0, height - top), overflowY: 'auto', boxSizing: 'border-box' };\n}\n\ntype PinVoice = 'idle' | 'starting' | 'recording' | 'transcribing';\n\n/**\n * The mic inside the pin's field: record, stop, and the transcript is appended to what is typed. A refused microphone, nothing heard or a\n * rejected transcription is said under the field and never touches the typed text. One capture (`createVoiceCapture`) per pin.\n */\nfunction PinMic({ session, transcribe, words }: { readonly session: FeedbackSession; readonly transcribe: PinTranscribe; readonly words: FeedbackLabels }) {\n const [phase, setPhase] = useState<PinVoice>('idle');\n const [problem, setProblem] = useState<string | undefined>(undefined);\n const capture = useRef<VoiceCapture | null>(null);\n const run = useRef(0);\n const work = useRef<AbortController | null>(null);\n capture.current ??= createVoiceCapture();\n // A closed pin releases the microphone and drops a transcription still in flight.\n useEffect(() => () => { run.current += 1; capture.current?.cancel(); work.current?.abort(); }, []);\n const start = async (): Promise<void> => {\n const attempt = ++run.current;\n setProblem(undefined); setPhase('starting');\n // Called straight from the click, so the browser ties the permission prompt to the person's gesture.\n const result = await capture.current!.start();\n if (attempt !== run.current) return;\n if (result.kind === 'refused') { setPhase('idle'); setProblem(result.reason); return; }\n setPhase('recording');\n };\n const finish = async (): Promise<void> => {\n const attempt = run.current;\n setPhase('transcribing');\n const recording = await capture.current!.stop().catch(() => null);\n if (attempt !== run.current) return;\n if (!recording) { setPhase('idle'); setProblem(words.voiceNothing); return; }\n const abort = new AbortController();\n work.current = abort;\n try {\n const spoken = (await transcribe(recording.audio, abort.signal)).trim();\n if (attempt !== run.current) return;\n if (!spoken) { setProblem(words.voiceNothing); return; }\n // Appended to what is typed NOW (the person may have kept typing), never replacing it.\n const typed = session.getSnapshot().bubble?.text ?? '';\n session.setBubbleText(`${typed}${typed === '' || /\\s$/.test(typed) ? '' : ' '}${spoken}`);\n } catch { if (attempt === run.current) setProblem(words.voiceFailed); }\n finally { if (work.current === abort) work.current = null; if (attempt === run.current) setPhase('idle'); }\n };\n const recording = phase === 'recording';\n return <>\n <Toggle className=\"boring-feedback-pin-mic\" data-testid=\"feedback-bubble-mic\" data-state={phase} pressed={recording}\n aria-label={recording ? words.voiceStop : words.voiceRecord} title={recording ? words.voiceStop : words.voiceRecord} disabled={phase === 'starting' || phase === 'transcribing'}\n onPressedChange={() => { if (phase === 'idle') void start(); else if (recording) void finish(); }}>\n {phase === 'transcribing' ? <span role=\"status\" aria-label={words.voiceTranscribing}>…</span>\n : recording ? <SquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Toggle>\n {problem && <span className=\"boring-feedback-voice-problem\" role=\"alert\" data-testid=\"feedback-bubble-voice-problem\">{problem}</span>}\n </>;\n}\n\n/** Turns a recording into text (`signal` aborts when the pin closes); a rejection keeps what was typed. */\nexport type PinTranscribe = (audio: Blob, signal: AbortSignal) => Promise<string>;\n\nconst touchScreen = (): boolean => globalThis.matchMedia?.('(pointer: coarse)').matches === true;\n\n/**\n * The pin beside a picked element: a text field with a mic inside it, \"Add note\" (Enter) and Cancel (Esc). Enter saves, Shift-Enter for a\n * new line, Esc discards the pin. On desktop one line says that holding Alt passes clicks to the app. In point mode it keeps clear of\n * the PointBar. `transcribe` turns the mic's recording into text; without it there is no mic.\n */\nexport function NoteBubble({ session, labels, transcribe }: { readonly session: FeedbackSession; readonly labels?: Partial<FeedbackLabels>; readonly transcribe?: PinTranscribe }) {\n const state = useExternal(session);\n const words = withLabels(labels);\n const bubble = state.bubble;\n const field = useRef<HTMLTextAreaElement>(null);\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n const [, redraw] = useState(0);\n const [announce, setAnnounce] = useState('');\n const box = useRef<HTMLDivElement>(null);\n // The bubble's natural height (its content plus borders), not the height it is clamped to, so it grows back when there is room.\n const size = (): number | undefined => (box.current ? box.current.scrollHeight + box.current.offsetHeight - box.current.clientHeight : 0) || undefined;\n useLayoutEffect(() => { if (bubble) setPlace(bubblePlace(bubble.element, size())); }, [bubble?.noteId, bubble?.element]);\n // Once drawn, place it again with its real height (taller with 44px touch targets and wrapped buttons).\n useLayoutEffect(() => {\n if (!bubble || !place || !box.current) return;\n const next = bubblePlace(bubble.element, size());\n if (next.top !== place.top || next.left !== place.left) setPlace(next);\n });\n useEffect(() => { if (bubble) field.current?.focus({ preventScroll: true }); }, [bubble?.noteId]);\n useEffect(() => {\n if (!bubble) return;\n const view = bubble.element.ownerDocument.defaultView;\n const follow = () => { setPlace(bubblePlace(bubble.element, size())); redraw(value => value + 1); };\n view?.addEventListener('scroll', follow, { capture: true, passive: true });\n view?.addEventListener('resize', follow);\n return () => { view?.removeEventListener('scroll', follow, { capture: true }); view?.removeEventListener('resize', follow); };\n }, [bubble?.noteId, bubble?.element]);\n // A polite live region outlives the bubble, so the cancellation is heard even though the dialog is gone.\n const cancel = (): void => { session.cancelSelection(); setAnnounce(words.selectionCancelled); };\n const live = <span data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"status\" aria-live=\"polite\" className=\"boring-feedback-sr\" data-testid=\"feedback-bubble-announce\"\n style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap' }}>{announce}</span>;\n if (!bubble || state.phase !== 'active') return state.phase === 'active' ? live : null;\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => {\n if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing && event.target === field.current) { event.preventDefault(); session.saveBubble(); }\n else if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); session.cancelBubble(); }\n };\n return <>{live}<Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label={`Note ${bubble.number}`} data-testid=\"feedback-bubble\" data-note={bubble.number}\n ref={box} className=\"boring-feedback-bubble\" style={place} onKeyDown={onKeyDown}>\n <span className=\"boring-feedback-bubble-head\"><span className=\"boring-feedback-pin\" aria-hidden=\"true\">{pinMark(bubble.number)}</span>\n <span className=\"boring-feedback-bubble-label\" data-testid=\"feedback-bubble-label\">{bubble.label}</span></span>\n <span className=\"boring-feedback-pin-field\">\n <Textarea ref={field} data-testid=\"feedback-bubble-input\" rows={2} placeholder={words.bubblePlaceholder} aria-label={words.bubblePlaceholder} value={bubble.text}\n onChange={event => session.setBubbleText(event.target.value)} />\n {transcribe && <PinMic key={bubble.noteId} session={session} transcribe={transcribe} words={words} />}\n </span>\n <span className=\"boring-feedback-bubble-actions\">\n <Button variant=\"ghost\" data-testid=\"feedback-cancel-selection\" onClick={cancel}>{words.cancelSelection}</Button>\n <Button variant=\"default\" data-testid=\"feedback-bubble-save\" onClick={() => session.saveBubble()}>{words.add}</Button>\n </span>\n {!touchScreen() && <span className=\"boring-feedback-keys\" data-testid=\"feedback-bubble-hint\">{words.altHint}</span>}\n </Card></>;\n}\n\nexport interface FeedbackChipProps {\n readonly session: FeedbackSession;\n /** Opens the review on first render (journeys). */\n readonly defaultOpen?: boolean;\n /** Offer Copy report in the review: the way to use feedback with no chat and no store. */\n readonly copy?: boolean;\n}\n\n/** The one composer chip after Done: `💬 Feedback · 3 notes · /settings/:section`. Click opens the review; × discards. */\nexport function FeedbackChip({ session, defaultOpen = false, copy = false }: FeedbackChipProps) {\n const state = useExternal(session);\n const [open, setOpen] = useState(defaultOpen);\n const chip = useRef<HTMLSpanElement>(null);\n if (state.phase !== 'review') return null;\n const count = noteCount(state);\n const pins = emptyPinCount(state);\n const refusal = state.send.kind !== 'idle' && state.send.kind !== 'sending' && state.send.kind !== 'saved' ? state.send.reason : undefined;\n return <span ref={chip} data-boring=\"feedback\" data-feedback-ignore=\"\" data-testid=\"feedback-chip\" data-send={state.send.kind} className=\"boring-feedback-chip\">\n <Button variant=\"outline\" className=\"min-w-0 max-w-full\" data-testid=\"feedback-chip-open\" aria-expanded={open} onClick={() => setOpen(value => !value)}\n title={refusal ?? 'Review your feedback before sending'}>\n <span aria-hidden=\"true\">💬</span> Feedback · {plural(count, 'note', 'notes')}{pins ? ` · ${plural(pins, 'pin', 'pins')} to describe` : ''} · <span className=\"boring-feedback-chip-route\">{state.route}</span>\n </Button>\n <Button variant=\"ghost\" size=\"icon-sm\" data-testid=\"feedback-chip-discard\" aria-label=\"Discard feedback\" title=\"Discard feedback\" onClick={() => { session.highlight(undefined); session.discard(); }}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n {state.problem && <span className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-chip-voice\">{state.problem}</span>}\n {refusal && <span className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-chip-problem\">{refusal}</span>}\n {/* In the page's body: the chip may sit in a transformed chat bar, where a fixed panel would not be placed against the viewport. */}\n {open && createPortal(<FeedbackReview session={session} anchor={chip.current} copy={copy} onClose={() => setOpen(false)} />, chip.current?.ownerDocument.body ?? document.body)}\n </span>;\n}\n\nfunction reviewPlace(anchor: Element | null): CSSProperties | undefined {\n if (!anchor) return undefined;\n const view = anchor.ownerDocument.defaultView;\n const width = view?.innerWidth ?? 1024, height = view?.innerHeight ?? 768;\n const rect = anchor.getBoundingClientRect();\n const panel = Math.min(416, width - 24);\n return { position: 'fixed', left: Math.min(Math.max(12, rect.left), width - panel - 12), bottom: Math.max(12, height - rect.top + 8), width: panel };\n}\n\n/** The compact review: notes in order, editable and removable; hovering a note highlights its element in the page, or says it is not here. */\nexport function FeedbackReview({ session, anchor = null, copy = false, onClose }: { readonly session: FeedbackSession; readonly anchor?: Element | null; readonly copy?: boolean; readonly onClose: () => void }) {\n const state = useExternal(session);\n const [where, setWhere] = useState<Readonly<Record<number, 'shown' | 'not-on-page'>>>({});\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n useLayoutEffect(() => { setPlace(reviewPlace(anchor)); }, [anchor]);\n useEffect(() => () => { session.highlight(undefined); }, [session]);\n const hover = (id: number | undefined): void => {\n const result = session.highlight(id);\n if (id !== undefined && result !== 'none') setWhere(value => ({ ...value, [id]: result }));\n };\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => { if (event.key === 'Escape') { event.stopPropagation(); onClose(); } };\n return <Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label=\"Review feedback\" data-testid=\"feedback-review\" className=\"boring-feedback-review\"\n style={place} onKeyDown={onKeyDown} onMouseLeave={() => hover(undefined)}>\n <header className=\"boring-feedback-sheet-header\"><h2>Feedback · {state.route}</h2>\n <Button variant=\"ghost\" size=\"icon\" aria-label=\"Close review\" title=\"Close review\" data-testid=\"feedback-review-close\" onClick={onClose}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button></header>\n {state.problem && <p className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-review-problem\">{state.problem}</p>}\n <ol className=\"boring-feedback-notes\">\n {state.notes.map(note => <li key={note.id} data-testid=\"feedback-review-note\" data-note={note.number} data-where={where[note.id]}\n onMouseEnter={() => hover(note.id)} onFocus={() => hover(note.id)}>\n <span className=\"boring-feedback-note-head\"><span className=\"boring-feedback-pin\" aria-hidden=\"true\">{pinMark(note.number)}</span>\n <ElementName note={note} />\n {note.from === 'voice' && <Badge variant=\"secondary\" data-testid=\"feedback-review-voice\">voice</Badge>}\n {where[note.id] === 'not-on-page' && <Badge variant=\"secondary\" data-testid=\"feedback-review-elsewhere\">not on this page</Badge>}\n <Button variant=\"ghost\" size=\"icon\" aria-label={`Remove note ${note.number}`} title={`Remove note ${note.number}`} data-testid=\"feedback-review-remove\" onClick={() => session.removeNote(note.id)}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button></span>\n <Textarea rows={2} aria-label={`Note ${note.number}`} data-testid=\"feedback-review-text\" value={note.text} placeholder={note.recorded ? 'Nothing was said here: type a note, or remove the pin' : undefined}\n onChange={event => session.setNoteText(note.id, event.target.value)} />\n </li>)}\n </ol>\n <label className=\"boring-feedback-field\"><span>General note</span>\n <Textarea rows={2} data-testid=\"feedback-review-general\" placeholder=\"Anything else? (optional)\" value={state.general} onChange={event => session.setGeneral(event.target.value)} /></label>\n <p className=\"boring-feedback-meta\">{state.stepCount > 1 ? `${plural(state.stepCount, 'step', 'steps')} recorded between the notes. ` : ''}{copy ? 'Copy the report and paste it anywhere.' : 'Sent with your next message.'}</p>\n {copy && <div className=\"boring-feedback-actions\">\n <Button variant=\"default\" data-testid=\"feedback-copy\" onClick={() => { void session.copy(); }}>Copy report</Button></div>}\n {copy && <p role=\"status\" aria-live=\"polite\" className=\"boring-feedback-status\" data-testid=\"feedback-status\" data-copy={state.copy.kind}>\n {state.copy.kind === 'copied' ? 'Report copied. Paste it anywhere.' : state.copy.kind === 'manual' ? 'The clipboard is not available here: select the report below and copy it.' : state.copy.kind === 'refused' ? state.copy.reason : ''}</p>}\n {copy && state.copy.kind === 'manual' && <Textarea readOnly className=\"boring-feedback-manual\" data-testid=\"feedback-manual-copy\" value={state.copy.text} rows={6} onFocus={event => event.currentTarget.select()} />}\n {copy && state.copy.kind === 'copied' && <details className=\"boring-feedback-copied\"><summary>Copied report</summary><pre data-testid=\"feedback-copy-text\">{state.copy.text}</pre></details>}\n </Card>;\n}\n\n/**\n * A note's element as one calm line: the readable name (semibold) and the file basename (muted, monospace), the full path in the\n * tooltip. The same reading as the pi-chat feedback card's element line (`readableElement` from `@hachej/boring-feedback/format`).\n */\nexport function ElementName({ note }: { readonly note: SessionNote }) {\n const readable = note.fallback ? readableElement(note.fallback, note.source) : { name: note.label };\n return <span className=\"boring-feedback-element\" data-testid=\"feedback-review-element\" title={'path' in readable && readable.path ? readable.path : note.label}>\n <span className=\"boring-feedback-element-name\" data-testid=\"feedback-review-name\">{readable.name}</span>\n {'file' in readable && readable.file && <span className=\"boring-feedback-element-file\" data-testid=\"feedback-review-file\">{readable.file}</span>}\n </span>;\n}\n\n/** What `useComposerFeedback` returns: the structural shape of pi-chat's `ComposerFeedback`. */\nexport interface ComposerFeedbackProp {\n readonly annotate: () => void;\n readonly active: boolean;\n readonly count: number;\n readonly clear: () => void;\n readonly attach: (text: string) => Promise<{ readonly kind: 'ok'; readonly text: string } | { readonly kind: 'refused'; readonly reason: string }>;\n readonly sent: () => void;\n}\n\n/** A Markdown fence longer than any backtick run in the text. */\nfunction fenced(text: string): string {\n const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map(match => match[0].length));\n const fence = '`'.repeat(Math.max(3, longest + 1));\n return `${fence}markdown\\n${text.endsWith('\\n') ? text : `${text}\\n`}${fence}`;\n}\n\n/** Touch or a compact screen: the panel covers the app and there is no hover, so annotating is point mode (use the app, then point). */\nfunction pointsInstead(): boolean {\n return globalThis.matchMedia?.('(pointer: coarse)').matches === true || (globalThis.innerWidth ?? 1024) < 768;\n}\n\n/**\n * Starts (or resumes) annotating in either mode, with Esc as a way out. The session swallows Esc on the page (it only closes a pin), so\n * this listens on the window's capture phase BEFORE the session starts, and ends the mode (Done: the notes wait for Send) when no pin is\n * open. From the review, `resume` keeps the notes, anchors and save guards: a second round adds to the same draft.\n */\nfunction startAnnotating(session: FeedbackSession): void {\n const view = globalThis.window;\n const onKey = (event: globalThis.KeyboardEvent): void => {\n // Esc that ends annotating is spent: a host layer around the composer (a panel that closes on Esc) must not also react to it.\n if (event.key === 'Escape' && !session.getSnapshot().bubble) { event.preventDefault(); event.stopPropagation(); void session.done(); }\n };\n view?.addEventListener('keydown', onKey, { capture: true });\n session.resume(pointsInstead() ? { mode: 'point' } : {});\n const stop = session.subscribe(() => {\n if (session.getSnapshot().phase === 'active') return;\n view?.removeEventListener('keydown', onKey, { capture: true });\n stop();\n });\n}\n\n/**\n * The composer's `feedback` prop for a session: ONE annotate toggle (targeting with a hover highlight; point mode on touch and compact\n * screens, and again after Esc to add more), a count of the notes taken, and Send attaching them: a saved report as its `@` mention (the host's mention resolver inlines\n * the stored report with the person's access), otherwise the rendered report inline in a fenced block. Pair it with `NoteBubble`.\n */\nexport function useComposerFeedback(session: FeedbackSession | undefined): ComposerFeedbackProp | undefined {\n const state = useSyncState(session);\n if (!session || !state) return undefined;\n const open = state.phase === 'active' || state.phase === 'processing';\n return {\n annotate: () => {\n const phase = session.getSnapshot().phase;\n if (phase === 'active') void session.done();\n else if (phase === 'idle' || phase === 'review') startAnnotating(session);\n },\n active: open,\n // A note being typed in the pin counts: Send saves it.\n count: noteCount(state) + (state.bubble?.fresh && state.bubble.text.trim() ? 1 : 0),\n clear: () => { session.highlight(undefined); session.discard(); },\n attach: async text => {\n // Send while annotating ends it first: the pin being typed is kept.\n if (session.getSnapshot().phase === 'active') await session.done();\n const attached = await session.attach();\n if (attached.kind === 'refused') return attached;\n const body = attached.kind === 'mention' ? `@${attached.path}` : fenced(attached.text);\n return { kind: 'ok', text: text.trim() ? `${text.trimEnd()}\\n\\n${body}` : body };\n },\n sent: () => session.sent(),\n };\n}\n\nconst NO_STATE = { getSnapshot: () => undefined, subscribe: () => () => {} };\nfunction useSyncState(session: FeedbackSession | undefined): FeedbackSessionSnapshot | undefined {\n return useExternal<FeedbackSessionSnapshot | undefined>(session ?? NO_STATE);\n}\n",
27
+ "content": "// The feedback session's look (FEEDBACK.md, \"UX\"): the note pin beside a picked element (NoteBubble: a text field with a mic inside it),\n// the slim bar and the chip with its review (optional, for hosts with no chat). Everything they show and do is the headless\n// `createFeedbackSession` from `@hachej/boring-feedback/ui` (hover labels, pins, steps, the draft and Send come from there, through the\n// privacy policy); this file is how it looks. `useComposerFeedback` adapts a session to the chat composer's optional `feedback` prop\n// (registry/pi-chat): ONE annotate toggle and a count chip, structurally, so neither item imports the other.\nimport { useEffect, useLayoutEffect, 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 { useExternal } from './use-external';\nimport { withLabels, type FeedbackLabels } from './feedback-labels';\n\nconst plural = (count: number, one: string, many: string): string => `${count} ${count === 1 ? one : many}`;\n/** Notes the chip and the bar count: every note that says something (not a pin still being typed), plus the general note. */\nexport function noteCount(state: FeedbackSessionSnapshot): number {\n return state.notes.filter(note => note.text.trim()).length + (state.general.trim() ? 1 : 0);\n}\n\nexport interface FeedbackBarProps {\n readonly session: FeedbackSession;\n readonly className?: string;\n /** Docked under the chat composer (the composer's `feedback.bar`) instead of floating at the top of the page. */\n readonly docked?: boolean;\n}\n\n/** Pins dropped while recording that have no note yet (nothing transcribed for them, or transcription failed). */\nexport function emptyPinCount(state: FeedbackSessionSnapshot): number {\n return state.notes.filter(note => note.recorded && !note.text.trim()).length;\n}\n\n/** `mm:ss` since `since` (ms), for the recording timer. */\nexport function elapsed(ms: number): string {\n const seconds = Math.max(0, Math.floor(ms / 1000));\n return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;\n}\n\n/** The running time of a recording, from when the bar saw it start; 00:00 when not recording. */\nfunction useRecordingTime(recording: boolean): string {\n const [since, setSince] = useState<number | undefined>(undefined);\n const [, tick] = useState(0);\n useEffect(() => {\n if (!recording) { setSince(undefined); return; }\n setSince(Date.now());\n const timer = setInterval(() => tick(value => value + 1), 500);\n return () => clearInterval(timer);\n }, [recording]);\n return elapsed(since === undefined ? 0 : Date.now() - since);\n}\n\nfunction barHint(state: FeedbackSessionSnapshot): string {\n if (state.phase === 'processing') return state.transcribing ? 'Transcribing…' : 'Preparing your feedback…';\n if (state.problem) return state.problem;\n if (state.voice.kind === 'starting') return 'Allow the microphone if your browser asks…';\n if (state.voice.kind === 'recording') return state.using ? 'Recording — using the app' : 'Recording — point and talk';\n return state.using ? 'Using the app: your clicks are recorded as steps' : 'Click what you want to comment on';\n}\n\n/**\n * Feedback mode's bar: `● Feedback · N notes · 🎙 Record · Hold ⌥ to use the app · Use app · Done · ✕`. While recording: a red dot, the\n * running time, \"Recording — point and talk\" and Stop. A refused microphone is said in the bar itself, never only in a tooltip.\n */\nexport function FeedbackBar({ session, className, docked = false }: FeedbackBarProps) {\n const state = useExternal(session);\n const recording = state.voice.kind === 'recording';\n const time = useRecordingTime(recording && (state.phase === 'active' || state.phase === 'processing'));\n if (state.phase !== 'active' && state.phase !== 'processing') return null;\n const processing = state.phase === 'processing';\n const pins = emptyPinCount(state);\n const voiceProblem = state.voice.kind === 'refused' ? state.voice : undefined;\n return <Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"toolbar\" aria-label=\"Feedback mode\" data-testid=\"feedback-bar\" data-docked={docked ? 'true' : 'false'}\n data-using={state.using ? 'true' : 'false'} data-recording={recording ? 'true' : 'false'} data-transcribing={state.transcribing ? 'true' : 'false'}\n className={['boring-feedback-bar', className].filter(Boolean).join(' ')}\n onKeyDown={event => { if (event.key === 'Escape' && state.bubble) { event.stopPropagation(); session.cancelBubble(); } }}>\n <span className=\"boring-feedback-bar-title\">Feedback</span>\n <span className=\"boring-feedback-bar-meta\" data-testid=\"feedback-bar-count\">{plural(noteCount(state), 'note', 'notes')}{pins ? ` · ${plural(pins, 'pin', 'pins')}` : ''}</span>\n {state.voiceAvailable && <Toggle data-testid=\"feedback-voice\" pressed={recording}\n disabled={processing || state.voice.kind === 'starting' || state.voice.kind === 'stopping'} title={recording ? 'Stop recording' : 'Record your voice while you point: each click drops a pin, what you say becomes its note'}\n onPressedChange={() => { void session.toggleVoice(); }}>\n {recording ? <><SquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" />Stop</> : <><MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{state.voice.kind === 'starting' ? 'Starting…' : state.voice.kind === 'stopping' ? 'Stopping…' : 'Record'}</>}\n </Toggle>}\n {recording && <Badge variant=\"destructive\" role=\"status\" data-testid=\"feedback-recording\">\n <span className=\"boring-feedback-timer\" data-testid=\"feedback-recording-time\">{time}</span></Badge>}\n <span className=\"boring-feedback-hint\" role=\"status\" aria-live=\"polite\" data-testid=\"feedback-bar-hint\">{barHint(state)}</span>\n {voiceProblem && <span className=\"boring-feedback-voice-problem\" role=\"alert\" data-testid=\"feedback-voice-problem\" data-code={voiceProblem.code}>{voiceProblem.reason}</span>}\n {!processing && <span className=\"boring-feedback-keys boring-feedback-alt\">Hold ⌥ to use the app</span>}\n {!processing && <Toggle data-testid=\"feedback-use-app\" pressed={state.useToggle} onPressedChange={pressed => session.setUseToggle(pressed)}>Use app</Toggle>}\n <Button variant=\"default\" data-testid=\"feedback-done\" disabled={processing} onClick={() => { void session.done(); }}>Done</Button>\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"feedback-discard\" aria-label=\"Discard feedback\" title=\"Discard feedback\" disabled={processing} onClick={() => session.discard()}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </Card>;\n}\n\nconst BUBBLE_WIDTH = 280;\n/** Pixels at the bottom of the viewport taken by point mode's bar (PointBar, `data-feedback-point-bar`), plus a gap; 0 without it. */\nexport function pointBarInset(document: Document): number {\n const bar = document.querySelector('[data-feedback-point-bar]');\n const height = document.defaultView?.innerHeight ?? 768;\n return bar ? Math.max(0, height - bar.getBoundingClientRect().top + 8) : 0;\n}\n\n/**\n * Beside the element: below it when the bubble (`size`, its measured height once drawn) fits above point mode's bar (if any), else\n * above it, else as low as it fits. Every branch is clamped to the space above the bar, so the bubble never covers it; a bubble taller\n * than that space scrolls inside it.\n */\nfunction bubblePlace(element: Element, size = 120): CSSProperties {\n const view = element.ownerDocument.defaultView;\n const width = view?.innerWidth ?? 1024, height = (view?.innerHeight ?? 768) - pointBarInset(element.ownerDocument);\n const rect = element.getBoundingClientRect();\n const left = Math.min(Math.max(8, rect.left), Math.max(8, width - BUBBLE_WIDTH - 8));\n const below = rect.bottom + 8;\n const above = rect.top - size - 8;\n const lowest = Math.max(8, height - size);\n const top = Math.min(below + size <= height ? below : above >= 8 ? above : lowest, lowest);\n return { position: 'fixed', top, left, width: Math.min(BUBBLE_WIDTH, width - 16), maxHeight: Math.max(0, height - top), overflowY: 'auto', boxSizing: 'border-box' };\n}\n\ntype PinVoice = 'idle' | 'starting' | 'recording' | 'transcribing';\n\n/**\n * The mic inside the pin's field: record, stop, and the transcript is appended to what is typed. A refused microphone, nothing heard or a\n * rejected transcription is said under the field and never touches the typed text. One capture (`createVoiceCapture`) per pin.\n */\nfunction PinMic({ session, transcribe, words }: { readonly session: FeedbackSession; readonly transcribe: PinTranscribe; readonly words: FeedbackLabels }) {\n const [phase, setPhase] = useState<PinVoice>('idle');\n const [problem, setProblem] = useState<string | undefined>(undefined);\n const capture = useRef<VoiceCapture | null>(null);\n const run = useRef(0);\n const work = useRef<AbortController | null>(null);\n capture.current ??= createVoiceCapture();\n // A closed pin releases the microphone and drops a transcription still in flight.\n useEffect(() => () => { run.current += 1; capture.current?.cancel(); work.current?.abort(); }, []);\n const start = async (): Promise<void> => {\n const attempt = ++run.current;\n setProblem(undefined); setPhase('starting');\n // Called straight from the click, so the browser ties the permission prompt to the person's gesture.\n const result = await capture.current!.start();\n if (attempt !== run.current) return;\n if (result.kind === 'refused') { setPhase('idle'); setProblem(result.reason); return; }\n setPhase('recording');\n };\n const finish = async (): Promise<void> => {\n const attempt = run.current;\n setPhase('transcribing');\n const recording = await capture.current!.stop().catch(() => null);\n if (attempt !== run.current) return;\n if (!recording) { setPhase('idle'); setProblem(words.voiceNothing); return; }\n const abort = new AbortController();\n work.current = abort;\n try {\n const spoken = (await transcribe(recording.audio, abort.signal)).trim();\n if (attempt !== run.current) return;\n if (!spoken) { setProblem(words.voiceNothing); return; }\n // Appended to what is typed NOW (the person may have kept typing), never replacing it.\n const typed = session.getSnapshot().bubble?.text ?? '';\n session.setBubbleText(`${typed}${typed === '' || /\\s$/.test(typed) ? '' : ' '}${spoken}`);\n } catch { if (attempt === run.current) setProblem(words.voiceFailed); }\n finally { if (work.current === abort) work.current = null; if (attempt === run.current) setPhase('idle'); }\n };\n const recording = phase === 'recording';\n return <>\n <Toggle className=\"boring-feedback-pin-mic\" data-testid=\"feedback-bubble-mic\" data-state={phase} pressed={recording}\n aria-label={recording ? words.voiceStop : words.voiceRecord} title={recording ? words.voiceStop : words.voiceRecord} disabled={phase === 'starting' || phase === 'transcribing'}\n onPressedChange={() => { if (phase === 'idle') void start(); else if (recording) void finish(); }}>\n {phase === 'transcribing' ? <span role=\"status\" aria-label={words.voiceTranscribing}>…</span>\n : recording ? <SquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Toggle>\n {problem && <span className=\"boring-feedback-voice-problem\" role=\"alert\" data-testid=\"feedback-bubble-voice-problem\">{problem}</span>}\n </>;\n}\n\n/** Turns a recording into text (`signal` aborts when the pin closes); a rejection keeps what was typed. */\nexport type PinTranscribe = (audio: Blob, signal: AbortSignal) => Promise<string>;\n\nconst touchScreen = (): boolean => globalThis.matchMedia?.('(pointer: coarse)').matches === true;\n\n/**\n * The pin beside a picked element: a text field with a mic inside it, \"Add note\" (Enter) and Cancel (Esc). Enter saves, Shift-Enter for a\n * new line, Esc discards the pin. On desktop one line says that holding Alt passes clicks to the app. In point mode it keeps clear of\n * the PointBar. `transcribe` turns the mic's recording into text; without it there is no mic.\n */\nexport function NoteBubble({ session, labels, transcribe }: { readonly session: FeedbackSession; readonly labels?: Partial<FeedbackLabels>; readonly transcribe?: PinTranscribe }) {\n const state = useExternal(session);\n const words = withLabels(labels);\n const bubble = state.bubble;\n const field = useRef<HTMLTextAreaElement>(null);\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n const [, redraw] = useState(0);\n const [announce, setAnnounce] = useState('');\n const box = useRef<HTMLDivElement>(null);\n // The bubble's natural height (its content plus borders), not the height it is clamped to, so it grows back when there is room.\n const size = (): number | undefined => (box.current ? box.current.scrollHeight + box.current.offsetHeight - box.current.clientHeight : 0) || undefined;\n useLayoutEffect(() => { if (bubble) setPlace(bubblePlace(bubble.element, size())); }, [bubble?.noteId, bubble?.element]);\n // Once drawn, place it again with its real height (taller with 44px touch targets and wrapped buttons).\n useLayoutEffect(() => {\n if (!bubble || !place || !box.current) return;\n const next = bubblePlace(bubble.element, size());\n if (next.top !== place.top || next.left !== place.left) setPlace(next);\n });\n useEffect(() => { if (bubble) field.current?.focus({ preventScroll: true }); }, [bubble?.noteId]);\n useEffect(() => {\n if (!bubble) return;\n const view = bubble.element.ownerDocument.defaultView;\n const follow = () => { setPlace(bubblePlace(bubble.element, size())); redraw(value => value + 1); };\n view?.addEventListener('scroll', follow, { capture: true, passive: true });\n view?.addEventListener('resize', follow);\n return () => { view?.removeEventListener('scroll', follow, { capture: true }); view?.removeEventListener('resize', follow); };\n }, [bubble?.noteId, bubble?.element]);\n // A polite live region outlives the bubble, so the cancellation is heard even though the dialog is gone.\n const cancel = (): void => { session.cancelSelection(); setAnnounce(words.selectionCancelled); };\n const live = <span data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"status\" aria-live=\"polite\" className=\"boring-feedback-sr\" data-testid=\"feedback-bubble-announce\"\n style={{ position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap' }}>{announce}</span>;\n if (!bubble || state.phase !== 'active') return state.phase === 'active' ? live : null;\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => {\n if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing && event.target === field.current) { event.preventDefault(); session.saveBubble(); }\n else if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); session.cancelBubble(); }\n };\n return <>{live}<Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label={`Note ${bubble.number}`} data-testid=\"feedback-bubble\" data-note={bubble.number}\n ref={box} className=\"boring-feedback-bubble\" style={place} onKeyDown={onKeyDown}>\n <span className=\"boring-feedback-bubble-head\"><span className=\"boring-feedback-pin\" aria-hidden=\"true\">{pinMark(bubble.number)}</span>\n <span className=\"boring-feedback-bubble-label\" data-testid=\"feedback-bubble-label\">{bubble.label}</span></span>\n <span className=\"boring-feedback-pin-field\">\n <Textarea ref={field} data-testid=\"feedback-bubble-input\" rows={2} placeholder={words.bubblePlaceholder} aria-label={words.bubblePlaceholder} value={bubble.text}\n onChange={event => session.setBubbleText(event.target.value)} />\n {transcribe && <PinMic key={bubble.noteId} session={session} transcribe={transcribe} words={words} />}\n </span>\n <span className=\"boring-feedback-bubble-actions\">\n <Button variant=\"ghost\" data-testid=\"feedback-cancel-selection\" onClick={cancel}>{words.cancelSelection}</Button>\n <Button variant=\"default\" data-testid=\"feedback-bubble-save\" onClick={() => session.saveBubble()}>{words.add}</Button>\n </span>\n {!touchScreen() && <span className=\"boring-feedback-keys\" data-testid=\"feedback-bubble-hint\">{words.altHint}</span>}\n </Card></>;\n}\n\nexport interface FeedbackChipProps {\n readonly session: FeedbackSession;\n /** Opens the review on first render (journeys). */\n readonly defaultOpen?: boolean;\n /** Offer Copy report in the review: the way to use feedback with no chat and no store. */\n readonly copy?: boolean;\n}\n\n/** The one composer chip after Done: `💬 Feedback · 3 notes · /settings/:section`. Click opens the review; × discards. */\nexport function FeedbackChip({ session, defaultOpen = false, copy = false }: FeedbackChipProps) {\n const state = useExternal(session);\n const [open, setOpen] = useState(defaultOpen);\n const chip = useRef<HTMLSpanElement>(null);\n if (state.phase !== 'review') return null;\n const count = noteCount(state);\n const pins = emptyPinCount(state);\n const refusal = state.send.kind !== 'idle' && state.send.kind !== 'sending' && state.send.kind !== 'saved' ? state.send.reason : undefined;\n return <span ref={chip} data-boring=\"feedback\" data-feedback-ignore=\"\" data-testid=\"feedback-chip\" data-send={state.send.kind} className=\"boring-feedback-chip\">\n <Button variant=\"outline\" className=\"min-w-0 max-w-full\" data-testid=\"feedback-chip-open\" aria-expanded={open} onClick={() => setOpen(value => !value)}\n title={refusal ?? 'Review your feedback before sending'}>\n <span aria-hidden=\"true\">💬</span> Feedback · {plural(count, 'note', 'notes')}{pins ? ` · ${plural(pins, 'pin', 'pins')} to describe` : ''} · <span className=\"boring-feedback-chip-route\">{state.route}</span>\n </Button>\n <Button variant=\"ghost\" size=\"icon-sm\" data-testid=\"feedback-chip-discard\" aria-label=\"Discard feedback\" title=\"Discard feedback\" onClick={() => { session.highlight(undefined); session.discard(); }}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n {state.problem && <span className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-chip-voice\">{state.problem}</span>}\n {refusal && <span className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-chip-problem\">{refusal}</span>}\n {/* In the page's body: the chip may sit in a transformed chat bar, where a fixed panel would not be placed against the viewport. */}\n {open && createPortal(<FeedbackReview session={session} anchor={chip.current} copy={copy} onClose={() => setOpen(false)} />, chip.current?.ownerDocument.body ?? document.body)}\n </span>;\n}\n\nfunction reviewPlace(anchor: Element | null): CSSProperties | undefined {\n if (!anchor) return undefined;\n const view = anchor.ownerDocument.defaultView;\n const width = view?.innerWidth ?? 1024, height = view?.innerHeight ?? 768;\n const rect = anchor.getBoundingClientRect();\n const panel = Math.min(416, width - 24);\n return { position: 'fixed', left: Math.min(Math.max(12, rect.left), width - panel - 12), bottom: Math.max(12, height - rect.top + 8), width: panel };\n}\n\n/** The compact review: notes in order, editable and removable; hovering a note highlights its element in the page, or says it is not here. */\nexport function FeedbackReview({ session, anchor = null, copy = false, onClose }: { readonly session: FeedbackSession; readonly anchor?: Element | null; readonly copy?: boolean; readonly onClose: () => void }) {\n const state = useExternal(session);\n const [where, setWhere] = useState<Readonly<Record<number, 'shown' | 'not-on-page'>>>({});\n const [place, setPlace] = useState<CSSProperties | undefined>(undefined);\n useLayoutEffect(() => { setPlace(reviewPlace(anchor)); }, [anchor]);\n useEffect(() => () => { session.highlight(undefined); }, [session]);\n const hover = (id: number | undefined): void => {\n const result = session.highlight(id);\n if (id !== undefined && result !== 'none') setWhere(value => ({ ...value, [id]: result }));\n };\n const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => { if (event.key === 'Escape') { event.stopPropagation(); onClose(); } };\n return <Card size=\"sm\" data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"dialog\" aria-label=\"Review feedback\" data-testid=\"feedback-review\" className=\"boring-feedback-review\"\n style={place} onKeyDown={onKeyDown} onMouseLeave={() => hover(undefined)}>\n <header className=\"boring-feedback-sheet-header\"><h2>Feedback · {state.route}</h2>\n <Button variant=\"ghost\" size=\"icon\" aria-label=\"Close review\" title=\"Close review\" data-testid=\"feedback-review-close\" onClick={onClose}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button></header>\n {state.problem && <p className=\"boring-feedback-warning\" role=\"alert\" data-testid=\"feedback-review-problem\">{state.problem}</p>}\n <ol className=\"boring-feedback-notes\">\n {state.notes.map(note => <li key={note.id} data-testid=\"feedback-review-note\" data-note={note.number} data-where={where[note.id]}\n onMouseEnter={() => hover(note.id)} onFocus={() => hover(note.id)}>\n <span className=\"boring-feedback-note-head\"><span className=\"boring-feedback-pin\" aria-hidden=\"true\">{pinMark(note.number)}</span>\n <ElementName note={note} />\n {note.from === 'voice' && <Badge variant=\"secondary\" data-testid=\"feedback-review-voice\">voice</Badge>}\n {where[note.id] === 'not-on-page' && <Badge variant=\"secondary\" data-testid=\"feedback-review-elsewhere\">not on this page</Badge>}\n <Button variant=\"ghost\" size=\"icon\" aria-label={`Remove note ${note.number}`} title={`Remove note ${note.number}`} data-testid=\"feedback-review-remove\" onClick={() => session.removeNote(note.id)}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button></span>\n <Textarea rows={2} aria-label={`Note ${note.number}`} data-testid=\"feedback-review-text\" value={note.text} placeholder={note.recorded ? 'Nothing was said here: type a note, or remove the pin' : undefined}\n onChange={event => session.setNoteText(note.id, event.target.value)} />\n </li>)}\n </ol>\n <label className=\"boring-feedback-field\"><span>General note</span>\n <Textarea rows={2} data-testid=\"feedback-review-general\" placeholder=\"Anything else? (optional)\" value={state.general} onChange={event => session.setGeneral(event.target.value)} /></label>\n <p className=\"boring-feedback-meta\">{state.stepCount > 1 ? `${plural(state.stepCount, 'step', 'steps')} recorded between the notes. ` : ''}{copy ? 'Copy the report and paste it anywhere.' : 'Sent with your next message.'}</p>\n {copy && <div className=\"boring-feedback-actions\">\n <Button variant=\"default\" data-testid=\"feedback-copy\" onClick={() => { void session.copy(); }}>Copy report</Button></div>}\n {copy && <p role=\"status\" aria-live=\"polite\" className=\"boring-feedback-status\" data-testid=\"feedback-status\" data-copy={state.copy.kind}>\n {state.copy.kind === 'copied' ? 'Report copied. Paste it anywhere.' : state.copy.kind === 'manual' ? 'The clipboard is not available here: select the report below and copy it.' : state.copy.kind === 'refused' ? state.copy.reason : ''}</p>}\n {copy && state.copy.kind === 'manual' && <Textarea readOnly className=\"boring-feedback-manual\" data-testid=\"feedback-manual-copy\" value={state.copy.text} rows={6} onFocus={event => event.currentTarget.select()} />}\n {copy && state.copy.kind === 'copied' && <details className=\"boring-feedback-copied\"><summary>Copied report</summary><pre data-testid=\"feedback-copy-text\">{state.copy.text}</pre></details>}\n </Card>;\n}\n\n/**\n * A note's element as one calm line: the readable name (semibold) and the file basename (muted, monospace), the full path in the\n * tooltip. The same reading as the pi-chat feedback card's element line (`readableElement` from `@hachej/boring-feedback/format`).\n */\nexport function ElementName({ note }: { readonly note: SessionNote }) {\n const readable = note.fallback ? readableElement(note.fallback, note.source) : { name: note.label };\n return <span className=\"boring-feedback-element\" data-testid=\"feedback-review-element\" title={'path' in readable && readable.path ? readable.path : note.label}>\n <span className=\"boring-feedback-element-name\" data-testid=\"feedback-review-name\">{readable.name}</span>\n {'file' in readable && readable.file && <span className=\"boring-feedback-element-file\" data-testid=\"feedback-review-file\">{readable.file}</span>}\n </span>;\n}\n\n/** What `useComposerFeedback` returns: the structural shape of pi-chat's `ComposerFeedback`. */\nexport interface ComposerFeedbackProp {\n readonly annotate: () => void;\n readonly active: boolean;\n readonly count: number;\n readonly clear: () => void;\n readonly attach: (text: string) => Promise<{ readonly kind: 'ok'; readonly text: string } | { readonly kind: 'refused'; readonly reason: string }>;\n readonly sent: () => void;\n}\n\n/** A Markdown fence longer than any backtick run in the text. */\nfunction fenced(text: string): string {\n const longest = Math.max(0, ...[...text.matchAll(/`+/g)].map(match => match[0].length));\n const fence = '`'.repeat(Math.max(3, longest + 1));\n return `${fence}markdown\\n${text.endsWith('\\n') ? text : `${text}\\n`}${fence}`;\n}\n\n/** Touch or a compact screen: the panel covers the app and there is no hover, so annotating is point mode (use the app, then point). */\nfunction pointsInstead(): boolean {\n return globalThis.matchMedia?.('(pointer: coarse)').matches === true || (globalThis.innerWidth ?? 1024) < 768;\n}\n\n/**\n * Starts (or resumes) annotating in either mode, with Esc as a way out. The session swallows Esc on the page (it only closes a pin), so\n * this listens on the window's capture phase BEFORE the session starts, and ends the mode (Done: the notes wait for Send) when no pin is\n * open. From the review, `resume` keeps the notes, anchors and save guards: a second round adds to the same draft.\n */\nfunction startAnnotating(session: FeedbackSession, mode: 'point' | undefined = pointsInstead() ? 'point' : undefined): void {\n const view = globalThis.window;\n const onKey = (event: globalThis.KeyboardEvent): void => {\n // Esc that ends annotating is spent: a host layer around the composer (a panel that closes on Esc) must not also react to it.\n if (event.key === 'Escape' && !session.getSnapshot().bubble) { event.preventDefault(); event.stopPropagation(); void session.done(); }\n };\n view?.addEventListener('keydown', onKey, { capture: true });\n session.resume(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\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",
28
28
  "type": "registry:component",
29
29
  "target": "components/feedback/feedback-session.tsx"
30
30
  },