@hachej/boring-ui-kit 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -126,7 +126,9 @@ Native document author records are preserved. They do not grant access or select
126
126
 
127
127
  ## Optional React canvas editor
128
128
 
129
- Import `CanvasEditor` and `CanvasAssetUrls` from `@hachej/boring-ui-kit/canvas-editor`. Install the pinned `tldraw` peer in addition to the canvas controller peers. Supply `controller`, a complete host-owned `assetUrls` map, and optionally `title`, `height`, `className`, `licenseKey` and the native `onMount` callback. Load `tldraw/tldraw.css` when selecting the canvas route; the native JavaScript loads lazily. Asset URLs must cover all pinned fonts, icons, translations and embed icons, so omitted values cannot silently select default CDN assets.
129
+ Import `CanvasEditor` and `CanvasAssetUrls` from `@hachej/boring-ui-kit/canvas-editor`. Install the pinned `tldraw` peer in addition to the canvas controller peers. Supply `controller`, a complete host-owned `assetUrls` map, and optionally `title`, `height`, `className`, `licenseKey` and the native `onMount` callback. The native JavaScript and `tldraw/tldraw.css` load lazily, only when a canvas mounts, so the host does not import the stylesheet itself. Asset URLs must cover all pinned fonts, icons, translations and embed icons, so omitted values cannot silently select default CDN assets.
130
+
131
+ **Self-hosted assets (host recipe).** Install `@tldraw/assets` at the same pin as `tldraw`, serve its package directory at one origin path (for example `/tldraw-assets`), and build `assetUrls` from `tldraw`'s own defaults with that origin in place of `https://cdn.tldraw.com/<version>`. The working example is [`examples/canvas-assets`](../../examples/canvas-assets): `self-hosted.mjs` is the map, `route.mjs` the route, and `journey.mjs` drives Chromium to prove that the canvas makes no request off the host origin and records no Content-Security-Policy violation (`npm run canvas-assets:journey`). Pass `licenseKey` from the host's own configuration, never a literal in source.
130
132
 
131
133
  The native toolbar and Save/Check saved version controls use the concrete controller. Lost acknowledgements expose Check save outcome; conflicts preserve local edits and expose explicit discard. Paste/import/upload handlers are disabled until asset/content policy is qualified. Mounting may create native author metadata, but never publishes. Unmount preserves the controller and the native session read-only flag. The host explicitly resets or derives that flag when selecting a later writable session. Use one native editor per store; existing host read-only state remains effective.
132
134
 
@@ -234,10 +234,39 @@ function parse(command, rest) {
234
234
  return flags;
235
235
  }
236
236
 
237
+ const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
238
+ const readJson = path => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return undefined; } };
239
+
240
+ /** The version specifiers of an app's package.json (and the root entry of its npm lockfile), as written before the CLI runs. */
241
+ function specifiers(app) {
242
+ const read = name => { const path = join(app, name), text = existsSync(path) ? readFileSync(path, 'utf8') : undefined; return { path, text, json: text === undefined ? undefined : readJson(path) }; };
243
+ return [read('package.json'), read('package-lock.json')].map((file, index) => ({ ...file, root: index === 0 ? file.json : file.json?.packages?.[''] }));
244
+ }
245
+
246
+ /**
247
+ * The shadcn CLI installs a block's dependencies by name (`npm install <name>`), and npm then saves `^<version>` over an exact pin the app
248
+ * already holds. The app owns its specifiers (AGENTS.md: pin dependencies), so after the CLI ran every dependency that was there before
249
+ * gets its old specifier back, in package.json and in the lockfile's root entry; dependencies the CLI added keep what it wrote.
250
+ */
251
+ function restoreSpecifiers(before, app, log) {
252
+ const restored = new Set();
253
+ for (const [index, name] of ['package.json', 'package-lock.json'].entries()) {
254
+ const old = before[index], now = specifiers(app)[index];
255
+ if (!old.root || !now.root) continue;
256
+ for (const field of DEPENDENCY_FIELDS) for (const [dependency, range] of Object.entries(old.root[field] ?? {})) {
257
+ if (now.root[field]?.[dependency] !== undefined && now.root[field][dependency] !== range) { now.root[field][dependency] = range; restored.add(dependency); now.changed = true; }
258
+ }
259
+ if (now.changed) writeFileSync(now.path, `${JSON.stringify(now.json, null, /^\{\n([ \t]+)"/.exec(now.text)?.[1] ?? 2)}${now.text.endsWith('\n') ? '\n' : ''}`);
260
+ }
261
+ if (restored.size) log(`kept the version specifiers of ${[...restored].sort().join(', ')} (the CLI had rewritten them)`);
262
+ }
263
+
237
264
  function shadcnAdd(app, args, log) {
238
265
  const argv = ['-y', SHADCN, 'add', ...args];
239
266
  log(`npx ${argv.join(' ')}`);
267
+ const before = specifiers(app);
240
268
  const result = spawnSync('npx', argv, { cwd: app, stdio: 'inherit', shell: process.platform === 'win32' });
269
+ restoreSpecifiers(before, app, log);
241
270
  return result.status ?? 1;
242
271
  }
