@hachej/boring-ui-kit 0.4.14 → 0.4.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/package.json +2 -2
  2. package/r/agent-target.json +4 -4
  3. package/r/app-shell.json +15 -15
  4. package/r/board-viewer.json +5 -5
  5. package/r/button.json +1 -1
  6. package/r/diagram-viewer.json +1 -1
  7. package/r/dock.json +1 -1
  8. package/r/documents.json +12 -12
  9. package/r/feedback-preview.json +1 -1
  10. package/r/feedback.json +6 -6
  11. package/r/file-tree.json +5 -5
  12. package/r/highlights.json +3 -3
  13. package/r/html-viewer.json +3 -3
  14. package/r/looks-viewer.json +1 -1
  15. package/r/maquette-viewer.json +2 -2
  16. package/r/markdown-editor.json +3 -3
  17. package/r/mockup-viewer.json +1 -1
  18. package/r/pi-ambient.json +9 -9
  19. package/r/pi-app.json +20 -20
  20. package/r/pi-artifacts.json +1 -1
  21. package/r/pi-chat-approval.json +1 -1
  22. package/r/pi-chat-attachments.json +1 -1
  23. package/r/pi-chat-markdown.json +1 -1
  24. package/r/pi-chat-mentions.json +1 -1
  25. package/r/pi-chat-pickers.json +1 -1
  26. package/r/pi-chat-plugins.json +11 -11
  27. package/r/pi-chat-question.json +1 -1
  28. package/r/pi-chat-slash.json +1 -1
  29. package/r/pi-chat-subagent.json +1 -1
  30. package/r/pi-chat-voice.json +2 -2
  31. package/r/pi-chat.json +5 -5
  32. package/r/pi-workspace.json +4 -4
  33. package/r/placed-agent.json +4 -4
  34. package/r/provider-setup.json +2 -2
  35. package/r/record-viewer.json +1 -1
  36. package/r/registry.json +167 -166
  37. package/r/requests-board.json +1 -1
  38. package/r/shared-chat.json +12 -11
  39. package/r/task-list-viewer.json +2 -2
  40. package/r/version-history.json +3 -3
  41. package/r/viewer-code.json +2 -2
  42. package/r/viewer-frame.json +5 -5
  43. package/r/viewer-html.json +4 -4
  44. package/r/viewer-image.json +2 -2
  45. package/r/viewer-markdown.json +5 -5
  46. package/r/viewer-pdf.json +3 -3
  47. package/r/viewers.json +6 -6
  48. package/r/windows.json +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-ui-kit",
3
- "version": "0.4.14",
3
+ "version": "0.4.15",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -134,7 +134,7 @@
134
134
  }
135
135
  },
136
136
  "peerDependencies": {
137
- "@hachej/boring-files": "^0.4.14",
137
+ "@hachej/boring-files": "^0.4.15",
138
138
  "@earendil-works/pi-durable": "~1.2.0",
139
139
  "react": "^19.0.0",
140
140
  "react-dom": "^19.0.0",
@@ -4,15 +4,15 @@
4
4
  "title": "Point and dictate on app data",
5
5
  "description": "Tap explicitly marked JSON app context, dictate once and show the same app agent’s native reply without opening chat. Host-controlled presentation; no feedback report or second controller.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.14",
8
- "@hachej/boring-feedback@^0.4.14",
7
+ "@hachej/boring-ui-kit@^0.4.15",
8
+ "@hachej/boring-feedback@^0.4.15",
9
9
  "@earendil-works/pi-durable@~1.2.0",
10
10
  "react@^19.0.0",
11
11
  "react-dom@^19.0.0"
12
12
  ],
13
13
  "registryDependencies": [
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/app-shell.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/pi-chat.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/app-shell.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/pi-chat.json",
16
16
  "button",
17
17
  "card",
18
18
  "message",
package/r/app-shell.json CHANGED
@@ -4,21 +4,21 @@
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.14",
8
- "@hachej/boring-ui-kit@^0.4.14",
7
+ "@hachej/boring-feedback@^0.4.15",
8
+ "@hachej/boring-ui-kit@^0.4.15",
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.14/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/pi-chat-plugins.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/pi-chat-voice.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/dock.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/feedback.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/utils.json",
21
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/button.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/pi-chat-plugins.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/pi-chat-voice.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/dock.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/feedback.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/utils.json",
21
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/button.json",
22
22
  "drawer",
23
23
  "sidebar",
24
24
  "avatar",
@@ -50,7 +50,7 @@
50
50
  },
