@hachej/boring-ui-kit 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -0
- package/dist/artifact-viewer.d.ts +18 -0
- package/dist/artifact-viewer.js +125 -0
- package/dist/html-preview.d.ts +1 -1
- package/dist/html-preview.js +1 -47
- package/dist/passive-html.d.ts +2 -0
- package/dist/passive-html.js +47 -0
- package/dist/voice-capture.d.ts +43 -0
- package/dist/voice-capture.js +186 -0
- package/package.json +10 -2
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +9 -9
- package/r/board-viewer.json +5 -5
- package/r/button.json +1 -1
- package/r/diagram-viewer.json +1 -1
- package/r/dock.json +1 -1
- package/r/documents.json +10 -10
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +5 -5
- package/r/file-tree.json +5 -5
- package/r/highlights.json +3 -3
- package/r/html-viewer.json +3 -3
- package/r/maquette-viewer.json +2 -2
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +1 -1
- package/r/pi-ambient.json +6 -6
- package/r/pi-app.json +16 -15
- package/r/pi-chat.json +37 -15
- package/r/pi-workspace.json +4 -4
- package/r/provider-setup.json +2 -2
- package/r/record-viewer.json +1 -1
- package/r/registry.json +148 -97
- package/r/requests-board.json +1 -1
- package/r/shared-chat.json +4 -4
- package/r/task-list-viewer.json +2 -2
- package/r/utils.json +6 -0
- package/r/version-history.json +3 -3
- package/r/viewers.json +6 -6
- package/r/windows.json +28 -0
package/r/shared-chat.json
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
"title": "Shared chat",
|
|
5
5
|
"description": "Optional multi-party host log presentation over PiChat's generic composition seams. No agent runtime or speaking policy.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.6",
|
|
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.
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.6/r/pi-chat.json",
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.6/r/theme.json",
|
|
14
14
|
"avatar",
|
|
15
15
|
"badge",
|
|
16
16
|
"button",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
48
|
"path": "registry/shared-chat/timeline.tsx",
|
|
49
|
-
"content": "'use client';\nimport { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { RefObject } from 'react';\nimport type { SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { MentionsConfig } from '../pi-chat/config';\nimport { MessageText } from '../pi-chat/message';\nimport { Markdown } from '../pi-chat/markdown';\nimport { useTranscript } from '../pi-chat/session';\nimport { TranscriptHistory } from '../pi-chat/history';\nimport { Message, MessageContent } from '@/components/ui/message';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { MessageScroller, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem, MessageScrollerButton } from '@/components/ui/message-scroller';\nimport { SharedMessage, RequestCard, sharedItems } from './messages';\nimport { HostTrace } from './host-trace';\nimport { useChatText } from './text';\n\nexport interface TimelineApi { readonly reset: () => void; readonly reveal: (id: string) => void }\nexport function SharedTimeline({ controller, api, focusComposer, openFile, mentions }: {\n readonly controller: SharedChatController; readonly api: RefObject<TimelineApi | undefined>;\n readonly focusComposer: () => void; readonly openFile?: ((path: string) => void) | undefined; readonly mentions: MentionsConfig;\n}) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels, icons } = useChatText();\n const items = useMemo(() => sharedItems(state.shared), [state.shared]);\n const connected = state.connection.kind === 'connected';\n const transcript = useTranscript(items, 60, 40, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier });\n const { stick, hidden } = transcript;\n const root = useRef<HTMLDivElement>(null);\n const [revealing, setRevealing] = useState<string>();\n useEffect(() => {\n const value = { reset: () => { transcript.reset(); void stick.scrollToBottom('instant'); }, reveal: setRevealing };\n api.current = value;\n return () => { if (api.current === value) api.current = undefined; };\n });\n useEffect(() => {\n if (!revealing) return;\n const key = `shared:${revealing}`;\n if (items.some(item => item.key === key)) {\n transcript.revealTo(key);\n const frame = requestAnimationFrame(() => {\n const parent = [...(root.current?.querySelectorAll<HTMLElement>('[data-shared-message]') ?? [])].find(element => element.dataset.sharedMessage === revealing);\n if (parent) { parent.scrollIntoView({ block: 'center', behavior: 'instant' }); setRevealing(undefined); }\n });\n return () => cancelAnimationFrame(frame);\n }\n if (state.history.kind === 'loading') return;\n if (state.history.kind === 'ready' && state.history.hasMore) void controller.loadEarlier().catch(() => setRevealing(undefined));\n else setRevealing(undefined);\n }, [revealing, items, hidden, state.history]);\n const context = { pieces: { mentions: true, skills: [], renderMention: mentions.renderSent }, openFile };\n const renderMention = (path: string, text: string) => mentions.renderSent?.(path) ?? text;\n return <MessageScroller>\n <MessageScrollerViewport ref={node => { root.current = node; stick.scrollRef(node); }} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"scrollbar-thumb-border scrollbar-track-transparent\">\n {items.length > 0 && (hidden > 0 || state.history.kind === 'loading' || state.history.kind === 'error' || state.history.kind === 'ready' && state.history.hasMore) && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div className=\"mx-auto w-full max-w-[720px] px-4 pt-6 pb-8 sm:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\" className=\"gap-0\">\n {transcript.visible(items).map((item, index) => {\n const { message, author, own } = item;\n const person = own || author.kind !== 'agent' || message.via !== undefined;\n return <MessageScrollerItem key={item.key} messageId={item.key} className={`[content-visibility:visible]${index > 0 ? item.first ? ' mt-6' : ' mt-2' : ''}`}>\n {item.separator && <div data-testid=\"time-separator\" className=\"mb-6 flex justify-center text-xs text-muted-foreground\"><time dateTime={message.at}>{labels.sharedTimestamp(message.at)}</time></div>}\n <SharedMessage shared={item} remove={controller.remove} canRemove={controller.canRemove} openFile={openFile} onReply={id => { controller.setReplyTo(id); focusComposer(); }} onReveal={setRevealing}>\n {message.card ? <article data-row-id={item.key} data-role=\"event\" className=\"max-w-[80%]\"><RequestCard card={message.card} action={controller.action} /></article>\n : <article data-row-id={item.key} data-role={person ? 'user' : 'assistant'} data-streaming={message.streaming ? 'true' : undefined} className=\"group/message\"><Message align={own ? 'end' : 'start'}><MessageContent>\n {own ? <MessageText text={message.text ?? ''} context={context} /> : <Bubble variant=\"muted\"><BubbleContent>\n {message.text && <Markdown text={message.text} streaming={Boolean(message.streaming)} renderMention={renderMention} />}\n {!message.via && author.kind === 'agent' && (message.streaming || message.steps?.length) && <HostTrace participant={author.name} steps={message.steps ?? []} streaming={Boolean(message.streaming)} active={item.showSpinner} />}\n </BubbleContent></Bubble>}\n </MessageContent></Message></article>}\n </SharedMessage>\n </MessageScrollerItem>;\n })}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {
|
|
49
|
+
"content": "'use client';\nimport { Children, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode, RefObject } from 'react';\nimport type { SharedChatController } from '@hachej/boring-ui-kit/shared-chat';\nimport type { MentionsConfig } from '../pi-chat/config';\nimport { MessageText } from '../pi-chat/message';\nimport { Markdown } from '../pi-chat/markdown';\nimport { useTranscript } from '../pi-chat/session';\nimport { TranscriptHistory } from '../pi-chat/history';\nimport { Message, MessageContent } from '@/components/ui/message';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { MessageScroller, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem, MessageScrollerButton } from '@/components/ui/message-scroller';\nimport { SharedMessage, RequestCard, sharedItems } from './messages';\nimport { HostTrace } from './host-trace';\nimport { useChatText } from './text';\n\nexport interface TimelineApi { readonly reset: () => void; readonly reveal: (id: string) => void }\nexport function SharedTimeline({ controller, api, focusComposer, openFile, mentions, pendingVoice }: {\n readonly controller: SharedChatController; readonly api: RefObject<TimelineApi | undefined>;\n readonly focusComposer: () => void; readonly openFile?: ((path: string) => void) | undefined; readonly mentions: MentionsConfig; readonly pendingVoice?: ReactNode;\n}) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels, icons } = useChatText();\n const items = useMemo(() => sharedItems(state.shared), [state.shared]);\n const connected = state.connection.kind === 'connected';\n const transcript = useTranscript(items, 60, 40, { state: state.history, connected: !state.disposed && connected, load: controller.loadEarlier });\n const { stick, hidden } = transcript;\n const root = useRef<HTMLDivElement>(null);\n const [revealing, setRevealing] = useState<string>();\n useEffect(() => {\n const value = { reset: () => { transcript.reset(); void stick.scrollToBottom('instant'); }, reveal: setRevealing };\n api.current = value;\n return () => { if (api.current === value) api.current = undefined; };\n });\n useEffect(() => {\n if (!revealing) return;\n const key = `shared:${revealing}`;\n if (items.some(item => item.key === key)) {\n transcript.revealTo(key);\n const frame = requestAnimationFrame(() => {\n const parent = [...(root.current?.querySelectorAll<HTMLElement>('[data-shared-message]') ?? [])].find(element => element.dataset.sharedMessage === revealing);\n if (parent) { parent.scrollIntoView({ block: 'center', behavior: 'instant' }); setRevealing(undefined); }\n });\n return () => cancelAnimationFrame(frame);\n }\n if (state.history.kind === 'loading') return;\n if (state.history.kind === 'ready' && state.history.hasMore) void controller.loadEarlier().catch(() => setRevealing(undefined));\n else setRevealing(undefined);\n }, [revealing, items, hidden, state.history]);\n const hasContent = items.length > 0 || Children.count(pendingVoice) > 0;\n const context = { pieces: { mentions: true, skills: [], renderMention: mentions.renderSent }, openFile };\n const renderMention = (path: string, text: string) => mentions.renderSent?.(path) ?? text;\n return <MessageScroller>\n <MessageScrollerViewport ref={node => { root.current = node; stick.scrollRef(node); }} data-testid=\"transcript-scroll\" aria-label={labels.messages} className=\"min-h-0 flex-1 scrollbar-thumb-border scrollbar-track-transparent\">\n {items.length > 0 && (hidden > 0 || state.history.kind === 'loading' || state.history.kind === 'error' || state.history.kind === 'ready' && state.history.hasMore) && <TranscriptHistory history={state.history} connected={!state.disposed && connected} onLoad={transcript.reveal} sentinelRef={transcript.sentinelRef} hidden={hidden} loaded={transcript.loaded} />}\n <div className=\"mx-auto w-full max-w-[720px] px-4 pt-6 pb-8 sm:px-6\">\n <MessageScrollerContent aria-label={labels.messages} aria-live=\"polite\" data-testid=\"transcript\" className=\"gap-0 [&>[data-local-voice]]:mt-6\">\n {transcript.visible(items).map((item, index) => {\n const { message, author, own } = item;\n const person = own || author.kind !== 'agent' || message.via !== undefined;\n return <MessageScrollerItem key={item.key} messageId={item.key} className={`[content-visibility:visible]${index > 0 ? item.first ? ' mt-6' : ' mt-2' : ''}`}>\n {item.separator && <div data-testid=\"time-separator\" className=\"mb-6 flex justify-center text-xs text-muted-foreground\"><time dateTime={message.at}>{labels.sharedTimestamp(message.at)}</time></div>}\n <SharedMessage shared={item} remove={controller.remove} canRemove={controller.canRemove} openFile={openFile} onReply={id => { controller.setReplyTo(id); focusComposer(); }} onReveal={setRevealing}>\n {message.card ? <article data-row-id={item.key} data-role=\"event\" className=\"max-w-[80%]\"><RequestCard card={message.card} action={controller.action} /></article>\n : <article data-row-id={item.key} data-role={person ? 'user' : 'assistant'} data-streaming={message.streaming ? 'true' : undefined} className=\"group/message\"><Message align={own ? 'end' : 'start'}><MessageContent>\n {own ? <MessageText text={message.text ?? ''} context={context} /> : <Bubble variant=\"muted\"><BubbleContent>\n {message.text && <Markdown text={message.text} streaming={Boolean(message.streaming)} renderMention={renderMention} />}\n {!message.via && author.kind === 'agent' && (message.streaming || message.steps?.length) && <HostTrace participant={author.name} steps={message.steps ?? []} streaming={Boolean(message.streaming)} active={item.showSpinner} />}\n </BubbleContent></Bubble>}\n </MessageContent></Message></article>}\n </SharedMessage>\n </MessageScrollerItem>;\n })}\n {pendingVoice}\n </MessageScrollerContent>\n </div>\n </MessageScrollerViewport>\n {hasContent && <div data-testid=\"transcript-navigation\" className=\"flex h-12 shrink-0 items-center justify-center\">{!stick.isAtBottom && <MessageScrollerButton className=\"static inset-auto translate-x-0\" data-testid=\"jump-latest\" aria-label={labels.jumpToLatest} title={labels.jumpToLatest} onClick={() => api.current?.reset()}><icons.jumpToLatest aria-hidden=\"true\" /></MessageScrollerButton>}</div>}\n </MessageScroller>;\n}\n",
|
|
50
50
|
"type": "registry:component",
|
|
51
51
|
"target": "components/shared-chat/timeline.tsx"
|
|
52
52
|
},
|
package/r/task-list-viewer.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"title": "Consumer task-list viewer",
|
|
5
5
|
"description": "A consumer-defined task-list schema and controller over the shared text buffer, with a restylable borrowed React renderer.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.6",
|
|
8
|
+
"@hachej/boring-files@^0.4.6",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"zod@^4.6.5"
|
package/r/utils.json
CHANGED
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
"react@^19.0.0"
|
|
10
10
|
],
|
|
11
11
|
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "registry/utils/use-draggable.ts",
|
|
14
|
+
"content": "'use client';\n\nimport { useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { PointerEvent, RefObject } from 'react';\n\nexport interface DragPosition { readonly left: number; readonly top: number }\n\n/** Pointer-capture drag shared with the agent layer. Controls remain clickable and the whole surface stays reachable. */\nexport function useDraggable({ box, position, onPositionChange, enabled }: {\n readonly box: RefObject<HTMLElement | null>;\n readonly position: DragPosition | null;\n readonly onPositionChange: (position: DragPosition) => void;\n readonly enabled: boolean;\n}) {\n const drag = useRef<{ dx: number; dy: number } | null>(null);\n const [viewport, setViewport] = useState(0);\n useEffect(() => { const resize = () => setViewport(count => count + 1); window.addEventListener('resize', resize); return () => window.removeEventListener('resize', resize); }, []);\n useLayoutEffect(() => {\n const rect = box.current?.getBoundingClientRect();\n if (!position || !enabled || !rect || !rect.width) return;\n const left = Math.min(Math.max(position.left, 0), Math.max(0, window.innerWidth - rect.width)), top = Math.min(Math.max(position.top, 0), Math.max(0, window.innerHeight - rect.height));\n if (left !== position.left || top !== position.top) onPositionChange({ left, top });\n }, [box, position, enabled, viewport, onPositionChange]);\n const onPointerDown = (event: PointerEvent<HTMLElement>) => {\n const rect = box.current?.getBoundingClientRect();\n if (!enabled || !rect || event.button !== 0 || (event.target as HTMLElement).closest('button, a[href]')) return;\n drag.current = { dx: event.clientX - rect.left, dy: event.clientY - rect.top };\n event.currentTarget.setPointerCapture?.(event.pointerId);\n };\n const onPointerMove = (event: PointerEvent<HTMLElement>) => {\n const grab = drag.current, rect = box.current?.getBoundingClientRect();\n if (!grab || !rect) return;\n onPositionChange({ left: Math.min(Math.max(event.clientX - grab.dx, 0), Math.max(0, window.innerWidth - rect.width)), top: Math.min(Math.max(event.clientY - grab.dy, 0), Math.max(0, window.innerHeight - rect.height)) });\n };\n const end = () => { drag.current = null; };\n return { onPointerDown, onPointerMove, onPointerUp: end, onPointerCancel: end };\n}\n",
|
|
15
|
+
"type": "registry:hook",
|
|
16
|
+
"target": "components/utils/use-draggable.ts"
|
|
17
|
+
},
|
|
12
18
|
{
|
|
13
19
|
"path": "registry/utils/utils.ts",
|
|
14
20
|
"content": "import { clsx } from 'clsx';\nimport type { ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); }\n\n/** Thrown when no automatic copy worked; `text` is what the person must copy by hand (a viewer shows it selected). */\nexport class ManualCopyError extends Error {\n readonly text: string;\n constructor(text: string) { super('Copy the text by hand'); this.name = 'ManualCopyError'; this.text = text; }\n}\n\n/**\n * Copies `text` using the host's callback when given, else the async Clipboard API (secure contexts only), else a hidden\n * textarea and `document.execCommand('copy')` (works over plain HTTP). Throws `ManualCopyError` when none worked.\n * This is the one place this item touches the clipboard; `npm run check` keeps it that way.\n */\nexport async function copyText(text: string, onCopy?: (text: string) => Promise<void>): Promise<void> {\n if (onCopy) return onCopy(text);\n try { const clipboard = globalThis.navigator?.clipboard; if (clipboard) { await clipboard.writeText(text); return; } }\n catch { /* denied or unfocused: use the selection route */ }\n const doc = globalThis.document;\n if (doc?.body && typeof doc.execCommand === 'function') {\n const area = doc.createElement('textarea');\n area.value = text;\n area.setAttribute('readonly', '');\n area.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none';\n const focus = doc.activeElement instanceof HTMLElement ? doc.activeElement : undefined;\n doc.body.append(area);\n try { area.select(); area.setSelectionRange(0, text.length); if (doc.execCommand('copy')) return; }\n catch { /* fall through */ }\n finally { area.remove(); focus?.focus({ preventScroll: true }); }\n }\n throw new ManualCopyError(text);\n}\n\n/**\n * A block's `labels` or `icons`: `defaults` with each partial override on top, later ones winning. A key an override leaves `undefined`\n * keeps the value below it, so a host can pass `{ title: maybe }` safely.\n */\nexport function withDefaults<T extends object>(defaults: T, ...overrides: readonly (Partial<T> | undefined)[]): T {\n const merged: Record<string, unknown> = { ...defaults as Record<string, unknown> };\n for (const override of overrides) for (const [key, value] of Object.entries(override ?? {})) if (value !== undefined) merged[key] = value;\n return merged as T;\n}\n",
|
package/r/version-history.json
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
"title": "Version history rows",
|
|
5
5
|
"description": "Shared view-only version rows and menu metadata with relative time, provenance and Current. No file reads, publication or restoration.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-files@^0.4.6",
|
|
8
8
|
"react@^19.0.0"
|
|
9
9
|
],
|
|
10
10
|
"registryDependencies": [
|
|
11
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
12
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.6/r/theme.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.6/r/button.json",
|
|
13
13
|
"button"
|
|
14
14
|
],
|
|
15
15
|
"files": [
|
package/r/viewers.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"title": "Viewers",
|
|
5
5
|
"description": "The standard viewer top bar (title, status, viewer controls, Refresh, Share, Copy, Download, Open in new tab, Close, overflow menu) with Tailwind panes over it: the Markdown editor with a formatting toolbar and prose styling, the passive HTML preview and source editor, an image viewer with zoom and pan, a PDF viewer, and LocalFileViewer for browser-only files. Owned, editable source over the borrowed controllers; no AI SDK.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.6",
|
|
8
|
+
"@hachej/boring-files@^0.4.6",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"marked@^17.0.6",
|
|
@@ -25,12 +25,12 @@
|
|
|
25
25
|
"pdfjs-dist@6.3.289"
|
|
26
26
|
],
|
|
27
27
|
"registryDependencies": [
|
|
28
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
29
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
30
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
28
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.6/r/theme.json",
|
|
29
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.6/r/utils.json",
|
|
30
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.6/r/button.json",
|
|
31
31
|
"button",
|
|
32
32
|
"toggle",
|
|
33
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
33
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.6/r/version-history.json",
|
|
34
34
|
"toggle-group",
|
|
35
35
|
"dialog",
|
|
36
36
|
"textarea",
|
package/r/windows.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "windows",
|
|
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.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"react@^19.0.0",
|
|
8
|
+
"react-dom@^19.0.0",
|
|
9
|
+
"lucide-react@^1.51.0"
|
|
10
|
+
],
|
|
11
|
+
"registryDependencies": [
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.6/r/utils.json",
|
|
13
|
+
"button",
|
|
14
|
+
"card",
|
|
15
|
+
"badge",
|
|
16
|
+
"drawer",
|
|
17
|
+
"dropdown-menu"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
{
|
|
21
|
+
"path": "registry/windows/windows.tsx",
|
|
22
|
+
"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};\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 } | null>(null);\n const startResize = (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 };\n };\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=\"\" aria-label={`Resize ${title}`} className=\"absolute bottom-0 right-0 cursor-se-resize touch-none\"\n onPointerDown={startResize} onPointerMove={event => { const start = sizing.current; if (start) resize(id, { ...start.rect, width: start.rect.width + event.clientX - start.x, height: start.rect.height + event.clientY - start.y }); }}\n onPointerUp={() => { sizing.current = null; }} onPointerCancel={() => { sizing.current = null; }} onKeyDown={resizeKey}>\n <GripIcon data-icon=\"inline-start\" aria-hidden=\"true\" />\n </Button>\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",
|
|
23
|
+
"type": "registry:component",
|
|
24
|
+
"target": "components/windows/windows.tsx"
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"type": "registry:block"
|
|
28
|
+
}
|