243
272
 
@@ -1,6 +1,7 @@
1
+ import 'tldraw/tldraw.css';
1
2
  import type { CSSProperties } from 'react';
2
3
  import type { TLOnMountHandler } from '@tldraw/editor';
3
- import type { TLDefaultFonts, TLUiAssetUrls } from 'tldraw';
4
+ import type { TLDefaultFonts, TLUiAssetUrls, TldrawProps } from 'tldraw';
4
5
  import type { CanvasController } from './canvas.js';
5
6
  import type { CanvasMountedTools } from './canvas-mounted.js';
6
7
  import type { ViewerComponents } from './viewer-components.js';
@@ -21,5 +22,11 @@ export interface CanvasEditorProps {
21
22
  /** Host presentation for the canvas controls; without it the package renders unstyled native buttons. */
22
23
  readonly components?: ViewerComponents;
23
24
  }
25
+ /** A borrowed native renderer for a host-owned document projection. It never saves the native store. */
26
+ export type CanvasProjectionProps = Pick<TldrawProps, 'store' | 'shapeUtils' | 'hideUi' | 'children' | 'onMount' | 'onUiEvent'> & {
27
+ readonly assetUrls: CanvasAssetUrls;
28
+ readonly licenseKey?: string | undefined;
29
+ };
30
+ export declare function NativeCanvasProjection({ assetUrls, licenseKey, onMount, ...props }: CanvasProjectionProps): import("react").JSX.Element;
24
31
  export default function NativeCanvas({ controller, assetUrls, title, className, height, licenseKey, onMount, onMountedTools, components }: CanvasEditorProps): import("react").JSX.Element;
25
32
  //# sourceMappingURL=canvas-editor-native.d.ts.map