51
51
  {
52
52
  "path": "registry/app-shell/back.ts",
53
- "content": "// The one Back manager of the app shell. A layer that is open (the agent sheet, another sheet, point mode, a pushed screen) asks for one\n// history entry with the same URL; the browser's Back closes the topmost layer instead of leaving the page, and closing a layer any\n// other way removes its entry, so the history stays as it was. Hosts: the AgentLayer (order `agent`) and, later, sheets and stacks.\nimport { useEffect, useRef } from 'react';\n\n/** The fixed priority of Back: `point` is handled first, then an open `sheet`, then the `agent` layer, then the `stack`, then the browser. */\nexport type BackOrder = 'point' | 'sheet' | 'agent' | 'stack';\nconst RANK: Readonly<Record<BackOrder, number>> = { point: 0, sheet: 1, agent: 2, stack: 3 };\n\ninterface Layer { readonly order: BackOrder; readonly close: () => void; popped: boolean }\n\nconst layers = new Set<Layer>();\n/** `popstate` events caused by our own `history.back()`, which must not close anything. */\nlet ignored = 0;\nlet listening = false;\n/** The layer kind the page was loaded on top of (history state survives a reload), until a layer adopts it. */\nlet persisted: unknown = typeof history === 'undefined' ? undefined : (history.state as { appShell?: unknown } | null)?.appShell;\n/** Layers that opened while our own `history.back()` was still pending: they push their entry once it has landed (a push in between cancels it). */\nconst waiting: (() => void)[] = [];\n\n/**\n * Runs `action` once the history entry of a sheet that is closing has really gone. A choice that navigates (the app's router replaces the\n * URL) must not run before: the sheet's own `history.back()` would undo it. Call it in the same handler that closes the sheet.\n */\nexport function afterBack(action: () => void): void {\n // The sheet's effect cleanup (which starts its `history.back()`) runs after this handler returns.\n setTimeout(() => { if (ignored > 0) waiting.push(action); else action(); }, 0);\n}\n\nconst topmost = (): Layer | undefined => [...layers].toSorted((a, b) => RANK[a.order] - RANK[b.order])[0];\n\nfunction onPopState(): void {\n if (ignored > 0) {\n ignored -= 1;\n if (ignored === 0) for (const start of waiting.splice(0)) start();\n return;\n }\n const top = topmost();\n if (!top) return;\n // The entry is already gone: the layer closes without calling `history.back()` again.\n top.popped = true;\n top.close();\n}\n\n/**\n * While `open`, the layer owns one history entry (`history.pushState({ appShell: order }, '')`, same URL). `popstate` closes only the\n * topmost open layer. Any other close (the effect ends because `open` became false or the component unmounted) calls `history.back()`\n * once, and that `popstate` is ignored. `close` may change between renders.\n */\nexport function useBackLayer(open: boolean, close: () => void, order: BackOrder): void {\n const latest = useRef(close);\n latest.current = close;\n useEffect(() => {\n if (!open) return;\n const layer: Layer = { order, close: () => latest.current(), popped: false };\n let started = false;\n const start = () => {\n started = true;\n // After a reload the page still sits on the entry its layer pushed before: that one layer adopts it instead of stacking a second.\n const adopt = persisted === order;\n persisted = undefined;\n if (!adopt) history.pushState({ appShell: order }, '');\n layers.add(layer);\n };\n if (!listening) { window.addEventListener('popstate', onPopState); listening = true; }\n // A sheet that closes as this layer opens (the account menu choosing « Améliorer l’app ») is still going back: wait for it.\n if (ignored > 0) waiting.push(start); else start();\n return () => {\n const at = waiting.indexOf(start);\n if (at >= 0) waiting.splice(at, 1);\n if (!started) return;\n layers.delete(layer);\n if (!layer.popped) { ignored += 1; history.back(); }\n };\n }, [open, order]);\n}\n",
53
+ "content": "// The one Back manager of the app shell. A layer that is open (the agent sheet, another sheet, point mode, a pushed screen) asks for one\n// history entry with the same URL; the browser's Back closes the topmost layer instead of leaving the page, and closing a layer any\n// other way removes its entry, so the history stays as it was. Hosts: the AgentLayer (order `agent`) and, later, sheets and stacks.\nimport { useEffect, useRef } from 'react';\n\n/** The fixed priority of Back: `point` is handled first, then an open `sheet`, then the `agent` layer, then the `stack`, then the browser. */\nexport type BackOrder = 'point' | 'sheet' | 'agent' | 'stack';\nconst RANK: Readonly<Record<BackOrder, number>> = { point: 0, sheet: 1, agent: 2, stack: 3 };\n\ninterface Layer { readonly order: BackOrder; readonly close: () => void; popped: boolean }\n\nconst layers = new Set<Layer>();\n/** `popstate` events caused by our own `history.back()`, which must not close anything. */\nlet ignored = 0;\nlet listening = false;\n/** The layer kind the page was loaded on top of (history state survives a reload), until a layer adopts it. */\nlet persisted: unknown = typeof history === 'undefined' ? undefined : (history.state as { appShell?: unknown } | null)?.appShell;\n/** Layers that opened while our own `history.back()` was still pending: they push their entry once it has landed (a push in between cancels it). */\nconst waiting: (() => void)[] = [];\n\n/**\n * Runs `action` once the history entry of a sheet that is closing has really gone. A choice that navigates (the app's router replaces the\n * URL) must not run before: the sheet's own `history.back()` would undo it. Call it in the same handler that closes the sheet.\n */\nexport function afterBack(action: () => void): void {\n // The sheet's effect cleanup (which starts its `history.back()`) runs after this handler returns.\n setTimeout(() => { if (ignored > 0) waiting.push(action); else action(); }, 0);\n}\n\n/**\n * Runs `action` after the open stack layers (a ListDetail's pushed detail) have gone back. A nav choice that replaces the URL must not\n * run before: the stack layer's cleanup `history.back()` would restore the detail's URL over it. The layers are released here, not in\n * their cleanup (which only runs once the choice has hidden the screen, too late), and their cleanup then does nothing.\n */\nexport function afterStacksBack(action: () => void): void {\n const stacks = [...layers].filter(layer => layer.order === 'stack' && !layer.popped);\n if (!stacks.length) { action(); return; }\n waiting.push(action);\n for (const layer of stacks) { layer.popped = true; layers.delete(layer); ignored += 1; history.back(); }\n}\n\nconst topmost = (): Layer | undefined => [...layers].toSorted((a, b) => RANK[a.order] - RANK[b.order])[0];\n\nfunction onPopState(): void {\n if (ignored > 0) {\n ignored -= 1;\n if (ignored === 0) for (const start of waiting.splice(0)) start();\n return;\n }\n const top = topmost();\n if (!top) return;\n // The entry is already gone: the layer closes without calling `history.back()` again.\n top.popped = true;\n top.close();\n}\n\n/**\n * While `open`, the layer owns one history entry (`history.pushState({ appShell: order }, '')`, same URL). `popstate` closes only the\n * topmost open layer. Any other close (the effect ends because `open` became false or the component unmounted) calls `history.back()`\n * once, and that `popstate` is ignored. `close` may change between renders.\n */\nexport function useBackLayer(open: boolean, close: () => void, order: BackOrder): void {\n const latest = useRef(close);\n latest.current = close;\n useEffect(() => {\n if (!open) return;\n const layer: Layer = { order, close: () => latest.current(), popped: false };\n let started = false;\n const start = () => {\n started = true;\n // After a reload the page still sits on the entry its layer pushed before: that one layer adopts it instead of stacking a second.\n const adopt = persisted === order;\n persisted = undefined;\n if (!adopt) history.pushState({ appShell: order }, '');\n layers.add(layer);\n };\n if (!listening) { window.addEventListener('popstate', onPopState); listening = true; }\n // A sheet that closes as this layer opens (the account menu choosing « Améliorer l’app ») is still going back: wait for it.\n if (ignored > 0) waiting.push(start); else start();\n return () => {\n const at = waiting.indexOf(start);\n if (at >= 0) waiting.splice(at, 1);\n if (!started) return;\n layers.delete(layer);\n if (!layer.popped) { ignored += 1; history.back(); }\n };\n }, [open, order]);\n}\n",
54
54
  "type": "registry:lib",
55
55
  "target": "components/app-shell/back.ts"
56
56
  },
@@ -74,19 +74,19 @@
74
74
  },
