@hachej/boring-ui-kit 0.4.25 → 0.4.27

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.
Files changed (101) hide show
  1. package/README.md +40 -3
  2. package/dist/chat-artifacts.d.ts +157 -0
  3. package/dist/chat-artifacts.js +274 -0
  4. package/dist/chat-dictation.d.ts +27 -0
  5. package/dist/chat-dictation.js +71 -0
  6. package/dist/chat-react-controller.d.ts +25 -0
  7. package/dist/chat-react-controller.js +72 -0
  8. package/dist/chat-react-conversations.d.ts +40 -0
  9. package/dist/chat-react-conversations.js +68 -0
  10. package/dist/chat-react-draft.d.ts +11 -0
  11. package/dist/chat-react-draft.js +91 -0
  12. package/dist/chat-react-remote.d.ts +33 -0
  13. package/dist/chat-react-remote.js +72 -0
  14. package/dist/chat-react-session.d.ts +170 -0
  15. package/dist/chat-react-session.js +367 -0
  16. package/dist/chat-react-transcript.d.ts +39 -0
  17. package/dist/chat-react-transcript.js +123 -0
  18. package/dist/chat-react.d.ts +62 -0
  19. package/dist/chat-react.js +95 -0
  20. package/dist/chat-text.d.ts +131 -0
  21. package/dist/chat-text.js +220 -0
  22. package/dist/chat-view.d.ts +2 -17
  23. package/dist/chat-view.js +1 -0
  24. package/dist/chat-voice-message.d.ts +65 -0
  25. package/dist/chat-voice-message.js +235 -0
  26. package/dist/chat-voice.d.ts +7 -0
  27. package/dist/chat-voice.js +4 -0
  28. package/dist/experience-catalog.d.ts +2 -2
  29. package/dist/experience-region-tree.d.ts +1 -1
  30. package/dist/markdown-editor-labels.d.ts +83 -0
  31. package/dist/markdown-editor-labels.js +75 -0
  32. package/dist/markdown-editor.d.ts +7 -1
  33. package/dist/markdown-editor.js +28 -18
  34. package/dist/markdown-extensions.d.ts +4 -2
  35. package/dist/markdown-extensions.js +2 -2
  36. package/dist/markdown-preserved.d.ts +5 -1
  37. package/dist/markdown-preserved.js +15 -4
  38. package/dist/markdown-proposal-diff.d.ts +3 -1
  39. package/dist/markdown-proposal-diff.js +6 -5
  40. package/dist/run-notifications.d.ts +42 -0
  41. package/dist/run-notifications.js +67 -0
  42. package/dist/shared-chat/index.d.ts +1 -1
  43. package/dist/shared-chat/index.js +5 -4
  44. package/dist/text-draft-controls.d.ts +5 -1
  45. package/dist/text-draft-controls.js +6 -5
  46. package/dist/text-draft-labels.d.ts +26 -0
  47. package/dist/text-draft-labels.js +47 -0
  48. package/dist/voice-attempt.d.ts +33 -0
  49. package/dist/voice-attempt.js +122 -0
  50. package/dist/voice-capture.d.ts +12 -0
  51. package/dist/voice-capture.js +17 -2
  52. package/package.json +14 -2
  53. package/r/agent-target.json +5 -9
  54. package/r/app-shell.json +19 -28
  55. package/r/board-viewer.json +8 -11
  56. package/r/button.json +2 -9
  57. package/r/diagram-viewer.json +0 -5
  58. package/r/dock.json +2 -2
  59. package/r/documents.json +13 -18
  60. package/r/feedback-preview.json +1 -7
  61. package/r/feedback.json +12 -19
  62. package/r/file-tree.json +4 -14
  63. package/r/highlights.json +4 -9
  64. package/r/html-viewer.json +3 -3
  65. package/r/looks-viewer.json +3 -6
  66. package/r/maquette-viewer.json +2 -20
  67. package/r/markdown-editor.json +4 -7
  68. package/r/mockup-viewer.json +2 -5
  69. package/r/pi-ambient.json +11 -19
  70. package/r/pi-app-kinds.json +10 -10
  71. package/r/pi-app.json +17 -27
  72. package/r/pi-artifacts.json +3 -4
  73. package/r/pi-chat-approval.json +2 -5
  74. package/r/pi-chat-attachments.json +2 -4
  75. package/r/pi-chat-markdown.json +3 -5
  76. package/r/pi-chat-mentions.json +2 -3
  77. package/r/pi-chat-pickers.json +2 -4
  78. package/r/pi-chat-plugins.json +11 -11
  79. package/r/pi-chat-question.json +1 -7
  80. package/r/pi-chat-slash.json +3 -6
  81. package/r/pi-chat-subagent.json +3 -8
  82. package/r/pi-chat-voice.json +7 -10
  83. package/r/pi-chat.json +25 -53
  84. package/r/pi-workspace.json +4 -7
  85. package/r/placed-agent.json +4 -5
  86. package/r/provider-setup.json +2 -8
  87. package/r/record-viewer.json +0 -9
  88. package/r/registry.json +182 -403
  89. package/r/requests-board.json +2 -10
  90. package/r/shared-chat.json +10 -24
  91. package/r/task-list-viewer.json +3 -9
  92. package/r/utils.json +14 -2
  93. package/r/version-history.json +3 -5
  94. package/r/viewer-code.json +2 -3
  95. package/r/viewer-frame.json +9 -17
  96. package/r/viewer-html.json +4 -5
  97. package/r/viewer-image.json +3 -4
  98. package/r/viewer-markdown.json +6 -8
  99. package/r/viewer-pdf.json +5 -7
  100. package/r/viewers.json +6 -6
  101. package/r/windows.json +3 -8
@@ -4,20 +4,17 @@
4
4
  "title": "Board viewer",
5
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
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.25",
8
- "@hachej/boring-files@^0.4.25",
7
+ "@hachej/boring-ui-kit@^0.4.27",
8
+ "@hachej/boring-files@^0.4.27",
9
9
  "react@^19.0.0",
10
10
  "lucide-react@1.51.0",
11
11
  "tldraw@5.5.2"
12
12
  ],
13
13
  "registryDependencies": [
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/maquette-viewer.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/mockup-viewer.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-artifacts.json",
17
- "button",
18
- "alert",
19
- "card",
20
- "tooltip"
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/maquette-viewer.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/mockup-viewer.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-artifacts.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/utils.json"
21
18
  ],
22
19
  "files": [
23
20
  {
@@ -28,13 +25,13 @@
28
25
  },
29
26
  {
30
27
  "path": "registry/board-viewer/board-viewer.tsx",
31
- "content": "'use client';\n\nimport { lazy, Suspense, useEffect, useMemo, useState, useSyncExternalStore } from 'react';\nimport type { 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 { 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\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",
28
+ "content": "'use client';\n\nimport { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { 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 { 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\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 // A changed revision refreshes the existing buffer below; it must not replace a dirty canvas. A new buffer starts from the latest text.\n const saved = useRef({ text, revision });\n useLayoutEffect(() => { saved.current = { text, revision }; });\n useEffect(() => {\n const { text, revision } = saved.current;\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 }, [client, identity, target, pinned]);\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 // eslint-disable-next-line react-hooks/set-state-in-effect -- a new read (client, target or sources) clears the previous read's error once; the effect then reads asynchronously\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
29
  "type": "registry:component",
33
30
  "target": "components/board-viewer/board-viewer.tsx"
34
31
  },
35
32
  {
36
33
  "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",
34
+ "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, useLayoutEffect, 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 { usePhone } from '../utils/use-media';\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 = usePhone();\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);\n useLayoutEffect(() => { 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
35
  "type": "registry:component",
39
36
  "target": "components/board-viewer/board-canvas.tsx"
40
37
  },
package/r/button.json CHANGED
@@ -9,19 +9,12 @@
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/utils.json",
13
- "dropdown-menu",
14
- "button",
15
- "toggle",
16
- "input",
17
- "textarea",
18
- "card",
19
- "separator"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/utils.json"
20
13
  ],