@@ -1,10 +1,22 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import 'tldraw/tldraw.css';
3
4
  import { useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
4
5
  import { useEditor, useValue, GeoShapeGeoStyle, LANGUAGES } from '@tldraw/editor';
5
6
  import { Tldraw, iconTypes, DEFAULT_EMBED_DEFINITIONS } from 'tldraw';
6
7
  import { createMountedCanvasTools } from './canvas-mounted.js';
7
8
  import { CanvasProposals } from './canvas-proposals.js';
9
+ export function NativeCanvasProjection({ assetUrls, licenseKey, onMount, ...props }) {
10
+ const signature = useMemo(() => assetSignature(assetUrls), [assetUrls]);
11
+ const assets = useMemo(() => ({ fonts: { ...assetUrls.fonts }, icons: { ...assetUrls.icons }, translations: { ...assetUrls.translations }, embedIcons: { ...assetUrls.embedIcons } }), [signature]);
12
+ return _jsx(Tldraw, { ...props, autoFocus: false, locale: "en", assetUrls: assets, ...(licenseKey === undefined ? {} : { licenseKey }), onMount: editor => {
13
+ editor.registerExternalAssetHandler('file', null);
14
+ editor.registerExternalAssetHandler('url', null);
15
+ for (const type of externalContentTypes)
16
+ editor.registerExternalContentHandler(type, null);
17
+ return onMount?.(editor);
18
+ } });
19
+ }
8
20
  const fontKeys = [
9
21
  'tldraw_draw', 'tldraw_draw_italic', 'tldraw_draw_bold', 'tldraw_draw_italic_bold',
10
22
  'tldraw_sans', 'tldraw_sans_italic', 'tldraw_sans_bold', 'tldraw_sans_italic_bold',
@@ -32,8 +44,6 @@ export default function NativeCanvas({ controller, assetUrls, title = 'Canvas',
32
44
  const mounted = useRef(true);
33
45
  const revokeTools = useRef(null);
34
46
  useLayoutEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
35
- const signature = useMemo(() => assetSignature(assetUrls), [assetUrls]);
36
- const assets = useMemo(() => ({ fonts: { ...assetUrls.fonts }, icons: { ...assetUrls.icons }, translations: { ...assetUrls.translations }, embedIcons: { ...assetUrls.embedIcons } }), [signature]);
37
47
  const outcome = state.save.kind === 'settled' ? state.save.result : undefined;
38
48
  const pending = state.save.kind === 'pending';
39
49
  const uncertain = outcome?.kind === 'unknown';
@@ -62,10 +72,6 @@ export default function NativeCanvas({ controller, assetUrls, title = 'Canvas',
62
72
  const attach = (editor) => {
63
73
  if (controller.getSnapshot().readOnly || controller.getSnapshot().lifecycle === 'disposed')
64
74
  editor.updateInstanceState({ isReadonly: true });
65
- editor.registerExternalAssetHandler('file', null);
66
- editor.registerExternalAssetHandler('url', null);
67
- for (const type of externalContentTypes)
68
- editor.registerExternalContentHandler(type, null);
69
75
  const cleanup = onMount?.(editor);
70
76
  setNativeEditor(editor);
71
77
  return () => { revokeTools.current?.(); setNativeEditor(current => current === editor ? null : current); cleanup?.(); };
@@ -78,7 +84,7 @@ export default function NativeCanvas({ controller, assetUrls, title = 'Canvas',
78
84
  }
79
85
  }, children: [_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", disabled: unavailable || !state.dirty || pending || uncertain || busy || state.problem !== null, onClick: () => void save(), children: "Save" }), _jsx(Control, { type: "button", disabled: state.lifecycle === 'disposed' || pending || uncertain || busy, onClick: () => void run(controller.actions.refresh), children: "Check saved version" })] }), uncertain && _jsxs("div", { role: "alert", children: ["Save acknowledgement was lost. Your current canvas is retained.", _jsx(Control, { type: "button", disabled: busy, onClick: () => void run(controller.actions.reconcile), children: "Check save outcome" }), _jsx(Control, { type: "button", disabled: busy || state.lifecycle === 'disposed', onClick: () => void run(controller.actions.abandon), children: "Keep draft and refresh" })] }), conflict && _jsxs("div", { role: "alert", children: ["The saved canvas changed. Your local edits have been kept.", _jsx(Control, { type: "button", disabled: pending || uncertain || busy, onClick: () => void run(controller.actions.discardToRemote), children: "Discard local changes and reload" })] }), state.problem && _jsx("p", { role: "alert", children: state.problem }), (outcome?.kind === 'denied' || outcome?.kind === 'unavailable' || outcome?.kind === 'file_too_large') && _jsx("p", { role: "alert", children: outcome.reason }), error && _jsx("p", { role: "alert", children: error }), _jsx(CanvasProposals, { components: components, controller: controller, state: state, editor: nativeEditor, busy: busy, run: run }), state.lifecycle === 'active' && _jsx("div", { "data-boring": "canvas-stage", style: { position: 'relative', height }, onClickCapture: event => { if (event.target instanceof Element && event.target.closest('a'))
80
86
  event.preventDefault(); }, onAuxClickCapture: event => { if (event.target instanceof Element && event.target.closest('a'))
81
- event.preventDefault(); }, children: _jsxs(Tldraw, { store: controller.store, hideUi: true, locale: "en", autoFocus: false, assetUrls: assets, ...(licenseKey === undefined ? {} : { licenseKey }), onMount: attach, children: [_jsx(CanvasTools, { components: components, controller: controller }), _jsx(MountedCommands, { controller: controller, revokeTools: revokeTools, ...(onMountedTools ? { onMountedTools } : {}) })] }) })] });
87
+ event.preventDefault(); }, children: _jsxs(NativeCanvasProjection, { store: controller.store, hideUi: true, assetUrls: assetUrls, licenseKey: licenseKey, onMount: attach, children: [_jsx(CanvasTools, { components: components, controller: controller }), _jsx(MountedCommands, { controller: controller, revokeTools: revokeTools, ...(onMountedTools ? { onMountedTools } : {}) })] }) })] });
82
88
  }
83
89
  const externalContentTypes = ['text', 'files', 'file-replace', 'embed', 'svg-text', 'url', 'tldraw', 'excalidraw'];
