@hachej/boring-ui-kit 0.4.57 → 0.4.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/boring-ui-blocks.mjs +49 -4
- package/dist/chat-react-conversations.d.ts +7 -1
- package/dist/chat-react-conversations.js +58 -18
- package/package.json +6 -6
- package/r/_shared.json +19 -0
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +17 -17
- package/r/board-viewer.json +10 -10
- package/r/button.json +4 -4
- package/r/data-table.json +21 -0
- package/r/diagram-viewer.json +6 -4
- package/r/dock.json +1 -1
- package/r/documents.json +13 -14
- package/r/drawing-viewer.json +6 -4
- package/r/empty-state.json +22 -0
- package/r/feedback.json +14 -14
- package/r/file-tree.json +4 -4
- package/r/highlights.json +3 -3
- package/r/html-viewer.json +3 -3
- package/r/kpi-stat.json +22 -0
- package/r/list-row.json +21 -0
- package/r/looks-viewer.json +6 -6
- package/r/maquette-viewer.json +7 -6
- package/r/markdown-editor.json +10 -9
- package/r/mockup-viewer.json +5 -5
- package/r/page-header.json +22 -0
- package/r/pi-ambient.json +12 -12
- package/r/pi-app-kinds.json +10 -10
- package/r/pi-app.json +17 -17
- package/r/pi-artifacts.json +4 -4
- package/r/pi-chat-approval.json +4 -4
- package/r/pi-chat-attachments.json +4 -4
- package/r/pi-chat-markdown.json +3 -3
- package/r/pi-chat-mentions.json +4 -4
- package/r/pi-chat-pickers.json +4 -4
- package/r/pi-chat-plugins.json +11 -11
- package/r/pi-chat-question.json +3 -3
- package/r/pi-chat-slash.json +5 -5
- package/r/pi-chat-subagent.json +4 -4
- package/r/pi-chat-voice.json +7 -7
- package/r/pi-chat.json +20 -20
- package/r/pi-workspace.json +3 -3
- package/r/placed-agent.json +7 -7
- package/r/provider-setup.json +4 -4
- package/r/record-viewer.json +5 -3
- package/r/registry.json +494 -280
- package/r/shared-chat.json +11 -11
- package/r/spaces.json +7 -5
- package/r/task-list-viewer.json +28 -3
- package/r/theme.json +115 -50
- package/r/version-history.json +2 -2
- package/r/viewer-code.json +5 -5
- package/r/viewer-frame.json +9 -9
- package/r/viewer-html.json +7 -7
- package/r/viewer-image.json +5 -5
- package/r/viewer-markdown.json +7 -7
- package/r/viewer-pdf.json +6 -6
- package/r/viewers.json +6 -6
- package/r/windows.json +4 -4
package/r/board-viewer.json
CHANGED
|
@@ -4,17 +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.59",
|
|
8
|
+
"@hachej/boring-files@^0.4.59",
|
|
9
9
|
"react@^19.0.0",
|
|
10
|
-
"
|
|
11
|
-
"tldraw@5.5.2"
|
|
10
|
+
"tldraw@5.6.0"
|
|
12
11
|
],
|
|
13
12
|
"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.
|
|
17
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/maquette-viewer.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/mockup-viewer.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-artifacts.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/utils.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
|
|
18
18
|
],
|
|
19
19
|
"files": [
|
|
20
20
|
{
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
"path": "registry/board-viewer/board-canvas.tsx",
|
|
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
|
+
"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 '../_shared/icons';\nimport { Button } from '@/components/ui/button';\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 {/* Generated app screens need their own canvas/ink pair over the native canvas. Authored HTML keeps its styling. */}\n <div className={`flex min-h-0 flex-1 flex-col overflow-hidden${kind === 'maquette' ? ' bg-background text-foreground' : ''}`} data-testid=\"board-frame\" data-frame={id}>\n <div 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 </div>\n </div>\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",
|
|
35
35
|
"type": "registry:component",
|
|
36
36
|
"target": "components/board-viewer/board-canvas.tsx"
|
|
37
37
|
},
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
{
|
|
45
45
|
"path": "registry/board-viewer/board-kind.tsx",
|
|
46
|
-
"content": "'use client';\n\nimport { lazy } from 'react';\nimport { LayoutDashboardIcon } from '
|
|
46
|
+
"content": "'use client';\n\nimport { lazy } from 'react';\nimport { LayoutDashboardIcon } from '../_shared/icons';\nimport type { ArtifactKind } from '../pi-artifacts/artifact';\nimport type { BoardHostProps } from './board-props';\n\nconst BoardViewer = lazy(() => import('./board-viewer').then(module => ({ default: module.BoardViewer })));\n\n/** An opt-in artifact kind. Its client and authenticated receipt identity belong to the host's selected workspace. The viewer loads on first open. */\nexport function createBoardKind(host: BoardHostProps): ArtifactKind {\n return { match: target => /\\.board\\.json$/i.test(target.resource.path), label: 'Board', icon: LayoutDashboardIcon,\n renderer: ({ artifact, text, revision, pinned }) => <BoardViewer {...host} target={artifact.target} text={text} revision={revision} pinned={pinned} /> };\n}\n",
|
|
47
47
|
"type": "registry:component",
|
|
48
48
|
"target": "components/board-viewer/board-kind.tsx"
|
|
49
49
|
}
|
package/r/button.json
CHANGED
|
@@ -5,16 +5,16 @@
|
|
|
5
5
|
"description": "Host action metadata composed with the consumer’s shadcn Button, Toggle and DropdownMenu.",
|
|
6
6
|
"dependencies": [
|
|
7
7
|
"react@^19.0.0",
|
|
8
|
-
"react-dom@^19.0.0"
|
|
9
|
-
"lucide-react@^1.51.0"
|
|
8
|
+
"react-dom@^19.0.0"
|
|
10
9
|
],
|
|
11
10
|
"registryDependencies": [
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/utils.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
|
|
13
13
|
],
|
|
14
14
|
"files": [
|
|
15
15
|
{
|
|
16
16
|
"path": "registry/button/actions.tsx",
|
|
17
|
-
"content": "'use client';\n\nimport { Fragment, useCallback, useRef } from 'react';\nimport type { ComponentProps, ComponentType, ReactNode, RefObject } from 'react';\nimport { EllipsisIcon } from '
|
|
17
|
+
"content": "'use client';\n\nimport { Fragment, useCallback, useRef } from 'react';\nimport type { ComponentProps, ComponentType, ReactNode, RefObject } from 'react';\nimport { EllipsisIcon } from '../_shared/icons';\nimport { DropdownMenu, DropdownMenuTrigger, DropdownMenuPortal, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut } from '@/components/ui/dropdown-menu';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { cn } from '../utils/utils';\n\nexport type BlockIcon = ComponentType<{ readonly className?: string | undefined; readonly 'aria-hidden'?: boolean | 'true' | 'false' | undefined }>;\n\n/** Host actions in a header, reply footer or menu. The host retains all execution policy. */\nexport interface BlockAction {\n readonly id: string;\n readonly label: string;\n readonly icon?: BlockIcon | undefined;\n readonly onSelect: () => void;\n readonly placement?: 'header' | 'menu' | undefined;\n readonly disabled?: boolean | undefined;\n readonly pressed?: boolean | undefined;\n readonly destructive?: boolean | undefined;\n}\n\nexport interface MenuItem {\n readonly id: string;\n readonly label: string;\n readonly description?: string;\n readonly icon?: ReactNode;\n readonly hint?: string;\n readonly shortcut?: string;\n readonly checked?: boolean;\n readonly disabled?: boolean;\n readonly destructive?: boolean;\n readonly separatorBefore?: boolean;\n readonly group?: string;\n readonly testId?: string;\n readonly onSelect: () => void;\n}\n\ntype ActionIconSize = Extract<ComponentProps<typeof Button>['size'], 'icon' | 'icon-sm'>;\n\nexport function ActionMenuItemContent({ item, testId }: { readonly item: MenuItem; readonly testId: string }) {\n return <>\n {item.icon && <span className=\"text-muted-foreground\">{item.icon}</span>}\n <span className=\"min-w-0 flex-1\"><span className=\"block truncate\">{item.label}</span>{item.description && <span data-testid={`${testId}-description`} className=\"block text-xs text-muted-foreground\">{item.description}</span>}</span>\n {item.hint && <span data-testid={`${testId}-hint`} className=\"shrink-0 text-xs text-muted-foreground\">{item.hint}</span>}\n {item.shortcut && <DropdownMenuShortcut>{item.shortcut}</DropdownMenuShortcut>}\n </>;\n}\n\n/**\n * Portal target and trigger ref for a menu that must open in its trigger's own document (a viewer in a pop-out window).\n * Put `ref` on the trigger button and `container` on `ActionMenuContent`.\n */\nexport function useMenuPortal() {\n const container = useRef<HTMLElement | null>(null);\n const ref = useCallback((node: HTMLElement | null) => { container.current = node?.ownerDocument.body ?? null; }, []);\n return { container, ref };\n}\n\n/** Maps host menu items to the consumer's shadcn dropdown items inside a `DropdownMenu`; the primitives own focus, positioning and dismissal. */\nexport function ActionMenuContent({ label, testId, items, container, align = 'end', children }: { readonly label: string; readonly testId: string; readonly items: readonly MenuItem[]; readonly container?: RefObject<HTMLElement | null>; readonly align?: 'start' | 'end'; readonly children?: ReactNode }) {\n const groups: MenuItem[][] = [];\n for (const item of items) {\n const last = groups[groups.length - 1];\n if (!last || item.separatorBefore || last[0]?.group !== item.group) groups.push([item]);\n else last.push(item);\n }\n const rows = groups.map((group, index) => <Fragment key={group[0]!.id}>\n {index > 0 && <DropdownMenuSeparator />}\n <DropdownMenuGroup>\n {group[0]?.group && <DropdownMenuLabel>{group[0].group}</DropdownMenuLabel>}\n {group.map(item => {\n const common = { disabled: item.disabled, 'data-testid': item.testId ?? `${testId}-${item.id}`, 'data-item': item.id, onClick: item.onSelect, className: 'min-h-8 max-sm:min-h-10 pointer-coarse:min-h-10' };\n const content = <ActionMenuItemContent item={item} testId={testId} />;\n return item.checked !== undefined\n ? <DropdownMenuRadioItem key={item.id} value={item.id} {...common}>{content}</DropdownMenuRadioItem>\n : <DropdownMenuItem key={item.id} variant={item.destructive ? 'destructive' : 'default'} {...common}>{content}</DropdownMenuItem>;\n })}\n </DropdownMenuGroup>\n </Fragment>);\n return <DropdownMenuPortal {...(container ? { container } : {})}>\n <DropdownMenuContent align={align} aria-label={label} data-testid={`${testId}-list`} className=\"w-64 max-w-[calc(100vw-1rem)]\">\n {children}\n {items.some(item => item.checked !== undefined) ? <DropdownMenuRadioGroup value={items.find(item => item.checked)?.id ?? ''}>{rows}</DropdownMenuRadioGroup> : rows}\n </DropdownMenuContent>\n </DropdownMenuPortal>;\n}\n\nexport function actionMenuItem(action: BlockAction, testId: string): MenuItem {\n const Icon = action.icon;\n return { id: action.id, label: action.label, ...(Icon ? { icon: <Icon data-icon=\"inline-start\" aria-hidden=\"true\" /> } : {}), ...(action.disabled ? { disabled: true } : {}), ...(action.destructive ? { destructive: true } : {}), testId: `${testId}-${action.id}`, onSelect: action.onSelect };\n}\n\nexport const headerActions = (actions: readonly BlockAction[] | undefined): readonly BlockAction[] => (actions ?? []).filter(action => action.placement !== 'menu');\nexport const menuActions = (actions: readonly BlockAction[] | undefined): readonly BlockAction[] => (actions ?? []).filter(action => action.placement === 'menu');\n\nexport function ActionButton({ action, testId, size = 'icon-sm', className, iconOnly = false }: { readonly action: BlockAction; readonly testId: string; readonly size?: ActionIconSize; readonly className?: string; readonly iconOnly?: boolean }) {\n const Icon = action.icon ?? (iconOnly ? EllipsisIcon : undefined);\n const content = Icon ? <Icon data-icon=\"inline-start\" aria-hidden=\"true\" /> : <span className=\"truncate\">{action.label}</span>;\n const layout = cn(!Icon && 'max-w-40 min-w-0', className);\n if (action.pressed !== undefined) return <Toggle size={size === 'icon' ? 'default' : 'sm'} aria-label={action.label} title={action.label} data-testid={`${testId}-${action.id}`} data-action={action.id} disabled={action.disabled} pressed={action.pressed} onPressedChange={() => action.onSelect()} className={layout}>{content}</Toggle>;\n return <Button size={Icon ? size : 'sm'} variant=\"ghost\" aria-label={action.label} title={action.label} data-testid={`${testId}-${action.id}`} data-action={action.id} disabled={action.disabled} onClick={action.onSelect} className={layout}>{content}</Button>;\n}\n\nexport function BlockActions({ actions, testId, menuLabel, size = 'icon-sm', className, iconOnly = false }: {\n readonly actions: readonly BlockAction[] | undefined;\n readonly testId: string;\n readonly menuLabel: string;\n readonly size?: ActionIconSize;\n readonly className?: string;\n readonly iconOnly?: boolean;\n}) {\n const { container, ref: portalRef } = useMenuPortal();\n const buttons = headerActions(actions), menu = menuActions(actions);\n if (!buttons.length && !menu.length) return null;\n const moreId = `${testId}-more`;\n return <div data-boring=\"block-actions\" data-actions={testId} className={cn('flex shrink-0 items-center gap-0.5', className)}>\n {buttons.map(action => <ActionButton key={action.id} action={action} testId={testId} size={size} iconOnly={iconOnly} />)}\n {menu.length > 0 && <DropdownMenu>\n <DropdownMenuTrigger render={<Button ref={portalRef} variant=\"ghost\" aria-label={menuLabel} title={menuLabel} size={size} data-testid={moreId}><EllipsisIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>} />\n <ActionMenuContent label={menuLabel} testId={moreId} container={container} items={menu.map(action => actionMenuItem(action, testId))} />\n </DropdownMenu>}\n </div>;\n}\n",
|
|
18
18
|
"type": "registry:ui",
|
|
19
19
|
"target": "components/button/actions.tsx"
|
|
20
20
|
},
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "data-table",
|
|
4
|
+
"title": "Data table",
|
|
5
|
+
"description": "A fixed-layout table that never scrolls sideways. Columns carry a priority; cells truncate; numbers are mono and right-aligned; below 520px of container width the rows stack as label/value pairs.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"react@^19.0.0"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [
|
|
10
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/utils.json"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "registry/data-table/data-table.tsx",
|
|
15
|
+
"content": "import type { ReactNode } from 'react';\nimport { cn } from '../utils/utils';\n\nexport type DataColumn<Row> = {\n id: string;\n header: string;\n cell: (row: Row) => ReactNode;\n /** Numbers: right-aligned, monospace, tabular. */\n numeric?: boolean;\n /**\n * 1 is the row's title in the stacked view and always shown in the table. 2 shows from 520px of container width. 3 shows only from\n * 720px. The container decides, never the viewport.\n */\n priority: 1 | 2 | 3;\n /** A fixed column width (a length or a percentage) for the fixed table layout. */\n width?: string;\n};\n\nexport type DataTableProps<Row> = {\n columns: DataColumn<Row>[];\n rows: Row[];\n rowKey: (row: Row) => string;\n /** Read by screen readers; visually hidden. */\n caption: string;\n className?: string;\n};\n\n// Container queries, not media queries: a table in a narrow side panel stacks even on a wide screen. Class names stay literal so the\n// Tailwind scanner sees them.\nconst visibility = (priority: 1 | 2 | 3) => (priority === 3 ? 'hidden @min-[720px]:table-cell' : '');\n\n/**\n * A fixed-layout table that never scrolls sideways. Cells truncate. Below 520px of container width each row becomes a title plus\n * label/value pairs (the priority-1 column is the title). Both forms render; the container query picks one.\n */\nexport function DataTable<Row>({ columns, rows, rowKey, caption, className }: DataTableProps<Row>) {\n const title = columns.find(column => column.priority === 1) ?? columns[0];\n const details = columns.filter(column => column !== title);\n return (\n <div className={cn('@container min-w-0 overflow-hidden', className)}>\n <table className=\"hidden w-full table-fixed border-collapse text-xs @min-[520px]:table\">\n <caption className=\"sr-only\">{caption}</caption>\n <thead>\n <tr className=\"text-left text-muted-foreground\">\n {columns.map(column => (\n <th\n key={column.id}\n scope=\"col\"\n style={{ width: column.width }}\n className={cn('truncate px-2 py-1.5 font-normal', visibility(column.priority), column.numeric && 'text-right')}\n >\n {column.header}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {rows.map(row => (\n <tr key={rowKey(row)} className=\"hover:bg-accent\">\n {columns.map(column => (\n <td\n key={column.id}\n className={cn('truncate px-2 py-2', visibility(column.priority), column.numeric && 'text-right font-mono tabular-nums')}\n >\n {column.cell(row)}\n </td>\n ))}\n </tr>\n ))}\n </tbody>\n </table>\n <ul className=\"flex flex-col @min-[520px]:hidden\">\n {rows.map(row => (\n <li key={rowKey(row)} className=\"flex min-w-0 flex-col gap-1 rounded-(--radius) px-2 py-2 hover:bg-accent\">\n <span className=\"truncate text-sm font-medium\">{title?.cell(row)}</span>\n <dl className=\"flex flex-col gap-1\">\n {details.map(column => (\n <div key={column.id} className=\"flex min-w-0 items-baseline justify-between gap-3 text-xs\">\n <dt className=\"shrink-0 text-muted-foreground\">{column.header}</dt>\n <dd className={cn('min-w-0 truncate text-right', column.numeric && 'font-mono tabular-nums')}>{column.cell(row)}</dd>\n </div>\n ))}\n </dl>\n </li>\n ))}\n </ul>\n </div>\n );\n}\n",
|
|
16
|
+
"type": "registry:component",
|
|
17
|
+
"target": "components/data-table/data-table.tsx"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"type": "registry:component"
|
|
21
|
+
}
|
package/r/diagram-viewer.json
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
"title": "Diagram viewer",
|
|
5
5
|
"description": "DiagramViewer, DiagramView and diagramKind: an agent's *.diagram.json (format v1, versioned: nodes, edges, optional groups, highlighted path and note) as boxes and numbered arrows with a legend that says what happens and who acts. Layers run left to right while they fit and top to bottom on a phone, scrolling inside their own box; no layout library. A superset of a maquette's workflow, which the maquette viewer draws with DiagramView. Its kind is in the pi-app-kinds default list, which an app passes to the pi-app artifact panel; an app kind placed before it wins.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"react@^19.0.0"
|
|
8
|
-
|
|
7
|
+
"react@^19.0.0"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [
|
|
10
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
|
|
9
11
|
],
|
|
10
12
|
"files": [
|
|
11
13
|
{
|
|
@@ -28,13 +30,13 @@
|
|
|
28
30
|
},
|
|
29
31
|
{
|
|
30
32
|
"path": "registry/diagram-viewer/diagram-viewer.tsx",
|
|
31
|
-
"content": "'use client';\n\n// The diagram viewer: an agent's `*.diagram.json` (format v1) drawn by `DiagramView`, with its highlighted path and note. `diagramKind` (diagram-kind.ts) is\n// the kind of the pi-app-kinds default list for `.diagram.json` files; an app kind placed before it in `artifactKinds` wins. The maquette\n// viewer draws its workflow (\"How it works\") with the same `DiagramView`. It draws data only: nothing in the file runs.\nimport { useMemo } from 'react';\nimport { InfoIcon, TriangleAlertIcon } from '
|
|
33
|
+
"content": "'use client';\n\n// The diagram viewer: an agent's `*.diagram.json` (format v1) drawn by `DiagramView`, with its highlighted path and note. `diagramKind` (diagram-kind.ts) is\n// the kind of the pi-app-kinds default list for `.diagram.json` files; an app kind placed before it in `artifactKinds` wins. The maquette\n// viewer draws its workflow (\"How it works\") with the same `DiagramView`. It draws data only: nothing in the file runs.\nimport { useMemo } from 'react';\nimport { InfoIcon, TriangleAlertIcon } from '../_shared/icons';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport { DIAGRAM_FORMAT_VERSION, parseDiagram } from './diagram';\nimport { diagramLabelsFor } from './diagram-labels';\nimport type { DiagramLabels, DiagramLocale } from './diagram-labels';\nimport { DiagramView } from './diagram-view';\n\nexport { DIAGRAM_FORMAT_VERSION, isDiagramPath, parseDiagram } from './diagram';\nexport type { Diagram, DiagramEdge, DiagramFileV1, DiagramGroup, DiagramNode, DiagramParse } from './diagram';\nexport { DiagramView } from './diagram-view';\nexport type { DiagramViewLabels } from './diagram-view';\nexport { defaultDiagramLabels, diagramLabelsFor, frenchDiagramLabels } from './diagram-labels';\nexport type { DiagramLabels, DiagramLocale } from './diagram-labels';\n\nexport interface DiagramViewerProps {\n /** The file's text (JSON). */\n readonly text: string;\n /** The base words: English by default. */\n readonly locale?: DiagramLocale | undefined;\n /** Any word over the locale's. */\n readonly labels?: Partial<DiagramLabels> | undefined;\n}\n\n/** A diagram file; a newer format version shows a notice, an unreadable file an inline error. */\nexport function DiagramViewer({ text, locale, labels: given }: DiagramViewerProps) {\n const labels = useMemo(() => diagramLabelsFor(locale, given), [locale, given]);\n const parsed = useMemo(() => parseDiagram(text), [text]);\n const newer = parsed.version !== undefined && parsed.version > DIAGRAM_FORMAT_VERSION;\n return <section data-boring=\"diagram-viewer\" data-state={parsed.kind} aria-label={parsed.kind === 'ok' ? parsed.diagram.title || labels.kind : labels.kind}\n className=\"mx-auto flex w-full max-w-5xl min-w-0 flex-col gap-3 px-3 pt-2 pb-4 sm:px-6\">\n {newer && <Alert data-testid=\"diagram-newer\"><InfoIcon aria-hidden=\"true\" /><AlertTitle>{labels.newerTitle}</AlertTitle><AlertDescription>{labels.newer(parsed.version!)}</AlertDescription></Alert>}\n {parsed.kind === 'ok' ? <DiagramView diagram={parsed.diagram} labels={labels} />\n : <Alert variant=\"destructive\" data-testid=\"diagram-error\" data-reason={parsed.reason}><TriangleAlertIcon aria-hidden=\"true\" /><AlertTitle>{labels.errorTitle}</AlertTitle>\n <AlertDescription>{parsed.reason === 'json' ? labels.invalidJson : labels.invalidShape}</AlertDescription></Alert>}\n </section>;\n}\n",
|
|
32
34
|
"type": "registry:component",
|
|
33
35
|
"target": "components/diagram-viewer/diagram-viewer.tsx"
|
|
34
36
|
},
|
|
35
37
|
{
|
|
36
38
|
"path": "registry/diagram-viewer/diagram-kind.tsx",
|
|
37
|
-
"content": "'use client';\n\nimport { lazy } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { WorkflowIcon } from '
|
|
39
|
+
"content": "'use client';\n\nimport { lazy } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { WorkflowIcon } from '../_shared/icons';\nimport { isDiagramPath } from './diagram';\nimport { diagramLabelsFor } from './diagram-labels';\nimport type { DiagramLabels, DiagramLocale } from './diagram-labels';\n\nconst DiagramViewer = lazy(() => import('./diagram-viewer').then(module => ({ default: module.DiagramViewer })));\n\n/**\n * The artifact kind of a diagram file (structurally an `ArtifactKind` of pi-artifacts): it matches `*.diagram.json`, names it, gives its icon\n * and draws it with `DiagramViewer`, loaded on first open. It is in the pi-app-kinds default list (`defaultKinds`); kinds in `artifactKinds` are consulted in order.\n */\nexport interface DiagramKind {\n readonly match: (target: { readonly resource: { readonly path: string } }) => boolean;\n readonly label: string;\n readonly icon: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }>;\n readonly renderer: (props: { readonly text: string }) => ReactNode;\n}\n\n/** A diagram kind in `locale` (\"Diagram\" in English, \"Diagramme\" in French) with any word overridden. */\nexport function createDiagramKind({ locale, labels }: { readonly locale?: DiagramLocale | undefined; readonly labels?: Partial<DiagramLabels> | undefined } = {}): DiagramKind {\n return {\n match: target => isDiagramPath(target.resource.path),\n label: diagramLabelsFor(locale, labels).kind,\n icon: WorkflowIcon,\n renderer: ({ text }) => <DiagramViewer text={text} locale={locale} labels={labels} />,\n };\n}\n\n/** The built-in diagram kind, in English. */\nexport const diagramKind: DiagramKind = createDiagramKind();\n",
|
|
38
40
|
"type": "registry:component",
|
|
39
41
|
"target": "components/diagram-viewer/diagram-kind.tsx"
|
|
40
42
|
}
|
package/r/dock.json
CHANGED
package/r/documents.json
CHANGED
|
@@ -4,22 +4,21 @@
|
|
|
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.
|
|
9
|
-
"react@^19.0.0"
|
|
10
|
-
"lucide-react@^1.51.0"
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.59",
|
|
8
|
+
"@hachej/boring-files@^0.4.59",
|
|
9
|
+
"react@^19.0.0"
|
|
11
10
|
],
|
|
12
11
|
"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.
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/utils.json",
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/button.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-artifacts.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-app.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-app-kinds.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/app-shell.json",
|
|
18
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/file-tree.json",
|
|
19
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/viewer-frame.json",
|
|
20
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/viewer-image.json",
|
|
21
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/viewer-pdf.json"
|
|
23
22
|
],
|
|
24
23
|
"files": [
|
|
25
24
|
{
|
package/r/drawing-viewer.json
CHANGED
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
"title": "Drawing viewer",
|
|
5
5
|
"description": "DrawingViewer, drawingKind and describeDrawing: an Excalidraw (.excalidraw) or draw.io (.drawio) file, read-only, drawn by the tool's own renderer on a white page fitted to the shapes: Excalidraw's exportToSvg (its fonts served by the app, never its CDN) and the official diagrams.net viewer, vendored in the kit (@hachej/boring-ui-kit/drawio-viewer), in a sandboxed frame without network. Shown as an image, so nothing in the file runs; an unreadable file shows an inline error. onRead hands the shapes and arrows the renderer read (kind, place, size, label, colours, from and to). Each renderer loads the first time a drawing of its format opens; the kind is not in the pi-app-kinds default list, so apps without drawings install nothing.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.59",
|
|
8
8
|
"react@^19.0.0",
|
|
9
|
-
"lucide-react@^1.51.0",
|
|
10
9
|
"@excalidraw/excalidraw@0.18.1"
|
|
11
10
|
],
|
|
11
|
+
"registryDependencies": [
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
|
|
13
|
+
],
|
|
12
14
|
"files": [
|
|
13
15
|
{
|
|
14
16
|
"path": "registry/drawing-viewer/drawing.ts",
|
|
@@ -42,13 +44,13 @@
|
|
|
42
44
|
},
|
|
43
45
|
{
|
|
44
46
|
"path": "registry/drawing-viewer/drawing-viewer.tsx",
|
|
45
|
-
"content": "'use client';\n\n// The drawing viewer (D-1398): an Excalidraw or draw.io file, read-only, drawn by the tool's own renderer on a white page fitted to the\n// shapes. Excalidraw draws with its library (`exportToSvg`); draw.io with the official diagrams.net viewer in a sandboxed frame without\n// network. Each renderer loads the first time a drawing of its format opens. The result is shown as an image from a blob URL, so nothing\n// in the file runs on this page, and what the renderer read goes to `onRead` (shapes and arrows, see drawing.ts) for describing it to a model.\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport { TriangleAlertIcon } from '
|
|
47
|
+
"content": "'use client';\n\n// The drawing viewer (D-1398): an Excalidraw or draw.io file, read-only, drawn by the tool's own renderer on a white page fitted to the\n// shapes. Excalidraw draws with its library (`exportToSvg`); draw.io with the official diagrams.net viewer in a sandboxed frame without\n// network. Each renderer loads the first time a drawing of its format opens. The result is shown as an image from a blob URL, so nothing\n// in the file runs on this page, and what the renderer read goes to `onRead` (shapes and arrows, see drawing.ts) for describing it to a model.\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport { TriangleAlertIcon } from '../_shared/icons';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport { Spinner } from '@/components/ui/spinner';\nimport { DrawingError, drawingFormat } from './drawing';\nimport type { Drawing, DrawingFailure, DrawingFormat, RenderedDrawing } from './drawing';\nimport { drawingLabelsFor } from './drawing-labels';\nimport type { DrawingLabels, DrawingLocale } from './drawing-labels';\n\nexport { boundsOf, describeDrawing, DrawingError, drawingFormat, isDrawingPath } from './drawing';\nexport type { Drawing, DrawingArrow, DrawingFailure, DrawingFormat, DrawingShape, RenderedDrawing } from './drawing';\nexport { defaultDrawingLabels, drawingLabelsFor, frenchDrawingLabels } from './drawing-labels';\nexport type { DrawingLabels, DrawingLocale } from './drawing-labels';\n\n/** Where the app serves Excalidraw's fonts by default: the package's `dist/prod` folder (its `fonts/` inside) at this path. */\nexport const DEFAULT_EXCALIDRAW_ASSETS = '/excalidraw/';\n\nexport interface DrawingViewerProps {\n /** The file's text. */\n readonly text: string;\n /** The file's path or name: `.excalidraw` or `.drawio` picks the format; without it the text decides. */\n readonly path?: string | undefined;\n /** The format, when the app knows it better than the path. */\n readonly format?: DrawingFormat | undefined;\n /** The drawing's name, for the reader. */\n readonly title?: string | undefined;\n /** Called with what the renderer read once the drawing is drawn, and what it was refused from the network (those parts are not drawn). */\n readonly onRead?: ((drawing: Drawing, details: { readonly blocked: readonly string[] }) => void) | undefined;\n /**\n * The URL of the folder where the app serves `@excalidraw/excalidraw/dist/prod/` (Excalidraw looks for `fonts/` in it), unless the page\n * set `window.EXCALIDRAW_ASSET_PATH` itself. `false`, or a folder that serves no font, draws text without Excalidraw's fonts; the\n * viewer never lets Excalidraw fetch them from its CDN.\n */\n readonly excalidrawAssets?: string | false | undefined;\n /** The base words: English by default. */\n readonly locale?: DrawingLocale | undefined;\n /** Any word over the locale's. */\n readonly labels?: Partial<DrawingLabels> | undefined;\n}\n\ntype State =\n | { readonly kind: 'loading' }\n | { readonly kind: 'drawn'; readonly rendered: RenderedDrawing; readonly url: string }\n | { readonly kind: 'error'; readonly reason: DrawingFailure };\n\nasync function render(format: DrawingFormat, text: string, assets: string | false, signal: AbortSignal): Promise<RenderedDrawing> {\n if (format === 'excalidraw') return (await import('./excalidraw-render')).renderExcalidraw(text, { assets });\n return (await import('./drawio-render')).renderDrawio(text, { signal });\n}\n\n/** One drawing; an unreadable or empty file shows an inline error. */\nexport function DrawingViewer({ text, path, format: given, title, onRead, excalidrawAssets = DEFAULT_EXCALIDRAW_ASSETS, locale, labels: words }: DrawingViewerProps) {\n const labels = useMemo(() => drawingLabelsFor(locale, words), [locale, words]);\n const format = given ?? drawingFormat(path, text);\n // The outcome of the drawing it was rendered for: another text, format or font folder shows loading until its own outcome lands.\n const [outcome, setOutcome] = useState<{ readonly text: string; readonly format: DrawingFormat; readonly assets: string | false; readonly state: State }>();\n const state: State = !format ? { kind: 'error', reason: 'unreadable' }\n : outcome && outcome.text === text && outcome.format === format && outcome.assets === excalidrawAssets ? outcome.state : { kind: 'loading' };\n const read = useRef(onRead);\n useLayoutEffect(() => { read.current = onRead; });\n // A drawn outcome's image lives until another outcome replaces it or the viewer unmounts, so going back to an earlier text never shows a\n // revoked image; that text is drawn again anyway.\n // Every image made is tracked from its creation, so one whose outcome never commits (onRead hid the viewer first) is still released, once.\n const made = useRef(new Set<string>());\n useEffect(() => {\n const url = outcome?.state.kind === 'drawn' ? outcome.state.url : undefined;\n const live = made.current;\n return () => { if (url && live.delete(url)) URL.revokeObjectURL(url); };\n }, [outcome]);\n useEffect(() => { const live = made.current; return () => { for (const url of live) URL.revokeObjectURL(url); live.clear(); }; }, []);\n useEffect(() => {\n if (!format) return;\n const abort = new AbortController();\n render(format, text, excalidrawAssets, abort.signal).then(rendered => {\n if (abort.signal.aborted) return;\n const url = URL.createObjectURL(new Blob([rendered.svg], { type: 'image/svg+xml' }));\n made.current.add(url);\n setOutcome({ text, format, assets: excalidrawAssets, state: { kind: 'drawn', rendered, url } });\n read.current?.(rendered.drawing, { blocked: rendered.blocked });\n }, (error: unknown) => {\n if (abort.signal.aborted) return;\n setOutcome({ text, format, assets: excalidrawAssets, state: { kind: 'error', reason: error instanceof DrawingError ? error.reason : 'renderer' } });\n });\n return () => abort.abort();\n }, [format, text, excalidrawAssets]);\n return <section data-boring=\"drawing-viewer\" data-state={state.kind} data-format={format} data-blocked={state.kind === 'drawn' ? state.rendered.blocked.length : undefined} aria-label={title ?? labels.kind} aria-busy={state.kind === 'loading' || undefined}\n className=\"flex w-full min-w-0 flex-col gap-3 px-3 pt-2 pb-4 sm:px-6\">\n {state.kind === 'loading' && <p role=\"status\" className=\"m-0 flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground\"><Spinner />{labels.loading}</p>}\n {state.kind === 'error' && <Alert variant=\"destructive\" data-testid=\"drawing-error\" data-reason={state.reason}><TriangleAlertIcon aria-hidden=\"true\" />\n <AlertTitle>{labels.errorTitle}</AlertTitle><AlertDescription>{labels.error(state.reason)}</AlertDescription></Alert>}\n {state.kind === 'drawn' && <figure className=\"m-0 min-w-0 overflow-auto bg-muted p-2\">\n <img data-testid=\"drawing-image\" src={state.url} width={Math.round(state.rendered.width) || undefined} height={Math.round(state.rendered.height) || undefined}\n alt={labels.image(state.rendered.drawing.format, state.rendered.drawing.shapes.length, state.rendered.drawing.arrows.length)}\n className=\"mx-auto block h-auto max-w-full\" draggable={false} />\n </figure>}\n </section>;\n}\n",
|
|
46
48
|
"type": "registry:component",
|
|
47
49
|
"target": "components/drawing-viewer/drawing-viewer.tsx"
|
|
48
50
|
},
|
|
49
51
|
{
|
|
50
52
|
"path": "registry/drawing-viewer/drawing-kind.tsx",
|
|
51
|
-
"content": "'use client';\n\nimport { lazy } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { PenToolIcon } from '
|
|
53
|
+
"content": "'use client';\n\nimport { lazy } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { PenToolIcon } from '../_shared/icons';\nimport { isDrawingPath } from './drawing';\nimport { drawingLabelsFor } from './drawing-labels';\nimport type { DrawingLabels, DrawingLocale } from './drawing-labels';\n\nconst DrawingViewer = lazy(() => import('./drawing-viewer').then(module => ({ default: module.DrawingViewer })));\n\n/**\n * The artifact kind of a drawing file (structurally an `ArtifactKind` of pi-artifacts): it matches `.excalidraw` and `.drawio` files, names\n * them and draws them with `DrawingViewer`, loaded on first open. It is not in the pi-app-kinds default list: an app that shows drawings\n * adds it to its `artifactKinds` (and installs this item), so apps without drawings never install Excalidraw.\n */\nexport interface DrawingKind {\n readonly match: (target: { readonly resource: { readonly path: string } }) => boolean;\n readonly label: string;\n readonly icon: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }>;\n /** Takes the artifact panel's view props (`ArtifactKindViewProps` of pi-artifacts): the file's name comes from `artifact.target`. */\n readonly renderer: (props: { readonly text: string; readonly artifact?: { readonly title?: string | undefined; readonly target: { readonly resource: { readonly path: string } } } | undefined }) => ReactNode;\n}\n\n/** A drawing kind in `locale` (\"Drawing\", \"Dessin\") with any word overridden, and where the app serves Excalidraw's fonts. */\nexport function createDrawingKind({ locale, labels, excalidrawAssets }: { readonly locale?: DrawingLocale | undefined; readonly labels?: Partial<DrawingLabels> | undefined; readonly excalidrawAssets?: string | false | undefined } = {}): DrawingKind {\n return {\n match: target => isDrawingPath(target.resource.path),\n label: drawingLabelsFor(locale, labels).kind,\n icon: PenToolIcon,\n renderer: ({ text, artifact }) => <DrawingViewer text={text} path={artifact?.target.resource.path} title={artifact?.title} locale={locale} labels={labels} excalidrawAssets={excalidrawAssets} />,\n };\n}\n\n/** The drawing kind, in English, with Excalidraw's fonts at the default path. */\nexport const drawingKind: DrawingKind = createDrawingKind();\n",
|
|
52
54
|
"type": "registry:component",
|
|
53
55
|
"target": "components/drawing-viewer/drawing-kind.tsx"
|
|
54
56
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "empty-state",
|
|
4
|
+
"title": "Empty state",
|
|
5
|
+
"description": "An icon, a title, an optional hint and an optional action, as a plain centred column on the surface.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"react@^19.0.0"
|
|
8
|
+
],
|
|
9
|
+
"registryDependencies": [
|
|
10
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/utils.json",
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "registry/empty-state/blank-state.tsx",
|
|
16
|
+
"content": "import type { ReactNode } from 'react';\nimport { cn } from '../utils/utils';\nimport { Inbox } from '../_shared/icons';\n\nexport type EmptyStateProps = {\n title: string;\n hint?: string;\n /** Defaults to the inbox icon from the kit's icon module. */\n icon?: ReactNode;\n /** One action, usually a Button from the app's ui set. */\n action?: ReactNode;\n className?: string;\n};\n\n/** Plain column, no dashed border: the shadcn Empty adds one, which the flat surface rule forbids and blocks may not restyle. */\nexport function EmptyState({ title, hint, icon, action, className }: EmptyStateProps) {\n return (\n <div className={cn('flex w-full min-w-0 flex-col items-center justify-center gap-2 p-6 text-center', className)}>\n <span className=\"flex size-6 items-center justify-center text-muted-foreground\">{icon ?? <Inbox aria-hidden className=\"size-6\" />}</span>\n <span className=\"text-sm font-medium\">{title}</span>\n {hint ? <span className=\"max-w-prose text-xs text-muted-foreground\">{hint}</span> : null}\n {action ? <div className=\"pt-1\">{action}</div> : null}\n </div>\n );\n}\n",
|
|
17
|
+
"type": "registry:component",
|
|
18
|
+
"target": "components/empty-state/blank-state.tsx"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"type": "registry:component"
|
|
22
|
+
}
|