75
75
  {
76
76
  "path": "registry/app-shell/app-shell.tsx",
77
- "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 /** Wide-screen navigation: `hover` reveals labels over the page on hover or keyboard focus. Default: `icon` (the native sidebar toggle). */\n readonly rail?: 'icon' | 'hover';\n readonly agent?: AgentSpec;\n /** Consume agent/open/input launch parameters on mount (default). `false` leaves the URL and layer untouched. */\n readonly agentLink?: false;\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, rail, title, nav, agent, improve, account, route, text }: { readonly rail: NonNullable<AppShellProps['rail']>; 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={rail !== 'hover' && layout === 'screen'} className=\"app-shell h-dvh min-h-0 overflow-hidden\">\n {!compact && <AppSidebar rail={rail} 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, rail = 'icon', 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} agentLink={props.agentLink}>\n <HeaderProvider><Shell layout={layout} rail={rail} title={title} nav={nav} agent={agent} account={account} route={route} improve={Boolean(improve)} text={text} /></HeaderProvider>\n </AgentLayerProvider>;\n}\n",
77
+ "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 »), or with `compactNav=\"sheet\"` no bar and the sidebar as a sheet from the header. 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 { afterStacksBack } from './back';\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 /** Wide-screen navigation: `hover` reveals labels over the page on hover or keyboard focus. Default: `icon` (the native sidebar toggle). */\n readonly rail?: 'icon' | 'hover';\n /** Phone navigation: `tabs` (default) is the bottom tab bar; `sheet` has no bar, the header's leading button opens the sidebar (the nav items, a screen's `sidebar` content and the account) as a sheet. */\n readonly compactNav?: 'tabs' | 'sheet';\n readonly agent?: AgentSpec;\n /** Consume agent/open/input launch parameters on mount (default). `false` leaves the URL and layer untouched. */\n readonly agentLink?: false;\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({ compactNav, layout, rail, title, nav, agent, improve, account, route, text }: { readonly compactNav: NonNullable<AppShellProps['compactNav']>; readonly rail: NonNullable<AppShellProps['rail']>; 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 sheet = compact && compactNav === 'sheet';\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 // A nav choice waits for an open detail's stack entry to go back, so the app's router can replace the URL without that Back restoring\n // the detail's. Choosing the screen already on show changes nothing and keeps the detail.\n const choose = (id: string) => { if (id === active.id) route.onChange(id); else afterStacksBack(() => route.onChange(id)); };\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={rail !== 'hover' && layout === 'screen'} className=\"app-shell h-dvh min-h-0 overflow-hidden\">\n {(!compact || sheet) && <AppSidebar rail={sheet ? 'icon' : rail} title={title} items={nav} activeId={active.id} onChange={choose} 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} menu={sheet} text={text} slotRef={setSlot} />\n <Screens items={nav} activeId={active.id} scrolls={layout === 'screen'} slot={slot} />\n {compact && !sheet && <TabBar items={nav} activeId={active.id} onChange={choose} text={text} />}\n </div>\n </AgentDockLayout>\n <AgentLayer />\n </SidebarProvider>;\n}\n\nexport function AppShell(props: AppShellProps) {\n const { layout, rail = 'icon', compactNav = 'tabs', 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} agentLink={props.agentLink}>\n <HeaderProvider><Shell compactNav={compactNav} layout={layout} rail={rail} title={title} nav={nav} agent={agent} account={account} route={route} improve={Boolean(improve)} text={text} /></HeaderProvider>\n </AgentLayerProvider>;\n}\n",
78
78
  "type": "registry:component",
79
79
  "target": "components/app-shell/app-shell.tsx"
80
80
  },
81
81
  {
82
82
  "path": "registry/app-shell/list-detail.tsx",
83
- "content": "'use client';\n\n// The one list/detail primitive of layout B. Expanded: the list (20rem, its own scroll) and the detail side by side. Compact: one pane at a\n// time; opening a detail is a stack step (one history entry, Back returns to the list) and the list stays mounted, hidden, so its scroll is\n// kept. Host concern: Pi has no layout. A deep link that mounts with a detail needs nothing more: the Back manager's entry sits above the\n// one the page was opened on, and the app's `onBack` (called by Back, and by « ← » which is Back) clears the selection and replaces the URL.\nimport { useContext } from 'react';\nimport type { ReactNode } from 'react';\nimport { Separator } from '@/components/ui/separator';\nimport { cn } from '../utils/utils';\nimport { ScreenActiveContext } from './agent-context';\nimport { useBackLayer } from './back';\nimport { useIsCompact } from './compact';\nimport { useScreenHeader } from './header';\n\nexport interface ListDetailProps {\n /** The list pane (the app's own list; a card list when compact is the app's choice through `useIsCompact`). */\n readonly list: ReactNode;\n /** `null` = nothing selected. */\n readonly detail: ReactNode | null;\n /** Clears the selection and replaces the URL: called by compact Back and by the header's « ← » (which goes through the browser's Back). */\n readonly onBack: () => void;\n /** The detail's header title when compact (default: the screen's own). */\n readonly title?: string;\n}\n\n/** Which panes show: both when expanded; when compact the detail if one is selected, else the list. */\nexport function listDetailView(compact: boolean, detail: ReactNode | null): 'list' | 'detail' | 'both' {\n return !compact ? 'both' : detail === null ? 'list' : 'detail';\n}\n\nexport function ListDetail({ list, detail, onBack, title }: ListDetailProps) {\n const compact = useIsCompact();\n const active = useContext(ScreenActiveContext);\n const view = listDetailView(compact, detail);\n const stacked = view === 'detail';\n useBackLayer(active && stacked, onBack, 'stack');\n // « ← » is the browser's Back, so there is one path: the popstate calls `onBack`, and the URL the entry below holds is the one the app then replaces.\n useScreenHeader(stacked ? { back: () => history.back(), ...(title ? { title } : {}) } : {}, { overlay: true });\n return <div data-testid=\"list-detail\" data-view={view} className=\"flex h-full min-h-0\">\n <div hidden={view === 'detail'} data-testid=\"list-pane\" className={cn('min-h-0 overflow-y-auto overscroll-contain', compact ? 'w-full' : 'w-80 shrink-0')}>{list}</div>\n {!compact && view !== 'detail' && <Separator orientation=\"vertical\" />}\n <div hidden={view === 'list'} data-testid=\"detail-pane\" className={cn('min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain', detail === null && 'bg-muted/30')}>{detail}</div>\n </div>;\n}\n",
83
+ "content": "'use client';\n\n// The one list/detail primitive of layout B. Expanded: the list (20rem, or a `railWidth` rail when `collapsible` and folded; its own scroll) and the detail side by side. Compact: one pane at a\n// time; opening a detail is a stack step (one history entry, Back returns to the list) and the list stays mounted, hidden, so its scroll is\n// kept. Host concern: Pi has no layout. A deep link that mounts with a detail needs nothing more: the Back manager's entry sits above the\n// one the page was opened on, and the app's `onBack` (called by Back, and by « ← » which is Back) clears the selection and replaces the URL.\nimport { useContext, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { ChevronsLeftIcon, ChevronsRightIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { cn } from '../utils/utils';\nimport { ScreenActiveContext } from './agent-context';\nimport { useBackLayer } from './back';\nimport { useIsCompact } from './compact';\nimport { useScreenHeader } from './header';\n\nexport interface ListDetailProps {\n /** The list pane (the app's own list; a card list when compact is the app's choice through `useIsCompact`). */\n readonly list: ReactNode | ((state: { readonly collapsed: boolean }) => ReactNode);\n /** `null` = nothing selected. */\n readonly detail: ReactNode | null;\n /** Clears the selection and replaces the URL: called by compact Back and by the header's « ← » (which goes through the browser's Back). */\n readonly onBack: () => void;\n /** The detail's header title when compact (default: the screen's own). */\n readonly title?: string;\n /** Wide screens only: the list pane can fold to a narrow rail (`railWidth`), with a toggle on its edge. Give `list` as a function to render the rail's content. Default: fixed 20rem, no toggle. */\n readonly collapsible?: boolean;\n /** Controlled fold state; without it the pane keeps its own (starting open). */\n readonly collapsed?: boolean;\n readonly onCollapsedChange?: (collapsed: boolean) => void;\n /** The rail's width when collapsed (any CSS length). Default `3rem`, the shell's icon rail. */\n readonly railWidth?: string;\n /** The toggle's accessible names. Default English. */\n readonly labels?: { readonly collapse: string; readonly expand: string };\n}\n\n/** Which panes show: both when expanded; when compact the detail if one is selected, else the list. */\nexport function listDetailView(compact: boolean, detail: ReactNode | null): 'list' | 'detail' | 'both' {\n return !compact ? 'both' : detail === null ? 'list' : 'detail';\n}\n\nexport function ListDetail({ list, detail, onBack, title, collapsible, collapsed: controlled, onCollapsedChange, railWidth = '3rem', labels = { collapse: 'Collapse the list', expand: 'Expand the list' } }: ListDetailProps) {\n const compact = useIsCompact();\n const [own, setOwn] = useState(false);\n const folds = Boolean(collapsible) && !compact;\n const collapsed = folds && (controlled ?? own);\n const toggle = () => { if (controlled === undefined) setOwn(!collapsed); onCollapsedChange?.(!collapsed); };\n const active = useContext(ScreenActiveContext);\n const view = listDetailView(compact, detail);\n const stacked = view === 'detail';\n useBackLayer(active && stacked, onBack, 'stack');\n // « ← » is the browser's Back, so there is one path: the popstate calls `onBack`, and the URL the entry below holds is the one the app then replaces.\n useScreenHeader(stacked ? { back: () => history.back(), ...(title ? { title } : {}) } : {}, { overlay: true });\n return <div data-testid=\"list-detail\" data-view={view} className=\"flex h-full min-h-0\">\n <div hidden={view === 'detail'} data-testid=\"list-pane\" data-collapsed={folds ? collapsed : undefined} style={collapsed ? { width: railWidth } : undefined}\n className={cn('min-h-0 overflow-y-auto overscroll-contain', compact ? 'w-full' : collapsed ? 'shrink-0' : 'w-80 shrink-0')}>{folds && <div className={cn('flex p-1', collapsed ? 'justify-center' : 'justify-end')}>\n <Button variant=\"ghost\" size=\"icon-sm\" data-testid=\"list-toggle\" aria-expanded={!collapsed} aria-label={collapsed ? labels.expand : labels.collapse} title={collapsed ? labels.expand : labels.collapse} onClick={toggle}>\n {collapsed ? <ChevronsRightIcon /> : <ChevronsLeftIcon />}\n </Button>\n </div>}\n {typeof list === 'function' ? list({ collapsed }) : list}</div>\n {!compact && view !== 'detail' && <Separator orientation=\"vertical\" />}\n <div hidden={view === 'list'} data-testid=\"detail-pane\" className={cn('min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain', detail === null && 'bg-muted/30')}>{detail}</div>\n </div>;\n}\n",
84
84
  "type": "registry:component",
85
85
  "target": "components/app-shell/list-detail.tsx"
86
86
  },