84
90
  const choices = [
@@ -1,6 +1,8 @@
1
1
  export type { CanvasMountedSubject, CanvasMountedInspection, CanvasMountedTools } from './canvas-mounted.js';
2
- import type { CanvasEditorProps } from './canvas-editor-native.js';
3
- export type { CanvasAssetUrls, CanvasEditorProps, CanvasFontUrls } from './canvas-editor-native.js';
2
+ import type { CanvasEditorProps, CanvasProjectionProps } from './canvas-editor-native.js';
3
+ export type { CanvasAssetUrls, CanvasEditorProps, CanvasFontUrls, CanvasProjectionProps } from './canvas-editor-native.js';
4
+ /** Native tldraw presentation for a host format such as board@1. No document controller or native persistence. */
5
+ export declare function CanvasProjection(props: CanvasProjectionProps): import("react").JSX.Element;
4
6
  /** Borrows one controller/store. The host supplies native CSS, fonts and any production license. */
5
7
  export declare function CanvasEditor(props: CanvasEditorProps): import("react").JSX.Element;
6
8
  //# sourceMappingURL=canvas-editor.d.ts.map
@@ -3,6 +3,11 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
3
3
  import { lazy, Suspense, useState, useSyncExternalStore } from 'react';
4
4
  import { TextDraftControls } from './text-draft-controls.js';
5
5
  const NativeCanvas = lazy(() => import('./canvas-editor-native.js'));
6
+ const NativeProjection = lazy(() => import('./canvas-editor-native.js').then(module => ({ default: module.NativeCanvasProjection })));
7
+ /** Native tldraw presentation for a host format such as board@1. No document controller or native persistence. */
8
+ export function CanvasProjection(props) {
9
+ return _jsx(Suspense, { fallback: _jsx("p", { role: "status", children: "Loading canvas" }), children: _jsx(NativeProjection, { ...props }) });
10
+ }
6
11
  /** Borrows one controller/store. The host supplies native CSS, fonts and any production license. */
7
12
  export function CanvasEditor(props) {
8
13
  const state = useSyncExternalStore(props.controller.subscribe, props.controller.getSnapshot, props.controller.getSnapshot);
@@ -47,6 +47,9 @@ export declare const defaultFileTreeLabels: {
47
47
  checkUpload: string;
48
48
  uploadStatusUnavailable: string;
49
49
  uploadFailed: (name: string) => string;
50
+ tooLarge: string;
51
+ uploadProgress: (name: string) => string;
52
+ cancelUpload: (name: string) => string;
50
53
  };
51
54
  export type FileTreeLabels = typeof defaultFileTreeLabels;
52
55
  /** The tree's words in French (`locale="fr"`). */
@@ -100,7 +103,26 @@ export interface FileTreeComponents {
100
103
  readonly className?: string;
101
104
  readonly children?: ReactNode;
102
105
  }) => ReactNode;
106
+ /** The host's shadcn `Progress` (`value` 0–100, `null` while the total is unknown), for uploads through `uploadBlob`. */
107
+ readonly Progress?: (props: {
108
+ readonly value: number | null;
109
+ readonly 'aria-label': string;
110
+ }) => ReactNode;
111
+ }
112
+ /** How far an upload through `uploadBlob` is. */
113
+ export interface UploadProgress {
114
+ readonly loaded: number;
115
+ readonly total: number;
103
116
  }
117
+ /**
118
+ * The host's upload of a large file: it stores the bytes and writes the workspace's pointer file itself, then resolves (with the
119
+ * pointer it wrote). The tree never writes anything for it; it reloads its listing. `signal` aborts it (the row's cancel button);
120
+ * a refusal rejects with `{ error: { code, message } }`, whose `message` the tree shows.
121
+ */
122
+ export type UploadBlob = (file: globalThis.File, options: {
123
+ readonly signal: AbortSignal;
124
+ readonly onProgress: (progress: UploadProgress) => void;
125
+ }) => Promise<unknown>;
104
126
  export interface FileTreeProps {
105
127
  readonly components: FileTreeComponents;
106
128
  readonly revisionProvider: RevisionProvider;
@@ -128,6 +150,12 @@ export interface FileTreeProps {
128
150
  readonly refreshKey?: unknown;
129
151
  /** Reload the open history panel whenever this value changes (for example the seq of the workspace's latest change event). */
130
152
  readonly historyKey?: unknown;
153
+ /** The inline cap in bytes: a larger upload goes to `uploadBlob`, or is refused with `tooLarge` without it. No cap when omitted. */
154
+ readonly maxBytes?: number;
155
+ /** The refusal shown for a file above `maxBytes` when there is no `uploadBlob`. */
156
+ readonly tooLarge?: string;
157
+ /** The host's large-file upload (see `UploadBlob`), used above `maxBytes`. */
158
+ readonly uploadBlob?: UploadBlob;
131
159
  }
132
160
  /** One borrowed binding powers browsing, search, upload and history. Historical previews never replace an editor's draft. */
133
161
  export declare function FileTree(props: FileTreeProps): import("react").JSX.Element;
@@ -135,5 +163,5 @@ export interface FileTreeViewProps extends Omit<FileTreeProps, 'revisionProvider
135
163
  readonly controller: FileTreeController;
136
164
  }
