@hachej/boring-ui-kit 0.4.26 → 0.4.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +15 -2
  2. package/bin/boring-ui-blocks.mjs +1 -1
  3. package/dist/chat-dictation.d.ts +27 -0
  4. package/dist/chat-dictation.js +71 -0
  5. package/dist/chat-react-session.d.ts +170 -0
  6. package/dist/chat-react-session.js +367 -0
  7. package/dist/chat-react.d.ts +2 -0
  8. package/dist/chat-react.js +1 -0
  9. package/dist/chat-text.d.ts +9 -0
  10. package/dist/chat-text.js +4 -0
  11. package/dist/chat-voice-message.d.ts +65 -0
  12. package/dist/chat-voice-message.js +235 -0
  13. package/dist/chat-voice.d.ts +7 -0
  14. package/dist/chat-voice.js +4 -0
  15. package/dist/experience-catalog.d.ts +2 -2
  16. package/dist/experience-region-tree.d.ts +1 -1
  17. package/dist/markdown-editor-labels.d.ts +83 -0
  18. package/dist/markdown-editor-labels.js +75 -0
  19. package/dist/markdown-editor.d.ts +7 -1
  20. package/dist/markdown-editor.js +28 -18
  21. package/dist/markdown-extensions.d.ts +4 -2
  22. package/dist/markdown-extensions.js +2 -2
  23. package/dist/markdown-preserved.d.ts +5 -1
  24. package/dist/markdown-preserved.js +15 -4
  25. package/dist/markdown-proposal-diff.d.ts +3 -1
  26. package/dist/markdown-proposal-diff.js +6 -5
  27. package/dist/text-draft-controls.d.ts +5 -1
  28. package/dist/text-draft-controls.js +6 -5
  29. package/dist/text-draft-labels.d.ts +26 -0
  30. package/dist/text-draft-labels.js +47 -0
  31. package/dist/voice-attempt.d.ts +33 -0
  32. package/dist/voice-attempt.js +122 -0
  33. package/package.json +9 -5
  34. package/r/agent-target.json +5 -5
  35. package/r/app-shell.json +14 -14
  36. package/r/board-viewer.json +7 -6
  37. package/r/button.json +1 -1
  38. package/r/dock.json +2 -2
  39. package/r/documents.json +12 -12
  40. package/r/feedback-preview.json +2 -2
  41. package/r/feedback.json +7 -7
  42. package/r/file-tree.json +4 -4
  43. package/r/highlights.json +3 -3
  44. package/r/html-viewer.json +3 -3
  45. package/r/looks-viewer.json +3 -2
  46. package/r/maquette-viewer.json +1 -1
  47. package/r/markdown-editor.json +3 -3
  48. package/r/mockup-viewer.json +1 -1
  49. package/r/pi-ambient.json +11 -11
  50. package/r/pi-app-kinds.json +10 -10
  51. package/r/pi-app.json +13 -13
  52. package/r/pi-artifacts.json +4 -4
  53. package/r/pi-chat-approval.json +3 -3
  54. package/r/pi-chat-attachments.json +1 -1
  55. package/r/pi-chat-markdown.json +2 -2
  56. package/r/pi-chat-mentions.json +1 -1
  57. package/r/pi-chat-pickers.json +1 -1
  58. package/r/pi-chat-plugins.json +11 -11
  59. package/r/pi-chat-question.json +2 -2
  60. package/r/pi-chat-slash.json +2 -2
  61. package/r/pi-chat-subagent.json +2 -2
  62. package/r/pi-chat-voice.json +5 -5
  63. package/r/pi-chat.json +22 -22
  64. package/r/pi-workspace.json +4 -4
  65. package/r/placed-agent.json +4 -4
  66. package/r/provider-setup.json +1 -1
  67. package/r/registry.json +193 -181
  68. package/r/requests-board.json +1 -1
  69. package/r/shared-chat.json +9 -9
  70. package/r/task-list-viewer.json +2 -2
  71. package/r/utils.json +14 -2
  72. package/r/version-history.json +2 -2
  73. package/r/viewer-code.json +2 -2
  74. package/r/viewer-frame.json +7 -7
  75. package/r/viewer-html.json +4 -4
  76. package/r/viewer-image.json +2 -2
  77. package/r/viewer-markdown.json +5 -5
  78. package/r/viewer-pdf.json +4 -4
  79. package/r/viewers.json +6 -6
  80. package/r/windows.json +3 -3
@@ -4,14 +4,14 @@
4
4
  "title": "HTML viewer",
5
5
  "description": "The HTML kind: `htmlKind` (match, loader) and `HtmlPane`, the passive HTML preview (sandboxed when the host opts in) and source editor, in the viewer frame. The pane loads the first time an HTML file opens.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.26",
7
+ "@hachej/boring-ui-kit@^0.4.28",
8
8
  "react@^19.0.0",
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/viewer-frame.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/utils.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/button.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/viewer-frame.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/utils.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/button.json"
15
15
  ],
