@hachej/boring-ui-kit 0.4.1 → 0.4.2

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.
@@ -67,9 +67,7 @@ function MarkdownEditorSession({ components, controller, title = 'Document', ini
67
67
  const Source = components?.textarea ?? 'textarea';
68
68
  const LinkInput = components?.input ?? 'input';
69
69
  const phone = useSyncExternalStore(subscribePhone, phoneSnapshot, desktopSnapshot);
70
- const [pinned, setPinned] = useState(formattingToolbar === 'pinned');
71
- useEffect(() => { setPinned(formattingToolbar === 'pinned'); }, [formattingToolbar]);
72
- const showPinned = pinned && !phone;
70
+ const showPinned = formattingToolbar === 'pinned' && !phone;
73
71
  const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
74
72
  const projected = useRef(null);
75
73
  const emitted = useRef(null);
@@ -268,7 +266,7 @@ function MarkdownEditorSession({ components, controller, title = 'Document', ini
268
266
  if (!unavailable && !uncertain)
269
267
  save();
270
268
  }
271
- }, children: [header && _jsxs("header", { children: [_jsx("strong", { children: title }), state.readOnly && _jsx("span", { children: "Read only" }), _jsx("span", { role: "status", children: state.lifecycle === 'disposed' ? 'Closed' : pending ? 'Saving…' : uncertain ? 'Save unconfirmed' : conflict ? 'Changed elsewhere' : state.dirty ? 'Unsaved changes' : 'Saved' }), mode === 'rich' && !phone && !unavailable && _jsx(Control, { type: "button", className: "boring-markdown-pin", "aria-label": "Pin formatting toolbar", title: "Pin formatting toolbar", "aria-pressed": pinned, onClick: () => setPinned(value => !value), children: "Aa" }), _jsx(Control, { type: "button", "aria-pressed": mode === 'source', disabled: !richOffered, onClick: switchMode, children: "Markdown source" }), !saving.enabled && _jsx(Control, { type: "button", disabled: unavailable || !state.dirty || pending || uncertain, onClick: save, children: "Save" })] }), _jsx(TextDraftControls, { components: components, recovery: state.recovery, actions: controller.actions, readOnly: state.readOnly, blocked: state.lifecycle !== 'active' || state.save.kind === 'pending' || (state.save.kind === 'settled' && state.save.result.kind === 'unknown') }), mode === 'rich' && showPinned && _jsx("div", { role: "toolbar", "aria-label": "Text formatting", className: "boring-markdown-toolbar", children: formatTools }), mode === 'rich' && dockVisible && createPortal(_jsxs("div", { role: "toolbar", "aria-label": "Text formatting", className: "boring-viewer boring-markdown-dock", "data-testid": "markdown-dock", "data-keyboard": keyboardInset > 0 || undefined, style: { bottom: keyboardInset }, onPointerDown: event => event.preventDefault(), children: [Rule && _jsx(Rule, { className: "boring-markdown-dock-rule" }), formatTools] }), document.body), mode === 'rich' && editor && !showPinned && !unavailable && _jsx(BubbleMenu, { editor: editor, updateDelay: 0, options: BUBBLE_OPTIONS, appendTo: bubbleContainer, role: "toolbar", "aria-label": "Text formatting", className: "boring-viewer boring-markdown-bubble", children: _jsxs(Bubble, { children: [tool('Bold', 'Bold', marks?.bold, () => chain().toggleBold().run()), tool('Italic', 'Italic', marks?.italic, () => chain().toggleItalic().run()), tool('Highlight', 'Highlight', marks?.highlight, () => chain().toggleHighlight().run()), tool('Code', 'Code', marks?.code, () => chain().toggleCode().run()), tool('Link', 'Link', marks?.link || linkDraft !== null, openLink)] }) }), mode === 'rich' && linkDraft !== null && _jsxs("form", { className: "boring-markdown-link", "aria-label": "Link address", onSubmit: event => { event.preventDefault(); applyLink(); }, children: [_jsx(LinkInput, { type: "text", inputMode: "url", autoFocus: true, "aria-label": "Link address", placeholder: "https://", value: linkDraft, onChange: event => setLinkDraft(event.currentTarget.value), onKeyDown: event => { if (event.key === 'Escape') {
269
+ }, children: [header && _jsxs("header", { children: [_jsx("strong", { children: title }), state.readOnly && _jsx("span", { children: "Read only" }), _jsx("span", { role: "status", children: state.lifecycle === 'disposed' ? 'Closed' : pending ? 'Saving…' : uncertain ? 'Save unconfirmed' : conflict ? 'Changed elsewhere' : state.dirty ? 'Unsaved changes' : 'Saved' }), _jsx(Control, { type: "button", "aria-pressed": mode === 'source', disabled: !richOffered, onClick: switchMode, children: "Markdown source" }), !saving.enabled && _jsx(Control, { type: "button", disabled: unavailable || !state.dirty || pending || uncertain, onClick: save, children: "Save" })] }), _jsx(TextDraftControls, { components: components, recovery: state.recovery, actions: controller.actions, readOnly: state.readOnly, blocked: state.lifecycle !== 'active' || state.save.kind === 'pending' || (state.save.kind === 'settled' && state.save.result.kind === 'unknown') }), mode === 'rich' && showPinned && _jsx("div", { role: "toolbar", "aria-label": "Text formatting", className: "boring-markdown-toolbar", children: formatTools }), mode === 'rich' && dockVisible && createPortal(_jsxs("div", { role: "toolbar", "aria-label": "Text formatting", className: "boring-viewer boring-markdown-dock", "data-testid": "markdown-dock", "data-keyboard": keyboardInset > 0 || undefined, style: { bottom: keyboardInset }, onPointerDown: event => event.preventDefault(), children: [Rule && _jsx(Rule, { className: "boring-markdown-dock-rule" }), formatTools] }), document.body), mode === 'rich' && editor && !showPinned && !unavailable && _jsx(BubbleMenu, { editor: editor, updateDelay: 0, options: BUBBLE_OPTIONS, appendTo: bubbleContainer, role: "toolbar", "aria-label": "Text formatting", className: "boring-viewer boring-markdown-bubble", children: _jsxs(Bubble, { children: [tool('Bold', 'Bold', marks?.bold, () => chain().toggleBold().run()), tool('Italic', 'Italic', marks?.italic, () => chain().toggleItalic().run()), tool('Highlight', 'Highlight', marks?.highlight, () => chain().toggleHighlight().run()), tool('Code', 'Code', marks?.code, () => chain().toggleCode().run()), tool('Link', 'Link', marks?.link || linkDraft !== null, openLink)] }) }), mode === 'rich' && linkDraft !== null && _jsxs("form", { className: "boring-markdown-link", "aria-label": "Link address", onSubmit: event => { event.preventDefault(); applyLink(); }, children: [_jsx(LinkInput, { type: "text", inputMode: "url", autoFocus: true, "aria-label": "Link address", placeholder: "https://", value: linkDraft, onChange: event => setLinkDraft(event.currentTarget.value), onKeyDown: event => { if (event.key === 'Escape') {
272
270
  event.preventDefault();
273
271
  setLinkDraft(null);
274
272
  editor?.commands.focus();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-ui-kit",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -114,7 +114,7 @@
114
114
  "./file-tree.css": "./styles/file-tree.css"
115
115
  },
116
116
  "peerDependencies": {
117
- "@hachej/boring-files": "^0.4.1",
117
+ "@hachej/boring-files": "^0.4.2",
118
118
  "@earendil-works/pi-durable": "~1.2.0",
119
119
  "react": "^19.0.0",
120
120
  "react-dom": "^19.0.0",
package/r/app-shell.json CHANGED
@@ -4,19 +4,19 @@
4
4
  "title": "App shell: layouts, list/detail and agent layer",
5
5
  "description": "AppShell with three layouts. B \"workspace\" puts a ListDetail on a screen (phone: the list, then the detail as a stack step with Back; wide: a 20rem list and the detail side by side, sidebar as an icon rail). C \"chat\" wraps the app's own AgentWorkspace (untouched, passed as the nav item's render; install the pi-app item yourself) in the same sidebar, account menu and header; it takes no shell agent. A \"screen\": one screen at a time, every screen kept mounted. On a phone bottom tabs (at most four; more give three plus a « Plus » sheet) and an avatar button that opens the account sheet; on a wide screen the shadcn sidebar (icon rail, NavUser). One header per screen through useScreenHeader (title, subtitle, back, up to two actions, the rest behind « … »). The account menu holds « Améliorer l'app » (the improve agent), the app's own entries and sign out. AppSheet is the one bottom sheet (one open at a time, Back closes it). It mounts the AgentLayer: one agent's PiChat above the app (phone: a drawer with peek, half and full snap points; wide: a right panel), the improve agent in the same layer, optional push-to-talk and point mode. Also useAgentContext and readAgentContext (the screen on show travels as `context` with each submit) and useBackLayer, the one Back manager. Built on explicitly pinned Base UI drawer and dropdown-menu recipes, plus the sidebar and avatar of the host's own shadcn style.",
6
6
  "dependencies": [
7
- "@hachej/boring-feedback@^0.4.1",
8
- "@hachej/boring-ui-kit@^0.4.1",
7
+ "@hachej/boring-feedback@^0.4.2",
8
+ "@hachej/boring-ui-kit@^0.4.2",
9
9
  "@base-ui/react@^1.9.0",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0",
12
12
  "lucide-react@^1.51.0"
13
13
  ],
14
14
  "registryDependencies": [
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/dock.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/feedback.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/utils.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/button.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/dock.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/feedback.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/utils.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/button.json",
20
20
  "drawer",
21
21
  "sidebar",
22
22
  "avatar",
@@ -66,7 +66,7 @@
66
66
  },
67
67
  {
68
68
  "path": "registry/app-shell/app-shell.tsx",
69
- "content": "'use client';\n\n// The app shell. Layout A (`screen`): one screen at a time. Compact: bottom tabs (at most four; more give three plus « Plus »). Expanded:\n// the shadcn sidebar (icon rail plus NavUser). Layout B (`workspace`): the same, with a screen made of a `ListDetail`; the sidebar starts\n// as the icon rail because the list pane is the wide navigation. Layout C (`chat`): the one nav item renders the app's `AgentWorkspace`,\n// untouched, below the same sidebar, account menu and header; there is no shell agent (the page is the chat). The shell imports nothing from pi-app: a layout C app installs the `pi-app` item itself. One header per screen\n// (`useScreenHeader`). The AgentLayer is mounted once, here.\n// Host concern: Pi has no layout; the screen on show travels as the agent's context (useAgentContext -> createPromptContext).\nimport { useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { SidebarProvider } from '@/components/ui/sidebar';\nimport { Button } from '@/components/ui/button';\nimport { AccountAvatar, AccountChoices } from './account';\nimport type { AccountSpec } from './account';\nimport { AgentDockLayout, AgentLayer, AgentLayerProvider } from './agent-layer';\nimport type { AgentSpec } from './agent-layer';\nimport { ScreenActiveContext, useAgentContext } from './agent-context';\nimport { useIsCompact } from './compact';\nimport { HeaderProvider, ScreenHeader } from './header';\nimport { AppSidebar, TabBar } from './nav';\nimport type { NavItem } from './nav';\nimport { AppSheet } from './app-sheet';\nimport { appShellText } from './text';\nimport type { AppShellLocale, AppShellText } from './text';\n\nexport interface AppShellProps {\n /** A `screen` (tabs), B `workspace` (list and detail), C `chat` (the app's `AgentWorkspace`; no `agent`). */\n readonly layout: 'screen' | 'workspace' | 'chat';\n readonly title: string;\n readonly nav: readonly NavItem[];\n readonly agent?: AgentSpec;\n /** The product manager: adds « Améliorer l’app » to the account menu. */\n readonly improve?: AgentSpec;\n readonly account?: AccountSpec;\n /** The language of the shell's own words and the layer's (« Se déconnecter », « Plus », « Ouvrir l’assistant »…). The agent's `chatProps.locale` wins when it sets one; else this; else English. */\n readonly locale?: AppShellLocale;\n /** The app's router owns the URL; the shell only reports the choice. */\n readonly route: { readonly id: string; readonly onChange: (id: string) => void };\n}\n\n/** Registers what is on show for the agent. A screen's own `useAgentContext` is mounted later and wins. */\nfunction ShellContext({ name }: { readonly name: string }): null {\n useAgentContext({ name, params: {} });\n return null;\n}\n\nfunction ScreenBody({ item }: { readonly item: NavItem }): ReactNode { return item.render(); }\n\n/** Every screen stays mounted; the ones not on show are `hidden` and do not register a header or a context. */\nfunction Screens({ items, activeId, scrolls }: { readonly items: readonly NavItem[]; readonly activeId: string; readonly scrolls: boolean }) {\n return <div className=\"relative min-h-0 flex-1\">\n {items.map(item => <ScreenActiveContext.Provider key={item.id} value={item.id === activeId}>\n <div hidden={item.id !== activeId} data-testid={`screen-${item.id}`} className={scrolls ? 'h-full overflow-y-auto overscroll-contain' : 'h-full overflow-hidden'}><ScreenBody item={item} /></div>\n </ScreenActiveContext.Provider>)}\n </div>;\n}\n\nfunction Shell({ layout, title, nav, agent, improve, account, route, text }: { readonly layout: AppShellProps['layout']; readonly title: string; readonly nav: readonly NavItem[]; readonly agent: AgentSpec | undefined; readonly improve: boolean; readonly account: AccountSpec | undefined; readonly route: AppShellProps['route']; readonly text: AppShellText }) {\n const compact = useIsCompact();\n const [accountOpen, setAccountOpen] = useState(false);\n const active = nav.find(item => item.id === route.id) ?? nav[0];\n if (!active) return null;\n const hasAccount = Boolean(account) || improve;\n const avatar = hasAccount ? <>\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"account-button\" aria-label={account?.name ?? text.account} title={account?.name ?? text.account} onClick={() => setAccountOpen(true)}><AccountAvatar account={account} /></Button>\n <AppSheet open={accountOpen} onOpenChange={setAccountOpen} title={text.account}>\n <AccountChoices account={account} improve={improve} text={text} done={() => setAccountOpen(false)} />\n </AppSheet>\n </> : null;\n // The structure is the same compact or expanded, so a resize never remounts a screen.\n return <SidebarProvider data-testid=\"app-shell\" defaultOpen={layout === 'screen'} className=\"app-shell h-dvh min-h-0 overflow-hidden\">\n {!compact && <AppSidebar title={title} items={nav} activeId={active.id} onChange={route.onChange} agent={agent} account={account} improve={improve} text={text} />}\n <AgentDockLayout>\n <div data-slot=\"sidebar-inset\" className=\"relative flex min-h-0 min-w-0 w-full flex-1 flex-col overflow-hidden bg-background\">\n <ShellContext name={active.label} />\n <ScreenHeader fallbackTitle={active.label} compact={compact} avatar={avatar} text={text} />\n <Screens items={nav} activeId={active.id} scrolls={layout === 'screen'} />\n {compact && <TabBar items={nav} activeId={active.id} onChange={route.onChange} text={text} />}\n </div>\n </AgentDockLayout>\n <AgentLayer />\n </SidebarProvider>;\n}\n\nexport function AppShell(props: AppShellProps) {\n const { layout, agent, improve, title, nav, account, route } = props;\n if (layout === 'chat' && agent) throw new Error('AppShell layout \"chat\" takes no `agent`: the page is the chat (use `improve` for the product manager)');\n const text = appShellText((agent ?? improve)?.chatProps?.locale ?? props.locale);\n return <AgentLayerProvider agent={agent} improve={improve} locale={props.locale}>\n <HeaderProvider><Shell layout={layout} title={title} nav={nav} agent={agent} account={account} route={route} improve={Boolean(improve)} text={text} /></HeaderProvider>\n </AgentLayerProvider>;\n}\n",
69
+ "content": "'use client';\n\n// The app shell. Layout A (`screen`): one screen at a time. Compact: bottom tabs (at most four; more give three plus « Plus »). Expanded:\n// the shadcn sidebar (icon rail plus NavUser). Layout B (`workspace`): the same, with a screen made of a `ListDetail`; the sidebar starts\n// as the icon rail because the list pane is the wide navigation. Layout C (`chat`): the one nav item renders the app's `AgentWorkspace`,\n// untouched, below the same sidebar, account menu and header; there is no shell agent (the page is the chat). The shell imports nothing from pi-app: a layout C app installs the `pi-app` item itself. One header per screen\n// (`useScreenHeader`). The AgentLayer is mounted once, here.\n// Host concern: Pi has no layout; the screen on show travels as the agent's context (useAgentContext -> createPromptContext).\nimport { useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { SidebarProvider } from '@/components/ui/sidebar';\nimport { Button } from '@/components/ui/button';\nimport { AccountAvatar, AccountChoices } from './account';\nimport type { AccountSpec } from './account';\nimport { AgentDockLayout, AgentLayer, AgentLayerProvider } from './agent-layer';\nimport type { AgentSpec } from './agent-layer';\nimport { ScreenActiveContext, useAgentContext } from './agent-context';\nimport { useIsCompact } from './compact';\nimport { HeaderProvider, ScreenHeader } from './header';\nimport { AppSidebar, TabBar } from './nav';\nimport type { NavItem } from './nav';\nimport { AppSheet } from './app-sheet';\nimport { appShellText } from './text';\nimport { ScreenHeaderSlotContext } from '../utils/screen-header-slot';\nimport type { AppShellLocale, AppShellText } from './text';\n\nexport interface AppShellProps {\n /** A `screen` (tabs), B `workspace` (list and detail), C `chat` (the app's `AgentWorkspace`; no `agent`). */\n readonly layout: 'screen' | 'workspace' | 'chat';\n readonly title: string;\n readonly nav: readonly NavItem[];\n readonly agent?: AgentSpec;\n /** The product manager: adds « Améliorer l’app » to the account menu. */\n readonly improve?: AgentSpec;\n readonly account?: AccountSpec;\n /** The language of the shell's own words and the layer's (« Se déconnecter », « Plus », « Ouvrir l’assistant »…). The agent's `chatProps.locale` wins when it sets one; else this; else English. */\n readonly locale?: AppShellLocale;\n /** The app's router owns the URL; the shell only reports the choice. */\n readonly route: { readonly id: string; readonly onChange: (id: string) => void };\n}\n\n/** Registers what is on show for the agent. A screen's own `useAgentContext` is mounted later and wins. */\nfunction ShellContext({ name }: { readonly name: string }): null {\n useAgentContext({ name, params: {} });\n return null;\n}\n\nfunction ScreenBody({ item }: { readonly item: NavItem }): ReactNode { return item.render(); }\n\n/** Every screen stays mounted; the ones not on show are `hidden` and do not register a header or a context. */\nfunction Screens({ items, activeId, scrolls, slot }: { readonly items: readonly NavItem[]; readonly activeId: string; readonly scrolls: boolean; readonly slot: HTMLElement | null }) {\n return <div className=\"relative min-h-0 flex-1\">\n {items.map(item => <ScreenActiveContext.Provider key={item.id} value={item.id === activeId}><ScreenHeaderSlotContext.Provider value={item.id === activeId ? slot : null}>\n <div hidden={item.id !== activeId} data-testid={`screen-${item.id}`} className={scrolls ? 'h-full overflow-y-auto overscroll-contain' : 'h-full overflow-hidden'}><ScreenBody item={item} /></div>\n </ScreenHeaderSlotContext.Provider></ScreenActiveContext.Provider>)}\n </div>;\n}\n\nfunction Shell({ layout, title, nav, agent, improve, account, route, text }: { readonly layout: AppShellProps['layout']; readonly title: string; readonly nav: readonly NavItem[]; readonly agent: AgentSpec | undefined; readonly improve: boolean; readonly account: AccountSpec | undefined; readonly route: AppShellProps['route']; readonly text: AppShellText }) {\n const compact = useIsCompact();\n const [accountOpen, setAccountOpen] = useState(false);\n // The header's slot for the screen on show (an embedded AgentWorkspace puts its bar's controls there).\n const [slot, setSlot] = useState<HTMLElement | null>(null);\n const active = nav.find(item => item.id === route.id) ?? nav[0];\n if (!active) return null;\n const hasAccount = Boolean(account) || improve;\n const avatar = hasAccount ? <>\n <Button variant=\"ghost\" size=\"icon\" data-testid=\"account-button\" aria-label={account?.name ?? text.account} title={account?.name ?? text.account} onClick={() => setAccountOpen(true)}><AccountAvatar account={account} /></Button>\n <AppSheet open={accountOpen} onOpenChange={setAccountOpen} title={text.account}>\n <AccountChoices account={account} improve={improve} text={text} done={() => setAccountOpen(false)} />\n </AppSheet>\n </> : null;\n // The structure is the same compact or expanded, so a resize never remounts a screen.\n return <SidebarProvider data-testid=\"app-shell\" defaultOpen={layout === 'screen'} className=\"app-shell h-dvh min-h-0 overflow-hidden\">\n {!compact && <AppSidebar title={title} items={nav} activeId={active.id} onChange={route.onChange} agent={agent} account={account} improve={improve} text={text} />}\n <AgentDockLayout>\n <div data-slot=\"sidebar-inset\" className=\"relative flex min-h-0 min-w-0 w-full flex-1 flex-col overflow-hidden bg-background\">\n <ShellContext name={active.label} />\n <ScreenHeader fallbackTitle={active.label} compact={compact} avatar={avatar} text={text} slotRef={setSlot} />\n <Screens items={nav} activeId={active.id} scrolls={layout === 'screen'} slot={slot} />\n {compact && <TabBar items={nav} activeId={active.id} onChange={route.onChange} text={text} />}\n </div>\n </AgentDockLayout>\n <AgentLayer />\n </SidebarProvider>;\n}\n\nexport function AppShell(props: AppShellProps) {\n const { layout, agent, improve, title, nav, account, route } = props;\n if (layout === 'chat' && agent) throw new Error('AppShell layout \"chat\" takes no `agent`: the page is the chat (use `improve` for the product manager)');\n const text = appShellText((agent ?? improve)?.chatProps?.locale ?? props.locale);\n return <AgentLayerProvider agent={agent} improve={improve} locale={props.locale}>\n <HeaderProvider><Shell layout={layout} title={title} nav={nav} agent={agent} account={account} route={route} improve={Boolean(improve)} text={text} /></HeaderProvider>\n </AgentLayerProvider>;\n}\n",
70
70
  "type": "registry:component",
71
71
  "target": "components/app-shell/app-shell.tsx"
72
72
  },
@@ -78,7 +78,7 @@
78
78
  },
79
79
  {
80
80
  "path": "registry/app-shell/header.tsx",
81
- "content": "'use client';\n\n// One header per screen. A screen calls `useScreenHeader` and the shell renders it; the most recently mounted caller wins as a whole\n// (a `ListDetail` inside a screen overlays « ← » and a title and keeps the screen's actions) and unmounting restores the previous one. A\n// screen the shell has hidden does not count (ScreenActiveContext). Host concern: Pi has no layout.\nimport { createContext, createElement, useContext, useLayoutEffect, useRef, useSyncExternalStore } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { ArrowLeftIcon } from 'lucide-react';\nimport { SidebarTrigger } from '@/components/ui/sidebar';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { ScreenActiveContext } from './agent-context';\nimport { HeaderActions } from './header-actions';\nimport type { ScreenHeaderAction } from './header-actions';\nimport { AgentTrigger } from './agent-layer';\nimport type { AppShellText } from './text';\n\ninterface Icon { className?: string }\nexport interface ScreenHeaderSpec {\n /** Default: the active nav label. */\n readonly title?: string;\n readonly subtitle?: string;\n /** Shows « ← ». */\n readonly back?: () => void;\n /** At most two are shown; the others go in « … ». */\n readonly actions?: readonly ScreenHeaderAction[];\n}\n\nexport type { ScreenHeaderAction } from './header-actions';\n\ninterface Entry { spec: ScreenHeaderSpec; readonly overlay: boolean }\ninterface Store {\n readonly entries: Entry[];\n version: number;\n readonly listeners: Set<() => void>;\n}\n\nconst HeaderContext = createContext<Store | null>(null);\n\n/** A number per icon component, so a change of icon is a change of header. */\nconst icons = new WeakMap<object, number>();\nlet iconCount = 0;\nconst iconId = (icon: ComponentType<Icon>): number => { let id = icons.get(icon); if (id === undefined) { iconCount += 1; id = iconCount; icons.set(icon, id); } return id; };\n\nconst touch = (store: Store) => { store.version += 1; for (const listener of [...store.listeners]) listener(); };\n\n/** Holds the screens' headers. `AppShell` mounts one. */\nexport function HeaderProvider({ children }: { readonly children: ReactNode }) {\n const store = useRef<Store>({ entries: [], version: 0, listeners: new Set() });\n return createElement(HeaderContext.Provider, { value: store.current }, children);\n}\n\n/**\n * Registers the calling screen's header while it is mounted and on show. The latest spec is always read at click time. An `overlay`\n * (the shell's own `ListDetail`) is merged over the screen's header whatever the effect order: it adds « ← » and a title, and keeps the actions.\n */\nexport function useScreenHeader(spec: ScreenHeaderSpec, options?: { readonly overlay?: boolean }): void {\n const store = useContext(HeaderContext);\n const active = useContext(ScreenActiveContext);\n const entry = useRef<Entry>({ spec, overlay: options?.overlay === true });\n entry.current.spec = spec;\n const key = JSON.stringify([spec.title, spec.subtitle, Boolean(spec.back), spec.actions?.map(action => [action.id, action.label, action.showLabel, action.href, action.target, action.rel, iconId(action.icon)])]);\n useLayoutEffect(() => {\n if (!store || !active) return;\n const mine = entry.current;\n store.entries.push(mine);\n touch(store);\n return () => { const at = store.entries.indexOf(mine); if (at >= 0) store.entries.splice(at, 1); touch(store); };\n }, [store, active]);\n useLayoutEffect(() => { if (store && active) touch(store); }, [store, active, key]);\n}\n\n/** The header of the screen on show (the most recently mounted caller), or undefined. Re-renders when screens come and go or change their header. */\nexport function useTopHeader(): { readonly spec: ScreenHeaderSpec } | undefined {\n const store = useContext(HeaderContext);\n useSyncExternalStore(\n listener => { store?.listeners.add(listener); return () => { store?.listeners.delete(listener); }; },\n () => store?.version ?? 0,\n () => 0,\n );\n // A getter, so a click reads the live specs (a callback that changed without a visible change is still the current one).\n return store?.entries.length ? { get spec() { return mergedSpec(store.entries); } } : undefined;\n}\n\n/** The latest ordinary entry is the whole header; explicit overlays (the shell's `ListDetail`) are merged over it field by field. */\nfunction mergedSpec(entries: readonly Entry[]): ScreenHeaderSpec {\n const base = entries.filter(entry => !entry.overlay).at(-1);\n const spec: Record<string, unknown> = { ...base?.spec };\n for (const entry of entries) if (entry.overlay) for (const [field, value] of Object.entries(entry.spec)) if (value !== undefined) spec[field] = value;\n return spec as ScreenHeaderSpec;\n}\n\n\n/**\n * The header of the screen on show: the avatar button (compact) or the sidebar trigger (expanded) or « ← », then the title and subtitle,\n * then up to two actions, « … » and the agent trigger. `avatar` is the compact account button.\n */\nexport function ScreenHeader({ fallbackTitle, compact, avatar, text }: { readonly fallbackTitle: string; readonly compact: boolean; readonly avatar: ReactNode; readonly text: AppShellText }) {\n const top = useTopHeader();\n const spec = top?.spec;\n const actions = spec?.actions ?? [];\n const run = (id: string) => top?.spec.actions?.find(action => action.id === id)?.onSelect();\n const back = spec?.back ? <Button variant=\"ghost\" size=\"icon\" data-testid=\"header-back\" aria-label={text.goBack} title={text.goBack} onClick={() => top?.spec.back?.()}><ArrowLeftIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button> : null;\n return <header data-testid=\"screen-header\" className=\"sticky top-0 z-10 flex min-h-14 shrink-0 items-center gap-1 bg-background px-2 pt-[env(safe-area-inset-top)]\">\n <Separator className=\"absolute inset-x-0 bottom-0\" />\n {compact ? (back ?? avatar) : <><SidebarTrigger />{back}</>}\n <div className=\"min-w-0 flex-1 px-1\">\n <h1 data-testid=\"header-title\" className=\"m-0 truncate text-base leading-tight font-semibold [font-family:var(--font-display,var(--font-sans))]\">{spec?.title ?? fallbackTitle}</h1>\n {spec?.subtitle && <p data-testid=\"header-subtitle\" className=\"m-0 truncate text-xs text-muted-foreground\">{spec.subtitle}</p>}\n </div>\n <HeaderActions actions={actions} compact={compact} text={text} prefix=\"header\" run={run} />\n {compact && back && avatar}\n <AgentTrigger />\n </header>;\n}\n",
81
+ "content": "'use client';\n\n// One header per screen. A screen calls `useScreenHeader` and the shell renders it; the most recently mounted caller wins as a whole\n// (a `ListDetail` inside a screen overlays « ← » and a title and keeps the screen's actions) and unmounting restores the previous one. A\n// screen the shell has hidden does not count (ScreenActiveContext). Host concern: Pi has no layout.\nimport { createContext, createElement, useContext, useLayoutEffect, useRef, useSyncExternalStore } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { ArrowLeftIcon } from 'lucide-react';\nimport { SidebarTrigger } from '@/components/ui/sidebar';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { ScreenActiveContext } from './agent-context';\nimport { HeaderActions } from './header-actions';\nimport type { ScreenHeaderAction } from './header-actions';\nimport { AgentTrigger } from './agent-layer';\nimport type { AppShellText } from './text';\n\ninterface Icon { className?: string }\nexport interface ScreenHeaderSpec {\n /** Default: the active nav label. */\n readonly title?: string;\n readonly subtitle?: string;\n /** Shows « ← ». */\n readonly back?: () => void;\n /** At most two are shown; the others go in « … ». */\n readonly actions?: readonly ScreenHeaderAction[];\n}\n\nexport type { ScreenHeaderAction } from './header-actions';\n\ninterface Entry { spec: ScreenHeaderSpec; readonly overlay: boolean }\ninterface Store {\n readonly entries: Entry[];\n version: number;\n readonly listeners: Set<() => void>;\n}\n\nconst HeaderContext = createContext<Store | null>(null);\n\n/** A number per icon component, so a change of icon is a change of header. */\nconst icons = new WeakMap<object, number>();\nlet iconCount = 0;\nconst iconId = (icon: ComponentType<Icon>): number => { let id = icons.get(icon); if (id === undefined) { iconCount += 1; id = iconCount; icons.set(icon, id); } return id; };\n\nconst touch = (store: Store) => { store.version += 1; for (const listener of [...store.listeners]) listener(); };\n\n/** Holds the screens' headers. `AppShell` mounts one. */\nexport function HeaderProvider({ children }: { readonly children: ReactNode }) {\n const store = useRef<Store>({ entries: [], version: 0, listeners: new Set() });\n return createElement(HeaderContext.Provider, { value: store.current }, children);\n}\n\n/**\n * Registers the calling screen's header while it is mounted and on show. The latest spec is always read at click time. An `overlay`\n * (the shell's own `ListDetail`) is merged over the screen's header whatever the effect order: it adds « ← » and a title, and keeps the actions.\n */\nexport function useScreenHeader(spec: ScreenHeaderSpec, options?: { readonly overlay?: boolean }): void {\n const store = useContext(HeaderContext);\n const active = useContext(ScreenActiveContext);\n const entry = useRef<Entry>({ spec, overlay: options?.overlay === true });\n entry.current.spec = spec;\n const key = JSON.stringify([spec.title, spec.subtitle, Boolean(spec.back), spec.actions?.map(action => [action.id, action.label, action.showLabel, action.href, action.target, action.rel, iconId(action.icon)])]);\n useLayoutEffect(() => {\n if (!store || !active) return;\n const mine = entry.current;\n store.entries.push(mine);\n touch(store);\n return () => { const at = store.entries.indexOf(mine); if (at >= 0) store.entries.splice(at, 1); touch(store); };\n }, [store, active]);\n useLayoutEffect(() => { if (store && active) touch(store); }, [store, active, key]);\n}\n\n/** The header of the screen on show (the most recently mounted caller), or undefined. Re-renders when screens come and go or change their header. */\nexport function useTopHeader(): { readonly spec: ScreenHeaderSpec } | undefined {\n const store = useContext(HeaderContext);\n useSyncExternalStore(\n listener => { store?.listeners.add(listener); return () => { store?.listeners.delete(listener); }; },\n () => store?.version ?? 0,\n () => 0,\n );\n // A getter, so a click reads the live specs (a callback that changed without a visible change is still the current one).\n return store?.entries.length ? { get spec() { return mergedSpec(store.entries); } } : undefined;\n}\n\n/** The latest ordinary entry is the whole header; explicit overlays (the shell's `ListDetail`) are merged over it field by field. */\nfunction mergedSpec(entries: readonly Entry[]): ScreenHeaderSpec {\n const base = entries.filter(entry => !entry.overlay).at(-1);\n const spec: Record<string, unknown> = { ...base?.spec };\n for (const entry of entries) if (entry.overlay) for (const [field, value] of Object.entries(entry.spec)) if (value !== undefined) spec[field] = value;\n return spec as ScreenHeaderSpec;\n}\n\n\n/**\n * The header of the screen on show: the avatar button (compact) or the sidebar trigger (expanded) or « ← », then the title and subtitle,\n * then the slot of the screen's own controls (`slotRef`), up to two actions, « … » and the agent trigger. `avatar` is the compact account button.\n */\nexport function ScreenHeader({ fallbackTitle, compact, avatar, text, slotRef }: { readonly fallbackTitle: string; readonly compact: boolean; readonly avatar: ReactNode; readonly text: AppShellText; readonly slotRef?: (element: HTMLElement | null) => void }) {\n const top = useTopHeader();\n const spec = top?.spec;\n const actions = spec?.actions ?? [];\n const run = (id: string) => top?.spec.actions?.find(action => action.id === id)?.onSelect();\n const back = spec?.back ? <Button variant=\"ghost\" size=\"icon\" data-testid=\"header-back\" aria-label={text.goBack} title={text.goBack} onClick={() => top?.spec.back?.()}><ArrowLeftIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button> : null;\n return <header data-testid=\"screen-header\" className=\"sticky top-0 z-10 flex min-h-14 shrink-0 items-center gap-1 bg-background px-2 pt-[env(safe-area-inset-top)]\">\n <Separator className=\"absolute inset-x-0 bottom-0\" />\n {compact ? (back ?? avatar) : <><SidebarTrigger />{back}</>}\n <div className=\"min-w-0 flex-1 px-1\">\n <h1 data-testid=\"header-title\" className=\"m-0 truncate text-base leading-tight font-semibold [font-family:var(--font-display,var(--font-sans))]\">{spec?.title ?? fallbackTitle}</h1>\n {spec?.subtitle && <p data-testid=\"header-subtitle\" className=\"m-0 truncate text-xs text-muted-foreground\">{spec.subtitle}</p>}\n </div>\n {/* An embedded page's own controls (`ScreenHeaderSlotContext`, for example AgentWorkspace layout=\"embedded\"), so it needs no second row. */}\n <div ref={slotRef} data-testid=\"header-slot\" className=\"flex shrink-0 items-center gap-1 empty:hidden\" />\n <HeaderActions actions={actions} compact={compact} text={text} prefix=\"header\" run={run} />\n {compact && back && avatar}\n <AgentTrigger />\n </header>;\n}\n",
82
82
  "type": "registry:component",
83
83
  "target": "components/app-shell/header.tsx"
84
84
  },
package/r/button.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "lucide-react@^1.51.0"
10
10
  ],
11
11
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/utils.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/utils.json",
13
13
  "dropdown-menu",
14
14
  "button",
15
15
  "toggle",
package/r/dock.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "react@^19.0.0"
8
8
  ],
9
9
  "registryDependencies": [
10
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/utils.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/utils.json"
11
11
  ],
12
12
  "files": [
13
13
  {
package/r/documents.json CHANGED
@@ -4,20 +4,20 @@
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.1",
8
- "@hachej/boring-files@^0.4.1",
7
+ "@hachej/boring-ui-kit@^0.4.2",
8
+ "@hachej/boring-files@^0.4.2",
9
9
  "react@^19.0.0",
10
10
  "lucide-react@^1.51.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/theme.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/utils.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/button.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/pi-chat.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/pi-app.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/app-shell.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/file-tree.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/viewers.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/theme.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/utils.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/button.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/pi-chat.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/pi-app.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/app-shell.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/file-tree.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/viewers.json",
21
21
  "drawer",
22
22
  "button",
23
23
  "scroll-area",
@@ -4,7 +4,7 @@
4
4
  "title": "Feedback preview",
5
5
  "description": "PreviewBanner: a live, reversible preview of a change in the page (Approve / Discard) over `@hachej/boring-feedback/preview`, whose subagent calls models through a host gateway. Separate from `feedback` so annotation-only apps do not install the model SDK; install `feedback` first (its styles cover the banner).",
6
6
  "dependencies": [
7
- "@hachej/boring-feedback@^0.4.1",
7
+ "@hachej/boring-feedback@^0.4.2",
8
8
  "@earendil-works/pi-ai@~1.2.0",
9
9
  "react@^19.0.0"
10
10
  ],
package/r/feedback.json CHANGED
@@ -4,16 +4,16 @@
4
4
  "title": "Feedback",
5
5
  "description": "Feedback from the chat composer: one Feedback button starts feedback mode on the application page (hover, click to pin a numbered note, hold ⌥ to use the app, optional voice), and one chip carries the notes to the next message. FeedbackBar, NoteBubble, FeedbackChip with its review, useComposerFeedback for the pi-chat composer, and PickerOverlay styles. Optional, not mounted by default: PointButton, AnnotateSheet, FeedbackList and FeedbackReport. Picking, masking, anchoring and placement stay in @hachej/boring-feedback/page and /ui, so restyling cannot weaken them.",
6
6
  "dependencies": [
7
- "@hachej/boring-feedback@^0.4.1",
8
- "@hachej/boring-ui-kit@^0.4.1",
9
- "@hachej/boring-files@^0.4.1",
7
+ "@hachej/boring-feedback@^0.4.2",
8
+ "@hachej/boring-ui-kit@^0.4.2",
9
+ "@hachej/boring-files@^0.4.2",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0",
12
12
  "lucide-react@^1.51.0"
13
13
  ],
14
14
  "registryDependencies": [
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/utils.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/utils.json",
17
17
  "button",
18
18
  "toggle",
19
19
  "badge",
package/r/file-tree.json CHANGED
@@ -4,15 +4,15 @@
4
4
  "title": "File tree",
5
5
  "description": "Browse, search, upload and inspect retained versions through one revisionProvider binding. Uses the consumer's shadcn Button and dropdown-menu with shared version-history rows; Base UI style only.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.1",
8
- "@hachej/boring-files@^0.4.1",
7
+ "@hachej/boring-ui-kit@^0.4.2",
8
+ "@hachej/boring-files@^0.4.2",
9
9
  "react@^19.0.0",
10
10
  "lucide-react@^1.51.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/theme.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/utils.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/version-history.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/theme.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/utils.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/version-history.json",
16
16
  "button",
17
17
  "dropdown-menu",
18
18
  "collapsible",
@@ -4,13 +4,13 @@
4
4
  "title": "HTML source and passive preview",
5
5
  "description": "Exact HTML source editing with a reconstructed passive preview and borrowed controller. Browser security qualification remains separate.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.1",
8
- "@hachej/boring-files@^0.4.1",
7
+ "@hachej/boring-ui-kit@^0.4.2",
8
+ "@hachej/boring-files@^0.4.2",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/button.json"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/button.json"
14
14
  ],
15
15
  "files": [
16
16
  {
@@ -0,0 +1,90 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "maquette-viewer",
4
+ "title": "Maquette viewer",
5
+ "description": "MaquetteViewer and maquetteKind: a PM agent's *.maquette.json mock-up (format v1, versioned) one screen at a time, drawn from the host's shadcn components, under one thin icon bar: Back, previous and next (also arrow keys and swipe), and a menu with guided tours and How it works (the workflow diagram in a dialog). Optional phone or desktop frame and #screen=<id> deep links. The pi-app artifact panel shows .maquette.json files with it by default; app artifactKinds win.",
6
+ "dependencies": [
7
+ "react@^19.0.0",
8
+ "lucide-react@^1.51.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/theme.json",
12
+ "alert",
13
+ "badge",
14
+ "bubble",
15
+ "button",
16
+ "card",
17
+ "empty",
18
+ "input",
19
+ "item",
20
+ "label",
21
+ "native-select",
22
+ "separator",
23
+ "table",
24
+ "dialog",
25
+ "dropdown-menu",
26
+ "popover"
27
+ ],
28
+ "files": [
29
+ {
30
+ "path": "registry/maquette-viewer/maquette.ts",
31
+ "content": "/*\n * The maquette format: a lo-fi mock-up a PM agent writes as `<name>.maquette.json` (screens built from a small catalogue of blocks, goto\n * links between them, an optional workflow and its cases). Plain data drawn by `MaquetteViewer`: nothing in it is built or runs.\n * The format is documented in registry/README.md (\"Maquette viewer\"). Model output is untrusted: `parseMaquette` reads it defensively,\n * keeps what it understands, bounds every list and string, and never throws.\n */\n\n/** The format version this viewer writes and reads. A file without `version` is read as version 1. */\nexport const MAQUETTE_FORMAT_VERSION = 1;\n\n/** A block as written in format v1. `type` picks the drawing; the other fields are read by the types that use them. */\nexport interface MaquetteBlockV1 {\n readonly type: string;\n /** The block's words (`text` and `title` are accepted as aliases). */\n readonly label?: string;\n readonly text?: string;\n readonly title?: string;\n /** The screen id a click on this block opens (buttons, links, list items, placeholders). */\n readonly goto?: string;\n /** A primary button. */\n readonly primary?: boolean;\n /** Nested blocks of `card`, `row`, `stack` (`blocks` is accepted as an alias). */\n readonly children?: readonly MaquetteBlockV1[];\n readonly blocks?: readonly MaquetteBlockV1[];\n /** `list` rows, or `select` options. */\n readonly items?: readonly string[];\n readonly options?: readonly string[];\n /** `table` header and rows. */\n readonly columns?: readonly string[];\n readonly rows?: readonly (readonly string[])[];\n /** `chat` messages; `from: \"user\"` (or `person`, `me`, `moi`) is the person's side. */\n readonly messages?: readonly { readonly from?: string; readonly text?: string }[];\n /** `document` lines. */\n readonly lines?: readonly string[];\n /** `input`, `select` and `chat` hint and value. */\n readonly placeholder?: string;\n readonly value?: string;\n /** A placeholder's note under its name. */\n readonly note?: string;\n}\n\n/** A maquette file as written in format v1. */\nexport interface MaquetteFileV1 {\n /** Absent means 1. */\n readonly version?: 1;\n readonly title?: string;\n /** The screen id play mode opens first; the first screen otherwise. */\n readonly start?: string;\n /** The frame screens are drawn in: a light phone or desktop frame. Absent: the panel's width. */\n readonly device?: 'phone' | 'desktop';\n /** Screens by id, in the order the stepper walks them. `note` says why the screen exists and what the person does there. */\n readonly screens: Readonly<Record<string, { readonly title?: string; readonly note?: string; readonly blocks: readonly MaquetteBlockV1[] }>>;\n /** The app's navigation bar, shown above every screen. */\n readonly nav?: readonly { readonly label: string; readonly goto: string }[];\n /** The workflow understood: states and the transitions between them (`on` is accepted as an alias of `label`). */\n readonly workflow?: {\n readonly states: readonly ({ readonly id: string; readonly label?: string } | string)[];\n readonly transitions: readonly { readonly from: string; readonly to: string; readonly label?: string; readonly on?: string; readonly by?: string }[];\n };\n /**\n * Cases. `steps` makes a guided tour: its screens in order, each with a caption (\"Camille receives the invoice and approves it\").\n * `path` (workflow state ids) highlights the case in the workflow diagram. A case may carry both.\n */\n readonly cases?: readonly { readonly name: string; readonly steps?: readonly { readonly screen: string; readonly caption?: string }[]; readonly path?: readonly string[] }[];\n}\n\n/** A block as the viewer draws it: every field read, bounded and defaulted. */\nexport interface MaquetteBlock {\n readonly type: string;\n readonly label: string;\n readonly goto: string | undefined;\n readonly primary: boolean;\n readonly children: readonly MaquetteBlock[];\n readonly items: readonly string[];\n readonly columns: readonly string[];\n readonly rows: readonly (readonly string[])[];\n readonly messages: readonly { readonly mine: boolean; readonly text: string }[];\n readonly lines: readonly string[];\n readonly placeholder: string;\n readonly value: string;\n readonly note: string;\n}\nexport interface MaquetteScreen { readonly id: string; readonly title: string; readonly note: string; readonly blocks: readonly MaquetteBlock[] }\nexport interface MaquetteState { readonly id: string; readonly label: string }\nexport interface MaquetteTransition { readonly from: string; readonly to: string; readonly label: string; readonly by: string }\nexport interface MaquetteWorkflow { readonly states: readonly MaquetteState[]; readonly transitions: readonly MaquetteTransition[] }\nexport interface MaquetteCase { readonly name: string; readonly steps: readonly { readonly screen: string; readonly caption: string }[]; readonly path: readonly string[] }\n/** A maquette ready to draw. */\nexport interface Maquette {\n readonly title: string;\n readonly start: string;\n readonly device: 'phone' | 'desktop' | undefined;\n readonly screens: readonly MaquetteScreen[];\n readonly nav: readonly { readonly label: string; readonly goto: string }[];\n readonly workflow: MaquetteWorkflow | null;\n readonly cases: readonly MaquetteCase[];\n}\n\n/**\n * The outcome of reading a file. `version` is the file's declared version (1 when absent); a version above `MAQUETTE_FORMAT_VERSION`\n * is read as v1, best effort. `invalid` is not JSON (`json`) or has no screen to show (`shape`).\n */\nexport type MaquetteParse =\n | { readonly kind: 'ok'; readonly maquette: Maquette; readonly version: number }\n | { readonly kind: 'invalid'; readonly reason: 'json' | 'shape'; readonly version: number | undefined };\n\nconst MINE = new Set(['user', 'person', 'me', 'moi']);\nconst str = (value: unknown, max = 400): string => typeof value === 'string' ? value.slice(0, max) : typeof value === 'number' && Number.isFinite(value) ? String(value) : '';\nconst arr = (value: unknown, max = 60): readonly unknown[] => Array.isArray(value) ? value.slice(0, max) : [];\nconst obj = (value: unknown): Record<string, unknown> => value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};\nconst strings = (value: unknown, max: number, length = 120): string[] => arr(value, max).map(item => str(item, length));\n\nfunction block(value: unknown, depth: number): MaquetteBlock | null {\n const raw = obj(value);\n const type = str(raw['type'], 40).trim().toLowerCase();\n if (!type || depth > 6) return null;\n const goto = str(raw['goto'], 60);\n return {\n type, label: str(raw['label'] ?? raw['text'] ?? raw['title']), goto: goto || undefined, primary: raw['primary'] === true,\n children: arr(raw['children'] ?? raw['blocks'], 40).map(child => block(child, depth + 1)).filter((child): child is MaquetteBlock => child !== null),\n items: strings(raw['items'] ?? raw['options'], 12),\n columns: strings(raw['columns'], 8, 40),\n rows: arr(raw['rows'], 12).map(row => strings(row, 8, 80)),\n messages: arr(raw['messages'], 10).map(item => { const message = obj(item); return { mine: MINE.has(str(message['from'], 20).toLowerCase()), text: str(message['text'], 600) }; }).filter(message => message.text),\n lines: strings(raw['lines'], 16, 300),\n placeholder: str(raw['placeholder'], 120), value: str(raw['value'], 120), note: str(raw['note'], 200),\n };\n}\n\n/** Reads a maquette (its JSON text or an already parsed value). Never throws. */\nexport function parseMaquette(input: unknown): MaquetteParse {\n let raw: unknown = input;\n if (typeof raw === 'string') {\n try { raw = JSON.parse(raw); } catch { return { kind: 'invalid', reason: 'json', version: undefined }; }\n }\n const spec = obj(raw);\n const declared = spec['version'];\n const version = declared === undefined ? 1 : typeof declared === 'number' && Number.isSafeInteger(declared) && declared >= 1 ? declared : undefined;\n if (version === undefined) return { kind: 'invalid', reason: 'shape', version: undefined };\n const screens = Object.entries(obj(spec['screens'])).slice(0, 30).map(([id, value]): MaquetteScreen => {\n const screen = obj(value);\n return { id: id.slice(0, 60), title: str(screen['title'], 120) || id.slice(0, 60), note: str(screen['note'], 300),\n blocks: arr(screen['blocks'] ?? screen['children']).map(item => block(item, 0)).filter((item): item is MaquetteBlock => item !== null) };\n });\n if (!screens.length) return { kind: 'invalid', reason: 'shape', version };\n const ids = new Set(screens.map(screen => screen.id));\n const flow = obj(spec['workflow']);\n const states = arr(flow['states'], 40).map((value): MaquetteState | null => {\n const state = obj(value), id = str(state['id'], 60) || str(value, 60);\n return id ? { id, label: str(state['label'], 80) || id } : null;\n }).filter((state): state is MaquetteState => state !== null);\n const known = new Set(states.map(state => state.id));\n const transitions = arr(flow['transitions'], 80).map((value): MaquetteTransition => {\n const transition = obj(value);\n return { from: str(transition['from'], 60), to: str(transition['to'], 60), label: str(transition['label'] ?? transition['on'], 80), by: str(transition['by'], 40) };\n }).filter(transition => known.has(transition.from) && known.has(transition.to));\n const start = str(spec['start'], 60), device = spec['device'];\n return { kind: 'ok', version, maquette: {\n title: str(spec['title'], 120), start: ids.has(start) ? start : screens[0]!.id, device: device === 'phone' || device === 'desktop' ? device : undefined, screens,\n nav: arr(spec['nav'], 8).map(value => { const item = obj(value); return { label: str(item['label'], 40), goto: str(item['goto'], 60) }; }).filter(item => item.label && ids.has(item.goto)),\n workflow: states.length ? { states, transitions } : null,\n cases: arr(spec['cases'], 10).map((value): MaquetteCase => {\n const item = obj(value);\n const steps = arr(item['steps'], 20).map(step => { const raw = obj(step); return { screen: str(raw['screen'], 60), caption: str(raw['caption'], 300) }; }).filter(step => ids.has(step.screen));\n return { name: str(item['name'], 80), steps, path: strings(item['path'], 20, 60).filter(id => known.has(id)) };\n }).filter(item => item.name && (item.steps.length > 0 || item.path.length > 0)),\n } };\n}\n\n/** Every screen id a block tree links to, in order, without repeats. */\nexport function linksOf(blocks: readonly MaquetteBlock[]): string[] {\n const found = new Set<string>();\n const walk = (list: readonly MaquetteBlock[]) => { for (const item of list) { if (item.goto) found.add(item.goto); walk(item.children); } };\n walk(blocks);\n return [...found];\n}\n\n/** True for a path the built-in maquette kind draws: a `.maquette.json` file. */\nexport const isMaquettePath = (path: string): boolean => /\\.maquette\\.json$/i.test(path);\n\n/** The screen id a URL fragment names (`#screen=<id>`, alone or among other `&`-separated keys), or undefined. */\nexport function screenFromFragment(fragment: string | undefined): string | undefined {\n if (!fragment) return undefined;\n try { return new URLSearchParams(fragment.replace(/^#/, '')).get('screen') ?? undefined; } catch { return undefined; }\n}\n",
32
+ "type": "registry:lib",
33
+ "target": "components/maquette-viewer/maquette.ts"
34
+ },
35
+ {
36
+ "path": "registry/maquette-viewer/maquette-labels.ts",
37
+ "content": "/* The maquette viewer's words in English and French. `locale` picks the base set; a host's `labels` still override any of them. */\n\nexport type MaquetteLocale = 'en' | 'fr';\n\nexport const defaultMaquetteLabels = {\n /** The kind's name on the artifact card and in the panel bar. */\n kind: 'Mock-up',\n screens: 'Screens',\n previous: 'Previous screen',\n next: 'Next screen',\n back: 'Back',\n more: 'More',\n appNavigation: 'App navigation',\n howItWorks: 'How it works',\n endTour: 'End tour',\n aboutScreen: 'About this screen',\n opens: (title: string) => `Opens “${title}”`,\n tourStep: (index: number, count: number) => `Step ${index} of ${count}`,\n transitionBy: (by: string) => `by ${by}`,\n unknownBlock: 'Not drawn in this mock-up',\n showing: (title: string) => `Showing ${title}`,\n newerTitle: 'Made with a newer version',\n newer: (version: number) => `This mock-up uses format version ${version}. This viewer reads version 1: parts it does not know may be missing.`,\n errorTitle: 'This mock-up cannot be shown',\n invalidJson: 'The file is not valid JSON. Ask the PM to present it again.',\n invalidShape: 'The file has no screen to show (a mock-up needs \"screens\": { \"<id>\": { \"title\", \"blocks\" } }).',\n};\nexport type MaquetteLabels = typeof defaultMaquetteLabels;\n\n/** Typed as `MaquetteLabels`, so a key added in English must be added here too. */\nexport const frenchMaquetteLabels: MaquetteLabels = {\n kind: 'Maquette',\n screens: 'Écrans',\n previous: 'Écran précédent',\n next: 'Écran suivant',\n back: 'Retour',\n more: 'Plus',\n appNavigation: 'Navigation de l’application',\n howItWorks: 'Comment ça marche',\n endTour: 'Terminer la visite',\n aboutScreen: 'À propos de cet écran',\n opens: title => `Ouvre « ${title} »`,\n tourStep: (index, count) => `Étape ${index} sur ${count}`,\n transitionBy: by => `par ${by}`,\n unknownBlock: 'Pas dessiné dans cette maquette',\n showing: title => `Écran affiché : ${title}`,\n newerTitle: 'Faite avec une version plus récente',\n newer: version => `Cette maquette utilise la version ${version} du format. Cet afficheur lit la version 1 : ce qu’il ne connaît pas peut manquer.`,\n errorTitle: 'Cette maquette ne peut pas être affichée',\n invalidJson: 'Le fichier n’est pas du JSON valide. Demandez au PM de la présenter à nouveau.',\n invalidShape: 'Le fichier n’a aucun écran à montrer (une maquette a besoin de « screens » : { \"<id>\": { \"title\", \"blocks\" } }).',\n};\n\n/** The viewer's words for `locale` (English by default) with the host's overrides. */\nexport const maquetteLabelsFor = (locale: MaquetteLocale | undefined, labels?: Partial<MaquetteLabels>): MaquetteLabels =>\n ({ ...(locale === 'fr' ? frenchMaquetteLabels : defaultMaquetteLabels), ...labels });\n",
38
+ "type": "registry:lib",
39
+ "target": "components/maquette-viewer/maquette-labels.ts"
40
+ },
41
+ {
42
+ "path": "registry/maquette-viewer/maquette-blocks.tsx",
43
+ "content": "'use client';\n\n// One maquette screen drawn from the host's own shadcn components: each catalogue type is a real Button, Input, NativeSelect, Badge, Table,\n// Item, Card or Bubble, so a mock-up looks like the app it announces. A type the catalogue does not know becomes a named placeholder\n// (a grey dashed Empty). Blocks with a goto open their screen; the item's CSS gives them a soft ring on hover and focus.\nimport type { ReactNode, Ref } from 'react';\nimport { BoxIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty';\nimport { Input } from '@/components/ui/input';\nimport { Item, ItemContent, ItemGroup, ItemTitle } from '@/components/ui/item';\nimport { Label } from '@/components/ui/label';\nimport { NativeSelect, NativeSelectOption } from '@/components/ui/native-select';\nimport { Separator } from '@/components/ui/separator';\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';\nimport type { Maquette, MaquetteBlock, MaquetteScreen } from './maquette';\nimport type { MaquetteLabels } from './maquette-labels';\n\n/** Opens a screen by id. */\nexport type MaquetteGo = (screen: string) => void;\n/** How a block reaches other screens: `go` opens one, `hint` names where a goto leads (the button's tooltip). */\nexport interface MaquetteLinks { readonly go: MaquetteGo; readonly hint: (screen: string) => string }\n\nconst SKELETON = [92, 100, 64];\n\nfunction Placeholder({ block, links, labels }: { readonly block: MaquetteBlock; readonly links: MaquetteLinks; readonly labels: MaquetteLabels }) {\n return <div data-testid=\"maquette-placeholder\" data-type={block.type}>\n <Empty data-maquette=\"placeholder\">\n <EmptyHeader>\n <EmptyMedia variant=\"icon\"><BoxIcon aria-hidden=\"true\" /></EmptyMedia>\n <EmptyTitle>{block.label || block.type}</EmptyTitle>\n <EmptyDescription>{block.note || labels.unknownBlock}</EmptyDescription>\n </EmptyHeader>\n {block.goto && <EmptyContent><GotoButton block={block} links={links} label={block.label || block.type} /></EmptyContent>}\n </Empty>\n </div>;\n}\n\n// A block with a goto opens its screen (its tooltip names it); one without is drawn but does nothing.\nfunction GotoButton({ block, links, label }: { readonly block: MaquetteBlock; readonly links: MaquetteLinks; readonly label: string }) {\n const target = block.goto;\n return <Button type=\"button\" variant={block.type === 'link' ? 'link' : block.primary ? 'default' : 'outline'} className=\"w-fit max-w-full pointer-coarse:min-h-11\"\n data-testid={target ? 'maquette-goto' : 'maquette-button'} data-goto={target} title={target ? links.hint(target) : undefined} onClick={() => { if (target) links.go(target); }}>\n <span className=\"truncate\">{label || '…'}</span>\n </Button>;\n}\n\n/** One block and its children. */\nexport function MaquetteBlockView({ block, links, labels }: { readonly block: MaquetteBlock; readonly links: MaquetteLinks; readonly labels: MaquetteLabels }): ReactNode {\n const children = block.children.map((child, index) => <MaquetteBlockView key={index} block={child} links={links} labels={labels} />);\n switch (block.type) {\n case 'heading': return <h4 className=\"m-0 text-base font-semibold text-balance\">{block.label}</h4>;\n case 'text': return <p className=\"m-0 text-sm text-pretty text-muted-foreground\">{block.label}</p>;\n case 'button': case 'link': return <GotoButton block={block} links={links} label={block.label} />;\n case 'input': return <Label className=\"flex-col items-stretch gap-1.5\">{block.label}\n <Input className=\"pointer-coarse:min-h-11\" placeholder={block.placeholder} defaultValue={block.value} /></Label>;\n case 'select': return <Label className=\"flex-col items-stretch gap-1.5\">{block.label}\n <NativeSelect className=\"w-full pointer-coarse:[&_select]:min-h-11\" defaultValue={block.value}>\n {(block.items.length ? block.items : [block.value || block.placeholder || '…']).map((option, index) => <NativeSelectOption key={index} value={option}>{option}</NativeSelectOption>)}\n </NativeSelect></Label>;\n case 'badge': return <Badge variant=\"secondary\">{block.label}</Badge>;\n case 'list': return <ItemGroup className=\"gap-2\">\n {block.label && <p className=\"m-0 text-sm font-medium\">{block.label}</p>}\n {block.items.map((item, index) => <Item key={index} variant=\"outline\" size=\"sm\"><ItemContent><ItemTitle>{item}</ItemTitle></ItemContent></Item>)}\n </ItemGroup>;\n case 'table': return <div className=\"min-w-0 overflow-x-auto\"><Table>\n <TableHeader><TableRow>{block.columns.map((column, index) => <TableHead key={index}>{column}</TableHead>)}</TableRow></TableHeader>\n <TableBody>{block.rows.map((row, index) => <TableRow key={index}>{row.map((cell, column) => <TableCell key={column}>{cell}</TableCell>)}</TableRow>)}</TableBody>\n </Table></div>;\n case 'card': return <Card size=\"sm\">\n {block.label && <CardHeader><CardTitle>{block.label}</CardTitle></CardHeader>}\n {children.length > 0 && <CardContent className=\"flex flex-col gap-3\">{children}</CardContent>}\n </Card>;\n case 'row': return <div className=\"flex flex-wrap items-start gap-3 *:min-w-40 *:flex-1\">{children}</div>;\n case 'stack': return <div className=\"flex flex-col gap-3\">{children}</div>;\n case 'chat': return <Card size=\"sm\">\n {block.label && <CardHeader><CardTitle>{block.label}</CardTitle></CardHeader>}\n <CardContent className=\"flex flex-col gap-2\">\n {block.messages.map((message, index) => <Bubble key={index} align={message.mine ? 'end' : 'start'} variant={message.mine ? 'default' : 'muted'}><BubbleContent>{message.text}</BubbleContent></Bubble>)}\n <Input className=\"pointer-coarse:min-h-11\" placeholder={block.placeholder || '…'} aria-label={block.placeholder || block.label || 'chat'} />\n {children}\n </CardContent>\n </Card>;\n case 'document': return <Card size=\"sm\">\n {block.label && <CardHeader><CardTitle>{block.label}</CardTitle></CardHeader>}\n <CardContent className=\"flex flex-col gap-2\">\n {block.lines.length ? block.lines.map((line, index) => <p key={index} className=\"m-0 text-sm text-muted-foreground\">{line}</p>)\n : SKELETON.map(width => <span key={width} aria-hidden=\"true\" className=\"block h-2 bg-muted\" style={{ width: `${width}%` }} />)}\n {children}\n </CardContent>\n </Card>;\n case 'placeholder': default: return <Placeholder block={block} links={links} labels={labels} />;\n }\n}\n\n/** A screen as the app would show it: the app's navigation bar (when the maquette has one), then its blocks. Its name is for screen readers and focus. */\nexport function MaquetteScreenView({ maquette, screen, links, labels, headingRef }: {\n readonly maquette: Maquette; readonly screen: MaquetteScreen; readonly links: MaquetteLinks; readonly labels: MaquetteLabels;\n readonly headingRef?: Ref<HTMLHeadingElement> | undefined;\n}) {\n return <div data-testid=\"maquette-screen\" data-screen={screen.id} className=\"flex min-w-0 flex-col gap-4\">\n {maquette.nav.length > 0 && <>\n <nav aria-label={labels.appNavigation} className=\"flex flex-wrap gap-1\">\n {maquette.nav.map(item => <Button key={item.goto} type=\"button\" size=\"sm\" variant={item.goto === screen.id ? 'secondary' : 'ghost'} className=\"pointer-coarse:min-h-11\"\n aria-current={item.goto === screen.id ? 'page' : undefined} data-testid=\"maquette-nav\" title={links.hint(item.goto)} onClick={() => links.go(item.goto)}>{item.label}</Button>)}\n </nav>\n <Separator />\n </>}\n <h3 ref={headingRef} tabIndex={-1} data-testid=\"maquette-screen-title\" className=\"sr-only\">{screen.title}</h3>\n {screen.blocks.map((block, index) => <MaquetteBlockView key={index} block={block} links={links} labels={labels} />)}\n </div>;\n}\n",
44
+ "type": "registry:component",
45
+ "target": "components/maquette-viewer/maquette-blocks.tsx"
46
+ },
47
+ {
48
+ "path": "registry/maquette-viewer/maquette-diagram.tsx",
49
+ "content": "'use client';\n\n// The workflow diagram: states in columns by distance from the first state, one arrow per transition with a numbered badge, and a legend\n// under it that says what happens and who acts (labels of any length). Columns run left to right while they fit the box; in a narrow box\n// (a phone) the steps run top to bottom instead. A diagram still wider than its box scrolls inside it, never the page.\nimport { useEffect, useId, useRef, useState } from 'react';\nimport { Badge } from '@/components/ui/badge';\nimport type { MaquetteWorkflow } from './maquette';\nimport type { MaquetteLabels } from './maquette-labels';\n\nconst H = 44, W_WIDE = 148, GX = 72, GY = 36, VX = 16, VY = 56, LOOP = 36;\n\ntype Point = { readonly x: number; readonly y: number };\n\n/** Each state's step: the shortest number of transitions from the first state; states it never reaches come after, one step each. */\nfunction steps(workflow: MaquetteWorkflow): Map<number, string[]> {\n const { states, transitions } = workflow;\n const depth = new Map<string, number>([[states[0]!.id, 0]]);\n for (let changed = true, guard = 0; changed && guard < 50; guard++) {\n changed = false;\n for (const t of transitions) if (depth.has(t.from) && !depth.has(t.to)) { depth.set(t.to, depth.get(t.from)! + 1); changed = true; }\n }\n let extra = Math.max(0, ...depth.values()) + 1;\n for (const s of states) if (!depth.has(s.id)) depth.set(s.id, extra++);\n const columns = new Map<number, string[]>();\n for (const s of states) { const d = depth.get(s.id)!; columns.set(d, [...(columns.get(d) ?? []), s.id]); }\n return columns;\n}\n\n/** The SVG path of a transition and where its number sits. */\nfunction arrow(a: Point, b: Point, W: number, vertical: boolean): { d: string; x: number; y: number } {\n if (!vertical) {\n // Next step: right edge to left edge. Same column, below: straight down. Otherwise (back, or skipping a step): a loop under both boxes.\n const kind = b.x > a.x && b.x - a.x <= W + GX ? 'forward' : b.x === a.x && b.y > a.y ? 'down' : 'back';\n const x1 = kind === 'forward' ? a.x + W : a.x + W / 2, y1 = kind === 'forward' ? a.y + H / 2 : a.y + H;\n const x2 = kind === 'forward' ? b.x : b.x + W / 2, y2 = kind === 'forward' ? b.y + H / 2 : kind === 'down' ? b.y : b.y + H;\n const d = kind === 'forward' ? `M${x1} ${y1} C${x1 + GX / 2} ${y1} ${x2 - GX / 2} ${y2} ${x2} ${y2}` : kind === 'down' ? `M${x1} ${y1} L${x2} ${y2}` : `M${x1} ${y1} C${x1} ${y1 + 30} ${x2} ${y2 + 30} ${x2} ${y2}`;\n return { d, x: kind === 'down' ? x1 + 12 : (x1 + x2) / 2, y: kind === 'back' ? Math.max(y1, y2) + 22 : (y1 + y2) / 2 };\n }\n // Next step: bottom edge to top edge. Same row: side to side. Otherwise (back, or skipping a step): a loop on the right.\n const kind = b.y > a.y && b.y - a.y <= H + VY ? 'forward' : b.y === a.y ? 'across' : 'back';\n const right = b.x > a.x;\n const x1 = kind === 'forward' ? a.x + W / 2 : kind === 'across' ? (right ? a.x + W : a.x) : a.x + W, y1 = kind === 'forward' ? a.y + H : a.y + H / 2;\n const x2 = kind === 'forward' ? b.x + W / 2 : kind === 'across' ? (right ? b.x : b.x + W) : b.x + W, y2 = kind === 'forward' ? b.y : b.y + H / 2;\n const out = Math.max(x1, x2) + LOOP - 8;\n const d = kind === 'forward' ? `M${x1} ${y1} C${x1} ${y1 + VY / 2} ${x2} ${y2 - VY / 2} ${x2} ${y2}` : kind === 'across' ? `M${x1} ${y1} L${x2} ${y2}` : `M${x1} ${y1} C${out} ${y1} ${out} ${y2} ${x2} ${y2}`;\n return { d, x: kind === 'back' ? out - 6 : (x1 + x2) / 2, y: (y1 + y2) / 2 };\n}\n\n/** The diagram and its legend. `highlight`: the states of the case on display, drawn stronger with the transitions between them. */\nexport function MaquetteDiagram({ workflow, highlight, labels }: { readonly workflow: MaquetteWorkflow; readonly highlight: readonly string[]; readonly labels: MaquetteLabels }) {\n const { states, transitions } = workflow;\n const box = useRef<HTMLDivElement>(null);\n const [room, setRoom] = useState(0);\n const marker = `maquette-arrow-${useId().replace(/:/g, '')}`;\n useEffect(() => {\n const element = box.current;\n if (!element) return undefined;\n const measure = () => setRoom(element.clientWidth);\n measure();\n if (typeof ResizeObserver === 'undefined') return undefined;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n const columns = steps(workflow);\n const count = Math.max(...columns.keys()) + 1, widest = Math.max(...[...columns.values()].map(ids => ids.length));\n const vertical = room > 0 && 16 + count * W_WIDE + (count - 1) * GX > room;\n const W = vertical ? Math.max(104, Math.min(W_WIDE, Math.floor((room - 16 - LOOP - (widest - 1) * VX) / widest))) : W_WIDE;\n const pos = new Map<string, Point>();\n for (const [d, ids] of columns) ids.forEach((id, i) => pos.set(id, vertical ? { x: 8 + i * (W + VX), y: 8 + d * (H + VY) } : { x: 8 + d * (W + GX), y: 8 + i * (H + GY) }));\n const points = [...pos.values()];\n const width = (vertical ? LOOP : 16) + Math.max(...points.map(p => p.x)) + W + 8, height = (vertical ? 16 : 40) + Math.max(...points.map(p => p.y)) + H;\n const on = new Set(highlight);\n const label = (id: string) => states.find(s => s.id === id)?.label ?? id;\n const fit = Math.floor((W - 16) / 7);\n return <div className=\"flex min-w-0 flex-col gap-4\">\n <div ref={box} data-testid=\"maquette-diagram\" data-layout={vertical ? 'vertical' : 'horizontal'} className=\"min-w-0 overflow-x-auto\">\n <svg role=\"img\" aria-label={states.map(s => s.label).join(' → ')} width={width} height={height} viewBox={`0 0 ${width} ${height}`} className=\"block\">\n <defs><marker id={marker} viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"6\" markerHeight=\"6\" orient=\"auto-start-reverse\"><path d=\"M0 0L10 5L0 10z\" className=\"fill-muted-foreground\" /></marker></defs>\n {transitions.map((t, i) => {\n const path = arrow(pos.get(t.from)!, pos.get(t.to)!, W, vertical);\n const lit = on.has(t.from) && on.has(t.to);\n return <g key={i} data-lit={lit || undefined}>\n <path d={path.d} fill=\"none\" className={lit ? 'stroke-primary' : 'stroke-muted-foreground/60'} strokeWidth={lit ? 2 : 1.25} markerEnd={`url(#${marker})`} />\n <circle cx={path.x} cy={path.y} r=\"9\" className={lit ? 'fill-primary stroke-primary' : 'fill-background stroke-border'} />\n <text x={path.x} y={path.y + 3.5} textAnchor=\"middle\" fontSize=\"10\" className={lit ? 'fill-primary-foreground' : 'fill-foreground'}>{i + 1}</text>\n </g>;\n })}\n {states.map(s => {\n const p = pos.get(s.id)!, lit = on.has(s.id);\n return <g key={s.id} data-state={s.id} data-lit={lit || undefined}>\n <rect x={p.x} y={p.y} width={W} height={H} rx=\"10\" className={lit ? 'fill-primary/10 stroke-primary' : 'fill-card stroke-border'} strokeWidth={lit ? 2 : 1} />\n <text x={p.x + W / 2} y={p.y + H / 2 + 4} textAnchor=\"middle\" fontSize=\"12\" className=\"fill-foreground\">{s.label.length > fit ? `${s.label.slice(0, fit - 1)}…` : s.label}<title>{s.label}</title></text>\n </g>;\n })}\n </svg>\n </div>\n {transitions.length > 0 && <ol data-testid=\"maquette-legend\" className=\"m-0 list-none p-0 grid gap-x-6 gap-y-2 text-sm sm:grid-cols-2\">\n {transitions.map((t, i) => <li key={i} className=\"flex min-w-0 items-start gap-2\">\n <Badge variant={on.has(t.from) && on.has(t.to) ? 'default' : 'outline'} className=\"tabular-nums\">{i + 1}</Badge>\n <span className=\"min-w-0 text-pretty\"><span className=\"font-medium\">{label(t.from)} → {label(t.to)}</span>{t.label && <>: {t.label}</>}\n {t.by && <span className=\"text-muted-foreground\"> ({labels.transitionBy(t.by)})</span>}</span>\n </li>)}\n </ol>}\n </div>;\n}\n",
50
+ "type": "registry:component",
51
+ "target": "components/maquette-viewer/maquette-diagram.tsx"
52
+ },
53
+ {
54
+ "path": "registry/maquette-viewer/maquette-viewer.tsx",
55
+ "content": "'use client';\n\n// The maquette viewer: a PM agent's `*.maquette.json` mock-up, one screen at a time. The screen is the content; the chrome is one thin bar\n// of icons: Back, ‹ 2 / 5 › (also arrow keys and swipe) and a menu holding the guided tours and \"How it works\" (the workflow, in a dialog).\n// During a tour the bar shows the step dots and ×, with the step's caption under it. `maquetteKind` is the kind the pi-app artifact panel\n// uses by default for `.maquette.json` files; an app's own kind in `artifactKinds` wins. It draws data only: nothing in the file runs.\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport type { ComponentType, KeyboardEvent, ReactNode, TouchEvent } from 'react';\nimport { ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon, CompassIcon, EllipsisIcon, InfoIcon, LayoutTemplateIcon, TriangleAlertIcon, WorkflowIcon, XIcon } from 'lucide-react';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent } from '@/components/ui/card';\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { MAQUETTE_FORMAT_VERSION, isMaquettePath, parseMaquette, screenFromFragment } from './maquette';\nimport type { Maquette, MaquetteCase, MaquetteScreen } from './maquette';\nimport { maquetteLabelsFor } from './maquette-labels';\nimport type { MaquetteLabels, MaquetteLocale } from './maquette-labels';\nimport { MaquetteScreenView } from './maquette-blocks';\nimport type { MaquetteLinks } from './maquette-blocks';\nimport { MaquetteDiagram } from './maquette-diagram';\n\nexport { MAQUETTE_FORMAT_VERSION, isMaquettePath, linksOf, parseMaquette, screenFromFragment } from './maquette';\nexport type { Maquette, MaquetteBlock, MaquetteBlockV1, MaquetteCase, MaquetteFileV1, MaquetteParse, MaquetteScreen, MaquetteState, MaquetteTransition, MaquetteWorkflow } from './maquette';\nexport { defaultMaquetteLabels, frenchMaquetteLabels, maquetteLabelsFor } from './maquette-labels';\nexport type { MaquetteLabels, MaquetteLocale } from './maquette-labels';\n\nexport interface MaquetteViewerProps {\n /** The file's text (JSON). */\n readonly text: string;\n /** The page's URL fragment: `screen=<id>` opens that screen (and follows later changes). */\n readonly fragment?: string | undefined;\n /** The base words: English by default. */\n readonly locale?: MaquetteLocale | undefined;\n /** Any word over the locale's. */\n readonly labels?: Partial<MaquetteLabels> | undefined;\n}\n\n/** Where the person is: the screens visited (the last is shown) and the guided tour under way. */\ninterface Walk { readonly history: readonly string[]; readonly tour: { readonly name: string; readonly index: number } | null }\n\nconst TYPING = 'input, select, textarea, [contenteditable=\"true\"]';\nconst ICON = 'pointer-coarse:min-h-11 pointer-coarse:min-w-11';\n\n/** One dot per tour step, the current one in the primary colour. */\nfunction Dots({ count, index, label }: { readonly count: number; readonly index: number; readonly label: string }) {\n return <svg role=\"img\" aria-label={label} data-testid=\"maquette-dots\" width={count * 12} height=\"8\" viewBox={`0 0 ${count * 12} 8`} className=\"shrink-0\">\n {Array.from({ length: count }, (_, i) => <circle key={i} cx={i * 12 + 6} cy=\"4\" r=\"3\" className={i === index ? 'fill-primary' : 'fill-muted-foreground/30'} />)}\n </svg>;\n}\n\n/** The workflow diagram, its legend and the cases' `path` highlights, in a dialog opened from the menu. */\nfunction HowItWorks({ maquette, labels, open, onOpenChange }: { readonly maquette: Maquette; readonly labels: MaquetteLabels; readonly open: boolean; readonly onOpenChange: (open: boolean) => void }) {\n const [shown, setShown] = useState<string | null>(null);\n const highlighted = maquette.cases.filter(item => item.path.length > 0);\n if (!maquette.workflow) return null;\n return <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent data-testid=\"maquette-how\" className=\"max-h-[85dvh] overflow-y-auto\">\n <DialogHeader><DialogTitle>{labels.howItWorks}</DialogTitle></DialogHeader>\n <MaquetteDiagram workflow={maquette.workflow} highlight={highlighted.find(item => item.name === shown)?.path ?? []} labels={labels} />\n {highlighted.length > 0 && <div role=\"group\" className=\"flex flex-wrap gap-2\">\n {highlighted.map(item => <Button key={item.name} type=\"button\" size=\"sm\" variant={shown === item.name ? 'secondary' : 'outline'} aria-pressed={shown === item.name}\n data-testid=\"maquette-case\" className=\"pointer-coarse:min-h-11\" onClick={() => setShown(current => current === item.name ? null : item.name)}>{item.name}</Button>)}\n </div>}\n </DialogContent>\n </Dialog>;\n}\n\nfunction Walkthrough({ maquette, labels, fragment }: { readonly maquette: Maquette; readonly labels: MaquetteLabels; readonly fragment: string | undefined }) {\n const exists = (id: string | undefined): id is string => !!id && maquette.screens.some(screen => screen.id === id);\n const linked = screenFromFragment(fragment);\n const [walk, setWalk] = useState<Walk>(() => ({ history: [exists(linked) ? linked : maquette.start], tour: null }));\n const [how, setHow] = useState(false);\n const seen = useRef(new Set<string>());\n const heading = useRef<HTMLHeadingElement>(null);\n const moved = useRef(false);\n const touch = useRef<{ x: number; y: number } | null>(null);\n const screens = maquette.screens;\n const current: MaquetteScreen = screens.find(screen => screen.id === walk.history.at(-1)) ?? screens[0]!;\n const position = screens.indexOf(current);\n const tour: MaquetteCase | undefined = walk.tour ? maquette.cases.find(item => item.name === walk.tour!.name) : undefined;\n const tours = maquette.cases.filter(item => item.steps.length > 0);\n const titleOf = (id: string) => screens.find(screen => screen.id === id)?.title ?? id;\n // Goto blocks pulse once, the first time their screen is shown.\n const firstView = !seen.current.has(current.id);\n useEffect(() => { seen.current.add(current.id); }, [current.id]);\n\n const move = (next: Walk) => { moved.current = true; setWalk(next); };\n const go = (id: string) => { if (exists(id) && id !== current.id) move({ history: [...walk.history, id], tour: null }); };\n const step = (delta: number) => {\n if (tour && walk.tour) {\n const index = walk.tour.index + delta, target = tour.steps[index];\n if (target) move({ history: [...walk.history, target.screen], tour: { name: tour.name, index } });\n return;\n }\n const target = screens[position + delta];\n if (target) move({ history: [...walk.history, target.id], tour: null });\n };\n const back = () => { if (walk.history.length > 1) move({ history: walk.history.slice(0, -1), tour: null }); };\n const startTour = (item: MaquetteCase) => move({ history: [...walk.history, item.steps[0]!.screen], tour: { name: item.name, index: 0 } });\n // A later `#screen=<id>` (the PM's \"see screen 2\" link) opens that screen.\n useEffect(() => { if (exists(linked) && linked !== walk.history.at(-1)) setWalk(previous => ({ history: [...previous.history, linked], tour: null })); }, [linked]); // eslint-disable-line react-hooks/exhaustive-deps\n // After a move, keyboard focus starts on the new screen, so Tab continues inside the screen just opened.\n useEffect(() => { if (moved.current) { moved.current = false; heading.current?.focus(); } }, [walk]);\n\n const index = tour && walk.tour ? walk.tour.index : position, count = tour ? tour.steps.length : screens.length;\n const links: MaquetteLinks = { go, hint: id => labels.opens(titleOf(id)) };\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.shiftKey || (event.target as Element).closest?.(TYPING)) return;\n if (event.altKey) { if (event.key === 'ArrowLeft') { event.preventDefault(); back(); } return; }\n if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); step(event.key === 'ArrowLeft' ? -1 : 1); }\n };\n const onTouchStart = (event: TouchEvent) => { const point = event.touches[0]; touch.current = point ? { x: point.clientX, y: point.clientY } : null; };\n const onTouchEnd = (event: TouchEvent) => {\n const start = touch.current, point = event.changedTouches[0];\n touch.current = null;\n if (!start || !point) return;\n const dx = point.clientX - start.x, dy = point.clientY - start.y;\n if (Math.abs(dx) > 56 && Math.abs(dx) > Math.abs(dy) * 1.5) step(dx < 0 ? 1 : -1);\n };\n const caption = tour && walk.tour ? tour.steps[walk.tour.index]?.caption : undefined;\n\n return <div data-testid=\"maquette-walk\" data-screen={current.id} data-tour={tour?.name} className=\"flex min-w-0 flex-col gap-2\" onKeyDown={onKeyDown}>\n <nav aria-label={labels.screens} data-testid=\"maquette-stepper\" className=\"flex items-center gap-1\">\n {walk.history.length > 1 && <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.back} data-testid=\"maquette-back\" onClick={back}><ArrowLeftIcon aria-hidden=\"true\" /></Button>}\n <div className=\"flex flex-1 items-center justify-center gap-1\">\n <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.previous} data-testid=\"maquette-previous\" disabled={index <= 0} onClick={() => step(-1)}><ChevronLeftIcon aria-hidden=\"true\" /></Button>\n {tour ? <Dots count={count} index={index} label={labels.tourStep(index + 1, count)} />\n : <span data-testid=\"maquette-position\" className=\"min-w-10 text-center text-sm text-muted-foreground tabular-nums\">{index + 1} / {count}</span>}\n <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.next} data-testid=\"maquette-next\" disabled={index >= count - 1} onClick={() => step(1)}><ChevronRightIcon aria-hidden=\"true\" /></Button>\n </div>\n {tour ? <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.endTour} data-testid=\"maquette-end-tour\" onClick={() => setWalk(previous => ({ ...previous, tour: null }))}><XIcon aria-hidden=\"true\" /></Button>\n : (tours.length > 0 || maquette.workflow) && <DropdownMenu>\n <DropdownMenuTrigger render={<Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.more} data-testid=\"maquette-menu\" />}><EllipsisIcon aria-hidden=\"true\" /></DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" data-testid=\"maquette-menu-list\">\n {tours.map(item => <DropdownMenuItem key={item.name} className=\"pointer-coarse:min-h-11\" data-testid=\"maquette-tour\" onClick={() => startTour(item)}><CompassIcon aria-hidden=\"true\" />{item.name}</DropdownMenuItem>)}\n {tours.length > 0 && maquette.workflow && <DropdownMenuSeparator />}\n {maquette.workflow && <DropdownMenuItem className=\"pointer-coarse:min-h-11\" data-testid=\"maquette-how-open\" onClick={() => setHow(true)}><WorkflowIcon aria-hidden=\"true\" />{labels.howItWorks}</DropdownMenuItem>}\n </DropdownMenuContent>\n </DropdownMenu>}\n </nav>\n {caption && <p data-testid=\"maquette-caption\" className=\"m-0 line-clamp-2 px-1 text-sm text-pretty text-muted-foreground\">{caption}</p>}\n\n <div className={maquette.device === 'phone' ? 'mx-auto flex w-full max-w-sm flex-col gap-2' : 'flex min-w-0 flex-col gap-2'}>\n <div className=\"min-w-0\" data-hint={firstView ? 'pulse' : undefined} onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>\n <Card data-testid=\"maquette-frame\" data-device={maquette.device ?? 'panel'}><CardContent>\n <MaquetteScreenView key={current.id} maquette={maquette} screen={current} links={links} labels={labels} headingRef={heading} />\n </CardContent></Card>\n </div>\n {current.note && <div className=\"flex min-w-0 items-center gap-1 px-1\">\n <p data-testid=\"maquette-note\" title={current.note} className=\"m-0 min-w-0 flex-1 truncate text-xs text-muted-foreground\">{current.note}</p>\n <Popover>\n <PopoverTrigger render={<Button type=\"button\" variant=\"ghost\" size=\"icon-xs\" className={ICON} aria-label={labels.aboutScreen} data-testid=\"maquette-note-more\" />}><InfoIcon aria-hidden=\"true\" /></PopoverTrigger>\n <PopoverContent align=\"end\" data-testid=\"maquette-note-full\">{current.note}</PopoverContent>\n </Popover>\n </div>}\n </div>\n <p aria-live=\"polite\" className=\"m-0 sr-only\">{labels.showing(current.title)}</p>\n <HowItWorks maquette={maquette} labels={labels} open={how} onOpenChange={setHow} />\n </div>;\n}\n\n/** A maquette file drawn one screen at a time; a newer format version shows a notice, an unreadable file an inline error. */\nexport function MaquetteViewer({ text, fragment, locale, labels: given }: MaquetteViewerProps) {\n const labels = useMemo(() => maquetteLabelsFor(locale, given), [locale, given]);\n const parsed = useMemo(() => parseMaquette(text), [text]);\n const newer = parsed.version !== undefined && parsed.version > MAQUETTE_FORMAT_VERSION;\n const notice = newer && <Alert data-testid=\"maquette-newer\"><InfoIcon aria-hidden=\"true\" /><AlertTitle>{labels.newerTitle}</AlertTitle><AlertDescription>{labels.newer(parsed.version!)}</AlertDescription></Alert>;\n return <section data-boring=\"maquette-viewer\" data-state={parsed.kind} aria-label={parsed.kind === 'ok' ? parsed.maquette.title || labels.kind : labels.kind}\n className=\"mx-auto flex w-full max-w-5xl min-w-0 flex-col gap-2 px-3 pt-1 pb-4 sm:px-6\">\n {notice}\n {parsed.kind === 'ok' ? <Walkthrough maquette={parsed.maquette} labels={labels} fragment={fragment} />\n : <Alert variant=\"destructive\" data-testid=\"maquette-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\n/**\n * The artifact kind of a maquette file (structurally an `ArtifactKind` of pi-chat): it matches `*.maquette.json`, names it, gives its icon\n * and draws it with `MaquetteViewer`. Pass it, or your own, in `artifactKinds`; kinds an app passes are consulted before the built-in one.\n */\nexport interface MaquetteKind {\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; readonly fragment?: string | undefined }) => ReactNode;\n}\n\n/** A maquette kind in `locale` (\"Mock-up\" in English, \"Maquette\" in French) with any word overridden. */\nexport function createMaquetteKind({ locale, labels }: { readonly locale?: MaquetteLocale | undefined; readonly labels?: Partial<MaquetteLabels> | undefined } = {}): MaquetteKind {\n return {\n match: target => isMaquettePath(target.resource.path),\n label: maquetteLabelsFor(locale, labels).kind,\n icon: LayoutTemplateIcon,\n renderer: ({ text, fragment }) => <MaquetteViewer text={text} fragment={fragment} locale={locale} labels={labels} />,\n };\n}\n\n/** The built-in maquette kind, in English. */\nexport const maquetteKind: MaquetteKind = createMaquetteKind();\n",
56
+ "type": "registry:component",
57
+ "target": "components/maquette-viewer/maquette-viewer.tsx"
58
+ }
59
+ ],
60
+ "css": {
61
+ "@layer components": {
62
+ "[data-boring=\"maquette-viewer\"] [data-maquette=\"placeholder\"]": {
63
+ "border-width": "var(--default-border-width, 1px)",
64
+ "border-style": "dashed",
65
+ "border-color": "var(--border)",
66
+ "background-color": "var(--muted)"
67
+ },
68
+ "[data-boring=\"maquette-viewer\"] [data-hint=\"pulse\"] [data-goto]": {
69
+ "animation": "var(--animate-pulse)",
70
+ "animation-iteration-count": "1"
71
+ },
72
+ "[data-boring=\"maquette-viewer\"] [data-goto]:focus-visible": {
73
+ "outline": "2px solid color-mix(in oklch, var(--ring) 55%, transparent)",
74
+ "outline-offset": "2px"
75
+ },
76
+ "@media (hover: hover) and (pointer: fine)": {
77
+ "[data-boring=\"maquette-viewer\"] [data-goto]:hover": {
78
+ "outline": "2px solid color-mix(in oklch, var(--ring) 55%, transparent)",
79
+ "outline-offset": "2px"
80
+ }
81
+ },
82
+ "@media (prefers-reduced-motion: reduce)": {
83
+ "[data-boring=\"maquette-viewer\"] [data-hint=\"pulse\"] [data-goto]": {
84
+ "animation": "none"
85
+ }
86
+ }
87
+ }
88
+ },
89
+ "type": "registry:component"
90
+ }
@@ -4,8 +4,8 @@
4
4
  "title": "Markdown editor",
5
5
  "description": "A host-owned styled wrapper over the concrete borrowed Markdown controller.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.1",
8
- "@hachej/boring-files@^0.4.1",
7
+ "@hachej/boring-ui-kit@^0.4.2",
8
+ "@hachej/boring-files@^0.4.2",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "marked@^17.0.6",
@@ -23,7 +23,7 @@
23
23
  "@tiptap/extension-floating-menu@^3.31.4"
24
24
  ],
25
25
  "registryDependencies": [
26
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/button.json"
26
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/button.json"
27
27
  ],
28
28
  "files": [
29
29
  {
package/r/pi-ambient.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "title": "Pi ambient agent",
5
5
  "description": "AmbientChat, a compact floating agent bar that grows into a chat window over an existing page, and AgentNotifications, toasts for finished, failed and waiting tasks (with the one guarded browser Notification helper). A block over the pi-chat item; its in-window artifact panel uses the pi-workspace layout.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.1",
7
+ "@hachej/boring-ui-kit@^0.4.2",
8
8
  "react@^19.0.0",
9
9
  "react-dom@^19.0.0",
10
10
  "@earendil-works/pi-durable@~1.2.0",
@@ -12,11 +12,11 @@
12
12
  "lucide-react@^1.51.0"
13
13
  ],
14
14
  "registryDependencies": [
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/theme.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/pi-chat.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/pi-workspace.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/utils.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/button.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/theme.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/pi-chat.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/pi-workspace.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/utils.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.2/r/button.json",
20
20
  "message-scroller",
21
21
  "button",
22
22
  "toggle",