137
165
  /** Renders a borrowed controller, for a workspace whose chat and tree share upload state. */
138
- export declare function FileTreeView({ components, controller, onOpen, selectedPath, className, uploadDirectory, locale, labels: given, upload, history, refresh, variant, title, onClose, refreshKey, historyKey }: FileTreeViewProps): import("react").JSX.Element;
166
+ export declare function FileTreeView({ components, controller, onOpen, selectedPath, className, uploadDirectory, locale, labels: given, upload, history, refresh, variant, title, onClose, refreshKey, historyKey, maxBytes, tooLarge, uploadBlob }: FileTreeViewProps): import("react").JSX.Element;
139
167
  //# sourceMappingURL=file-tree-view.d.ts.map
@@ -48,6 +48,9 @@ export const defaultFileTreeLabels = {
48
48
  checkUpload: 'Check upload status',
49
49
  uploadStatusUnavailable: 'Upload status unavailable',
50
50
  uploadFailed: (name) => `${name}: upload failed`,
51
+ tooLarge: 'The file is too large to upload.',
52
+ uploadProgress: (name) => `Uploading ${name}`,
53
+ cancelUpload: (name) => `Cancel uploading ${name}`,
51
54
  };
52
55
  /** The tree's words in French (`locale="fr"`). */
53
56
  export const frenchFileTreeLabels = {
@@ -94,6 +97,9 @@ export const frenchFileTreeLabels = {
94
97
  checkUpload: 'Vérifier l’import',
95
98
  uploadStatusUnavailable: 'État de l’import indisponible',
96
99
  uploadFailed: name => `${name} : échec de l’import`,
100
+ tooLarge: 'Le fichier est trop volumineux pour être importé.',
101
+ uploadProgress: name => `Import de ${name}`,
102
+ cancelUpload: name => `Annuler l’import de ${name}`,
97
103
  };
98
104
  const LOCALES = { en: defaultFileTreeLabels, fr: frenchFileTreeLabels };
99
105
  function fileKind(path, labels) {
@@ -129,8 +135,8 @@ function BoundFileTree(props) {
129
135
  return owned?.provider === props.revisionProvider ? _jsx(FileTreeView, { ...props, controller: owned.controller }) : _jsx("p", { role: "status", children: props.labels?.loading ?? LOCALES[props.locale ?? 'en'].loading });
130
136
  }
131
137
  /** Renders a borrowed controller, for a workspace whose chat and tree share upload state. */
132
- export function FileTreeView({ components, controller, onOpen, selectedPath, className, uploadDirectory = 'uploads', locale = 'en', labels: given, upload = true, history = true, refresh = true, variant = 'list', title, onClose, refreshKey, historyKey }) {
133
- const { Button, Actions, Menu: SidebarMenu, MenuItem: SidebarMenuItem, MenuSub: SidebarMenuSub, Input, Collapsible: CollapsibleRoot, CollapsibleTrigger, CollapsibleContent, Empty } = components;
138
+ export function FileTreeView({ components, controller, onOpen, selectedPath, className, uploadDirectory = 'uploads', locale = 'en', labels: given, upload = true, history = true, refresh = true, variant = 'list', title, onClose, refreshKey, historyKey, maxBytes, tooLarge, uploadBlob }) {
139
+ const { Button, Actions, Menu: SidebarMenu, MenuItem: SidebarMenuItem, MenuSub: SidebarMenuSub, Input, Collapsible: CollapsibleRoot, CollapsibleTrigger, CollapsibleContent, Empty, Progress } = components;
134
140
  const list = variant === 'list';
135
141
  const labels = { ...LOCALES[locale], ...given };
136
142
  const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
@@ -154,9 +160,49 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
154
160
  const [uploadError, setUploadError] = useState('');
155
161
  const [historyPath, setHistoryPath] = useState();
156
162
  const [localFailures, setLocalFailures] = useState(new Map());
163
+ // Uploads through the host's `uploadBlob`, by a key of their own: the name, the progress and the abort behind the cancel button.
164
+ const [blobUploads, setBlobUploads] = useState(new Map());
165
+ const blobAborts = useRef(new Set());
166
+ useEffect(() => () => { for (const abort of blobAborts.current)
167
+ abort.abort(); }, []);
168
+ const uploadLarge = async (file, send) => {
169
+ const key = `${file.name}:${Math.random()}`, abort = new AbortController();
170
+ const set = (progress) => setBlobUploads(previous => new Map(previous).set(key, { name: file.name, progress, abort }));
171
+ blobAborts.current.add(abort);
172
+ set(undefined);
173
+ try {
174
+ await send(file, { signal: abort.signal, onProgress: progress => { if (!abort.signal.aborted)
175
+ set(progress); } });
176
+ if (abort.signal.aborted)
177
+ return;
178
+ // The host wrote the pointer file: the listing is read again, never written.
179
+ const { query, expanded } = controller.getSnapshot();
180
+ if (query)
181
+ void controller.search(query);
182
+ else
183
+ for (const directory of ['', ...expanded])
184
+ void controller.refresh(directory);
185
+ }
186
+ catch (error) {
187
+ if (!abort.signal.aborted)
188
+ setUploadError(refusalOf(error) ?? labels.uploadFailed(file.name));
189
+ }
190
+ finally {
191
+ blobAborts.current.delete(abort);
192
+ setBlobUploads(previous => { const next = new Map(previous); next.delete(key); return next; });
193
+ }
194
+ };
157
195
  const uploadFiles = async (files) => {
196
+ setUploadError('');
158
197
  for (const file of files) {
159
198
  const path = uploadDirectory ? `${uploadDirectory}/${file.name}` : file.name;
199
+ if (maxBytes !== undefined && file.size > maxBytes) {
200
+ if (uploadBlob)
201
+ void uploadLarge(file, uploadBlob);
202
+ else
203
+ setLocalFailures(previous => new Map(previous).set(path, { file, reason: tooLarge ?? labels.tooLarge }));
204
+ continue;
205
+ }
160
206
  setLocalFailures(previous => { const next = new Map(previous); next.delete(path); return next; });
161
207
  try {
162
208
  await controller.upload({ path, bytes: new Uint8Array(await file.arrayBuffer()), mediaType: file.type || 'application/octet-stream' });
@@ -269,15 +315,17 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
269
315
  const folder = entry.kind === 'directory';
270
316
  const temporary = temporaryPaths.has(entry.path);
271
317
  const status = uploadRows.get(entry.path);
272
- const name = entry.path.split('/').at(-1);
318
+ // A pointer file shows the file it stands for: its name, kind and size (`withBlobPointers` resolved it where the listing was read).
319
+ const blob = entry.blob && 'url' in entry.blob ? entry.blob : undefined;
320
+ const name = blob?.name ?? entry.path.split('/').at(-1);
273
321
  const parent = (state.query || temporary) && entry.path.includes('/') ? entry.path.slice(0, entry.path.lastIndexOf('/')) : '';
274
322
  const children = folder ? state.directories.get(entry.path) : undefined;
275
323
  const childCount = children?.kind === 'ready' && children.cursor === undefined ? labels.childCount(children.entries.length) : undefined;
276
- const kind = folder ? { icon: Folder, label: labels.folder } : fileKind(entry.path, labels);
324
+ const kind = folder ? { icon: Folder, label: labels.folder } : fileKind(blob?.name ?? entry.path, labels);
277
325
  const Icon = kind.icon;
278
326
  const button = _jsxs(Button, { type: "button", variant: selectedPath === entry.path ? 'secondary' : 'ghost', "data-active": selectedPath === entry.path, tabIndex: -1, title: entry.path, disabled: temporary, className: "file-tree-row-button", onClick: () => { focus(entry.path); if (!folder)
279
- onOpen?.(entry.path); }, children: [folder ? _jsx(ChevronRight, { "aria-hidden": "true" }) : list && _jsx("span", { className: "file-tree-chevron-space", "aria-hidden": "true" }), _jsx(Icon, { "aria-hidden": "true" }), _jsxs("span", { className: "file-tree-copy", children: [_jsx("span", { className: "file-tree-name", children: name }), list ? folder ? childCount !== undefined && _jsx("span", { "data-file-meta": true, children: childCount }) : _jsxs("span", { "data-file-meta": true, children: [kind.label, " \u00B7 ", fileSize(entry.size, locale), parent && ` · ${parent}`] }) : parent && _jsx("span", { className: "file-tree-parent", "data-file-meta": true, children: parent })] })] });
280
- const item = _jsxs(SidebarMenuItem, { role: "treeitem", "data-path": entry.path, "data-upload-state": status?.kind, "aria-busy": status?.kind === 'pending' || undefined, "aria-level": level, "aria-selected": selectedPath === entry.path, "aria-expanded": folder ? state.expanded.has(entry.path) : undefined, tabIndex: focusPath === entry.path ? 0 : -1, onFocus: event => { if (event.target === event.currentTarget)
327
+ onOpen?.(entry.path); }, children: [folder ? _jsx(ChevronRight, { "aria-hidden": "true" }) : list && _jsx("span", { className: "file-tree-chevron-space", "aria-hidden": "true" }), _jsx(Icon, { "aria-hidden": "true" }), _jsxs("span", { className: "file-tree-copy", children: [_jsx("span", { className: "file-tree-name", children: name }), list ? folder ? childCount !== undefined && _jsx("span", { "data-file-meta": true, children: childCount }) : _jsxs("span", { "data-file-meta": true, children: [kind.label, " \u00B7 ", fileSize(blob?.size ?? entry.size, locale), parent && ` · ${parent}`] }) : parent && _jsx("span", { className: "file-tree-parent", "data-file-meta": true, children: parent })] })] });
328
+ const item = _jsxs(SidebarMenuItem, { role: "treeitem", "data-path": entry.path, "data-blob-type": blob?.type, "data-upload-state": status?.kind, "aria-busy": status?.kind === 'pending' || undefined, "aria-level": level, "aria-selected": selectedPath === entry.path, "aria-expanded": folder ? state.expanded.has(entry.path) : undefined, tabIndex: focusPath === entry.path ? 0 : -1, onFocus: event => { if (event.target === event.currentTarget)
281
329
  setFocused(entry.path); }, onKeyDown: event => { if (event.target === event.currentTarget)
282
330
  keyboard(event, entry); }, className: "file-tree-item", children: [_jsxs("div", { className: "flex items-center gap-1", children: [folder ? _jsx(CollapsibleTrigger, { render: button }) : button, status?.kind === 'pending' && _jsx(LoaderCircle, { "data-upload-spinner": true, className: "animate-spin motion-reduce:animate-none", "aria-hidden": "true" }), status?.kind === 'failed' && _jsx(Button, { type: "button", variant: "ghost", "data-upload-retry": true, "aria-label": status.retryLabel, onClick: status.retry, children: status.retryLabel }), !folder && !temporary && !status && _jsx(FileActions, { Actions: Actions, controller: controller, entry: entry, labels: labels, onOpen: onOpen ? () => onOpen(entry.path) : undefined, onHistory: history ? () => setHistoryPath(entry.path) : undefined, onError: setUploadError })] }), status?.kind === 'failed' && _jsx("span", { "data-upload-error": true, children: status.reason }), folder && !state.query && _jsx(CollapsibleContent, { render: _jsx(SidebarMenuSub, { role: "group" }), children: renderEntries(entry.path, level + 1) })] }, entry.path);
283
331
  return folder ? _jsx(CollapsibleRoot, { render: item, open: state.expanded.has(entry.path), onOpenChange: () => { void controller.toggle(entry.path); } }, entry.path) : item;
@@ -304,13 +352,32 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
304
352
  const files = Array.from(event.currentTarget.files ?? []);
305
353
  event.currentTarget.value = '';
306
354
  void uploadFiles(files);
307
- } }), dropping && _jsx("div", { className: "file-tree-drop", role: "status", children: _jsx(Empty, { icon: _jsx(Upload, { "aria-hidden": "true" }), title: labels.dropFiles }) }), uploadError && _jsx("p", { role: "alert", children: uploadError }), _jsxs(SidebarMenu, { ref: tree, role: "tree", "aria-label": labels.tree, className: "file-tree-menu", children: [state.query ? _jsxs(_Fragment, { children: [state.search.entries.map(entry => renderEntry(entry, 1)), listingStatus(state.search, '', true)] }) : renderEntries('', 1), temporaryEntries.map(entry => renderEntry(entry, 1))] }), _jsxs("ul", { "aria-label": labels.uploads, "aria-live": "polite", "aria-relevant": "additions text", className: "file-tree-announcements", children: [state.uploads.map(upload => _jsxs("li", { "data-operation": upload.operationId, children: [upload.path, ": ", upload.state.kind === 'pending' ? labels.uploading : upload.state.result.kind === 'committed' ? labels.saved : upload.state.result.kind === 'partial' ? labels.unconfirmed : upload.state.result.reason] }, upload.operationId)), [...localFailures].map(([path, failure]) => _jsxs("li", { children: [path, ": ", failure.reason] }, path))] }), history && historyPath !== undefined && _jsx(FileHistory, { components: components, locale: locale, controller: controller, path: historyPath, labels: labels, reload: historyKey, onClose: () => setHistoryPath(undefined) }, historyPath)] });
355
+ } }), dropping && _jsx("div", { className: "file-tree-drop", role: "status", children: _jsx(Empty, { icon: _jsx(Upload, { "aria-hidden": "true" }), title: labels.dropFiles }) }), uploadError && _jsx("p", { role: "alert", children: uploadError }), blobUploads.size > 0 && _jsx("ul", { "aria-label": labels.uploads, className: "file-tree-blob-uploads", children: [...blobUploads].map(([key, item]) => {
356
+ const percent = item.progress && item.progress.total > 0 ? Math.min(100, Math.round(item.progress.loaded / item.progress.total * 100)) : null;
357
+ return _jsxs("li", { "data-blob-upload": item.name, "data-progress": percent ?? undefined, className: "flex items-center gap-2", children: [_jsx("span", { className: "min-w-0 flex-1 truncate", children: item.name }), Progress ? _jsx("span", { className: "w-24 shrink-0", children: _jsx(Progress, { value: percent, "aria-label": labels.uploadProgress(item.name) }) }) : _jsx("span", { role: "status", children: percent === null ? labels.uploading : `${percent}%` }), _jsx(Button, { type: "button", variant: "ghost", size: "icon", "data-blob-upload-cancel": true, "aria-label": labels.cancelUpload(item.name), title: labels.cancelUpload(item.name), onClick: () => item.abort.abort(), children: _jsx(X, { "aria-hidden": "true" }) })] }, key);
358
+ }) }), _jsxs(SidebarMenu, { ref: tree, role: "tree", "aria-label": labels.tree, className: "file-tree-menu", children: [state.query ? _jsxs(_Fragment, { children: [state.search.entries.map(entry => renderEntry(entry, 1)), listingStatus(state.search, '', true)] }) : renderEntries('', 1), temporaryEntries.map(entry => renderEntry(entry, 1))] }), _jsxs("ul", { "aria-label": labels.uploads, "aria-live": "polite", "aria-relevant": "additions text", className: "file-tree-announcements", children: [state.uploads.map(upload => _jsxs("li", { "data-operation": upload.operationId, children: [upload.path, ": ", upload.state.kind === 'pending' ? labels.uploading : upload.state.result.kind === 'committed' ? labels.saved : upload.state.result.kind === 'partial' ? labels.unconfirmed : upload.state.result.reason] }, upload.operationId)), [...localFailures].map(([path, failure]) => _jsxs("li", { children: [path, ": ", failure.reason] }, path))] }), history && historyPath !== undefined && _jsx(FileHistory, { components: components, locale: locale, controller: controller, path: historyPath, labels: labels, reload: historyKey, onClose: () => setHistoryPath(undefined) }, historyPath)] });
359
+ }
360
+ /** The message of a host refusal `{ error: { code, message } }` (or an Error's), if any. */
361
+ function refusalOf(error) {
362
+ const message = typeof error === 'object' && error !== null && 'error' in error && typeof error.error === 'object' && error.error !== null && 'message' in error.error ? error.error.message : error instanceof Error ? error.message : undefined;
363
+ return typeof message === 'string' && message ? message : undefined;
308
364
  }
309
365
  function FileActions({ Actions, controller, entry, labels, onOpen, onHistory, onError }) {
310
366
  const lifetime = useRef(undefined);
311
367
  useEffect(() => () => lifetime.current?.abort(), []);
368
+ const blob = entry.blob && 'url' in entry.blob ? entry.blob : undefined;
312
369
  const download = async () => {
313
370
  onError('');
371
+ if (blob) {
372
+ // The host serves a blob's bytes at its URL, with its own disposition; nothing is read into the page.
373
+ const link = document.createElement('a');
374
+ link.href = blob.url;
375
+ link.download = blob.name;
376
+ document.body.append(link);
377
+ link.click();
378
+ link.remove();
379
+ return;
380
+ }
314
381
  lifetime.current?.abort();
315
382
  const abort = new AbortController();
316
383
  lifetime.current = abort;
@@ -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.3",
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.3",
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.3",
8
+ "@hachej/boring-ui-kit@^0.4.3",
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.3/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/dock.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/feedback.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/utils.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/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
  },