87
87
  {
88
88
  "path": "registry/app-shell/header.tsx",
89
- "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",
89
+ "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 /** Controls rendered right after the sidebar trigger (or « ← »), before the title: a screen that owns its row, left-aligned. */\n readonly leading?: ReactNode;\n /** The title's face: `display` (default, the theme's display font) or `sans`. */\n readonly titleFace?: 'display' | 'sans';\n /** A dense 48 px row instead of 56 px. */\n readonly dense?: boolean;\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.titleFace, spec.dense, 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 // `leading` is a node with no stable key, so it is a new value on each render of the screen and touches the store each time; that only re-renders the header, which is cheap and intended.\n useLayoutEffect(() => { if (store && active) touch(store); }, [store, active, key, spec.leading]);\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. `menu` (compact only) makes the leading button open the sidebar sheet and leaves the account to the sidebar's footer.\n */\nexport function ScreenHeader({ fallbackTitle, compact, avatar, text, slotRef, menu }: { readonly menu?: boolean; 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 ${spec?.dense ? 'min-h-12' : '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 ?? (menu ? <SidebarTrigger /> : avatar)) : <><SidebarTrigger />{back}</>}\n {spec?.leading != null && <div data-testid=\"header-leading\" className=\"flex shrink-0 items-center gap-1\">{spec.leading}</div>}\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 ${spec?.titleFace === 'sans' ? 'font-sans' : '[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 && !menu && avatar}\n <AgentTrigger />\n </header>;\n}\n",
90
90
  "type": "registry:component",
91
91
  "target": "components/app-shell/header.tsx"
92
92
  },
@@ -98,7 +98,7 @@
98
98
  },