21
14
  "files": [
22
15
  {
23
16
  "path": "registry/button/actions.tsx",
24
- "content": "'use client';\n\nimport { Fragment, useCallback, useRef } from 'react';\nimport type { ComponentProps, ComponentType, ReactNode, RefObject } from 'react';\nimport { EllipsisIcon } from 'lucide-react';\nimport { DropdownMenu, DropdownMenuTrigger, DropdownMenuPortal, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut } from '@/components/ui/dropdown-menu';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { cn } from '../utils/utils';\n\nexport type BlockIcon = ComponentType<{ readonly className?: string | undefined; readonly 'aria-hidden'?: boolean | 'true' | 'false' | undefined }>;\n\n/** Host actions in a header, reply footer or menu. The host retains all execution policy. */\nexport interface BlockAction {\n readonly id: string;\n readonly label: string;\n readonly icon?: BlockIcon | undefined;\n readonly onSelect: () => void;\n readonly placement?: 'header' | 'menu' | undefined;\n readonly disabled?: boolean | undefined;\n readonly pressed?: boolean | undefined;\n readonly destructive?: boolean | undefined;\n}\n\nexport interface MenuItem {\n readonly id: string;\n readonly label: string;\n readonly description?: string;\n readonly icon?: ReactNode;\n readonly hint?: string;\n readonly shortcut?: string;\n readonly checked?: boolean;\n readonly disabled?: boolean;\n readonly destructive?: boolean;\n readonly separatorBefore?: boolean;\n readonly group?: string;\n readonly testId?: string;\n readonly onSelect: () => void;\n}\n\ntype ActionIconSize = Extract<ComponentProps<typeof Button>['size'], 'icon' | 'icon-sm'>;\n\nexport function ActionMenuItemContent({ item, testId }: { readonly item: MenuItem; readonly testId: string }) {\n return <>\n {item.icon && <span className=\"text-muted-foreground\">{item.icon}</span>}\n <span className=\"min-w-0 flex-1\"><span className=\"block truncate\">{item.label}</span>{item.description && <span data-testid={`${testId}-description`} className=\"block text-xs text-muted-foreground\">{item.description}</span>}</span>\n {item.hint && <span data-testid={`${testId}-hint`} className=\"shrink-0 text-xs text-muted-foreground\">{item.hint}</span>}\n {item.shortcut && <DropdownMenuShortcut>{item.shortcut}</DropdownMenuShortcut>}\n </>;\n}\n\n/**\n * Portal target and trigger ref for a menu that must open in its trigger's own document (a viewer in a pop-out window).\n * Put `ref` on the trigger button and `container` on `ActionMenuContent`.\n */\nexport function useMenuPortal() {\n const container = useRef<HTMLElement | null>(null);\n const ref = useCallback((node: HTMLElement | null) => { container.current = node?.ownerDocument.body ?? null; }, []);\n return { container, ref };\n}\n\n/** Maps host menu items to the consumer's shadcn dropdown items inside a `DropdownMenu`; the primitives own focus, positioning and dismissal. */\nexport function ActionMenuContent({ label, testId, items, container, align = 'end', children }: { readonly label: string; readonly testId: string; readonly items: readonly MenuItem[]; readonly container?: RefObject<HTMLElement | null>; readonly align?: 'start' | 'end'; readonly children?: ReactNode }) {\n const groups: MenuItem[][] = [];\n for (const item of items) {\n const last = groups[groups.length - 1];\n if (!last || item.separatorBefore || last[0]?.group !== item.group) groups.push([item]);\n else last.push(item);\n }\n const rows = groups.map((group, index) => <Fragment key={group[0]!.id}>\n {index > 0 && <DropdownMenuSeparator />}\n <DropdownMenuGroup>\n {group[0]?.group && <DropdownMenuLabel>{group[0].group}</DropdownMenuLabel>}\n {group.map(item => {\n const common = { disabled: item.disabled, 'data-testid': item.testId ?? `${testId}-${item.id}`, 'data-item': item.id, onClick: item.onSelect, className: 'min-h-8 max-sm:min-h-10 pointer-coarse:min-h-10' };\n const content = <ActionMenuItemContent item={item} testId={testId} />;\n return item.checked !== undefined\n ? <DropdownMenuRadioItem key={item.id} value={item.id} {...common}>{content}</DropdownMenuRadioItem>\n : <DropdownMenuItem key={item.id} variant={item.destructive ? 'destructive' : 'default'} {...common}>{content}</DropdownMenuItem>;\n })}\n </DropdownMenuGroup>\n </Fragment>);\n return <DropdownMenuPortal {...(container ? { container } : {})}>\n <DropdownMenuContent align={align} aria-label={label} data-testid={`${testId}-list`} className=\"w-64 max-w-[calc(100vw-1rem)]\">\n {children}\n {items.some(item => item.checked !== undefined) ? <DropdownMenuRadioGroup value={items.find(item => item.checked)?.id ?? ''}>{rows}</DropdownMenuRadioGroup> : rows}\n </DropdownMenuContent>\n </DropdownMenuPortal>;\n}\n\nexport function actionMenuItem(action: BlockAction, testId: string): MenuItem {\n const Icon = action.icon;\n return { id: action.id, label: action.label, ...(Icon ? { icon: <Icon data-icon=\"inline-start\" aria-hidden=\"true\" /> } : {}), ...(action.disabled ? { disabled: true } : {}), ...(action.destructive ? { destructive: true } : {}), testId: `${testId}-${action.id}`, onSelect: action.onSelect };\n}\n\nexport const headerActions = (actions: readonly BlockAction[] | undefined): readonly BlockAction[] => (actions ?? []).filter(action => action.placement !== 'menu');\nexport const menuActions = (actions: readonly BlockAction[] | undefined): readonly BlockAction[] => (actions ?? []).filter(action => action.placement === 'menu');\n\nexport function ActionButton({ action, testId, size = 'icon-sm', className, iconOnly = false }: { readonly action: BlockAction; readonly testId: string; readonly size?: ActionIconSize; readonly className?: string; readonly iconOnly?: boolean }) {\n const Icon = action.icon ?? (iconOnly ? EllipsisIcon : undefined);\n const content = Icon ? <Icon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <span className=\"truncate\">{action.label}</span>;\n const layout = cn(!Icon && 'max-w-40 min-w-0', className);\n if (action.pressed !== undefined) return <Toggle size={size === 'icon' ? 'default' : 'sm'} aria-label={action.label} title={action.label} data-testid={`${testId}-${action.id}`} data-action={action.id} disabled={action.disabled} pressed={action.pressed} onPressedChange={() => action.onSelect()} className={layout}>{content}</Toggle>;\n return <Button size={Icon ? size : 'sm'} variant=\"ghost\" aria-label={action.label} title={action.label} data-testid={`${testId}-${action.id}`} data-action={action.id} disabled={action.disabled} onClick={action.onSelect} className={layout}>{content}</Button>;\n}\n\nexport function BlockActions({ actions, testId, menuLabel, size = 'icon-sm', className, iconOnly = false }: {\n readonly actions: readonly BlockAction[] | undefined;\n readonly testId: string;\n readonly menuLabel: string;\n readonly size?: ActionIconSize;\n readonly className?: string;\n readonly iconOnly?: boolean;\n}) {\n const portal = useMenuPortal();\n const buttons = headerActions(actions), menu = menuActions(actions);\n if (!buttons.length && !menu.length) return null;\n const moreId = `${testId}-more`;\n return <div data-boring=\"block-actions\" data-actions={testId} className={cn('flex shrink-0 items-center gap-0.5', className)}>\n {buttons.map(action => <ActionButton key={action.id} action={action} testId={testId} size={size} iconOnly={iconOnly} />)}\n {menu.length > 0 && <DropdownMenu>\n <DropdownMenuTrigger render={<Button ref={portal.ref} variant=\"ghost\" aria-label={menuLabel} title={menuLabel} size={size} data-testid={moreId}><EllipsisIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>} />\n <ActionMenuContent label={menuLabel} testId={moreId} container={portal.container} items={menu.map(action => actionMenuItem(action, testId))} />\n </DropdownMenu>}\n </div>;\n}\n",
17
+ "content": "'use client';\n\nimport { Fragment, useCallback, useRef } from 'react';\nimport type { ComponentProps, ComponentType, ReactNode, RefObject } from 'react';\nimport { EllipsisIcon } from 'lucide-react';\nimport { DropdownMenu, DropdownMenuTrigger, DropdownMenuPortal, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut } from '@/components/ui/dropdown-menu';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { cn } from '../utils/utils';\n\nexport type BlockIcon = ComponentType<{ readonly className?: string | undefined; readonly 'aria-hidden'?: boolean | 'true' | 'false' | undefined }>;\n\n/** Host actions in a header, reply footer or menu. The host retains all execution policy. */\nexport interface BlockAction {\n readonly id: string;\n readonly label: string;\n readonly icon?: BlockIcon | undefined;\n readonly onSelect: () => void;\n readonly placement?: 'header' | 'menu' | undefined;\n readonly disabled?: boolean | undefined;\n readonly pressed?: boolean | undefined;\n readonly destructive?: boolean | undefined;\n}\n\nexport interface MenuItem {\n readonly id: string;\n readonly label: string;\n readonly description?: string;\n readonly icon?: ReactNode;\n readonly hint?: string;\n readonly shortcut?: string;\n readonly checked?: boolean;\n readonly disabled?: boolean;\n readonly destructive?: boolean;\n readonly separatorBefore?: boolean;\n readonly group?: string;\n readonly testId?: string;\n readonly onSelect: () => void;\n}\n\ntype ActionIconSize = Extract<ComponentProps<typeof Button>['size'], 'icon' | 'icon-sm'>;\n\nexport function ActionMenuItemContent({ item, testId }: { readonly item: MenuItem; readonly testId: string }) {\n return <>\n {item.icon && <span className=\"text-muted-foreground\">{item.icon}</span>}\n <span className=\"min-w-0 flex-1\"><span className=\"block truncate\">{item.label}</span>{item.description && <span data-testid={`${testId}-description`} className=\"block text-xs text-muted-foreground\">{item.description}</span>}</span>\n {item.hint && <span data-testid={`${testId}-hint`} className=\"shrink-0 text-xs text-muted-foreground\">{item.hint}</span>}\n {item.shortcut && <DropdownMenuShortcut>{item.shortcut}</DropdownMenuShortcut>}\n </>;\n}\n\n/**\n * Portal target and trigger ref for a menu that must open in its trigger's own document (a viewer in a pop-out window).\n * Put `ref` on the trigger button and `container` on `ActionMenuContent`.\n */\nexport function useMenuPortal() {\n const container = useRef<HTMLElement | null>(null);\n const ref = useCallback((node: HTMLElement | null) => { container.current = node?.ownerDocument.body ?? null; }, []);\n return { container, ref };\n}\n\n/** Maps host menu items to the consumer's shadcn dropdown items inside a `DropdownMenu`; the primitives own focus, positioning and dismissal. */\nexport function ActionMenuContent({ label, testId, items, container, align = 'end', children }: { readonly label: string; readonly testId: string; readonly items: readonly MenuItem[]; readonly container?: RefObject<HTMLElement | null>; readonly align?: 'start' | 'end'; readonly children?: ReactNode }) {\n const groups: MenuItem[][] = [];\n for (const item of items) {\n const last = groups[groups.length - 1];\n if (!last || item.separatorBefore || last[0]?.group !== item.group) groups.push([item]);\n else last.push(item);\n }\n const rows = groups.map((group, index) => <Fragment key={group[0]!.id}>\n {index > 0 && <DropdownMenuSeparator />}\n <DropdownMenuGroup>\n {group[0]?.group && <DropdownMenuLabel>{group[0].group}</DropdownMenuLabel>}\n {group.map(item => {\n const common = { disabled: item.disabled, 'data-testid': item.testId ?? `${testId}-${item.id}`, 'data-item': item.id, onClick: item.onSelect, className: 'min-h-8 max-sm:min-h-10 pointer-coarse:min-h-10' };\n const content = <ActionMenuItemContent item={item} testId={testId} />;\n return item.checked !== undefined\n ? <DropdownMenuRadioItem key={item.id} value={item.id} {...common}>{content}</DropdownMenuRadioItem>\n : <DropdownMenuItem key={item.id} variant={item.destructive ? 'destructive' : 'default'} {...common}>{content}</DropdownMenuItem>;\n })}\n </DropdownMenuGroup>\n </Fragment>);\n return <DropdownMenuPortal {...(container ? { container } : {})}>\n <DropdownMenuContent align={align} aria-label={label} data-testid={`${testId}-list`} className=\"w-64 max-w-[calc(100vw-1rem)]\">\n {children}\n {items.some(item => item.checked !== undefined) ? <DropdownMenuRadioGroup value={items.find(item => item.checked)?.id ?? ''}>{rows}</DropdownMenuRadioGroup> : rows}\n </DropdownMenuContent>\n </DropdownMenuPortal>;\n}\n\nexport function actionMenuItem(action: BlockAction, testId: string): MenuItem {\n const Icon = action.icon;\n return { id: action.id, label: action.label, ...(Icon ? { icon: <Icon data-icon=\"inline-start\" aria-hidden=\"true\" /> } : {}), ...(action.disabled ? { disabled: true } : {}), ...(action.destructive ? { destructive: true } : {}), testId: `${testId}-${action.id}`, onSelect: action.onSelect };\n}\n\nexport const headerActions = (actions: readonly BlockAction[] | undefined): readonly BlockAction[] => (actions ?? []).filter(action => action.placement !== 'menu');\nexport const menuActions = (actions: readonly BlockAction[] | undefined): readonly BlockAction[] => (actions ?? []).filter(action => action.placement === 'menu');\n\nexport function ActionButton({ action, testId, size = 'icon-sm', className, iconOnly = false }: { readonly action: BlockAction; readonly testId: string; readonly size?: ActionIconSize; readonly className?: string; readonly iconOnly?: boolean }) {\n const Icon = action.icon ?? (iconOnly ? EllipsisIcon : undefined);\n const content = Icon ? <Icon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <span className=\"truncate\">{action.label}</span>;\n const layout = cn(!Icon && 'max-w-40 min-w-0', className);\n if (action.pressed !== undefined) return <Toggle size={size === 'icon' ? 'default' : 'sm'} aria-label={action.label} title={action.label} data-testid={`${testId}-${action.id}`} data-action={action.id} disabled={action.disabled} pressed={action.pressed} onPressedChange={() => action.onSelect()} className={layout}>{content}</Toggle>;\n return <Button size={Icon ? size : 'sm'} variant=\"ghost\" aria-label={action.label} title={action.label} data-testid={`${testId}-${action.id}`} data-action={action.id} disabled={action.disabled} onClick={action.onSelect} className={layout}>{content}</Button>;\n}\n\nexport function BlockActions({ actions, testId, menuLabel, size = 'icon-sm', className, iconOnly = false }: {\n readonly actions: readonly BlockAction[] | undefined;\n readonly testId: string;\n readonly menuLabel: string;\n readonly size?: ActionIconSize;\n readonly className?: string;\n readonly iconOnly?: boolean;\n}) {\n const { container, ref: portalRef } = useMenuPortal();\n const buttons = headerActions(actions), menu = menuActions(actions);\n if (!buttons.length && !menu.length) return null;\n const moreId = `${testId}-more`;\n return <div data-boring=\"block-actions\" data-actions={testId} className={cn('flex shrink-0 items-center gap-0.5', className)}>\n {buttons.map(action => <ActionButton key={action.id} action={action} testId={testId} size={size} iconOnly={iconOnly} />)}\n {menu.length > 0 && <DropdownMenu>\n <DropdownMenuTrigger render={<Button ref={portalRef} variant=\"ghost\" aria-label={menuLabel} title={menuLabel} size={size} data-testid={moreId}><EllipsisIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>} />\n <ActionMenuContent label={menuLabel} testId={moreId} container={container} items={menu.map(action => actionMenuItem(action, testId))} />\n </DropdownMenu>}\n </div>;\n}\n",
25
18
  "type": "registry:ui",
26
19
  "target": "components/button/actions.tsx"
27
20
  },
@@ -7,11 +7,6 @@
7
7
  "react@^19.0.0",
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
- "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/theme.json",
12
- "alert",
13
- "badge"
14
- ],
15
10
  "files": [
16
11
  {
17
12
  "path": "registry/diagram-viewer/diagram.ts",
package/r/dock.json CHANGED
@@ -7,12 +7,12 @@
7
7
  "react@^19.0.0"
8
8
  ],
9
9
  "registryDependencies": [
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/utils.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/utils.json"
11
11
  ],
12
12
  "files": [
13
13
  {
14
14
  "path": "registry/dock/dock.tsx",
15
- "content": "'use client';\n\nimport { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\nimport { cn } from '../utils/utils';\n\n/**\n * Composable docks: `DockLayout` is a row holding one `DockMain` (the page's own content, an app for example) and any number of\n * `Dock`s on its left or right (a chat, a list of sessions, a file explorer, an artifact panel). Each dock is resizable from its inner\n * edge and has its own placement: docked, closed, floating (the content renders its own floating surface, for example `AmbientChat`) or,\n * on a narrow layout, a full-screen sheet. `useDock(id)` reaches a dock from anywhere inside the layout, so a button in a chat header can\n * open the files or detach the chat. Layout only: what the docks show is the host's.\n */\n\n/** What a dock is doing now. `floating` keeps the content mounted in a zero-width slot so it can render its own floating surface. */\nexport type DockPlacement = 'page' | 'docked' | 'floating' | 'sheet' | 'closed';\n\n/** What a dock and the controls around it can do. */\nexport interface DockApi {\n readonly id: string;\n readonly placement: DockPlacement;\n readonly open: boolean;\n readonly floating: boolean;\n /** Whether the layout is wide enough to dock it (false below `narrowBelow`). */\n readonly canDock: boolean;\n readonly width: number;\n /** Page fills the main area; docked sits beside it; floating uses the host's ambient surface. Opens the dock. */\n readonly setPlacement: (next: 'page' | 'docked' | 'floating') => void;\n readonly setOpen: (next: boolean) => void;\n readonly toggle: () => void;\n /** Float it (only for a `floatable` dock). Opens it if closed. */\n readonly float: () => void;\n /** Put it back beside the main content at its last docked width. Opens it if closed. */\n readonly dock: () => void;\n /** Set the docked width (clamped and remembered like a drag), for example to widen a dock while it shows a document. */\n readonly setWidth: (width: number) => void;\n}\n\nexport interface DockLayoutProps {\n readonly children: ReactNode;\n /** Prefix of the remembered widths, placements and open states (`<storageKey>.<dock id>.width`, …). */\n readonly storageKey?: string;\n /** `session` (default) remembers the layout for the browser tab; `local` across visits. */\n readonly persist?: 'session' | 'local';\n /** The main content is never narrower than this while docks are beside it. */\n readonly minMain?: number;\n readonly className?: string;\n}\n\nexport interface DockProps {\n readonly id: string;\n readonly side: 'left' | 'right';\n /** The dock's content, or a function of its api (to render a floating surface while `floating`, or a close button). Stays mounted in every placement. */\n readonly children: ReactNode | ((api: DockApi) => ReactNode);\n readonly defaultWidth?: number;\n readonly minWidth?: number;\n /** Opt in to floating: dragging below `dragBelow`, Alt+Arrow towards the edge and `api.float` float it, and it floats when the layout is narrow. */\n readonly floatable?: boolean;\n /**\n * Dragging the divider until the dock would be narrower than this shows a hint and releasing floats it (`floatable`) or closes it.\n * Absent: the dock stops at `minWidth`.\n */\n readonly dragBelow?: number;\n /** Below this layout width the dock cannot be docked: it floats (`floatable`), becomes a full-screen sheet while open (`narrow: 'sheet'`) or is hidden. */\n readonly narrowBelow?: number;\n readonly narrow?: 'float' | 'sheet' | 'hide';\n /** Controlled open state (with `onOpenChange`); uncontrolled from `defaultOpen` (true) and remembered otherwise. */\n readonly open?: boolean;\n readonly defaultOpen?: boolean;\n readonly onOpenChange?: (open: boolean) => void;\n /** Floating at first, before the person docks it. */\n readonly defaultFloating?: boolean;\n /** Initial placement. Takes precedence over defaultFloating; remembered choices take precedence over both. */\n readonly defaultPlacement?: 'page' | 'docked' | 'floating';\n /**\n * While docked, the dock is the containing block of its content's fixed overlays (a full-screen sheet, a drawer, a viewer's full screen):\n * they cover the dock, not the page. Floating and sheet placements are unaffected.\n */\n readonly contained?: boolean;\n readonly onPlacementChange?: (placement: DockPlacement) => void;\n /** The region's and the divider's accessible names, and the hint shown while a drag would float or close the dock. */\n readonly labels?: { readonly region?: string | undefined; readonly resize?: string | undefined; readonly floatHint?: string | undefined; readonly closeHint?: string | undefined } | undefined;\n readonly className?: string;\n}\n\nexport interface DockMainProps { readonly children: ReactNode; readonly className?: string }\n\ninterface DockRecord { readonly width?: number | undefined; readonly placement?: 'page' | 'docked' | 'floating' | undefined; readonly floating?: boolean | undefined; readonly open?: boolean | undefined }\ninterface LayoutContext {\n readonly size: number;\n readonly minMain: number;\n readonly records: Readonly<Record<string, DockRecord>>;\n readonly apis: Readonly<Record<string, DockApi>>;\n readonly update: (id: string, patch: DockRecord) => void;\n readonly publish: (id: string, api: DockApi | undefined) => void;\n readonly read: (id: string) => DockRecord;\n readonly occupied: (except: string) => number;\n}\nconst Layout = createContext<LayoutContext | null>(null);\nconst STEP = 24, BIG_STEP = 96;\n\nfunction store(persist: 'session' | 'local'): Storage | undefined { try { return persist === 'local' ? globalThis.localStorage : globalThis.sessionStorage; } catch { return undefined; } }\n\n/** The row of docks and main content. Measures its own width, so it works inside any container. */\nexport function DockLayout({ children, storageKey = 'boring.dock', persist = 'session', minMain = 360, className }: DockLayoutProps) {\n const root = useRef<HTMLDivElement>(null);\n const [size, setSize] = useState(0);\n const [records, setRecords] = useState<Record<string, DockRecord>>({});\n const [apis, setApis] = useState<Record<string, DockApi>>({});\n const widths = useRef<Record<string, number>>({});\n useLayoutEffect(() => {\n const element = root.current;\n if (!element) return;\n const measure = () => setSize(element.getBoundingClientRect().width);\n measure();\n if (typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n const read = useCallback((id: string): DockRecord => {\n const storage = store(persist);\n const get = (name: string) => { try { return storage?.getItem(`${storageKey}.${id}.${name}`) ?? null; } catch { return null; } };\n const width = Number(get('width')), floating = get('floating'), open = get('open'), placement = get('placement');\n return { width: Number.isFinite(width) && width > 0 ? width : undefined, placement: placement === 'page' || placement === 'docked' || placement === 'floating' ? placement : undefined,\n floating: floating === null ? undefined : floating === '1', open: open === null ? undefined : open === '1' };\n }, [persist, storageKey]);\n const update = useCallback((id: string, patch: DockRecord) => {\n setRecords(current => ({ ...current, [id]: { ...current[id], ...patch } }));\n const storage = store(persist);\n for (const [name, value] of Object.entries(patch)) {\n if (value === undefined) continue;\n try { storage?.setItem(`${storageKey}.${id}.${name}`, typeof value === 'boolean' ? (value ? '1' : '0') : typeof value === 'number' ? String(Math.round(value)) : value); } catch { /* the layout is a convenience */ }\n }\n }, [persist, storageKey]);\n const publish = useCallback((id: string, api: DockApi | undefined) => {\n widths.current = { ...widths.current, [id]: api?.placement === 'docked' ? api.width : 0 };\n setApis(current => {\n if (api === undefined) { const { [id]: _removed, ...rest } = current; return rest; }\n const previous = current[id];\n return previous && sameApi(previous, api) ? current : { ...current, [id]: api };\n });\n }, []);\n const occupied = useCallback((except: string) => Object.entries(widths.current).reduce((total, [id, width]) => id === except ? total : total + width, 0), []);\n const value = useMemo(() => ({ size, minMain, records, apis, update, publish, read, occupied }), [size, minMain, records, apis, update, publish, read, occupied]);\n return <Layout.Provider value={value}>\n <div ref={root} data-boring=\"dock-layout\" className={cn('relative flex h-full min-h-0 min-w-0 flex-1', className)}>{children}</div>\n </Layout.Provider>;\n}\n\nconst sameApi = (a: DockApi, b: DockApi) => a.placement === b.placement && a.open === b.open && a.width === b.width && a.canDock === b.canDock;\n\n/** The page's own content between the docks; it takes the remaining width. */\nexport function DockMain({ children, className }: DockMainProps) {\n const layout = useContext(Layout);\n const replaced = Object.values(layout?.apis ?? {}).some(api => api.placement === 'page');\n return <main data-boring=\"dock-main\" hidden={replaced} inert={replaced} className={cn('relative min-h-0 min-w-0 flex-1 flex-col overflow-hidden', replaced ? 'hidden' : 'flex', className)}>{children}</main>;\n}\n\n/** A dock's api from anywhere inside the `DockLayout` (undefined until that dock has rendered). */\nexport function useDock(id: string): DockApi | undefined {\n const layout = useContext(Layout);\n if (!layout) throw new Error('useDock must be used inside a DockLayout');\n return layout.apis[id];\n}\n\n/** One dock beside the main content. Order the children as they should appear: left docks, `DockMain`, right docks. */\nexport function Dock({ id, side, children, defaultWidth = 400, minWidth = 280, floatable = false, dragBelow, narrowBelow, narrow = floatable ? 'float' : 'hide',\n open: controlledOpen, defaultOpen = true, onOpenChange, defaultFloating = false, defaultPlacement, contained = false, onPlacementChange, labels, className }: DockProps) {\n const layout = useContext(Layout);\n if (!layout) throw new Error('Dock must be used inside a DockLayout');\n const { size, minMain, update, publish, occupied } = layout;\n const [initial] = useState(() => layout.read(id));\n const record = { ...initial, ...layout.records[id] };\n const [dragging, setDragging] = useState(false);\n const [hint, setHint] = useState(false);\n const section = useRef<HTMLElement>(null);\n const controlled = controlledOpen !== undefined;\n const open = controlled ? controlledOpen : record.open ?? defaultOpen;\n const narrowNow = narrowBelow !== undefined && size > 0 && size < narrowBelow;\n const chosen = record.placement ?? (record.floating === undefined ? defaultPlacement ?? (defaultFloating ? 'floating' : 'docked') : record.floating ? 'floating' : 'docked');\n const placement: DockPlacement = !open ? 'closed'\n : chosen === 'page' ? 'page'\n : narrowNow ? (narrow === 'float' && floatable ? 'floating' : narrow === 'sheet' ? 'sheet' : 'closed')\n : chosen === 'floating' && floatable ? 'floating' : 'docked';\n const max = Math.max(minWidth, size - minMain - occupied(id));\n const width = Math.round(Math.min(Math.max(record.width ?? defaultWidth, minWidth), max));\n\n const setOpen = useCallback((next: boolean) => { if (!controlled) update(id, { open: next }); onOpenChange?.(next); }, [controlled, id, onOpenChange, update]);\n const setPlacement = useCallback((next: 'page' | 'docked' | 'floating') => {\n if (next === 'floating' && !floatable) return;\n update(id, { placement: next, floating: next === 'floating' });\n if (!open) setOpen(true);\n }, [floatable, id, open, setOpen, update]);\n const api: DockApi = {\n id, placement, open, floating: placement === 'floating', canDock: !narrowNow, width,\n setPlacement, setOpen, toggle: () => setOpen(!open),\n float: () => setPlacement('floating'),\n dock: () => setPlacement('docked'),\n setWidth: (next: number) => resize(next),\n };\n const latest = useRef(api); latest.current = api;\n // What `useDock` hands out: the current state, with actions that always reach this render's dock.\n const actions = useMemo(() => ({ setPlacement: (next: 'page' | 'docked' | 'floating') => latest.current.setPlacement(next), setOpen: (next: boolean) => latest.current.setOpen(next), toggle: () => latest.current.toggle(), float: () => latest.current.float(), dock: () => latest.current.dock(), setWidth: (next: number) => latest.current.setWidth(next) }), []);\n useLayoutEffect(() => { publish(id, { ...latest.current, ...actions }); });\n useLayoutEffect(() => () => publish(id, undefined), [id, publish]);\n const reported = useRef(placement);\n useLayoutEffect(() => { if (reported.current !== placement) { reported.current = placement; onPlacementChange?.(placement); } }, [placement, onPlacementChange]);\n\n const resize = (next: number) => update(id, { width: Math.min(Math.max(next, minWidth), max) });\n // Past `dragBelow` the dock keeps its last width; releasing there floats or closes it.\n const drag = (event: PointerEvent<HTMLElement>) => {\n if (!dragging) return;\n const box = section.current?.getBoundingClientRect();\n if (!box) return;\n const target = side === 'left' ? event.clientX - box.left : box.right - event.clientX;\n const below = dragBelow !== undefined && target < dragBelow;\n setHint(below);\n if (!below) resize(target);\n };\n const leave = () => { if (floatable) api.float(); else setOpen(false); };\n const release = (event: PointerEvent<HTMLElement>) => { event.currentTarget.releasePointerCapture?.(event.pointerId); setDragging(false); if (hint) leave(); setHint(false); };\n const keys = (event: KeyboardEvent<HTMLElement>) => {\n const outward = side === 'left' ? 'ArrowLeft' : 'ArrowRight', inward = side === 'left' ? 'ArrowRight' : 'ArrowLeft';\n if (event.altKey && event.key === outward && (floatable || dragBelow !== undefined)) { event.preventDefault(); leave(); return; }\n const step = event.shiftKey ? BIG_STEP : STEP;\n const next = event.key === inward ? width + step : event.key === outward ? width - step : event.key === 'Home' ? minWidth : event.key === 'End' ? max : undefined;\n if (next === undefined) return;\n event.preventDefault(); resize(next);\n };\n\n const docked = placement === 'docked';\n const divider = docked && <div role=\"separator\" tabIndex={0} aria-orientation=\"vertical\" aria-label={labels?.resize ?? 'Resize panel'} aria-valuemin={minWidth} aria-valuemax={max} aria-valuenow={width}\n data-testid={`dock-divider-${id}`} data-dragging={dragging ? 'true' : undefined}\n // No text selection or native drag from the divider: after a double-click reset, the next press must still resize.\n onPointerDown={event => { event.preventDefault(); event.currentTarget.focus(); event.currentTarget.setPointerCapture?.(event.pointerId); setDragging(true); }} onPointerMove={drag}\n onDragStart={event => event.preventDefault()}\n onPointerUp={release} onPointerCancel={() => { setDragging(false); setHint(false); }} onKeyDown={keys} onDoubleClick={() => resize(defaultWidth)}\n className=\"group relative z-10 -mx-1.5 w-3 shrink-0 cursor-col-resize touch-none select-none outline-none\">\n <span aria-hidden=\"true\" className={cn('absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border/65 transition-[width,background-color] group-hover:w-0.5 group-hover:bg-ring group-focus-visible:w-0.5 group-focus-visible:bg-ring motion-reduce:transition-none', dragging && 'w-0.5 bg-ring')} />\n </div>;\n const hidden = placement === 'closed';\n const panel = <section ref={section} data-boring=\"dock\" data-dock={id} data-side={side} data-placement={placement} aria-label={labels?.region}\n {...(hidden ? { hidden: true, inert: true } : {})}\n style={docked ? { width } : undefined}\n className={cn('relative flex min-h-0 min-w-0 flex-col', docked && 'shrink-0 overflow-hidden bg-background text-foreground', (docked || placement === 'page') && contained && '[contain:layout]', placement === 'page' && 'flex-1 overflow-hidden bg-background text-foreground',\n placement === 'floating' && 'w-0 flex-none overflow-visible', placement === 'sheet' && 'fixed inset-0 z-50 bg-background text-foreground', hidden && 'hidden',\n dragging && '[&_iframe]:pointer-events-none select-none', className)}>\n {typeof children === 'function' ? children(api) : children}\n {hint && <div role=\"status\" data-testid={`dock-hint-${id}`} className=\"pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-background/75 p-4\">\n <span className=\"text-sm font-medium text-foreground\">\n {floatable ? labels?.floatHint ?? 'Release to float' : labels?.closeHint ?? 'Release to close'}</span>\n </div>}\n </section>;\n return side === 'left' ? <>{panel}{divider}</> : <>{divider}{panel}</>;\n}\n",
15
+ "content": "'use client';\n\nimport { readPersisted, writePersisted } from '../utils/persisted';\n\nimport { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode } from 'react';\nimport { cn } from '../utils/utils';\n\n/**\n * Composable docks: `DockLayout` is a row holding one `DockMain` (the page's own content, an app for example) and any number of\n * `Dock`s on its left or right (a chat, a list of sessions, a file explorer, an artifact panel). Each dock is resizable from its inner\n * edge and has its own placement: docked, closed, floating (the content renders its own floating surface, for example `AmbientChat`) or,\n * on a narrow layout, a full-screen sheet. `useDock(id)` reaches a dock from anywhere inside the layout, so a button in a chat header can\n * open the files or detach the chat. Layout only: what the docks show is the host's.\n */\n\n/** What a dock is doing now. `floating` keeps the content mounted in a zero-width slot so it can render its own floating surface. */\nexport type DockPlacement = 'page' | 'docked' | 'floating' | 'sheet' | 'closed';\n\n/** What a dock and the controls around it can do. */\nexport interface DockApi {\n readonly id: string;\n readonly placement: DockPlacement;\n readonly open: boolean;\n readonly floating: boolean;\n /** Whether the layout is wide enough to dock it (false below `narrowBelow`). */\n readonly canDock: boolean;\n readonly width: number;\n /** Page fills the main area; docked sits beside it; floating uses the host's ambient surface. Opens the dock. */\n readonly setPlacement: (next: 'page' | 'docked' | 'floating') => void;\n readonly setOpen: (next: boolean) => void;\n readonly toggle: () => void;\n /** Float it (only for a `floatable` dock). Opens it if closed. */\n readonly float: () => void;\n /** Put it back beside the main content at its last docked width. Opens it if closed. */\n readonly dock: () => void;\n /** Set the docked width (clamped and remembered like a drag), for example to widen a dock while it shows a document. */\n readonly setWidth: (width: number) => void;\n}\n\nexport interface DockLayoutProps {\n readonly children: ReactNode;\n /** Prefix of the remembered widths, placements and open states (`<storageKey>.<dock id>.width`, …). */\n readonly storageKey?: string;\n /** `session` (default) remembers the layout for the browser tab; `local` across visits. */\n readonly persist?: 'session' | 'local';\n /** The main content is never narrower than this while docks are beside it. */\n readonly minMain?: number;\n readonly className?: string;\n}\n\nexport interface DockProps {\n readonly id: string;\n readonly side: 'left' | 'right';\n /** The dock's content, or a function of its api (to render a floating surface while `floating`, or a close button). Stays mounted in every placement. */\n readonly children: ReactNode | ((api: DockApi) => ReactNode);\n readonly defaultWidth?: number;\n readonly minWidth?: number;\n /** Opt in to floating: dragging below `dragBelow`, Alt+Arrow towards the edge and `api.float` float it, and it floats when the layout is narrow. */\n readonly floatable?: boolean;\n /**\n * Dragging the divider until the dock would be narrower than this shows a hint and releasing floats it (`floatable`) or closes it.\n * Absent: the dock stops at `minWidth`.\n */\n readonly dragBelow?: number;\n /** Below this layout width the dock cannot be docked: it floats (`floatable`), becomes a full-screen sheet while open (`narrow: 'sheet'`) or is hidden. */\n readonly narrowBelow?: number;\n readonly narrow?: 'float' | 'sheet' | 'hide';\n /** Controlled open state (with `onOpenChange`); uncontrolled from `defaultOpen` (true) and remembered otherwise. */\n readonly open?: boolean;\n readonly defaultOpen?: boolean;\n readonly onOpenChange?: (open: boolean) => void;\n /** Floating at first, before the person docks it. */\n readonly defaultFloating?: boolean;\n /** Initial placement. Takes precedence over defaultFloating; remembered choices take precedence over both. */\n readonly defaultPlacement?: 'page' | 'docked' | 'floating';\n /**\n * While docked, the dock is the containing block of its content's fixed overlays (a full-screen sheet, a drawer, a viewer's full screen):\n * they cover the dock, not the page. Floating and sheet placements are unaffected.\n */\n readonly contained?: boolean;\n readonly onPlacementChange?: (placement: DockPlacement) => void;\n /** The region's and the divider's accessible names, and the hint shown while a drag would float or close the dock. */\n readonly labels?: { readonly region?: string | undefined; readonly resize?: string | undefined; readonly floatHint?: string | undefined; readonly closeHint?: string | undefined } | undefined;\n readonly className?: string;\n}\n\nexport interface DockMainProps { readonly children: ReactNode; readonly className?: string }\n\ninterface DockRecord { readonly width?: number | undefined; readonly placement?: 'page' | 'docked' | 'floating' | undefined; readonly floating?: boolean | undefined; readonly open?: boolean | undefined }\ninterface LayoutContext {\n readonly size: number;\n readonly minMain: number;\n readonly records: Readonly<Record<string, DockRecord>>;\n readonly apis: Readonly<Record<string, DockApi>>;\n readonly update: (id: string, patch: DockRecord) => void;\n readonly publish: (id: string, api: DockApi | undefined) => void;\n readonly read: (id: string) => DockRecord;\n readonly occupied: (except: string) => number;\n}\nconst Layout = createContext<LayoutContext | null>(null);\nconst STEP = 24, BIG_STEP = 96;\n\n/** The row of docks and main content. Measures its own width, so it works inside any container. */\nexport function DockLayout({ children, storageKey = 'boring.dock', persist = 'session', minMain = 360, className }: DockLayoutProps) {\n const root = useRef<HTMLDivElement>(null);\n const [size, setSize] = useState(0);\n const [records, setRecords] = useState<Record<string, DockRecord>>({});\n const [apis, setApis] = useState<Record<string, DockApi>>({});\n const widths = useRef<Record<string, number>>({});\n useLayoutEffect(() => {\n const element = root.current;\n if (!element) return;\n const measure = () => setSize(element.getBoundingClientRect().width);\n measure();\n if (typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n const read = useCallback((id: string): DockRecord => {\n const get = (name: string) => readPersisted(`${storageKey}.${id}.${name}`, persist);\n const width = Number(get('width')), floating = get('floating'), open = get('open'), placement = get('placement');\n return { width: Number.isFinite(width) && width > 0 ? width : undefined, placement: placement === 'page' || placement === 'docked' || placement === 'floating' ? placement : undefined,\n floating: floating === null ? undefined : floating === '1', open: open === null ? undefined : open === '1' };\n }, [persist, storageKey]);\n const update = useCallback((id: string, patch: DockRecord) => {\n setRecords(current => ({ ...current, [id]: { ...current[id], ...patch } }));\n for (const [name, value] of Object.entries(patch)) {\n if (value === undefined) continue;\n writePersisted(`${storageKey}.${id}.${name}`, typeof value === 'boolean' ? (value ? '1' : '0') : typeof value === 'number' ? String(Math.round(value)) : value, persist);\n }\n }, [persist, storageKey]);\n const publish = useCallback((id: string, api: DockApi | undefined) => {\n widths.current = { ...widths.current, [id]: api?.placement === 'docked' ? api.width : 0 };\n setApis(current => {\n if (api === undefined) { const { [id]: _removed, ...rest } = current; return rest; }\n const previous = current[id];\n return previous && sameApi(previous, api) ? current : { ...current, [id]: api };\n });\n }, []);\n const occupied = useCallback((except: string) => Object.entries(widths.current).reduce((total, [id, width]) => id === except ? total : total + width, 0), []);\n const value = useMemo(() => ({ size, minMain, records, apis, update, publish, read, occupied }), [size, minMain, records, apis, update, publish, read, occupied]);\n return <Layout.Provider value={value}>\n <div ref={root} data-boring=\"dock-layout\" className={cn('relative flex h-full min-h-0 min-w-0 flex-1', className)}>{children}</div>\n </Layout.Provider>;\n}\n\nconst sameApi = (a: DockApi, b: DockApi) => a.placement === b.placement && a.open === b.open && a.width === b.width && a.canDock === b.canDock;\n\n/** The page's own content between the docks; it takes the remaining width. */\nexport function DockMain({ children, className }: DockMainProps) {\n const layout = useContext(Layout);\n const replaced = Object.values(layout?.apis ?? {}).some(api => api.placement === 'page');\n return <main data-boring=\"dock-main\" hidden={replaced} inert={replaced} className={cn('relative min-h-0 min-w-0 flex-1 flex-col overflow-hidden', replaced ? 'hidden' : 'flex', className)}>{children}</main>;\n}\n\n/** A dock's api from anywhere inside the `DockLayout` (undefined until that dock has rendered). */\nexport function useDock(id: string): DockApi | undefined {\n const layout = useContext(Layout);\n if (!layout) throw new Error('useDock must be used inside a DockLayout');\n return layout.apis[id];\n}\n\n/** One dock beside the main content. Order the children as they should appear: left docks, `DockMain`, right docks. */\nexport function Dock({ id, side, children, defaultWidth = 400, minWidth = 280, floatable = false, dragBelow, narrowBelow, narrow = floatable ? 'float' : 'hide',\n open: controlledOpen, defaultOpen = true, onOpenChange, defaultFloating = false, defaultPlacement, contained = false, onPlacementChange, labels, className }: DockProps) {\n const layout = useContext(Layout);\n if (!layout) throw new Error('Dock must be used inside a DockLayout');\n const { size, minMain, update, publish, occupied } = layout;\n const [initial] = useState(() => layout.read(id));\n const record = { ...initial, ...layout.records[id] };\n const [dragging, setDragging] = useState(false);\n const [hint, setHint] = useState(false);\n const section = useRef<HTMLElement>(null);\n const controlled = controlledOpen !== undefined;\n const open = controlled ? controlledOpen : record.open ?? defaultOpen;\n const narrowNow = narrowBelow !== undefined && size > 0 && size < narrowBelow;\n const chosen = record.placement ?? (record.floating === undefined ? defaultPlacement ?? (defaultFloating ? 'floating' : 'docked') : record.floating ? 'floating' : 'docked');\n const placement: DockPlacement = !open ? 'closed'\n : chosen === 'page' ? 'page'\n : narrowNow ? (narrow === 'float' && floatable ? 'floating' : narrow === 'sheet' ? 'sheet' : 'closed')\n : chosen === 'floating' && floatable ? 'floating' : 'docked';\n const max = Math.max(minWidth, size - minMain - occupied(id));\n const width = Math.round(Math.min(Math.max(record.width ?? defaultWidth, minWidth), max));\n\n const setOpen = useCallback((next: boolean) => { if (!controlled) update(id, { open: next }); onOpenChange?.(next); }, [controlled, id, onOpenChange, update]);\n const setPlacement = useCallback((next: 'page' | 'docked' | 'floating') => {\n if (next === 'floating' && !floatable) return;\n update(id, { placement: next, floating: next === 'floating' });\n if (!open) setOpen(true);\n }, [floatable, id, open, setOpen, update]);\n const api: DockApi = {\n id, placement, open, floating: placement === 'floating', canDock: !narrowNow, width,\n setPlacement, setOpen, toggle: () => setOpen(!open),\n float: () => setPlacement('floating'),\n dock: () => setPlacement('docked'),\n setWidth: (next: number) => resize(next),\n };\n // eslint-disable-next-line react-hooks/refs -- latest-value ref: useDock's stable actions may run from a consumer's layout effect, before this component's own effects\n const latest = useRef(api); latest.current = api;\n // What `useDock` hands out: the current state, with actions that always reach this render's dock.\n const actions = useMemo(() => ({ setPlacement: (next: 'page' | 'docked' | 'floating') => latest.current.setPlacement(next), setOpen: (next: boolean) => latest.current.setOpen(next), toggle: () => latest.current.toggle(), float: () => latest.current.float(), dock: () => latest.current.dock(), setWidth: (next: number) => latest.current.setWidth(next) }), []);\n useLayoutEffect(() => { publish(id, { ...latest.current, ...actions }); });\n useLayoutEffect(() => () => publish(id, undefined), [id, publish]);\n const reported = useRef(placement);\n useLayoutEffect(() => { if (reported.current !== placement) { reported.current = placement; onPlacementChange?.(placement); } }, [placement, onPlacementChange]);\n\n const resize = (next: number) => update(id, { width: Math.min(Math.max(next, minWidth), max) });\n // Past `dragBelow` the dock keeps its last width; releasing there floats or closes it.\n const drag = (event: PointerEvent<HTMLElement>) => {\n if (!dragging) return;\n const box = section.current?.getBoundingClientRect();\n if (!box) return;\n const target = side === 'left' ? event.clientX - box.left : box.right - event.clientX;\n const below = dragBelow !== undefined && target < dragBelow;\n setHint(below);\n if (!below) resize(target);\n };\n const leave = () => { if (floatable) api.float(); else setOpen(false); };\n const release = (event: PointerEvent<HTMLElement>) => { event.currentTarget.releasePointerCapture?.(event.pointerId); setDragging(false); if (hint) leave(); setHint(false); };\n const keys = (event: KeyboardEvent<HTMLElement>) => {\n const outward = side === 'left' ? 'ArrowLeft' : 'ArrowRight', inward = side === 'left' ? 'ArrowRight' : 'ArrowLeft';\n if (event.altKey && event.key === outward && (floatable || dragBelow !== undefined)) { event.preventDefault(); leave(); return; }\n const step = event.shiftKey ? BIG_STEP : STEP;\n const next = event.key === inward ? width + step : event.key === outward ? width - step : event.key === 'Home' ? minWidth : event.key === 'End' ? max : undefined;\n if (next === undefined) return;\n event.preventDefault(); resize(next);\n };\n\n const docked = placement === 'docked';\n const divider = docked && <div role=\"separator\" tabIndex={0} aria-orientation=\"vertical\" aria-label={labels?.resize ?? 'Resize panel'} aria-valuemin={minWidth} aria-valuemax={max} aria-valuenow={width}\n data-testid={`dock-divider-${id}`} data-dragging={dragging ? 'true' : undefined}\n // No text selection or native drag from the divider: after a double-click reset, the next press must still resize.\n onPointerDown={event => { event.preventDefault(); event.currentTarget.focus(); event.currentTarget.setPointerCapture?.(event.pointerId); setDragging(true); }} onPointerMove={drag}\n onDragStart={event => event.preventDefault()}\n onPointerUp={release} onPointerCancel={() => { setDragging(false); setHint(false); }} onKeyDown={keys} onDoubleClick={() => resize(defaultWidth)}\n className=\"group relative z-10 -mx-1.5 w-3 shrink-0 cursor-col-resize touch-none select-none outline-none\">\n <span aria-hidden=\"true\" className={cn('absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-border/65 transition-[width,background-color] group-hover:w-0.5 group-hover:bg-ring group-focus-visible:w-0.5 group-focus-visible:bg-ring motion-reduce:transition-none', dragging && 'w-0.5 bg-ring')} />\n </div>;\n const hidden = placement === 'closed';\n const panel = <section ref={section} data-boring=\"dock\" data-dock={id} data-side={side} data-placement={placement} aria-label={labels?.region}\n {...(hidden ? { hidden: true, inert: true } : {})}\n style={docked ? { width } : undefined}\n className={cn('relative flex min-h-0 min-w-0 flex-col', docked && 'shrink-0 overflow-hidden bg-background text-foreground', (docked || placement === 'page') && contained && '[contain:layout]', placement === 'page' && 'flex-1 overflow-hidden bg-background text-foreground',\n placement === 'floating' && 'w-0 flex-none overflow-visible', placement === 'sheet' && 'fixed inset-0 z-50 bg-background text-foreground', hidden && 'hidden',\n dragging && '[&_iframe]:pointer-events-none select-none', className)}>\n {typeof children === 'function' ? children(api) : children}\n {hint && <div role=\"status\" data-testid={`dock-hint-${id}`} className=\"pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-background/75 p-4\">\n <span className=\"text-sm font-medium text-foreground\">\n {floatable ? labels?.floatHint ?? 'Release to float' : labels?.closeHint ?? 'Release to close'}</span>\n </div>}\n </section>;\n return side === 'left' ? <>{panel}{divider}</> : <>{divider}{panel}</>;\n}\n",
16
16
  "type": "registry:component",
17
17
  "target": "components/dock/dock.tsx"
18
18
  }
package/r/documents.json CHANGED
@@ -4,32 +4,27 @@
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.25",
8
- "@hachej/boring-files@^0.4.25",
7
+ "@hachej/boring-ui-kit@^0.4.27",
8
+ "@hachej/boring-files@^0.4.27",
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.25/r/theme.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/utils.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/button.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-artifacts.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-app.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/pi-app-kinds.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/app-shell.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/file-tree.json",
21
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/viewer-frame.json",
22
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/viewer-image.json",
23
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.25/r/viewer-pdf.json",
24
- "drawer",
25
- "button",
26
- "scroll-area",
27
- "separator"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/utils.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/button.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-artifacts.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-app.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-app-kinds.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/app-shell.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/file-tree.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/viewer-frame.json",
21
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/viewer-image.json",
22
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/viewer-pdf.json"
28
23
  ],
29
24
  "files": [
30
25
  {
31
26
  "path": "registry/documents/documents.tsx",
32
- "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 { Suspense, 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-artifacts/artifact';\nimport type { CustomViewers, ViewerOptions } from '../pi-app/artifact-panel';\nimport { FileViewer } from '../pi-app/file-viewer';\nimport { lazyView, ViewLoading } from '../pi-app/viewer-kinds';\nimport { defaultKinds } from '../pi-app-kinds/pi-app-kinds';\nimport { imageKind } from '../viewer-image/image-kind';\nimport { pdfKind } from '../viewer-pdf/pdf-kind';\nimport { ViewerFrame } from '../viewer-frame/viewer-frame';\nimport { useViewerText } from '../viewer-frame/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 // The panes load the first time an image or a PDF revision opens (D-257), like the file viewer's.\n const ImagePane = lazyView(imageKind.load), PdfPane = lazyView(pdfKind.load);\n return <Suspense fallback={<ViewerFrame testId=\"viewer-pending\" title={nameOf(path)} status={status} onClose={onClose}><ViewLoading label={viewer.loading} /></ViewerFrame>}>\n {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 </Suspense>;\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 // The viewers' kinds: the pi-app default list in the item's locale.\n const kinds = useMemo(() => defaultKinds({ locale: language }), [language]);\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, kinds, blobBase, ...(viewers ? { viewers } : {}), ...(share ? { share } : {}) }), [provider, kinds, 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 // The file on show was renamed, moved or deleted from the list: follow it to its new path, or close it (its old path is gone).\n const renamed = (from: string, to: string) => { setPath(current => current === from ? to : current); setPinned(undefined); };\n const deleted = (gone: string) => { setPath(current => { if (current !== gone) return current; dirty.current = false; return undefined; }); setPinned(undefined); };\n const list = tree ? <div className=\"min-h-0\">\n <FileTreeView controller={tree} onOpen={choose} onRenamed={renamed} onDeleted={deleted} 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",
27
+ "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 { Suspense, 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-artifacts/artifact';\nimport type { CustomViewers, ViewerOptions } from '../pi-app/artifact-panel';\nimport { FileViewer } from '../pi-app/file-viewer';\nimport { lazyView, ViewLoading } from '../pi-app/viewer-kinds';\nimport { defaultKinds } from '../pi-app-kinds/pi-app-kinds';\nimport { imageKind } from '../viewer-image/image-kind';\nimport { pdfKind } from '../viewer-pdf/pdf-kind';\nimport { ViewerFrame } from '../viewer-frame/viewer-frame';\nimport { useViewerText } from '../viewer-frame/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 // eslint-disable-next-line react-hooks/set-state-in-effect -- the tree controller is a resource owned by this effect run: created after commit, disposed in the cleanup\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// The panes load the first time an image or a PDF revision opens (D-257), like the file viewer's.\nconst ImagePane = lazyView(imageKind.load), PdfPane = lazyView(pdfKind.load);\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 type Read = { readonly kind: 'failed' } | { readonly kind: 'ready'; readonly bytes: Uint8Array; readonly mediaType: string };\n // The read of this provider, path and revision; any other is still loading.\n const [read, setRead] = useState<{ readonly provider: RevisionProvider; readonly path: string; readonly revision: string; readonly result: Read }>();\n const state: { readonly kind: 'loading' } | Read = read && read.provider === provider && read.path === path && read.revision === revision ? read.result : { kind: 'loading' };\n const { labels: viewer } = useViewerText();\n useEffect(() => {\n const abort = new AbortController();\n const settle = (result: Read) => { if (!abort.signal.aborted) setRead({ provider, path, revision, result }); };\n provider.read({ target: provider.locate(path), revision: { kind: 'exact', value: revision } }, abort.signal).then(result => {\n settle(result.kind === 'available' ? { kind: 'ready', bytes: result.snapshot.bytes, mediaType: result.snapshot.mediaType } : { kind: 'failed' });\n }, () => settle({ 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 <Suspense fallback={<ViewerFrame testId=\"viewer-pending\" title={nameOf(path)} status={status} onClose={onClose}><ViewLoading label={viewer.loading} /></ViewerFrame>}>\n {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 </Suspense>;\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 // The viewers' kinds: the pi-app default list in the item's locale.\n const kinds = useMemo(() => defaultKinds({ locale: language }), [language]);\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, kinds, blobBase, ...(viewers ? { viewers } : {}), ...(share ? { share } : {}) }), [provider, kinds, 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 // The file on show was renamed, moved or deleted from the list: follow it to its new path, or close it (its old path is gone).\n const renamed = (from: string, to: string) => { setPath(current => current === from ? to : current); setPinned(undefined); };\n const deleted = (gone: string) => { setPath(current => { if (current !== gone) return current; dirty.current = false; return undefined; }); setPinned(undefined); };\n const list = tree ? <div className=\"min-h-0\">\n <FileTreeView controller={tree} onOpen={choose} onRenamed={renamed} onDeleted={deleted} 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",
33
28
  "type": "registry:component",
34
29
  "target": "components/documents/documents.tsx"
35
30
  },
@@ -4,16 +4,10 @@
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.25",
7
+ "@hachej/boring-feedback@^0.4.27",
8
8
  "@earendil-works/pi-ai@~1.2.0",
9
9
  "react@^19.0.0"
10
10
  ],
11
- "registryDependencies": [
12
- "button",
13
- "card",
14
- "badge",
15
- "input"
16
- ],
17
11
  "files": [
18
12
  {
19
13
  "path": "registry/feedback-preview/preview-banner.tsx",