@hachej/boring-ui-kit 0.4.26 → 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.
- package/README.md +15 -2
- package/dist/chat-dictation.d.ts +27 -0
- package/dist/chat-dictation.js +71 -0
- package/dist/chat-react-session.d.ts +170 -0
- package/dist/chat-react-session.js +367 -0
- package/dist/chat-react.d.ts +2 -0
- package/dist/chat-react.js +1 -0
- package/dist/chat-text.d.ts +9 -0
- package/dist/chat-text.js +4 -0
- package/dist/chat-voice-message.d.ts +65 -0
- package/dist/chat-voice-message.js +235 -0
- package/dist/chat-voice.d.ts +7 -0
- package/dist/chat-voice.js +4 -0
- package/dist/experience-catalog.d.ts +2 -2
- package/dist/experience-region-tree.d.ts +1 -1
- package/dist/markdown-editor-labels.d.ts +83 -0
- package/dist/markdown-editor-labels.js +75 -0
- package/dist/markdown-editor.d.ts +7 -1
- package/dist/markdown-editor.js +28 -18
- package/dist/markdown-extensions.d.ts +4 -2
- package/dist/markdown-extensions.js +2 -2
- package/dist/markdown-preserved.d.ts +5 -1
- package/dist/markdown-preserved.js +15 -4
- package/dist/markdown-proposal-diff.d.ts +3 -1
- package/dist/markdown-proposal-diff.js +6 -5
- package/dist/text-draft-controls.d.ts +5 -1
- package/dist/text-draft-controls.js +6 -5
- package/dist/text-draft-labels.d.ts +26 -0
- package/dist/text-draft-labels.js +47 -0
- package/dist/voice-attempt.d.ts +33 -0
- package/dist/voice-attempt.js +122 -0
- package/package.json +6 -2
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +14 -14
- package/r/board-viewer.json +7 -6
- package/r/button.json +1 -1
- package/r/dock.json +2 -2
- package/r/documents.json +12 -12
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +7 -7
- package/r/file-tree.json +4 -4
- package/r/highlights.json +3 -3
- package/r/html-viewer.json +3 -3
- package/r/looks-viewer.json +3 -2
- package/r/maquette-viewer.json +1 -1
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +1 -1
- package/r/pi-ambient.json +9 -9
- package/r/pi-app-kinds.json +10 -10
- package/r/pi-app.json +13 -13
- package/r/pi-artifacts.json +2 -2
- package/r/pi-chat-approval.json +2 -2
- package/r/pi-chat-attachments.json +1 -1
- package/r/pi-chat-markdown.json +2 -2
- package/r/pi-chat-mentions.json +1 -1
- package/r/pi-chat-pickers.json +1 -1
- package/r/pi-chat-plugins.json +11 -11
- package/r/pi-chat-question.json +1 -1
- package/r/pi-chat-slash.json +2 -2
- package/r/pi-chat-subagent.json +1 -1
- package/r/pi-chat-voice.json +5 -5
- package/r/pi-chat.json +19 -19
- package/r/pi-workspace.json +4 -4
- package/r/placed-agent.json +4 -4
- package/r/provider-setup.json +1 -1
- package/r/registry.json +180 -168
- package/r/requests-board.json +1 -1
- package/r/shared-chat.json +9 -9
- package/r/task-list-viewer.json +2 -2
- package/r/utils.json +14 -2
- package/r/version-history.json +2 -2
- package/r/viewer-code.json +2 -2
- package/r/viewer-frame.json +7 -7
- package/r/viewer-html.json +4 -4
- package/r/viewer-image.json +2 -2
- package/r/viewer-markdown.json +5 -5
- package/r/viewer-pdf.json +3 -3
- package/r/viewers.json +6 -6
- package/r/windows.json +3 -3
package/r/board-viewer.json
CHANGED
|
@@ -4,16 +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.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
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.
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
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"
|
|
17
18
|
],
|
|
18
19
|
"files": [
|
|
19
20
|
{
|
|
@@ -30,7 +31,7 @@
|
|
|
30
31
|
},
|
|
31
32
|
{
|
|
32
33
|
"path": "registry/board-viewer/board-canvas.tsx",
|
|
33
|
-
"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 { 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);\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",
|
|
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",
|
|
34
35
|
"type": "registry:component",
|
|
35
36
|
"target": "components/board-viewer/board-canvas.tsx"
|
|
36
37
|
},
|
package/r/button.json
CHANGED
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.
|
|
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 // 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",
|
|
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,22 +4,22 @@
|
|
|
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.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
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.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
17
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
18
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
19
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
20
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
21
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
22
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
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"
|
|
23
23
|
],
|
|
24
24
|
"files": [
|
|
25
25
|
{
|
package/r/feedback-preview.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"title": "Feedback preview",
|
|
5
5
|
"description": "PreviewBanner: a live, reversible preview of a change in the page (Approve / Discard) over `@hachej/boring-feedback/preview`, whose subagent calls models through a host gateway. Separate from `feedback` so annotation-only apps do not install the model SDK; install `feedback` first (its styles cover the banner).",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-feedback@^0.4.
|
|
7
|
+
"@hachej/boring-feedback@^0.4.27",
|
|
8
8
|
"@earendil-works/pi-ai@~1.2.0",
|
|
9
9
|
"react@^19.0.0"
|
|
10
10
|
],
|
package/r/feedback.json
CHANGED
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
"title": "Feedback",
|
|
5
5
|
"description": "Feedback from the chat composer: one Feedback button starts feedback mode on the application page (hover, click to pin a numbered note, hold ⌥ to use the app, optional voice), and one chip carries the notes to the next message. FeedbackBar, NoteBubble, FeedbackChip with its review, useComposerFeedback for the pi-chat composer, and PickerOverlay styles. Optional, not mounted by default: PointButton, AnnotateSheet, FeedbackList and FeedbackReport. Picking, masking, anchoring and placement stay in @hachej/boring-feedback/page and /ui, so restyling cannot weaken them.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-feedback@^0.4.
|
|
8
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
9
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-feedback@^0.4.27",
|
|
8
|
+
"@hachej/boring-ui-kit@^0.4.27",
|
|
9
|
+
"@hachej/boring-files@^0.4.27",
|
|
10
10
|
"react@^19.0.0",
|
|
11
11
|
"react-dom@^19.0.0",
|
|
12
12
|
"lucide-react@^1.51.0"
|
|
13
13
|
],
|
|
14
14
|
"registryDependencies": [
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
17
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/pi-chat-voice.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/utils.json"
|
|
18
18
|
],
|
|
19
19
|
"files": [
|
|
20
20
|
{
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
},
|
|
68
68
|
{
|
|
69
69
|
"path": "registry/feedback/voice-input.tsx",
|
|
70
|
-
"content": "// A microphone button for the composer: records on the first click, stops on the second, transcribes the recording and appends the\n// text to the composer draft. It never sends: the person reads the transcript and presses Send. Pass it to `PiChat` as `tools`.\nimport {
|
|
70
|
+
"content": "// A microphone button for the composer: records on the first click, stops on the second, transcribes the recording and appends the\n// text to the composer draft. It never sends: the person reads the transcript and presses Send. Pass it to `PiChat` as `tools`.\nimport { useDictation } from '@hachej/boring-ui-kit/chat-react/voice';\nimport { Loader2Icon, MicIcon, SquareIcon, XIcon } from 'lucide-react';\nimport type { NativeChatController } from '@hachej/boring-ui-kit/native-chat';\nimport { appendToDraft } from '@hachej/boring-ui-kit/chat-text';\nimport { useChatText } from '../pi-chat/labels';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\n\nexport interface VoiceInputProps {\n /** The chat whose composer draft receives the transcript. */\n readonly controller: NativeChatController;\n /** Turns the recording into text. `signal` aborts when the person cancels or leaves the page; a rejection shows an error. */\n readonly transcribe: (audio: Blob, signal: AbortSignal) => Promise<string>;\n /** Capture limit (default five minutes); the person still finishes before text is appended. */\n readonly maxMs?: number | undefined;\n}\n\nexport function VoiceInput({ controller, transcribe, maxMs }: VoiceInputProps) {\n const { labels } = useChatText();\n const { phase, problem, start, finish, cancel } = useDictation({ transcribe, labels, maxMs, target: controller,\n onText: (spoken, signal) => {\n if (!signal.aborted) controller.setText(appendToDraft(controller.getSnapshot().draft.text, spoken));\n },\n });\n const onClick = () => { if (phase === 'idle') start(); else finish(); };\n const recording = phase === 'recording';\n const busy = phase === 'transcribing';\n return (\n <div className=\"flex items-center gap-1.5\">\n {(phase === 'starting' || recording || busy) && <Button variant=\"ghost\" size=\"icon\" data-testid=\"composer-voice-cancel\" aria-label={labels.voiceCancel} title={labels.voiceCancel} onClick={cancel}>\n <XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n <Toggle data-testid=\"composer-voice\" data-state={phase} pressed={recording} aria-label={phase === 'starting' ? labels.voiceCancel : recording ? labels.voiceStop : labels.voiceRecord}\n title={phase === 'starting' ? labels.voiceCancel : recording ? labels.voiceStop : labels.voiceRecord} disabled={busy} onPressedChange={onClick}>\n {busy ? <Loader2Icon data-icon=\"inline-start\" className=\"animate-spin motion-reduce:animate-none\" role=\"status\" aria-label={labels.voiceTranscribing} />\n : recording ? <SquareIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n : <MicIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}\n </Toggle>\n {problem && <span role=\"alert\" data-testid=\"composer-voice-problem\" className=\"max-w-56 text-xs leading-4 text-destructive\">{problem}</span>}\n </div>\n );\n}\n",
|
|
71
71
|
"type": "registry:component",
|
|
72
72
|
"target": "components/feedback/voice-input.tsx"
|
|
73
73
|
},
|
package/r/file-tree.json
CHANGED
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
"title": "File tree",
|
|
5
5
|
"description": "Browse, search, upload, rename, move, delete and inspect retained versions through one revisionProvider binding. Uses the consumer's shadcn Button, dropdown-menu and dialog with shared version-history rows; Base UI style only.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.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.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
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/version-history.json"
|
|
15
15
|
],
|
|
16
16
|
"files": [
|
|
17
17
|
{
|
package/r/highlights.json
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
"title": "Anchored highlights",
|
|
5
5
|
"description": "Exact Markdown highlights and comments in a conditional workspace sidecar.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.27",
|
|
8
|
+
"@hachej/boring-files@^0.4.27",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0"
|
|
11
11
|
],
|
|
12
12
|
"registryDependencies": [
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/markdown-editor.json"
|
|
14
14
|
],
|
|
15
15
|
"files": [
|
|
16
16
|
{
|
package/r/html-viewer.json
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
"title": "HTML source and passive preview",
|
|
5
5
|
"description": "Exact HTML source editing with a reconstructed passive preview and borrowed controller. Browser security qualification remains separate.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.27",
|
|
8
|
+
"@hachej/boring-files@^0.4.27",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0"
|
|
11
11
|
],
|
|
12
12
|
"registryDependencies": [
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/button.json"
|
|
14
14
|
],
|
|
15
15
|
"files": [
|
|
16
16
|
{
|
package/r/looks-viewer.json
CHANGED
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
"lucide-react@^1.51.0"
|
|
9
9
|
],
|
|
10
10
|
"registryDependencies": [
|
|
11
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/mockup-viewer.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/utils.json"
|
|
12
13
|
],
|
|
13
14
|
"files": [
|
|
14
15
|
{
|
|
15
16
|
"path": "registry/looks-viewer/looks-viewer.tsx",
|
|
16
|
-
"content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { CheckIcon } from 'lucide-react';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';\nimport { MockupViewer } from '../mockup-viewer/mockup-viewer';\nimport type { ShadcnTheme } from '../mockup-viewer/mockup-viewer';\nimport { besideLooks, parseLooks, swatches } from './looks';\nimport type { Look, Looks } from './looks';\n\nexport { isLooksPath, parseLooks, parseShadcnTheme, swatches, besideLooks, shadcnValue, SHADCN_COLOR_VARIABLES, SHADCN_SHAPE_VARIABLES } from './looks';\nexport type { Look, Looks, LayoutVariant } from './looks';\n\n\n/** The person pressed Pick: the host says it in the conversation (or records it); the viewer only marks it. */\nexport interface LookPick {\n readonly kind: 'look' | 'layout';\n readonly id: string;\n readonly label: string;\n /** The looks file the pick was made in. */\n readonly path: string;\n}\n\nexport interface LooksViewerProps {\n readonly text: string;\n readonly path?: string | undefined;\n /** Another file of the workspace as text (the artifact panel's `read`): the key screen and the layouts, next to the looks file. */\n readonly read?: ((path: string) => Promise<string | undefined>) | undefined;\n readonly onPick?: ((pick: LookPick) => void) | undefined;\n readonly locale?: 'en' | 'fr' | undefined;\n readonly onReady?: (() => void) | undefined;\n readonly onFailed?: ((reason: string) => void) | undefined;\n}\n\nconst LABELS = {\n en: { looks: 'Pick a look', layouts: 'Pick a layout', layoutsIn: (look: string) => `In the ${look} look`, pick: 'Pick', picked: 'Picked', light: 'Light', dark: 'Dark',\n loading: 'Loading the looks…', missing: 'The key screen cannot be read.', invalid: 'These looks cannot be shown', font: 'Font', radius: 'Roundness', look: (label: string) => `${label} look`, layout: (label: string) => `${label} layout` },\n fr: { looks: 'Choisir un style', layouts: 'Choisir une mise en page', layoutsIn: (look: string) => `Dans le style ${look}`, pick: 'Choisir', picked: 'Choisi', light: 'Clair', dark: 'Sombre',\n loading: 'Chargement des styles…', missing: \"L'écran clé est illisible.\", invalid: 'Ces styles ne peuvent pas être affichés', font: 'Police', radius: 'Arrondi', look: (label: string) => `Style ${label}`, layout: (label: string) => `Mise en page ${label}` },\n} as const;\n\n/** The key screen drawn at a desktop (or, in a narrow column, phone) width and scaled into the card, so a look reads as the whole app. */\nfunction ScaledMockup({ text, theme, mode, title, onReady }: { readonly text: string; readonly theme: ShadcnTheme; readonly mode?: 'light' | 'dark' | undefined; readonly title: string; readonly onReady: () => void }) {\n const outer = useRef<HTMLDivElement>(null);\n const [width, setWidth] = useState(0);\n useLayoutEffect(() => {\n const element = outer.current;\n if (!element) return undefined;\n const measure = () => setWidth(element.getBoundingClientRect().width);\n measure();\n if (typeof ResizeObserver === 'undefined') return undefined;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n // A phone draws the phone layout of the screen; a narrow column on a desktop still draws the desktop one, smaller.\n const phone = typeof matchMedia === 'function' && matchMedia('(max-width: 639px)').matches;\n const virtual = phone ? { w: 390, h: 640 } : { w: 1024, h: 600 };\n const scale = width > 0 ? width / virtual.w : 0.5;\n return <div ref={outer} data-testid=\"looks-frame\" className=\"relative w-full overflow-hidden bg-muted\" style={{ height: Math.round(virtual.h * scale) }}>\n <div className=\"absolute top-0 left-0 origin-top-left\" style={{ width: virtual.w, height: virtual.h, transform: `scale(${scale})` }}>\n <MockupViewer text={text} themeOverride={theme} mode={mode} title={title} onReady={onReady} />\n </div>\n </div>;\n}\n\n/** The swatch's corner, in a 15px square: a rem radius at 16px, capped. */\nconst corner = (radius: string) => Math.min(15, Math.max(0, parseFloat(radius) * (radius.endsWith('px') ? 1 : 16)));\n\nfunction Swatches({ look, dark, labels }: { readonly look: Look; readonly dark: boolean; readonly labels: (typeof LABELS)['en' | 'fr'] }) {\n const swatch = swatches(look, dark);\n return <div data-testid=\"looks-swatches\" className=\"flex flex-wrap items-center gap-3 text-xs text-muted-foreground\">\n <svg role=\"img\" aria-label={swatch.colors.map(color => color.name).join(', ')} width={swatch.colors.length * 20} height=\"18\" viewBox={`0 0 ${swatch.colors.length * 20} 18`}>\n {swatch.colors.map((color, index) => <circle key={color.name} cx={index * 20 + 9} cy=\"9\" r=\"8\" fill={color.value} stroke=\"currentColor\" strokeOpacity=\"0.3\"><title>{`${color.name}: ${color.value}`}</title></circle>)}\n </svg>\n <span title={labels.font} style={{ fontFamily: look.theme.theme?.['font-sans'] }}>{swatch.heading === swatch.font ? swatch.font : `${swatch.heading} / ${swatch.font}`}</span>\n <span className=\"flex items-center gap-1\" title={labels.radius}><svg aria-hidden=\"true\" width=\"18\" height=\"18\" viewBox=\"0 0 18 18\"><path d={`M2 17 V${2 + corner(swatch.radius)} Q2 2 ${2 + corner(swatch.radius)} 2 H17`} fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" /></svg>{swatch.radius}</span>\n </div>;\n}\n\nexport function LooksViewer({ text, path = '', read, onPick, locale = 'en', onReady, onFailed }: LooksViewerProps) {\n const labels = LABELS[locale];\n const parsed = useMemo(() => parseLooks(text), [text]);\n const spec: Looks | undefined = parsed.kind === 'ok' ? parsed.looks : undefined;\n const sources = useMemo(() => spec ? [...new Set([spec.mockup, ...spec.layouts.map(layout => layout.mockup)])] : [], [spec]);\n const [files, setFiles] = useState<{ readonly key: string; readonly texts: ReadonlyMap<string, string> } | 'missing'>();\n const key = JSON.stringify([path, sources]);\n useEffect(() => {\n if (!sources.length) return undefined;\n let live = true;\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a new read (sources, path or reader) shows loading until it settles; once per change\n setFiles(undefined);\n void Promise.all(sources.map(async relative => [relative, read ? await read(besideLooks(path, relative)).catch(() => undefined) : undefined] as const)).then(entries => {\n if (!live) return;\n if (entries.some(([, value]) => value === undefined)) { setFiles('missing'); onFailed?.('a mock-up named by the looks file cannot be read'); return; }\n setFiles({ key, texts: new Map(entries as [string, string][]) });\n });\n return () => { live = false; };\n }, [key, read]); // eslint-disable-line react-hooks/exhaustive-deps -- key covers path and sources by value; onFailed is a notification, not a reason to read again\n const [mode, setMode] = useState<'light' | 'dark' | undefined>();\n const [look, setLook] = useState<string | undefined>(spec?.look);\n const [layout, setLayout] = useState<string>();\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a new looks file resets the chosen look and layout, once per text\n useEffect(() => { setLook(spec?.look); setLayout(undefined); }, [text]); // eslint-disable-line react-hooks/exhaustive-deps -- spec derives from text\n const frames = spec ? (spec.looks.length > 1 ? spec.looks.length : 0) + spec.layouts.length : 0;\n const ready = useRef(new Set<string>());\n useEffect(() => { ready.current = new Set(); }, [text]);\n const frameReady = useCallback((id: string) => () => {\n ready.current.add(id);\n if (ready.current.size === frames) onReady?.();\n }, [frames, onReady]);\n useEffect(() => { if (parsed.kind === 'invalid') onFailed?.(`invalid looks file: ${parsed.issues.slice(0, 3).join('; ')}`); }, [parsed]); // eslint-disable-line react-hooks/exhaustive-deps -- reports once per parse; a new onFailed callback alone does not report again\n if (parsed.kind === 'invalid' || !spec) return <Alert role=\"alert\" data-testid=\"looks-invalid\"><AlertTitle>{labels.invalid}</AlertTitle>\n <AlertDescription>{parsed.kind === 'invalid' ? parsed.issues.slice(0, 6).join('; ') : ''}</AlertDescription></Alert>;\n if (files === 'missing') return <Alert role=\"alert\"><AlertTitle>{labels.missing}</AlertTitle></Alert>;\n if (!files || files.key !== key) return <p role=\"status\" className=\"p-3 text-muted-foreground\">{labels.loading}</p>;\n const keyScreen = files.texts.get(spec.mockup)!;\n const inLook = spec.looks.find(item => item.id === look) ?? spec.looks.find(item => item.id === spec.look) ?? spec.looks[0]!;\n const dark = mode === 'dark';\n const pick = (kind: LookPick['kind'], id: string, label: string) => { if (kind === 'look') setLook(id); else setLayout(id); onPick?.({ kind, id, label, path }); };\n return <div data-boring=\"looks-viewer\" className=\"flex flex-col gap-6 p-3 md:p-4\">\n <div className=\"flex items-center justify-between gap-3\">\n <h2 className=\"text-lg font-semibold\">{spec.looks.length > 1 ? labels.looks : labels.layouts}</h2>\n <ToggleGroup value={[mode ?? 'host']} onValueChange={(value: unknown[]) => { const next = value[0]; setMode(next === 'light' || next === 'dark' ? next : undefined); }} aria-label={`${labels.light} / ${labels.dark}`}>\n <ToggleGroupItem value=\"light\" data-testid=\"looks-light\">{labels.light}</ToggleGroupItem>\n <ToggleGroupItem value=\"dark\" data-testid=\"looks-dark\">{labels.dark}</ToggleGroupItem>\n </ToggleGroup>\n </div>\n {spec.looks.length > 1 && <section aria-label={labels.looks} data-testid=\"looks-grid\" className=\"grid gap-4 md:grid-cols-2\">\n {spec.looks.map(item => <Choice key={item.id} testId={`look-${item.id}`} title={item.label} line={item.personality} chosen={inLook.id === item.id && look !== undefined} labels={labels}\n onPick={() => pick('look', item.id, item.label)} extra={<Swatches look={item} dark={dark} labels={labels} />}>\n <ScaledMockup text={keyScreen} theme={item.theme} mode={mode} title={labels.look(item.label)} onReady={frameReady(`look:${item.id}`)} />\n </Choice>)}\n </section>}\n {spec.layouts.length > 0 && <section aria-label={labels.layouts} data-testid=\"layouts-grid\" className=\"flex flex-col gap-3\">\n {spec.looks.length > 1 && <h2 className=\"text-lg font-semibold\">{labels.layouts}</h2>}\n <p className=\"text-sm text-muted-foreground\">{labels.layoutsIn(inLook.label)}</p>\n <div className={spec.layouts.length > 2 ? 'grid gap-4 md:grid-cols-2 xl:grid-cols-3' : 'grid gap-4 md:grid-cols-2'}>\n {spec.layouts.map(item => <Choice key={item.id} testId={`layout-${item.id}`} title={item.label} line={item.why} chosen={layout === item.id} labels={labels} onPick={() => pick('layout', item.id, item.label)}>\n <ScaledMockup text={files.texts.get(item.mockup)!} theme={inLook.theme} mode={mode} title={labels.layout(item.label)} onReady={frameReady(`layout:${item.id}`)} />\n </Choice>)}\n </div>\n </section>}\n </div>;\n}\n\nfunction Choice({ testId, title, line, chosen, labels, onPick, extra, children }: { readonly testId: string; readonly title: string; readonly line?: string | undefined; readonly chosen: boolean; readonly labels: (typeof LABELS)['en' | 'fr']; readonly onPick: () => void; readonly extra?: ReactNode; readonly children: ReactNode }) {\n return <Card data-testid={testId} data-chosen={chosen ? '' : undefined}>\n <CardContent>{children}</CardContent>\n <CardHeader>\n <CardTitle>{title}</CardTitle>\n {line && <CardDescription>{line}</CardDescription>}\n <CardAction>\n <Button size=\"sm\" variant={chosen ? 'default' : 'outline'} aria-pressed={chosen} data-testid={`${testId}-pick`} onClick={onPick}>\n {chosen && <CheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}{chosen ? labels.picked : labels.pick}\n </Button>\n </CardAction>\n {extra}\n </CardHeader>\n </Card>;\n}\n",
|
|
17
|
+
"content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { CheckIcon } from 'lucide-react';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';\nimport { MockupViewer } from '../mockup-viewer/mockup-viewer';\nimport { usePhone } from '../utils/use-media';\nimport type { ShadcnTheme } from '../mockup-viewer/mockup-viewer';\nimport { besideLooks, parseLooks, swatches } from './looks';\nimport type { Look, Looks } from './looks';\n\nexport { isLooksPath, parseLooks, parseShadcnTheme, swatches, besideLooks, shadcnValue, SHADCN_COLOR_VARIABLES, SHADCN_SHAPE_VARIABLES } from './looks';\nexport type { Look, Looks, LayoutVariant } from './looks';\n\n\n/** The person pressed Pick: the host says it in the conversation (or records it); the viewer only marks it. */\nexport interface LookPick {\n readonly kind: 'look' | 'layout';\n readonly id: string;\n readonly label: string;\n /** The looks file the pick was made in. */\n readonly path: string;\n}\n\nexport interface LooksViewerProps {\n readonly text: string;\n readonly path?: string | undefined;\n /** Another file of the workspace as text (the artifact panel's `read`): the key screen and the layouts, next to the looks file. */\n readonly read?: ((path: string) => Promise<string | undefined>) | undefined;\n readonly onPick?: ((pick: LookPick) => void) | undefined;\n readonly locale?: 'en' | 'fr' | undefined;\n readonly onReady?: (() => void) | undefined;\n readonly onFailed?: ((reason: string) => void) | undefined;\n}\n\nconst LABELS = {\n en: { looks: 'Pick a look', layouts: 'Pick a layout', layoutsIn: (look: string) => `In the ${look} look`, pick: 'Pick', picked: 'Picked', light: 'Light', dark: 'Dark',\n loading: 'Loading the looks…', missing: 'The key screen cannot be read.', invalid: 'These looks cannot be shown', font: 'Font', radius: 'Roundness', look: (label: string) => `${label} look`, layout: (label: string) => `${label} layout` },\n fr: { looks: 'Choisir un style', layouts: 'Choisir une mise en page', layoutsIn: (look: string) => `Dans le style ${look}`, pick: 'Choisir', picked: 'Choisi', light: 'Clair', dark: 'Sombre',\n loading: 'Chargement des styles…', missing: \"L'écran clé est illisible.\", invalid: 'Ces styles ne peuvent pas être affichés', font: 'Police', radius: 'Arrondi', look: (label: string) => `Style ${label}`, layout: (label: string) => `Mise en page ${label}` },\n} as const;\n\n/** The key screen drawn at a desktop (or, in a narrow column, phone) width and scaled into the card, so a look reads as the whole app. */\nfunction ScaledMockup({ text, theme, mode, title, onReady }: { readonly text: string; readonly theme: ShadcnTheme; readonly mode?: 'light' | 'dark' | undefined; readonly title: string; readonly onReady: () => void }) {\n const outer = useRef<HTMLDivElement>(null);\n const [width, setWidth] = useState(0);\n useLayoutEffect(() => {\n const element = outer.current;\n if (!element) return undefined;\n const measure = () => setWidth(element.getBoundingClientRect().width);\n measure();\n if (typeof ResizeObserver === 'undefined') return undefined;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n // A phone draws the phone layout of the screen; a narrow column on a desktop still draws the desktop one, smaller.\n const phone = usePhone();\n const virtual = phone ? { w: 390, h: 640 } : { w: 1024, h: 600 };\n const scale = width > 0 ? width / virtual.w : 0.5;\n return <div ref={outer} data-testid=\"looks-frame\" className=\"relative w-full overflow-hidden bg-muted\" style={{ height: Math.round(virtual.h * scale) }}>\n <div className=\"absolute top-0 left-0 origin-top-left\" style={{ width: virtual.w, height: virtual.h, transform: `scale(${scale})` }}>\n <MockupViewer text={text} themeOverride={theme} mode={mode} title={title} onReady={onReady} />\n </div>\n </div>;\n}\n\n/** The swatch's corner, in a 15px square: a rem radius at 16px, capped. */\nconst corner = (radius: string) => Math.min(15, Math.max(0, parseFloat(radius) * (radius.endsWith('px') ? 1 : 16)));\n\nfunction Swatches({ look, dark, labels }: { readonly look: Look; readonly dark: boolean; readonly labels: (typeof LABELS)['en' | 'fr'] }) {\n const swatch = swatches(look, dark);\n return <div data-testid=\"looks-swatches\" className=\"flex flex-wrap items-center gap-3 text-xs text-muted-foreground\">\n <svg role=\"img\" aria-label={swatch.colors.map(color => color.name).join(', ')} width={swatch.colors.length * 20} height=\"18\" viewBox={`0 0 ${swatch.colors.length * 20} 18`}>\n {swatch.colors.map((color, index) => <circle key={color.name} cx={index * 20 + 9} cy=\"9\" r=\"8\" fill={color.value} stroke=\"currentColor\" strokeOpacity=\"0.3\"><title>{`${color.name}: ${color.value}`}</title></circle>)}\n </svg>\n <span title={labels.font} style={{ fontFamily: look.theme.theme?.['font-sans'] }}>{swatch.heading === swatch.font ? swatch.font : `${swatch.heading} / ${swatch.font}`}</span>\n <span className=\"flex items-center gap-1\" title={labels.radius}><svg aria-hidden=\"true\" width=\"18\" height=\"18\" viewBox=\"0 0 18 18\"><path d={`M2 17 V${2 + corner(swatch.radius)} Q2 2 ${2 + corner(swatch.radius)} 2 H17`} fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" /></svg>{swatch.radius}</span>\n </div>;\n}\n\nexport function LooksViewer({ text, path = '', read, onPick, locale = 'en', onReady, onFailed }: LooksViewerProps) {\n const labels = LABELS[locale];\n const parsed = useMemo(() => parseLooks(text), [text]);\n const spec: Looks | undefined = parsed.kind === 'ok' ? parsed.looks : undefined;\n const sources = useMemo(() => spec ? [...new Set([spec.mockup, ...spec.layouts.map(layout => layout.mockup)])] : [], [spec]);\n const [files, setFiles] = useState<{ readonly key: string; readonly texts: ReadonlyMap<string, string> } | 'missing'>();\n const key = JSON.stringify([path, sources]);\n useEffect(() => {\n if (!sources.length) return undefined;\n let live = true;\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a new read (sources, path or reader) shows loading until it settles; once per change\n setFiles(undefined);\n void Promise.all(sources.map(async relative => [relative, read ? await read(besideLooks(path, relative)).catch(() => undefined) : undefined] as const)).then(entries => {\n if (!live) return;\n if (entries.some(([, value]) => value === undefined)) { setFiles('missing'); onFailed?.('a mock-up named by the looks file cannot be read'); return; }\n setFiles({ key, texts: new Map(entries as [string, string][]) });\n });\n return () => { live = false; };\n }, [key, read]); // eslint-disable-line react-hooks/exhaustive-deps -- key covers path and sources by value; onFailed is a notification, not a reason to read again\n const [mode, setMode] = useState<'light' | 'dark' | undefined>();\n const [look, setLook] = useState<string | undefined>(spec?.look);\n const [layout, setLayout] = useState<string>();\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a new looks file resets the chosen look and layout, once per text\n useEffect(() => { setLook(spec?.look); setLayout(undefined); }, [text]); // eslint-disable-line react-hooks/exhaustive-deps -- spec derives from text\n const frames = spec ? (spec.looks.length > 1 ? spec.looks.length : 0) + spec.layouts.length : 0;\n const ready = useRef(new Set<string>());\n useEffect(() => { ready.current = new Set(); }, [text]);\n const frameReady = useCallback((id: string) => () => {\n ready.current.add(id);\n if (ready.current.size === frames) onReady?.();\n }, [frames, onReady]);\n useEffect(() => { if (parsed.kind === 'invalid') onFailed?.(`invalid looks file: ${parsed.issues.slice(0, 3).join('; ')}`); }, [parsed]); // eslint-disable-line react-hooks/exhaustive-deps -- reports once per parse; a new onFailed callback alone does not report again\n if (parsed.kind === 'invalid' || !spec) return <Alert role=\"alert\" data-testid=\"looks-invalid\"><AlertTitle>{labels.invalid}</AlertTitle>\n <AlertDescription>{parsed.kind === 'invalid' ? parsed.issues.slice(0, 6).join('; ') : ''}</AlertDescription></Alert>;\n if (files === 'missing') return <Alert role=\"alert\"><AlertTitle>{labels.missing}</AlertTitle></Alert>;\n if (!files || files.key !== key) return <p role=\"status\" className=\"p-3 text-muted-foreground\">{labels.loading}</p>;\n const keyScreen = files.texts.get(spec.mockup)!;\n const inLook = spec.looks.find(item => item.id === look) ?? spec.looks.find(item => item.id === spec.look) ?? spec.looks[0]!;\n const dark = mode === 'dark';\n const pick = (kind: LookPick['kind'], id: string, label: string) => { if (kind === 'look') setLook(id); else setLayout(id); onPick?.({ kind, id, label, path }); };\n return <div data-boring=\"looks-viewer\" className=\"flex flex-col gap-6 p-3 md:p-4\">\n <div className=\"flex items-center justify-between gap-3\">\n <h2 className=\"text-lg font-semibold\">{spec.looks.length > 1 ? labels.looks : labels.layouts}</h2>\n <ToggleGroup value={[mode ?? 'host']} onValueChange={(value: unknown[]) => { const next = value[0]; setMode(next === 'light' || next === 'dark' ? next : undefined); }} aria-label={`${labels.light} / ${labels.dark}`}>\n <ToggleGroupItem value=\"light\" data-testid=\"looks-light\">{labels.light}</ToggleGroupItem>\n <ToggleGroupItem value=\"dark\" data-testid=\"looks-dark\">{labels.dark}</ToggleGroupItem>\n </ToggleGroup>\n </div>\n {spec.looks.length > 1 && <section aria-label={labels.looks} data-testid=\"looks-grid\" className=\"grid gap-4 md:grid-cols-2\">\n {spec.looks.map(item => <Choice key={item.id} testId={`look-${item.id}`} title={item.label} line={item.personality} chosen={inLook.id === item.id && look !== undefined} labels={labels}\n onPick={() => pick('look', item.id, item.label)} extra={<Swatches look={item} dark={dark} labels={labels} />}>\n <ScaledMockup text={keyScreen} theme={item.theme} mode={mode} title={labels.look(item.label)} onReady={frameReady(`look:${item.id}`)} />\n </Choice>)}\n </section>}\n {spec.layouts.length > 0 && <section aria-label={labels.layouts} data-testid=\"layouts-grid\" className=\"flex flex-col gap-3\">\n {spec.looks.length > 1 && <h2 className=\"text-lg font-semibold\">{labels.layouts}</h2>}\n <p className=\"text-sm text-muted-foreground\">{labels.layoutsIn(inLook.label)}</p>\n <div className={spec.layouts.length > 2 ? 'grid gap-4 md:grid-cols-2 xl:grid-cols-3' : 'grid gap-4 md:grid-cols-2'}>\n {spec.layouts.map(item => <Choice key={item.id} testId={`layout-${item.id}`} title={item.label} line={item.why} chosen={layout === item.id} labels={labels} onPick={() => pick('layout', item.id, item.label)}>\n <ScaledMockup text={files.texts.get(item.mockup)!} theme={inLook.theme} mode={mode} title={labels.layout(item.label)} onReady={frameReady(`layout:${item.id}`)} />\n </Choice>)}\n </div>\n </section>}\n </div>;\n}\n\nfunction Choice({ testId, title, line, chosen, labels, onPick, extra, children }: { readonly testId: string; readonly title: string; readonly line?: string | undefined; readonly chosen: boolean; readonly labels: (typeof LABELS)['en' | 'fr']; readonly onPick: () => void; readonly extra?: ReactNode; readonly children: ReactNode }) {\n return <Card data-testid={testId} data-chosen={chosen ? '' : undefined}>\n <CardContent>{children}</CardContent>\n <CardHeader>\n <CardTitle>{title}</CardTitle>\n {line && <CardDescription>{line}</CardDescription>}\n <CardAction>\n <Button size=\"sm\" variant={chosen ? 'default' : 'outline'} aria-pressed={chosen} data-testid={`${testId}-pick`} onClick={onPick}>\n {chosen && <CheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}{chosen ? labels.picked : labels.pick}\n </Button>\n </CardAction>\n {extra}\n </CardHeader>\n </Card>;\n}\n",
|
|
17
18
|
"type": "registry:component",
|
|
18
19
|
"target": "components/looks-viewer/looks-viewer.tsx"
|
|
19
20
|
},
|
package/r/maquette-viewer.json
CHANGED
package/r/markdown-editor.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"title": "Markdown editor",
|
|
5
5
|
"description": "A host-owned styled wrapper over the concrete borrowed Markdown controller.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.27",
|
|
8
|
+
"@hachej/boring-files@^0.4.27",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"marked@^17.0.6",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"lucide-react@^1.51.0"
|
|
25
25
|
],
|
|
26
26
|
"registryDependencies": [
|
|
27
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
27
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.27/r/button.json"
|
|
28
28
|
],
|
|
29
29
|
"files": [
|
|
30
30
|
{
|
package/r/mockup-viewer.json
CHANGED