99
99
  {
100
100
  "path": "registry/app-shell/nav.tsx",
101
- "content": "'use client';\n\n// The navigation: a bottom tab bar when compact, the shadcn sidebar (icon rail plus NavUser, the sidebar-07 and sidebar-08 patterns) when\n// expanded. Choosing an item only calls `route.onChange`: the app's router owns the URL, and the shell adds no history entry.\nimport { useEffect, useRef, useState } from 'react';\nimport type { ComponentType, KeyboardEvent, ReactNode } from 'react';\nimport { EllipsisIcon, SparklesIcon } from 'lucide-react';\nimport { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarRail, SidebarSeparator, useSidebar } from '@/components/ui/sidebar';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { afterBack } from './back';\nimport { NavUser } from './account';\nimport { useAgentLayer } from './agent-layer';\nimport type { AgentSpec } from './agent-layer';\nimport type { AccountSpec } from './account';\nimport { AppSheet, SHEET_ROW } from './app-sheet';\nimport type { AppShellText } from './text';\n\nexport interface NavItem {\n readonly id: string;\n readonly label: string;\n readonly icon: ComponentType<{ className?: string }>;\n readonly badge?: boolean;\n readonly render: () => ReactNode;\n}\n\n/** The most tabs the bar shows. */\nconst MAX_TABS = 4;\n\n/** Up to four items are all tabs; more than four give three tabs and the rest behind « Plus ». */\nexport function splitNav<T>(items: readonly T[]): { readonly tabs: readonly T[]; readonly overflow: readonly T[] } {\n return items.length <= MAX_TABS ? { tabs: items, overflow: [] } : { tabs: items.slice(0, MAX_TABS - 1), overflow: items.slice(MAX_TABS - 1) };\n}\n\n/** The host's Badge marks a screen with news. Its classes only place it. */\nconst Dot = ({ className }: { readonly className?: string }) => <Badge aria-hidden=\"true\" data-testid=\"nav-badge\" className={className} />;\n\n/** Publishes the bar's measured height as `--app-shell-tabbar` on the document root (the drawer is portaled, so the shell root is too high up to reach it). */\nfunction useTabbarHeight(bar: { readonly current: HTMLElement | null }, shown: boolean): void {\n useEffect(() => {\n const root = document.documentElement;\n const node = bar.current;\n if (!shown || !node) { root.style.setProperty('--app-shell-tabbar', '0px'); return; }\n const measure = () => root.style.setProperty('--app-shell-tabbar', `${node.getBoundingClientRect().height}px`);\n measure();\n const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(measure) : undefined;\n observer?.observe(node);\n return () => { observer?.disconnect(); root.style.removeProperty('--app-shell-tabbar'); };\n }, [bar, shown]);\n}\n\n/** Layout for a tab on the host's Button: icon over label, filling its share of the bar. */\nconst TAB = 'relative h-auto min-w-0 flex-1 flex-col gap-0.5';\n\n/** The tab bar's arrow keys: Left and Right move the focus between the tabs (wrapping). */\nconst arrows = (event: KeyboardEvent<HTMLElement>) => {\n const buttons = [...event.currentTarget.querySelectorAll<HTMLElement>('[role=tab]')];\n const at = buttons.indexOf(document.activeElement as HTMLElement);\n const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;\n if (at < 0 || !step) return;\n event.preventDefault();\n buttons[(at + step + buttons.length) % buttons.length]!.focus();\n};\n\n/** The compact tab bar. With one item there is none. Exported for tests. */\nexport function TabBar({ items, activeId, onChange, text }: { readonly items: readonly NavItem[]; readonly activeId: string; readonly onChange: (id: string) => void; readonly text: AppShellText }) {\n const bar = useRef<HTMLElement>(null);\n const [plusOpen, setPlusOpen] = useState(false);\n const shown = items.length > 1;\n useTabbarHeight(bar, shown);\n if (!shown) return null;\n const { tabs, overflow } = splitNav(items);\n const inOverflow = overflow.find(item => item.id === activeId);\n const tab = (item: NavItem) => <Button key={item.id} variant={item.id === activeId ? 'secondary' : 'ghost'} role=\"tab\" data-testid={`tab-${item.id}`} aria-selected={item.id === activeId} tabIndex={item.id === activeId ? 0 : -1}\n onClick={() => onChange(item.id)} className={TAB}>\n <item.icon />{item.label}{item.badge && <Dot className=\"absolute top-1 left-1/2 ml-2\" />}\n </Button>;\n const Plus = overflow.length ? (inOverflow?.icon ?? EllipsisIcon) : undefined;\n return <nav ref={bar} role=\"tablist\" aria-label={text.tabs} data-testid=\"tabbar\" data-feedback-ignore=\"\" onKeyDown={arrows}\n className=\"relative z-(--app-shell-z-tabs) box-border flex min-h-[calc(3.5rem+env(safe-area-inset-bottom))] shrink-0 bg-background pb-[env(safe-area-inset-bottom)]\">\n <Separator className=\"absolute inset-x-0 top-0\" />\n {tabs.map(tab)}\n {Plus && <>\n <Button variant={inOverflow ? 'secondary' : 'ghost'} role=\"tab\" data-testid=\"tab-plus\" aria-selected={Boolean(inOverflow)} aria-haspopup=\"dialog\" tabIndex={inOverflow ? 0 : -1} onClick={() => setPlusOpen(true)}\n className={TAB}>\n <Plus />{inOverflow?.label ?? text.more}{!inOverflow && overflow.some(item => item.badge) && <Dot className=\"absolute top-1 left-1/2 ml-2\" />}\n </Button>\n <AppSheet open={plusOpen} onOpenChange={setPlusOpen} title={text.more}>\n {overflow.map(item => <Button key={item.id} variant={item.id === activeId ? 'secondary' : 'ghost'} size=\"lg\" data-testid={`plus-${item.id}`} aria-current={item.id === activeId} className={SHEET_ROW}\n onClick={() => { setPlusOpen(false); afterBack(() => onChange(item.id)); }}>\n <item.icon data-icon=\"inline-start\" />{item.label}{item.badge && <Dot className=\"ml-auto\" />}\n </Button>)}\n </AppSheet>\n </>}\n </nav>;\n}\n\n/** The expanded sidebar: the app title, the nav items (none with one item) and the account in the footer. */\nexport function AppSidebar({ rail = 'icon', title, items, activeId, onChange, agent, account, improve, text }: {\n readonly rail?: 'icon' | 'hover'; readonly title: string; readonly items: readonly NavItem[]; readonly activeId: string; readonly onChange: (id: string) => void;\n readonly agent: AgentSpec | undefined; readonly account: AccountSpec | undefined; readonly improve: boolean; readonly text: AppShellText;\n}) {\n const { setOpen } = useSidebar();\n const layer = useAgentLayer();\n const pressed = layer.state !== 'closed' && layer.showing === 'agent';\n const AgentIcon = agent?.icon ?? SparklesIcon;\n const agentLabel = agent?.label ?? agent?.chatProps?.labels?.title ?? text.assistant;\n const sidebar = <Sidebar collapsible=\"icon\" variant=\"inset\" data-testid=\"app-sidebar\">\n <SidebarHeader><p data-testid=\"sidebar-title\" className=\"m-0 truncate px-2 py-1 text-base font-semibold group-data-[collapsible=icon]:hidden\">{title}</p></SidebarHeader>\n <SidebarContent>\n {items.length > 1 && <SidebarGroup><SidebarGroupContent><SidebarMenu>\n {items.map(item => <SidebarMenuItem key={item.id}>\n <SidebarMenuButton isActive={item.id === activeId} tooltip={item.label} data-testid={`nav-${item.id}`} className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11\" onClick={() => onChange(item.id)}>\n <item.icon />\n <span>{item.label}</span>\n {item.badge && <Dot className=\"ml-auto group-data-[collapsible=icon]:hidden\" />}\n </SidebarMenuButton>\n </SidebarMenuItem>)}\n </SidebarMenu></SidebarGroupContent></SidebarGroup>}\n {/* The assistant is a layer, never a destination: no route, it toggles the right panel. */}\n {agent && <>\n {items.length > 1 && <SidebarSeparator />}\n <SidebarGroup><SidebarGroupContent><SidebarMenu><SidebarMenuItem>\n <SidebarMenuButton isActive={pressed} aria-pressed={pressed} tooltip={agentLabel} data-testid=\"nav-agent\" className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11\"\n onClick={() => { if (pressed) layer.close(); else layer.open('agent', 'half'); }}>\n <AgentIcon /><span>{agentLabel}</span>\n </SidebarMenuButton>\n </SidebarMenuItem></SidebarMenu></SidebarGroupContent></SidebarGroup>\n </>}\n </SidebarContent>\n <SidebarFooter><NavUser account={account} improve={improve} text={text} /></SidebarFooter>\n <SidebarRail />\n </Sidebar>;\n if (rail !== 'hover') return sidebar;\n // Reserve the native inset icon gap even while the fixed sidebar reveals its labels over the page.\n return <div data-rail=\"hover\" className=\"relative z-20 w-[calc(var(--sidebar-width-icon)+(--spacing(4)))] shrink-0\"\n onPointerEnter={event => { if (event.pointerType === 'mouse' || event.pointerType === 'pen') setOpen(true); }}\n onPointerLeave={() => setOpen(false)}\n onFocusCapture={event => { if (event.target.matches(':focus-visible')) setOpen(true); }}\n onBlurCapture={event => { if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false); }}\n onKeyDownCapture={event => { if (event.key === 'Escape' && event.target instanceof Node && event.currentTarget.contains(event.target)) { event.preventDefault(); event.stopPropagation(); setOpen(false); } }}>\n {sidebar}\n </div>;\n}\n",
101
+ "content": "'use client';\n\n// The navigation: a bottom tab bar when compact, the shadcn sidebar (icon rail plus NavUser, the sidebar-07 and sidebar-08 patterns) when\n// expanded. Choosing an item only calls `route.onChange`: the app's router owns the URL, and the shell adds no history entry.\nimport { useEffect, useRef, useState } from 'react';\nimport type { ComponentType, KeyboardEvent, ReactNode } from 'react';\nimport { EllipsisIcon, SparklesIcon } from 'lucide-react';\nimport { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarRail, SidebarSeparator, useSidebar } from '@/components/ui/sidebar';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { afterBack } from './back';\nimport { NavUser } from './account';\nimport { useAgentLayer } from './agent-layer';\nimport type { AgentSpec } from './agent-layer';\nimport type { AccountSpec } from './account';\nimport { AppSheet, SHEET_ROW } from './app-sheet';\nimport type { AppShellText } from './text';\n\nexport interface NavItem {\n readonly id: string;\n readonly label: string;\n readonly icon: ComponentType<{ className?: string }>;\n readonly badge?: boolean;\n readonly render: () => ReactNode;\n /** The screen's own content inside the shell's sidebar (its list, say) while this item is on show: above the nav items, which move below it. `collapsed` is true on the icon rail, so the content can fold with it. */\n readonly sidebar?: (state: { readonly collapsed: boolean }) => ReactNode;\n}\n\n/** The most tabs the bar shows. */\nconst MAX_TABS = 4;\n\n/** Up to four items are all tabs; more than four give three tabs and the rest behind « Plus ». */\nexport function splitNav<T>(items: readonly T[]): { readonly tabs: readonly T[]; readonly overflow: readonly T[] } {\n return items.length <= MAX_TABS ? { tabs: items, overflow: [] } : { tabs: items.slice(0, MAX_TABS - 1), overflow: items.slice(MAX_TABS - 1) };\n}\n\n/** The host's Badge marks a screen with news. Its classes only place it. */\nconst Dot = ({ className }: { readonly className?: string }) => <Badge aria-hidden=\"true\" data-testid=\"nav-badge\" className={className} />;\n\n/** Publishes the bar's measured height as `--app-shell-tabbar` on the document root (the drawer is portaled, so the shell root is too high up to reach it). */\nfunction useTabbarHeight(bar: { readonly current: HTMLElement | null }, shown: boolean): void {\n useEffect(() => {\n const root = document.documentElement;\n const node = bar.current;\n if (!shown || !node) { root.style.setProperty('--app-shell-tabbar', '0px'); return; }\n const measure = () => root.style.setProperty('--app-shell-tabbar', `${node.getBoundingClientRect().height}px`);\n measure();\n const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(measure) : undefined;\n observer?.observe(node);\n return () => { observer?.disconnect(); root.style.removeProperty('--app-shell-tabbar'); };\n }, [bar, shown]);\n}\n\n/** Layout for a tab on the host's Button: icon over label, filling its share of the bar. */\nconst TAB = 'relative h-auto min-w-0 flex-1 flex-col gap-0.5';\n\n/** The tab bar's arrow keys: Left and Right move the focus between the tabs (wrapping). */\nconst arrows = (event: KeyboardEvent<HTMLElement>) => {\n const buttons = [...event.currentTarget.querySelectorAll<HTMLElement>('[role=tab]')];\n const at = buttons.indexOf(document.activeElement as HTMLElement);\n const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;\n if (at < 0 || !step) return;\n event.preventDefault();\n buttons[(at + step + buttons.length) % buttons.length]!.focus();\n};\n\n/** The compact tab bar. With one item there is none. Exported for tests. */\nexport function TabBar({ items, activeId, onChange, text }: { readonly items: readonly NavItem[]; readonly activeId: string; readonly onChange: (id: string) => void; readonly text: AppShellText }) {\n const bar = useRef<HTMLElement>(null);\n const [plusOpen, setPlusOpen] = useState(false);\n const shown = items.length > 1;\n useTabbarHeight(bar, shown);\n if (!shown) return null;\n const { tabs, overflow } = splitNav(items);\n const inOverflow = overflow.find(item => item.id === activeId);\n const tab = (item: NavItem) => <Button key={item.id} variant={item.id === activeId ? 'secondary' : 'ghost'} role=\"tab\" data-testid={`tab-${item.id}`} aria-selected={item.id === activeId} tabIndex={item.id === activeId ? 0 : -1}\n onClick={() => onChange(item.id)} className={TAB}>\n <item.icon />{item.label}{item.badge && <Dot className=\"absolute top-1 left-1/2 ml-2\" />}\n </Button>;\n const Plus = overflow.length ? (inOverflow?.icon ?? EllipsisIcon) : undefined;\n return <nav ref={bar} role=\"tablist\" aria-label={text.tabs} data-testid=\"tabbar\" data-feedback-ignore=\"\" onKeyDown={arrows}\n className=\"relative z-(--app-shell-z-tabs) box-border flex min-h-[calc(3.5rem+env(safe-area-inset-bottom))] shrink-0 bg-background pb-[env(safe-area-inset-bottom)]\">\n <Separator className=\"absolute inset-x-0 top-0\" />\n {tabs.map(tab)}\n {Plus && <>\n <Button variant={inOverflow ? 'secondary' : 'ghost'} role=\"tab\" data-testid=\"tab-plus\" aria-selected={Boolean(inOverflow)} aria-haspopup=\"dialog\" tabIndex={inOverflow ? 0 : -1} onClick={() => setPlusOpen(true)}\n className={TAB}>\n <Plus />{inOverflow?.label ?? text.more}{!inOverflow && overflow.some(item => item.badge) && <Dot className=\"absolute top-1 left-1/2 ml-2\" />}\n </Button>\n <AppSheet open={plusOpen} onOpenChange={setPlusOpen} title={text.more}>\n {overflow.map(item => <Button key={item.id} variant={item.id === activeId ? 'secondary' : 'ghost'} size=\"lg\" data-testid={`plus-${item.id}`} aria-current={item.id === activeId} className={SHEET_ROW}\n onClick={() => { setPlusOpen(false); afterBack(() => onChange(item.id)); }}>\n <item.icon data-icon=\"inline-start\" />{item.label}{item.badge && <Dot className=\"ml-auto\" />}\n </Button>)}\n </AppSheet>\n </>}\n </nav>;\n}\n\n/** The expanded sidebar: the app title, the nav items (none with one item) and the account in the footer. */\nexport function AppSidebar({ rail = 'icon', title, items, activeId, onChange, agent, account, improve, text }: {\n readonly rail?: 'icon' | 'hover'; readonly title: string; readonly items: readonly NavItem[]; readonly activeId: string; readonly onChange: (id: string) => void;\n readonly agent: AgentSpec | undefined; readonly account: AccountSpec | undefined; readonly improve: boolean; readonly text: AppShellText;\n}) {\n const { setOpen, setOpenMobile, state, isMobile } = useSidebar();\n // In the phone sheet, choosing a screen closes it.\n const choose = (id: string) => { setOpenMobile(false); onChange(id); };\n const own = items.find(item => item.id === activeId)?.sidebar?.({ collapsed: state === 'collapsed' && !isMobile });\n const layer = useAgentLayer();\n const pressed = layer.state !== 'closed' && layer.showing === 'agent';\n const AgentIcon = agent?.icon ?? SparklesIcon;\n const agentLabel = agent?.label ?? agent?.chatProps?.labels?.title ?? text.assistant;\n const sidebar = <Sidebar collapsible=\"icon\" variant=\"inset\" data-testid=\"app-sidebar\">\n {/* The phone sheet is portaled out of the shell root, so the `.app-shell` reset would not reach its buttons: this wrapper carries it (`contents`: no box of its own). */}\n <div className=\"app-shell contents\">\n <SidebarHeader><p data-testid=\"sidebar-title\" className=\"m-0 truncate px-2 py-1 text-base font-semibold group-data-[collapsible=icon]:hidden\">{title}</p></SidebarHeader>\n <SidebarContent>\n {own != null && <div data-testid=\"sidebar-screen\" className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\">{own}</div>}\n {items.length > 1 && <SidebarGroup className={own != null ? 'shrink-0' : undefined}><SidebarGroupContent><SidebarMenu>\n {items.map(item => <SidebarMenuItem key={item.id}>\n <SidebarMenuButton isActive={item.id === activeId} tooltip={item.label} data-testid={`nav-${item.id}`} className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11\" onClick={() => choose(item.id)}>\n <item.icon />\n <span>{item.label}</span>\n {item.badge && <Dot className=\"ml-auto group-data-[collapsible=icon]:hidden\" />}\n </SidebarMenuButton>\n </SidebarMenuItem>)}\n </SidebarMenu></SidebarGroupContent></SidebarGroup>}\n {/* The assistant is a layer, never a destination: no route, it toggles the right panel. */}\n {agent && <>\n {items.length > 1 && <SidebarSeparator />}\n <SidebarGroup><SidebarGroupContent><SidebarMenu><SidebarMenuItem>\n <SidebarMenuButton isActive={pressed} aria-pressed={pressed} tooltip={agentLabel} data-testid=\"nav-agent\" className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11\"\n onClick={() => { setOpenMobile(false); if (pressed) layer.close(); else layer.open('agent', 'half'); }}>\n <AgentIcon /><span>{agentLabel}</span>\n </SidebarMenuButton>\n </SidebarMenuItem></SidebarMenu></SidebarGroupContent></SidebarGroup>\n </>}\n </SidebarContent>\n <SidebarFooter><NavUser account={account} improve={improve} text={text} /></SidebarFooter>\n <SidebarRail />\n </div>\n </Sidebar>;\n if (rail !== 'hover') return sidebar;\n // Reserve the native inset icon gap even while the fixed sidebar reveals its labels over the page.\n return <div data-rail=\"hover\" className=\"relative z-20 w-[calc(var(--sidebar-width-icon)+(--spacing(4)))] shrink-0\"\n onPointerEnter={event => { if (event.pointerType === 'mouse' || event.pointerType === 'pen') setOpen(true); }}\n onPointerLeave={() => setOpen(false)}\n onFocusCapture={event => { if (event.target.matches(':focus-visible')) setOpen(true); }}\n onBlurCapture={event => { if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false); }}\n onKeyDownCapture={event => { if (event.key === 'Escape' && event.target instanceof Node && event.currentTarget.contains(event.target)) { event.preventDefault(); event.stopPropagation(); setOpen(false); } }}>\n {sidebar}\n </div>;\n}\n",
102
102
  "type": "registry:component",
103
103
  "target": "components/app-shell/nav.tsx"
104
104
  },