16
16
  "files": [
17
17
  {
@@ -8,8 +8,8 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/viewer-frame.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/utils.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/viewer-frame.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/utils.json"
13
13
  ],
14
14
  "files": [
15
15
  {
@@ -4,7 +4,7 @@
4
4
  "title": "Markdown viewer",
5
5
  "description": "The Markdown kind: `markdownKind` (match, loader) and `MarkdownPane`, the tiptap editor with a formatting toolbar and prose styling over the kit's revisioned Markdown controller, in the viewer frame. The pane and tiptap load the first time a Markdown file opens.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.26",
7
+ "@hachej/boring-ui-kit@^0.4.28",
8
8
  "react@^19.0.0",
9
9
  "lucide-react@^1.51.0",
10
10
  "marked@^17.0.6",
@@ -22,10 +22,10 @@
22
22
  "@tiptap/extension-floating-menu@^3.31.4"
23
23
  ],
24
24
  "registryDependencies": [
25
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/viewer-frame.json",
26
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/markdown-editor.json",
27
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/utils.json",
28
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/button.json"
25
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/viewer-frame.json",
26
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/markdown-editor.json",
27
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/utils.json",
28
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/button.json"
29
29
  ],
30
30
  "files": [
31
31
  {
package/r/viewer-pdf.json CHANGED
@@ -4,14 +4,14 @@
4
4
  "title": "PDF viewer",
5
5
  "description": "The PDF kind: `pdfKind` (match, loader) and `PdfPane` in the viewer frame (the browser's viewer, or pdf.js pages where there is none), and LocalFileViewer for browser-only files. The pane loads the first time a PDF opens.",
6
6
  "dependencies": [
7
- "@hachej/boring-files@^0.4.26",
7
+ "@hachej/boring-files@^0.4.28",
8
8
  "react@^19.0.0",
9
9
  "lucide-react@^1.51.0",
10
- "pdfjs-dist@6.3.289"
10
+ "pdfjs-dist@6.4.299"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/viewer-frame.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/viewer-image.json"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/viewer-frame.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/viewer-image.json"
15
15
  ],
16
16
  "files": [
17
17
  {
package/r/viewers.json CHANGED
@@ -4,12 +4,12 @@
4
4
  "title": "Viewers",
5
5
  "description": "Deprecated aggregate: installs every viewer item (viewer-frame, viewer-markdown, viewer-html, viewer-image, viewer-pdf, viewer-code). Add the items you use instead.",
6
6
  "registryDependencies": [
7
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/viewer-frame.json",
8
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/viewer-markdown.json",
9
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/viewer-html.json",
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/viewer-image.json",
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/viewer-pdf.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/viewer-code.json"
7
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/viewer-frame.json",
8
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/viewer-markdown.json",
9
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/viewer-html.json",
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/viewer-image.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/viewer-pdf.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/viewer-code.json"
13
13
  ],
14
14
  "files": [],
15
15
  "type": "registry:component"
package/r/windows.json CHANGED
@@ -2,19 +2,19 @@
2
2
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
3
  "name": "windows",
4
4
  "title": "Floating windows and launcher",
5
- "description": "Host utility windows with shared pointer-capture drag, resize, focus stacking, minimize/close, remembered rectangles and a keyboard launcher; full-screen Drawer below 641px. No agent or content persistence.",
5
+ "description": "Host utility windows with shared pointer-capture drag, resize, focus stacking, minimize/close, remembered rectangles and a keyboard launcher; full-screen Drawer below 768px. No agent or content persistence.",
6
6
  "dependencies": [
7
7
  "react@^19.0.0",
8
8
  "react-dom@^19.0.0",
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.26/r/utils.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.28/r/utils.json"
13
13
  ],
14
14
  "files": [
15
15
  {
16
16
  "path": "registry/windows/windows.tsx",
17
- "content": "'use client';\n\n// Host concern: Pi has no window layout. These surfaces borrow their content and remember presentation only.\nimport { createPortal } from 'react-dom';\nimport { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState, useId } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode, RefObject } from 'react';\nimport { GripIcon, MinusIcon, PanelsTopLeftIcon, XIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardAction, CardContent, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Badge } from '@/components/ui/badge';\nimport { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { useDraggable } from '../utils/use-draggable';\nexport { useDraggable } from '../utils/use-draggable';\nexport type { DragPosition } from '../utils/use-draggable';\n\nexport interface WindowRect { readonly x: number; readonly y: number; readonly width: number; readonly height: number }\nexport type WindowStatus = 'closed' | 'open' | 'minimized';\nexport interface WindowApi {\n readonly id: string;\n readonly status: WindowStatus;\n readonly rect: WindowRect;\n readonly open: () => void;\n readonly close: () => void;\n readonly minimize: () => void;\n}\ninterface WindowRecord { readonly rect: WindowRect; readonly status: WindowStatus }\ninterface WindowState { readonly records: Readonly<Record<string, WindowRecord>>; readonly order: readonly string[]; readonly focus: string | null; readonly request: number }\ninterface WindowsContext {\n readonly state: WindowState;\n readonly phone: boolean;\n readonly host: HTMLDivElement | null;\n readonly launcher: RefObject<HTMLButtonElement | null>;\n readonly register: (id: string, rect: WindowRect, open: boolean) => () => void;\n readonly change: (id: string, status: WindowStatus) => void;\n readonly raise: (id: string) => void;\n readonly resize: (id: string, rect: WindowRect) => void;\n}\nconst Windows = createContext<WindowsContext | null>(null);\nconst DEFAULT_RECT: WindowRect = { x: 48, y: 64, width: 400, height: 320 };\nconst MIN_WIDTH = 240, MIN_HEIGHT = 160;\nconst windowRecord = (records: WindowState['records'], id: string): WindowRecord | undefined => Object.hasOwn(records, id) ? records[id] : undefined;\nconst useWindows = () => { const value = useContext(Windows); if (!value) throw new Error('Windows need a WindowLayer'); return value; };\nconst storage = (persist: 'session' | 'local') => { try { return persist === 'local' ? window.localStorage : window.sessionStorage; } catch { return undefined; } };\nconst clamp = (rect: WindowRect): WindowRect => {\n const width = Math.min(Math.max(rect.width, MIN_WIDTH), window.innerWidth), height = Math.min(Math.max(rect.height, MIN_HEIGHT), window.innerHeight);\n return { width, height, x: Math.min(Math.max(rect.x, 0), Math.max(0, window.innerWidth - width)), y: Math.min(Math.max(rect.y, 0), Math.max(0, window.innerHeight - height)) };\n};\ntype Edges = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';\n/** The rectangle after dragging `edges` of `start` by (dx, dy): the opposite edges stay put, the minimum and the viewport bound the rest. */\nconst edgeRect = (start: WindowRect, edges: Edges, dx: number, dy: number): WindowRect => {\n let { x, y, width, height } = start;\n if (edges.includes('e')) width = Math.min(Math.max(start.width + dx, MIN_WIDTH), Math.max(MIN_WIDTH, window.innerWidth - start.x));\n if (edges.includes('s')) height = Math.min(Math.max(start.height + dy, MIN_HEIGHT), Math.max(MIN_HEIGHT, window.innerHeight - start.y));\n if (edges.includes('w')) { const right = start.x + start.width; x = Math.min(Math.max(start.x + dx, 0), right - MIN_WIDTH); width = right - x; }\n if (edges.includes('n')) { const bottom = start.y + start.height; y = Math.min(Math.max(start.y + dy, 0), bottom - MIN_HEIGHT); height = bottom - y; }\n return { x, y, width, height };\n};\nconst HANDLES: readonly { readonly edges: Edges; readonly className: string }[] = [\n { edges: 'n', className: 'inset-x-2 top-0 h-1.5 cursor-n-resize' }, { edges: 's', className: 'inset-x-2 bottom-0 h-1.5 cursor-s-resize' },\n { edges: 'w', className: 'inset-y-2 left-0 w-1.5 cursor-w-resize' }, { edges: 'e', className: 'inset-y-2 right-0 w-1.5 cursor-e-resize' },\n { edges: 'nw', className: 'left-0 top-0 size-2 cursor-nw-resize' }, { edges: 'ne', className: 'right-0 top-0 size-2 cursor-ne-resize' },\n { edges: 'sw', className: 'bottom-0 left-0 size-2 cursor-sw-resize' },\n];\nconst validRect = (rect: unknown): rect is WindowRect => !!rect && typeof rect === 'object' && ['x', 'y', 'width', 'height'].every(key => typeof (rect as Record<string, unknown>)[key] === 'number' && Number.isFinite((rect as Record<string, number>)[key])) && (rect as WindowRect).width > 0 && (rect as WindowRect).height > 0;\n\n/** One viewport window stack. Like DockLayout, persistence is tab-local unless `persist=\"local\"` is selected. */\nexport function WindowLayer({ children, storageKey = 'boring.windows', persist = 'session' }: { readonly children: ReactNode; readonly storageKey?: string; readonly persist?: 'session' | 'local' }) {\n const [state, setState] = useState<WindowState>({ records: {}, order: [], focus: null, request: 0 });\n const current = useRef(state), mounted = useRef(new Set<string>()), launcher = useRef<HTMLButtonElement>(null);\n const [phone, setPhone] = useState(false);\n const [host, setHost] = useState<HTMLDivElement | null>(null);\n const commit = useCallback((next: WindowState) => { current.current = next; setState(next); }, []);\n const save = useCallback((id: string, record: WindowRecord) => { try { storage(persist)?.setItem(`${storageKey}.${id}`, JSON.stringify(record)); } catch { /* the layout is a convenience */ } }, [persist, storageKey]);\n const register = useCallback((id: string, rect: WindowRect, open: boolean) => {\n if (mounted.current.has(id)) throw new Error(`Duplicate floating window id: ${id}`);\n mounted.current.add(id);\n let record = windowRecord(current.current.records, id) ?? { rect, status: open ? 'open' : 'closed' } as WindowRecord;\n try {\n const saved = JSON.parse(storage(persist)?.getItem(`${storageKey}.${id}`) ?? 'null');\n if (saved && validRect(saved.rect) && ['closed', 'open', 'minimized'].includes(saved.status)) record = saved;\n } catch { /* malformed or unavailable storage leaves the default */ }\n record = { ...record, rect: window.innerWidth < 641 ? record.rect : clamp(record.rect) };\n const previous = current.current, order = previous.order.filter(key => key !== id);\n if (record.status === 'open') order.push(id);\n commit({ ...previous, records: { ...previous.records, [id]: record }, order, focus: record.status === 'open' ? id : previous.focus, request: previous.request + (record.status === 'open' ? 1 : 0) });\n return () => {\n mounted.current.delete(id);\n const previous = current.current, { [id]: _removed, ...records } = previous.records;\n commit({ ...previous, records, order: previous.order.filter(key => key !== id) });\n };\n }, [commit, persist, storageKey]);\n const change = useCallback((id: string, status: WindowStatus) => {\n const previous = current.current, record = windowRecord(previous.records, id); if (!record || !mounted.current.has(id)) return;\n const order = previous.order.filter(key => key !== id); if (status === 'open') order.push(id);\n const next = { ...record, status }; save(id, next);\n commit({ records: { ...previous.records, [id]: next }, order, focus: status === 'open' ? id : order.at(-1) ?? null, request: previous.request + 1 });\n }, [commit, save]);\n const raise = useCallback((id: string) => {\n const previous = current.current;\n if (windowRecord(previous.records, id)?.status !== 'open' || previous.order.at(-1) === id) return;\n commit({ ...previous, order: [...previous.order.filter(key => key !== id), id] });\n }, [commit]);\n const resize = useCallback((id: string, rect: WindowRect) => {\n const previous = current.current, record = windowRecord(previous.records, id); if (!record || !validRect(rect)) return;\n const next = { ...record, rect: clamp(rect) }; save(id, next); commit({ ...previous, records: { ...previous.records, [id]: next } });\n }, [commit, save]);\n useLayoutEffect(() => {\n const measure = () => {\n const narrow = window.innerWidth < 641; setPhone(narrow);\n if (!narrow) for (const [id, record] of Object.entries(current.current.records)) resize(id, record.rect);\n };\n measure(); window.addEventListener('resize', measure); return () => window.removeEventListener('resize', measure);\n }, [resize]);\n useLayoutEffect(() => { if (state.request && state.focus === null) launcher.current?.focus({ preventScroll: true }); }, [state.focus, state.request]);\n const value = useMemo(() => ({ state, phone, host, launcher, register, change, raise, resize }), [state, phone, host, register, change, raise, resize]);\n return <Windows.Provider value={value}>{children}<div ref={setHost} data-boring=\"window-layer\" className=\"pointer-events-none fixed inset-0 z-40 isolate\" /></Windows.Provider>;\n}\n\n/** Reach a window from controls anywhere inside its layer (undefined until the FloatingWindow registers). */\nexport function useWindow(id: string): WindowApi | undefined {\n const { state, change } = useWindows(), record = windowRecord(state.records, id);\n return useMemo(() => record ? { id, ...record, open: () => change(id, 'open'), close: () => change(id, 'closed'), minimize: () => change(id, 'minimized') } : undefined, [id, record, change]);\n}\n\n/** Non-modal on desktop; the front window uses the host's full-screen Drawer below 641px. */\nexport function FloatingWindow({ id, title, children, defaultRect = DEFAULT_RECT, defaultOpen = false }: {\n readonly id: string; readonly title: string; readonly children: ReactNode; readonly defaultRect?: WindowRect; readonly defaultOpen?: boolean;\n}) {\n const { state, phone, host, launcher, register, change, raise, resize } = useWindows();\n const initial = useRef({ defaultRect, defaultOpen });\n useLayoutEffect(() => register(id, initial.current.defaultRect, initial.current.defaultOpen), [id, register]);\n const record = windowRecord(state.records, id), rect = record?.rect ?? defaultRect, visible = record?.status === 'open';\n const box = useRef<HTMLDivElement>(null), titleId = useId();\n const front = state.order.at(-1) === id;\n const position = useMemo(() => ({ left: rect.x, top: rect.y }), [rect.x, rect.y]);\n const move = useCallback((next: { left: number; top: number }) => resize(id, { ...rect, x: next.left, y: next.top }), [id, rect, resize]);\n const drag = useDraggable({ box, position, onPositionChange: move, enabled: !phone && visible });\n const focus = () => box.current?.querySelector<HTMLElement>('button, input, textarea, select, a[href], [tabindex=\"0\"]') ?? box.current;\n useLayoutEffect(() => { if (visible && state.focus === id) focus()?.focus({ preventScroll: true }); }, [visible, state.focus, state.request, id, phone]);\n const sizing = useRef<{ x: number; y: number; rect: WindowRect; edges: Edges } | null>(null);\n const startResize = (edges: Edges, event: PointerEvent<HTMLElement>) => {\n if (event.button !== 0) return;\n event.preventDefault(); event.currentTarget.focus(); event.currentTarget.setPointerCapture?.(event.pointerId);\n sizing.current = { x: event.clientX, y: event.clientY, rect, edges };\n };\n const moveResize = (event: PointerEvent<HTMLElement>) => { const start = sizing.current; if (start) resize(id, edgeRect(start.rect, start.edges, event.clientX - start.x, event.clientY - start.y)); };\n const endResize = () => { sizing.current = null; };\n const resizeKey = (event: KeyboardEvent<HTMLElement>) => {\n const step = event.shiftKey ? 96 : 24;\n const width = rect.width + (event.key === 'ArrowRight' ? step : event.key === 'ArrowLeft' ? -step : 0), height = rect.height + (event.key === 'ArrowDown' ? step : event.key === 'ArrowUp' ? -step : 0);\n if (width === rect.width && height === rect.height) return;\n event.preventDefault(); resize(id, { ...rect, width, height });\n };\n const controls = <>\n <Button variant=\"ghost\" size=\"icon\" aria-label={`Minimize ${title}`} onClick={() => change(id, 'minimized')}><MinusIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n <Button variant=\"ghost\" size=\"icon\" aria-label={`Close ${title}`} onClick={() => change(id, 'closed')}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </>;\n if (phone) return <Drawer open={visible && front} onOpenChange={open => { if (!open && visible && front) change(id, 'closed'); }}>\n <DrawerContent ref={box} data-window={id} data-placement=\"fullscreen\" aria-describedby={undefined} initialFocus={focus} finalFocus={() => state.focus === null ? launcher.current : false}\n style={{ inset: 0, width: '100%', height: '100dvh', maxHeight: '100dvh', transform: 'none' }}>\n <DrawerHeader><div className=\"flex items-center gap-2\"><DrawerTitle className=\"flex-1\">{title}</DrawerTitle>{controls}</div></DrawerHeader>\n <div data-base-ui-swipe-ignore=\"\" className=\"flex min-h-0 flex-1 flex-col overflow-auto p-4\">{children}</div>\n </DrawerContent>\n </Drawer>;\n return host ? createPortal(<Card className=\"pointer-events-auto\" ref={box} role=\"dialog\" aria-modal={false} aria-labelledby={titleId} tabIndex={-1} hidden={!visible} inert={!visible} data-window={id} data-placement=\"floating\"\n style={{ position: 'fixed', left: rect.x, top: rect.y, width: rect.width, height: rect.height, zIndex: 1 + state.order.indexOf(id), display: visible ? undefined : 'none' }}\n onPointerDownCapture={() => raise(id)} onFocusCapture={() => raise(id)} onKeyDown={event => { if (event.key === 'Escape' && !event.defaultPrevented) { event.stopPropagation(); change(id, 'closed'); } }}>\n <CardHeader data-window-drag=\"\" {...drag} className=\"cursor-move touch-none select-none\">\n <CardTitle id={titleId}>{title}</CardTitle><CardAction className=\"flex items-center gap-1\">{controls}</CardAction>\n </CardHeader>\n <CardContent className=\"min-h-0 flex-1 overflow-auto\">{children}</CardContent>\n <Button variant=\"ghost\" size=\"icon-sm\" data-window-resize=\"se\" aria-label={`Resize ${title}`} className=\"absolute bottom-0 right-0 cursor-se-resize touch-none\"\n onPointerDown={event => startResize('se', event)} onPointerMove={moveResize} onPointerUp={endResize} onPointerCancel={endResize} onKeyDown={resizeKey}>\n <GripIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n </Button>\n {HANDLES.map(handle => <div key={handle.edges} data-window-resize={handle.edges} aria-hidden=\"true\" className={`absolute touch-none ${handle.className}`}\n onPointerDown={event => startResize(handle.edges, event)} onPointerMove={moveResize} onPointerUp={endResize} onPointerCancel={endResize} />)}\n </Card>, host) : null;\n}\n\n/** The host supplies the available windows; the native menu owns arrow keys, typeahead and Escape. */\nexport function WindowLauncher({ windows, label = 'Windows' }: { readonly windows: readonly { readonly id: string; readonly title: string }[]; readonly label?: string }) {\n const { state, change, launcher } = useWindows(), selected = useRef(false);\n return <DropdownMenu onOpenChange={open => { if (open) selected.current = false; }}>\n <DropdownMenuTrigger render={<Button ref={launcher} variant=\"outline\" data-window-launcher=\"\" />}><PanelsTopLeftIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{label}</DropdownMenuTrigger>\n <DropdownMenuContent finalFocus={() => selected.current ? false : true}>\n <DropdownMenuGroup>{windows.map(item => {\n const record = windowRecord(state.records, item.id);\n return <DropdownMenuItem key={item.id} disabled={!record} data-window-open={record && record.status !== 'closed' ? 'true' : undefined}\n onClick={() => { selected.current = true; change(item.id, 'open'); }}>\n <span className=\"flex-1\">{item.title}</span>{record && record.status !== 'closed' && <Badge variant=\"secondary\">{record.status === 'minimized' ? 'Minimized' : 'Open'}</Badge>}\n </DropdownMenuItem>;\n })}</DropdownMenuGroup>\n </DropdownMenuContent>\n </DropdownMenu>;\n}\n",
17
+ "content": "'use client';\n\nimport { PHONE_BELOW, usePhone } from '../utils/use-media';\nimport { readPersistedJson, writePersistedJson } from '../utils/persisted';\n\n// Host concern: Pi has no window layout. These surfaces borrow their content and remember presentation only.\nimport { createPortal } from 'react-dom';\nimport { createContext, useCallback, useContext, useLayoutEffect, useMemo, useRef, useState, useId } from 'react';\nimport type { KeyboardEvent, PointerEvent, ReactNode, RefObject } from 'react';\nimport { GripIcon, MinusIcon, PanelsTopLeftIcon, XIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardAction, CardContent, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Badge } from '@/components/ui/badge';\nimport { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { useDraggable } from '../utils/use-draggable';\nexport { useDraggable } from '../utils/use-draggable';\nexport type { DragPosition } from '../utils/use-draggable';\n\nexport interface WindowRect { readonly x: number; readonly y: number; readonly width: number; readonly height: number }\nexport type WindowStatus = 'closed' | 'open' | 'minimized';\nexport interface WindowApi {\n readonly id: string;\n readonly status: WindowStatus;\n readonly rect: WindowRect;\n readonly open: () => void;\n readonly close: () => void;\n readonly minimize: () => void;\n}\ninterface WindowRecord { readonly rect: WindowRect; readonly status: WindowStatus }\ninterface WindowState { readonly records: Readonly<Record<string, WindowRecord>>; readonly order: readonly string[]; readonly focus: string | null; readonly request: number }\ninterface WindowsContext {\n readonly state: WindowState;\n readonly phone: boolean;\n readonly host: HTMLDivElement | null;\n readonly launcher: RefObject<HTMLButtonElement | null>;\n readonly register: (id: string, rect: WindowRect, open: boolean) => () => void;\n readonly change: (id: string, status: WindowStatus) => void;\n readonly raise: (id: string) => void;\n readonly resize: (id: string, rect: WindowRect) => void;\n}\nconst Windows = createContext<WindowsContext | null>(null);\nconst DEFAULT_RECT: WindowRect = { x: 48, y: 64, width: 400, height: 320 };\nconst MIN_WIDTH = 240, MIN_HEIGHT = 160;\nconst windowRecord = (records: WindowState['records'], id: string): WindowRecord | undefined => Object.hasOwn(records, id) ? records[id] : undefined;\nconst useWindows = () => { const value = useContext(Windows); if (!value) throw new Error('Windows need a WindowLayer'); return value; };\nconst clamp = (rect: WindowRect): WindowRect => {\n const width = Math.min(Math.max(rect.width, MIN_WIDTH), window.innerWidth), height = Math.min(Math.max(rect.height, MIN_HEIGHT), window.innerHeight);\n return { width, height, x: Math.min(Math.max(rect.x, 0), Math.max(0, window.innerWidth - width)), y: Math.min(Math.max(rect.y, 0), Math.max(0, window.innerHeight - height)) };\n};\ntype Edges = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';\n/** The rectangle after dragging `edges` of `start` by (dx, dy): the opposite edges stay put, the minimum and the viewport bound the rest. */\nconst edgeRect = (start: WindowRect, edges: Edges, dx: number, dy: number): WindowRect => {\n let { x, y, width, height } = start;\n if (edges.includes('e')) width = Math.min(Math.max(start.width + dx, MIN_WIDTH), Math.max(MIN_WIDTH, window.innerWidth - start.x));\n if (edges.includes('s')) height = Math.min(Math.max(start.height + dy, MIN_HEIGHT), Math.max(MIN_HEIGHT, window.innerHeight - start.y));\n if (edges.includes('w')) { const right = start.x + start.width; x = Math.min(Math.max(start.x + dx, 0), right - MIN_WIDTH); width = right - x; }\n if (edges.includes('n')) { const bottom = start.y + start.height; y = Math.min(Math.max(start.y + dy, 0), bottom - MIN_HEIGHT); height = bottom - y; }\n return { x, y, width, height };\n};\nconst HANDLES: readonly { readonly edges: Edges; readonly className: string }[] = [\n { edges: 'n', className: 'inset-x-2 top-0 h-1.5 cursor-n-resize' }, { edges: 's', className: 'inset-x-2 bottom-0 h-1.5 cursor-s-resize' },\n { edges: 'w', className: 'inset-y-2 left-0 w-1.5 cursor-w-resize' }, { edges: 'e', className: 'inset-y-2 right-0 w-1.5 cursor-e-resize' },\n { edges: 'nw', className: 'left-0 top-0 size-2 cursor-nw-resize' }, { edges: 'ne', className: 'right-0 top-0 size-2 cursor-ne-resize' },\n { edges: 'sw', className: 'bottom-0 left-0 size-2 cursor-sw-resize' },\n];\nconst validRect = (rect: unknown): rect is WindowRect => !!rect && typeof rect === 'object' && ['x', 'y', 'width', 'height'].every(key => typeof (rect as Record<string, unknown>)[key] === 'number' && Number.isFinite((rect as Record<string, number>)[key])) && (rect as WindowRect).width > 0 && (rect as WindowRect).height > 0;\n\n/** One viewport window stack. Like DockLayout, persistence is tab-local unless `persist=\"local\"` is selected. */\nexport function WindowLayer({ children, storageKey = 'boring.windows', persist = 'session' }: { readonly children: ReactNode; readonly storageKey?: string; readonly persist?: 'session' | 'local' }) {\n const [state, setState] = useState<WindowState>({ records: {}, order: [], focus: null, request: 0 });\n const current = useRef(state), mounted = useRef(new Set<string>()), launcher = useRef<HTMLButtonElement>(null);\n const phone = usePhone();\n const [host, setHost] = useState<HTMLDivElement | null>(null);\n const commit = useCallback((next: WindowState) => { current.current = next; setState(next); }, []);\n const save = useCallback((id: string, record: WindowRecord) => writePersistedJson(`${storageKey}.${id}`, record, persist), [persist, storageKey]);\n const register = useCallback((id: string, rect: WindowRect, open: boolean) => {\n if (mounted.current.has(id)) throw new Error(`Duplicate floating window id: ${id}`);\n mounted.current.add(id);\n let record = windowRecord(current.current.records, id) ?? { rect, status: open ? 'open' : 'closed' } as WindowRecord;\n try {\n const saved = readPersistedJson(`${storageKey}.${id}`, persist);\n if (saved && typeof saved === 'object' && 'rect' in saved && validRect(saved.rect) && 'status' in saved && (saved.status === 'closed' || saved.status === 'open' || saved.status === 'minimized')) record = { rect: saved.rect, status: saved.status };\n } catch { /* malformed or unavailable storage leaves the default */ }\n record = { ...record, rect: window.innerWidth < PHONE_BELOW ? record.rect : clamp(record.rect) };\n const previous = current.current, order = previous.order.filter(key => key !== id);\n if (record.status === 'open') order.push(id);\n commit({ ...previous, records: { ...previous.records, [id]: record }, order, focus: record.status === 'open' ? id : previous.focus, request: previous.request + (record.status === 'open' ? 1 : 0) });\n return () => {\n mounted.current.delete(id);\n const previous = current.current, { [id]: _removed, ...records } = previous.records;\n commit({ ...previous, records, order: previous.order.filter(key => key !== id) });\n };\n }, [commit, persist, storageKey]);\n const change = useCallback((id: string, status: WindowStatus) => {\n const previous = current.current, record = windowRecord(previous.records, id); if (!record || !mounted.current.has(id)) return;\n const order = previous.order.filter(key => key !== id); if (status === 'open') order.push(id);\n const next = { ...record, status }; save(id, next);\n commit({ records: { ...previous.records, [id]: next }, order, focus: status === 'open' ? id : order.at(-1) ?? null, request: previous.request + 1 });\n }, [commit, save]);\n const raise = useCallback((id: string) => {\n const previous = current.current;\n if (windowRecord(previous.records, id)?.status !== 'open' || previous.order.at(-1) === id) return;\n commit({ ...previous, order: [...previous.order.filter(key => key !== id), id] });\n }, [commit]);\n const resize = useCallback((id: string, rect: WindowRect) => {\n const previous = current.current, record = windowRecord(previous.records, id); if (!record || !validRect(rect)) return;\n const next = { ...record, rect: clamp(rect) }; save(id, next); commit({ ...previous, records: { ...previous.records, [id]: next } });\n }, [commit, save]);\n useLayoutEffect(() => {\n const measure = () => {\n const narrow = window.innerWidth < PHONE_BELOW;\n if (!narrow) for (const [id, record] of Object.entries(current.current.records)) resize(id, record.rect);\n };\n measure(); window.addEventListener('resize', measure); return () => window.removeEventListener('resize', measure);\n }, [resize]);\n useLayoutEffect(() => { if (state.request && state.focus === null) launcher.current?.focus({ preventScroll: true }); }, [state.focus, state.request]);\n const value = useMemo(() => ({ state, phone, host, launcher, register, change, raise, resize }), [state, phone, host, register, change, raise, resize]);\n return <Windows.Provider value={value}>{children}<div ref={setHost} data-boring=\"window-layer\" className=\"pointer-events-none fixed inset-0 z-40 isolate\" /></Windows.Provider>;\n}\n\n/** Reach a window from controls anywhere inside its layer (undefined until the FloatingWindow registers). */\nexport function useWindow(id: string): WindowApi | undefined {\n const { state, change } = useWindows(), record = windowRecord(state.records, id);\n return useMemo(() => record ? { id, ...record, open: () => change(id, 'open'), close: () => change(id, 'closed'), minimize: () => change(id, 'minimized') } : undefined, [id, record, change]);\n}\n\n/** Non-modal on desktop; the front window uses the host's full-screen Drawer below 768px. */\nexport function FloatingWindow({ id, title, children, defaultRect = DEFAULT_RECT, defaultOpen = false }: {\n readonly id: string; readonly title: string; readonly children: ReactNode; readonly defaultRect?: WindowRect; readonly defaultOpen?: boolean;\n}) {\n const { state, phone, host, launcher, register, change, raise, resize } = useWindows();\n const initial = useRef({ defaultRect, defaultOpen });\n useLayoutEffect(() => register(id, initial.current.defaultRect, initial.current.defaultOpen), [id, register]);\n const record = windowRecord(state.records, id), rect = record?.rect ?? defaultRect, visible = record?.status === 'open';\n const box = useRef<HTMLDivElement>(null), titleId = useId();\n const front = state.order.at(-1) === id;\n const position = useMemo(() => ({ left: rect.x, top: rect.y }), [rect.x, rect.y]);\n const move = useCallback((next: { left: number; top: number }) => resize(id, { ...rect, x: next.left, y: next.top }), [id, rect, resize]);\n const drag = useDraggable({ box, position, onPositionChange: move, enabled: !phone && visible });\n const focus = () => box.current?.querySelector<HTMLElement>('button, input, textarea, select, a[href], [tabindex=\"0\"]') ?? box.current;\n useLayoutEffect(() => { if (visible && state.focus === id) focus()?.focus({ preventScroll: true }); }, [visible, state.focus, state.request, id, phone]);\n const sizing = useRef<{ x: number; y: number; rect: WindowRect; edges: Edges } | null>(null);\n const startResize = (edges: Edges, event: PointerEvent<HTMLElement>) => {\n if (event.button !== 0) return;\n event.preventDefault(); event.currentTarget.focus(); event.currentTarget.setPointerCapture?.(event.pointerId);\n sizing.current = { x: event.clientX, y: event.clientY, rect, edges };\n };\n const moveResize = (event: PointerEvent<HTMLElement>) => { const start = sizing.current; if (start) resize(id, edgeRect(start.rect, start.edges, event.clientX - start.x, event.clientY - start.y)); };\n const endResize = () => { sizing.current = null; };\n const resizeKey = (event: KeyboardEvent<HTMLElement>) => {\n const step = event.shiftKey ? 96 : 24;\n const width = rect.width + (event.key === 'ArrowRight' ? step : event.key === 'ArrowLeft' ? -step : 0), height = rect.height + (event.key === 'ArrowDown' ? step : event.key === 'ArrowUp' ? -step : 0);\n if (width === rect.width && height === rect.height) return;\n event.preventDefault(); resize(id, { ...rect, width, height });\n };\n const controls = <>\n <Button variant=\"ghost\" size=\"icon\" aria-label={`Minimize ${title}`} onClick={() => change(id, 'minimized')}><MinusIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n <Button variant=\"ghost\" size=\"icon\" aria-label={`Close ${title}`} onClick={() => change(id, 'closed')}><XIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </>;\n if (phone) return <Drawer open={visible && front} onOpenChange={open => { if (!open && visible && front) change(id, 'closed'); }}>\n <DrawerContent ref={box} data-window={id} data-placement=\"fullscreen\" aria-describedby={undefined} initialFocus={focus} finalFocus={() => state.focus === null ? launcher.current : false}\n style={{ inset: 0, width: '100%', height: '100dvh', maxHeight: '100dvh', transform: 'none' }}>\n <DrawerHeader><div className=\"flex items-center gap-2\"><DrawerTitle className=\"flex-1\">{title}</DrawerTitle>{controls}</div></DrawerHeader>\n <div data-base-ui-swipe-ignore=\"\" className=\"flex min-h-0 flex-1 flex-col overflow-auto p-4\">{children}</div>\n </DrawerContent>\n </Drawer>;\n return host ? createPortal(<Card className=\"pointer-events-auto\" ref={box} role=\"dialog\" aria-modal={false} aria-labelledby={titleId} tabIndex={-1} hidden={!visible} inert={!visible} data-window={id} data-placement=\"floating\"\n style={{ position: 'fixed', left: rect.x, top: rect.y, width: rect.width, height: rect.height, zIndex: 1 + state.order.indexOf(id), display: visible ? undefined : 'none' }}\n onPointerDownCapture={() => raise(id)} onFocusCapture={() => raise(id)} onKeyDown={event => { if (event.key === 'Escape' && !event.defaultPrevented) { event.stopPropagation(); change(id, 'closed'); } }}>\n <CardHeader data-window-drag=\"\" {...drag} className=\"cursor-move touch-none select-none\">\n <CardTitle id={titleId}>{title}</CardTitle><CardAction className=\"flex items-center gap-1\">{controls}</CardAction>\n </CardHeader>\n <CardContent className=\"min-h-0 flex-1 overflow-auto\">{children}</CardContent>\n <Button variant=\"ghost\" size=\"icon-sm\" data-window-resize=\"se\" aria-label={`Resize ${title}`} className=\"absolute bottom-0 right-0 cursor-se-resize touch-none\"\n onPointerDown={event => startResize('se', event)} onPointerMove={moveResize} onPointerUp={endResize} onPointerCancel={endResize} onKeyDown={resizeKey}>\n <GripIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n </Button>\n {HANDLES.map(handle => <div key={handle.edges} data-window-resize={handle.edges} aria-hidden=\"true\" className={`absolute touch-none ${handle.className}`}\n onPointerDown={event => startResize(handle.edges, event)} onPointerMove={moveResize} onPointerUp={endResize} onPointerCancel={endResize} />)}\n </Card>, host) : null;\n}\n\n/** The host supplies the available windows; the native menu owns arrow keys, typeahead and Escape. */\nexport function WindowLauncher({ windows, label = 'Windows' }: { readonly windows: readonly { readonly id: string; readonly title: string }[]; readonly label?: string }) {\n const { state, change, launcher } = useWindows(), selected = useRef(false);\n return <DropdownMenu onOpenChange={open => { if (open) selected.current = false; }}>\n <DropdownMenuTrigger render={<Button ref={launcher} variant=\"outline\" data-window-launcher=\"\" />}><PanelsTopLeftIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{label}</DropdownMenuTrigger>\n <DropdownMenuContent finalFocus={() => selected.current ? false : true}>\n <DropdownMenuGroup>{windows.map(item => {\n const record = windowRecord(state.records, item.id);\n return <DropdownMenuItem key={item.id} disabled={!record} data-window-open={record && record.status !== 'closed' ? 'true' : undefined}\n onClick={() => { selected.current = true; change(item.id, 'open'); }}>\n <span className=\"flex-1\">{item.title}</span>{record && record.status !== 'closed' && <Badge variant=\"secondary\">{record.status === 'minimized' ? 'Minimized' : 'Open'}</Badge>}\n </DropdownMenuItem>;\n })}</DropdownMenuGroup>\n </DropdownMenuContent>\n </DropdownMenu>;\n}\n",
18
18
  "type": "registry:component",
19
19
  "target": "components/windows/windows.tsx"
20
20
  }