@@ -160,7 +160,7 @@
160
160
  },
161
161
  "@layer utilities": {
162
162
  "@media (max-width: 640px), (pointer: coarse)": {
163
- ".app-shell :is([data-slot=\"button\"], [data-slot=\"toggle\"], [class~=\"group/button\"], [class~=\"group/toggle\"])": {
163
+ ".app-shell :is([data-slot=\"button\"], [data-slot=\"toggle\"], [class~=\"group/button\"], [class~=\"group/toggle\"]):not([data-dense]):not([data-dense] *)": {
164
164
  "min-height": "2.75rem",
165
165
  "min-width": "2.75rem"
166
166
  }
@@ -4,16 +4,16 @@
4
4
  "title": "Board viewer",
5
5
  "description": "A board@1 artifact kind for *.board.json. Lazy native tldraw projection of maquette screens, sandboxed HTML mock-ups, goto arrows and notes. Conditional board-file autosave; phone viewing without accidental frame edits. Hosts supply their resource client, asset URLs and license key.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.14",
8
- "@hachej/boring-files@^0.4.14",
7
+ "@hachej/boring-ui-kit@^0.4.15",
8
+ "@hachej/boring-files@^0.4.15",
9
9
  "react@^19.0.0",
10
10
  "lucide-react@1.51.0",
11
11
  "tldraw@5.5.2"
12
12
  ],
13
13
  "registryDependencies": [
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/maquette-viewer.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/mockup-viewer.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/pi-artifacts.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/maquette-viewer.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/mockup-viewer.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/pi-artifacts.json",
17
17
  "button",
18
18
  "alert",
19
19
  "card",
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.14/r/utils.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/utils.json",
13
13
  "dropdown-menu",
14
14
  "button",
15
15
  "toggle",
@@ -8,7 +8,7 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/theme.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/theme.json",
12
12
  "alert",
13
13
  "badge"
14
14
  ],
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.14/r/utils.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/utils.json"
11
11
  ],
12
12
  "files": [
13
13
  {
package/r/documents.json CHANGED
@@ -4,22 +4,22 @@
4
4
  "title": "Documents: the Library, a file and its revisions",
5
5
  "description": "The workspace's Library as the list, the chosen file in its viewer and its retained revisions beside it (an older one opens read-only). DocumentSheet opens one document over the app for a host's « Voir la fiche », with an optional « Toutes les fiches ». Every read goes through one revisionProvider binding.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.14",
8
- "@hachej/boring-files@^0.4.14",
7
+ "@hachej/boring-ui-kit@^0.4.15",
8
+ "@hachej/boring-files@^0.4.15",
9
9
  "react@^19.0.0",
10
10
  "lucide-react@^1.51.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/theme.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/utils.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/button.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/pi-artifacts.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/pi-app.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/app-shell.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/file-tree.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/viewer-frame.json",
21
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/viewer-image.json",
22
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/viewer-pdf.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/theme.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/utils.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/button.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/pi-artifacts.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/pi-app.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/app-shell.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/file-tree.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/viewer-frame.json",
21
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/viewer-image.json",
22
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/viewer-pdf.json",
23
23
  "drawer",
24
24
  "button",
25
25
  "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.14",
7
+ "@hachej/boring-feedback@^0.4.15",
8
8
  "@earendil-works/pi-ai@~1.2.0",
9
9
  "react@^19.0.0"
10
10
  ],
package/r/feedback.json CHANGED
@@ -4,17 +4,17 @@
4
4
  "title": "Feedback",
5
5
  "description": "Feedback from the chat composer: one Feedback button starts feedback mode on the application page (hover, click to pin a numbered note, hold ⌥ to use the app, optional voice), and one chip carries the notes to the next message. FeedbackBar, NoteBubble, FeedbackChip with its review, useComposerFeedback for the pi-chat composer, and PickerOverlay styles. Optional, not mounted by default: PointButton, AnnotateSheet, FeedbackList and FeedbackReport. Picking, masking, anchoring and placement stay in @hachej/boring-feedback/page and /ui, so restyling cannot weaken them.",
6
6
  "dependencies": [
7
- "@hachej/boring-feedback@^0.4.14",
8
- "@hachej/boring-ui-kit@^0.4.14",
9
- "@hachej/boring-files@^0.4.14",
7
+ "@hachej/boring-feedback@^0.4.15",
8
+ "@hachej/boring-ui-kit@^0.4.15",
9
+ "@hachej/boring-files@^0.4.15",
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.14/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/pi-chat-voice.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/utils.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/pi-chat-voice.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/utils.json",
18
18
  "button",
19
19
  "toggle",
20
20
  "badge",
package/r/file-tree.json CHANGED
@@ -4,15 +4,15 @@
4
4
  "title": "File tree",
5
5
  "description": "Browse, search, upload, rename, move, delete and inspect retained versions through one revisionProvider binding. Uses the consumer's shadcn Button, dropdown-menu and dialog with shared version-history rows; Base UI style only.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.14",
8
- "@hachej/boring-files@^0.4.14",
7
+ "@hachej/boring-ui-kit@^0.4.15",
8
+ "@hachej/boring-files@^0.4.15",
9
9
  "react@^19.0.0",
10
10
  "lucide-react@^1.51.0"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/theme.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/utils.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/version-history.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/theme.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/utils.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/version-history.json",
16
16
  "button",
17
17
  "dialog",
18
18
  "dropdown-menu",
package/r/highlights.json CHANGED
@@ -4,13 +4,13 @@
4
4
  "title": "Anchored highlights",
5
5
  "description": "Exact Markdown highlights and comments in a conditional workspace sidecar.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.14",
8
- "@hachej/boring-files@^0.4.14",
7
+ "@hachej/boring-ui-kit@^0.4.15",
8
+ "@hachej/boring-files@^0.4.15",
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.14/r/markdown-editor.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/markdown-editor.json",
14
14
  "button",
15
15
  "textarea",
16
16
  "label",
@@ -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.14",
8
- "@hachej/boring-files@^0.4.14",
7
+ "@hachej/boring-ui-kit@^0.4.15",
8
+ "@hachej/boring-files@^0.4.15",
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.14/r/button.json"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/button.json"
14
14
  ],
15
15
  "files": [
16
16
  {
@@ -8,7 +8,7 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/mockup-viewer.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/mockup-viewer.json",
12
12
  "alert",
13
13
  "button",
14
14
  "card",
@@ -8,8 +8,8 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/theme.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/diagram-viewer.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/theme.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/diagram-viewer.json",
13
13
  "alert",
14
14
  "badge",
15
15
  "bubble",
@@ -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.14",
8
- "@hachej/boring-files@^0.4.14",
7
+ "@hachej/boring-ui-kit@^0.4.15",
8
+ "@hachej/boring-files@^0.4.15",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "marked@^17.0.6",
@@ -24,7 +24,7 @@
24
24
  "lucide-react@^1.51.0"
25
25
  ],
26
26
  "registryDependencies": [
27
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/button.json",
27
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/button.json",
28
28
  "button",
29
29
  "toggle",
30
30
  "dropdown-menu"
@@ -10,7 +10,7 @@
10
10
  "zod@^4.6.5"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/viewer-frame.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/viewer-frame.json",
14
14
  "badge",
15
15
  "toggle",
16
16
  "tooltip"
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.14",
7
+ "@hachej/boring-ui-kit@^0.4.15",
8
8
  "react@^19.0.0",
9
9
  "react-dom@^19.0.0",
10
10
  "@earendil-works/pi-durable@~1.2.0",
@@ -12,14 +12,14 @@
12
12
  "lucide-react@^1.51.0"
13
13
  ],
14
14
  "registryDependencies": [
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/theme.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/pi-chat.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/pi-chat-plugins.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/pi-chat-question.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/pi-artifacts.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/pi-workspace.json",
21
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/utils.json",
22
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/button.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/theme.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/pi-chat.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/pi-chat-plugins.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/pi-chat-question.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/pi-artifacts.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/pi-workspace.json",
21
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/utils.json",
22
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.15/r/button.json",
23
23
  "message-scroller",
24
24
  "button",
25
25
  "toggle",