@hachej/boring-ui-kit 0.4.51 → 0.4.52

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 (50) hide show
  1. package/dist/file-tree-view.js +37 -23
  2. package/dist/file-tree.d.ts +12 -0
  3. package/dist/file-tree.js +56 -4
  4. package/package.json +2 -2
  5. package/r/agent-target.json +4 -4
  6. package/r/app-shell.json +11 -11
  7. package/r/board-viewer.json +6 -6
  8. package/r/button.json +1 -1
  9. package/r/dock.json +1 -1
  10. package/r/documents.json +12 -12
  11. package/r/drawing-viewer.json +1 -1
  12. package/r/feedback.json +6 -6
  13. package/r/file-tree.json +4 -4
  14. package/r/highlights.json +3 -3
  15. package/r/html-viewer.json +3 -3
  16. package/r/looks-viewer.json +2 -2
  17. package/r/maquette-viewer.json +1 -1
  18. package/r/markdown-editor.json +3 -3
  19. package/r/mockup-viewer.json +1 -1
  20. package/r/pi-ambient.json +8 -8
  21. package/r/pi-app-kinds.json +10 -10
  22. package/r/pi-app.json +10 -10
  23. package/r/pi-artifacts.json +2 -2
  24. package/r/pi-chat-approval.json +2 -2
  25. package/r/pi-chat-attachments.json +1 -1
  26. package/r/pi-chat-markdown.json +2 -2
  27. package/r/pi-chat-mentions.json +1 -1
  28. package/r/pi-chat-pickers.json +1 -1
  29. package/r/pi-chat-plugins.json +11 -11
  30. package/r/pi-chat-question.json +1 -1
  31. package/r/pi-chat-slash.json +2 -2
  32. package/r/pi-chat-subagent.json +1 -1
  33. package/r/pi-chat-voice.json +3 -3
  34. package/r/pi-chat.json +6 -6
  35. package/r/pi-workspace.json +3 -3
  36. package/r/placed-agent.json +4 -4
  37. package/r/provider-setup.json +1 -1
  38. package/r/registry.json +162 -162
  39. package/r/shared-chat.json +6 -6
  40. package/r/spaces.json +1 -1
  41. package/r/task-list-viewer.json +2 -2
  42. package/r/version-history.json +2 -2
  43. package/r/viewer-code.json +2 -2
  44. package/r/viewer-frame.json +4 -4
  45. package/r/viewer-html.json +4 -4
  46. package/r/viewer-image.json +2 -2
  47. package/r/viewer-markdown.json +5 -5
  48. package/r/viewer-pdf.json +3 -3
  49. package/r/viewers.json +6 -6
  50. package/r/windows.json +1 -1
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { ChevronRight, Download, File, FileCode, FileImage, FileSpreadsheet, FileText, Folder, FolderInput, FolderOpen, History, LoaderCircle, MoreHorizontal, Pencil, Search, Trash2, Upload, X } from 'lucide-react';
4
4
  import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
5
- import { createFileTreeController } from './file-tree.js';
5
+ import { createFileTreeController, folderWritable } from './file-tree.js';
6
6
  /** Every word of the tree, so a host can translate it. Functions receive what they name. */
7
7
  export const defaultFileTreeLabels = {
8
8
  region: 'Workspace files',
@@ -199,11 +199,11 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
199
199
  return;
200
200
  shownKey.current = refreshKey;
201
201
  const { query, expanded } = controller.getSnapshot();
202
+ // Open folders are read again while searching too: their listing carries the host's word on writing in them.
202
203
  if (query)
203
204
  void controller.search(query);
204
- else
205
- for (const directory of ['', ...expanded])
206
- void controller.refresh(directory);
205
+ for (const directory of ['', ...expanded])
206
+ void controller.refresh(directory);
207
207
  }, [controller, refreshKey]);
208
208
  const tree = useRef(null);
209
209
  const fileInput = useRef(null);
@@ -269,6 +269,13 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
269
269
  }
270
270
  }
271
271
  };
272
+ // The host's per-folder word (`FilePage.writable`): a folder it marks read-only offers no upload, retry, rename, move, delete or drop.
273
+ const writableIn = (folder) => folderWritable(state.directories, folder);
274
+ const isListedFolder = (path) => [...state.directories.values(), state.search].some(listing => listing.entries.some(entry => entry.path === path && entry.kind === 'directory'));
275
+ /** A row changes its folder (and a folder row its own content): both must be writable. */
276
+ const pathWritable = (path, folder = isListedFolder(path)) => writableIn(parentPath(path)) && (!folder || writableIn(path));
277
+ const entryWritable = (entry) => pathWritable(entry.path, entry.kind === 'directory');
278
+ const canUpload = upload && writableIn(uploadDirectory);
272
279
  const uploadRows = new Map();
273
280
  for (const item of state.uploads) {
274
281
  if (item.state.kind === 'pending')
@@ -279,11 +286,14 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
279
286
  uploadRows.set(item.path, { kind: 'failed', size: item.size,
280
287
  reason: item.state.result.reason,
281
288
  retryLabel: item.state.result.kind === 'unknown' ? labels.checkUpload : labels.retry,
282
- retry: () => { void controller.retry(item.operationId).catch(error => setUploadError(error instanceof Error ? error.message : labels.uploadStatusUnavailable)); } });
289
+ // Checking an unconfirmed upload writes nothing; trying a refused one again writes, so a read-only folder offers only the first.
290
+ ...(item.state.result.kind === 'unknown' || writableIn(parentPath(item.path)) ? {
291
+ retry: () => { void controller.retry(item.operationId).catch(error => setUploadError(error instanceof Error ? error.message : labels.uploadStatusUnavailable)); }
292
+ } : {}) });
283
293
  }
284
294
  for (const [path, failure] of localFailures)
285
295
  uploadRows.set(path, { kind: 'failed', size: failure.file.size, reason: failure.reason,
286
- retryLabel: labels.retry, retry: () => { void uploadFiles([failure.file]); } });
296
+ retryLabel: labels.retry, ...(writableIn(parentPath(path)) ? { retry: () => { void uploadFiles([failure.file]); } } : {}) });
287
297
  const uploadButton = _jsxs(Button, { type: "button", variant: "secondary", "data-file-upload": true, onClick: () => fileInput.current?.click(), children: [_jsx(Upload, { "aria-hidden": "true" }), labels.upload] });
288
298
  const mutationRows = new Map();
289
299
  for (const item of state.mutations)
@@ -314,7 +324,8 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
314
324
  };
315
325
  const commitRename = (path, value) => {
316
326
  const name = value.trim();
317
- if (!name || name === baseName(path)) {
327
+ // The folder may have become read-only while the name was typed: nothing is sent.
328
+ if (!name || name === baseName(path) || !pathWritable(path)) {
318
329
  setRenaming(undefined);
319
330
  focus(path);
320
331
  return;
@@ -331,7 +342,7 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
331
342
  };
332
343
  const moveTo = (path, folder) => {
333
344
  setAsking(undefined);
334
- if (parentPath(path) === folder)
345
+ if (parentPath(path) === folder || !pathWritable(path) || !writableIn(folder))
335
346
  return;
336
347
  void controller.move(path, folder).then(report).catch(refused(path));
337
348
  };
@@ -339,7 +350,7 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
339
350
  requestAnimationFrame(() => focus(path)); };
340
351
  const draggedPath = (event) => event.dataTransfer.types.includes(DRAGGED_FILE) ? dragged ?? event.dataTransfer.getData(DRAGGED_FILE) : undefined;
341
352
  /** A folder (or `''`, the root) accepts a dragged file that is not already in it. */
342
- const dropHandlers = (folder) => can.rename ? {
353
+ const dropHandlers = (folder) => can.rename && writableIn(folder) ? {
343
354
  onDragEnter: (event) => { const path = draggedPath(event); if (path === undefined || parentPath(path) === folder)
344
355
  return; event.preventDefault(); event.stopPropagation(); setDropTarget(folder); },
345
356
  onDragOver: (event) => { const path = draggedPath(event); if (path === undefined || parentPath(path) === folder)
@@ -355,7 +366,7 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
355
366
  event.stopPropagation();
356
367
  setDropTarget(undefined);
357
368
  setDragged(undefined);
358
- if (path && parentPath(path) !== folder)
369
+ if (path && parentPath(path) !== folder && pathWritable(path))
359
370
  void controller.move(path, folder).then(report).catch(refused(path));
360
371
  },
361
372
  } : {};
@@ -389,7 +400,7 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
389
400
  node.focus();
390
401
  };
391
402
  /** Files only: a provider renames and deletes regular files, and a row busy uploading or changing waits. */
392
- const mutable = (entry) => !temporaryPaths.has(entry.path) && uploadRows.get(entry.path)?.kind !== 'pending' && mutationRows.get(entry.path)?.state.kind !== 'pending';
403
+ const mutable = (entry) => entryWritable(entry) && !temporaryPaths.has(entry.path) && uploadRows.get(entry.path)?.kind !== 'pending' && mutationRows.get(entry.path)?.state.kind !== 'pending';
393
404
  /** A blob pointer's row shows the file it stands for, not the pointer's own name: it can be moved or deleted, not renamed in place. */
394
405
  const renameable = (entry) => !entry.blob;
395
406
  const keyboard = (event, entry) => {
@@ -452,7 +463,7 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
452
463
  if (listing.kind === 'error')
453
464
  return _jsxs("li", { role: "none", children: [_jsx("span", { role: "alert", children: listing.reason }), " ", _jsx(Button, { type: "button", variant: "ghost", onClick: () => { void (search ? controller.search(state.query) : controller.refresh(directory)); }, children: labels.retry })] });
454
465
  return _jsxs(_Fragment, { children: [listing.entries.length === 0 && (directory || temporaryEntries.length === 0) && _jsx("li", { role: "none", className: list && !directory ? undefined : 'file-tree-status', children: list && !directory
455
- ? _jsx(Empty, { className: "file-tree-empty", icon: _jsx(FolderOpen, { "aria-hidden": "true" }), title: search ? labels.noMatches : labels.empty, description: search ? undefined : labels.emptyHint, children: !search && upload && uploadButton })
466
+ ? _jsx(Empty, { className: "file-tree-empty", icon: _jsx(FolderOpen, { "aria-hidden": "true" }), title: search ? labels.noMatches : labels.empty, description: search ? undefined : labels.emptyHint, children: !search && canUpload && uploadButton })
456
467
  : _jsx("span", { children: search ? labels.noMatches : labels.empty }) }), listing.cursor !== undefined && _jsx("li", { role: "none", children: _jsx(Button, { type: "button", variant: "ghost", onClick: () => { void (search ? controller.moreSearch() : controller.more(directory)); }, children: labels.loadMore }) })] });
457
468
  };
458
469
  const renderEntries = (directory, level) => _jsxs(_Fragment, { children: [(state.directories.get(directory)?.entries ?? []).map(entry => renderEntry(entry, level)), listingStatus(state.directories.get(directory), directory)] });
@@ -474,7 +485,7 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
474
485
  const decoration = decorate?.(entry) ?? undefined;
475
486
  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", draggable: draggable || undefined, onClick: () => { focus(entry.path); if (!folder)
476
487
  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 }), decoration?.detail && _jsx("span", { "data-file-detail": true, children: decoration.detail })] })] });
477
- const editing = renaming?.path === entry.path && can.rename;
488
+ const editing = renaming?.path === entry.path && can.rename && entryWritable(entry);
478
489
  const mutationFailed = mutation?.state.kind === 'settled';
479
490
  const item = _jsxs(SidebarMenuItem, { role: "treeitem", "data-path": entry.path, "data-blob-type": blob?.type, "data-upload-state": status?.kind, "data-mutation-state": mutation ? mutationFailed ? 'failed' : 'pending' : undefined, "data-drop-target": folder && dropTarget === entry.path ? 'true' : undefined, "data-dragging": dragged === entry.path || undefined, "aria-busy": status?.kind === 'pending' || mutation?.state.kind === 'pending' || undefined, ...(folder ? dropHandlers(entry.path) : {}), ...(draggable ? {
480
491
  onDragStart: (event) => {
@@ -507,43 +518,46 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
507
518
  setRenaming(current => current?.path === entry.path ? undefined : current); } })] }) : folder ? _jsx(CollapsibleTrigger, { render: button }) : button, decoration?.badge && !editing && (() => {
508
519
  const badge = { tone: decoration.badge.tone, children: decoration.badge.label, role: 'note', 'aria-label': `${name}: ${decoration.badge.label}`, title: decoration.badge.label, 'data-file-badge': '', 'data-tone': decoration.badge.tone };
509
520
  return Badge ? _jsx(Badge, { ...badge }) : _jsx("span", { role: badge.role, "aria-label": badge['aria-label'], title: badge.title, "data-file-badge": "", "data-tone": badge.tone, children: badge.children });
510
- })(), (status?.kind === 'pending' || mutation?.state.kind === 'pending') && _jsx(LoaderCircle, { "data-upload-spinner": status ? '' : undefined, "data-mutation-spinner": mutation ? '' : undefined, 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 }), mutation && mutationFailed && _jsx(Button, { type: "button", variant: "ghost", size: "icon", "data-mutation-dismiss": true, "aria-label": labels.dismiss(entry.path), title: labels.dismiss(entry.path), onClick: () => controller.dismiss(mutation.operationId), children: _jsx(X, { "aria-hidden": "true" }) }), !temporary && !status && !editing && mutation?.state.kind !== 'pending' && (!folder || changeable && (can.rename || can.delete)) && _jsx(FileActions, { Actions: Actions, controller: controller, entry: entry, labels: labels, onOpen: onOpen && !folder ? () => onOpen(entry.path) : undefined, onHistory: history && !folder ? () => setHistoryPath(entry.path) : undefined, onError: setUploadError, onRename: changeable && can.rename && renameable(entry) ? () => startRename(entry.path) : undefined, onMove: changeable && can.rename && Dialog ? () => ask('move', entry.path) : undefined, onDelete: changeable && can.delete ? () => ask('delete', entry.path) : undefined })] }), status?.kind === 'failed' && _jsx("span", { "data-upload-error": true, children: status.reason }), editing && renaming?.error && _jsx("span", { "data-mutation-error": true, role: "alert", children: renaming.error }), mutation && mutationFailed && _jsx("span", { "data-mutation-error": true, children: mutationReason(mutation) }), folder && !state.query && _jsx(CollapsibleContent, { render: _jsx(SidebarMenuSub, { role: "group" }), children: renderEntries(entry.path, level + 1) })] }, entry.path);
521
+ })(), (status?.kind === 'pending' || mutation?.state.kind === 'pending') && _jsx(LoaderCircle, { "data-upload-spinner": status ? '' : undefined, "data-mutation-spinner": mutation ? '' : undefined, className: "animate-spin motion-reduce:animate-none", "aria-hidden": "true" }), status?.kind === 'failed' && status.retry && _jsx(Button, { type: "button", variant: "ghost", "data-upload-retry": true, "aria-label": status.retryLabel, onClick: status.retry, children: status.retryLabel }), mutation && mutationFailed && _jsx(Button, { type: "button", variant: "ghost", size: "icon", "data-mutation-dismiss": true, "aria-label": labels.dismiss(entry.path), title: labels.dismiss(entry.path), onClick: () => controller.dismiss(mutation.operationId), children: _jsx(X, { "aria-hidden": "true" }) }), !temporary && !status && !editing && mutation?.state.kind !== 'pending' && (!folder || changeable && (can.rename || can.delete)) && _jsx(FileActions, { Actions: Actions, controller: controller, entry: entry, labels: labels, onOpen: onOpen && !folder ? () => onOpen(entry.path) : undefined, onHistory: history && !folder ? () => setHistoryPath(entry.path) : undefined, onError: setUploadError, onRename: changeable && can.rename && renameable(entry) ? () => startRename(entry.path) : undefined, onMove: changeable && can.rename && Dialog ? () => ask('move', entry.path) : undefined, onDelete: changeable && can.delete ? () => ask('delete', entry.path) : undefined })] }), status?.kind === 'failed' && _jsx("span", { "data-upload-error": true, children: status.reason }), editing && renaming?.error && _jsx("span", { "data-mutation-error": true, role: "alert", children: renaming.error }), mutation && mutationFailed && _jsx("span", { "data-mutation-error": true, children: mutationReason(mutation) }), folder && !state.query && _jsx(CollapsibleContent, { render: _jsx(SidebarMenuSub, { role: "group" }), children: renderEntries(entry.path, level + 1) })] }, entry.path);
511
522
  return folder ? _jsx(CollapsibleRoot, { render: item, open: state.expanded.has(entry.path), onOpenChange: () => { void controller.toggle(entry.path); } }, entry.path) : item;
512
523
  };
513
524
  const visibleListing = state.query ? state.search : state.directories.get('');
514
525
  const paginated = visibleListing?.kind === 'ready' && visibleListing.cursor !== undefined;
515
526
  const rootDrop = dropHandlers('');
516
527
  const dialogPath = asking?.path ?? '';
517
- const dialogIsFolder = [...state.directories.values(), state.search].some(listing => listing.entries.some(entry => entry.path === dialogPath && entry.kind === 'directory'));
518
- const folders = asking?.kind === 'move' ? [...new Set(['', ...[...state.directories.values(), state.search].flatMap(listing => listing.entries.filter(entry => entry.kind === 'directory').map(entry => entry.path))])].filter(folder => folder !== dialogPath && !folder.startsWith(`${dialogPath}/`)).sort() : [];
519
- return _jsxs("section", { "data-boring": "file-tree", "data-variant": variant, "data-dropping": dropping || undefined, "data-drop-target": dropTarget === '' ? 'true' : undefined, className: className, "aria-label": labels.region, onDragEnter: event => { rootDrop.onDragEnter?.(event); if (upload && event.dataTransfer.types.includes('Files')) {
528
+ const dialogIsFolder = isListedFolder(dialogPath);
529
+ // A dialog opened before its folder became read-only closes, and its confirm checks again.
530
+ const askingAllowed = asking !== undefined && pathWritable(asking.path, dialogIsFolder);
531
+ const folders = asking?.kind === 'move' ? [...new Set(['', ...[...state.directories.values(), state.search].flatMap(listing => listing.entries.filter(entry => entry.kind === 'directory').map(entry => entry.path))])].filter(folder => folder !== dialogPath && !folder.startsWith(`${dialogPath}/`) && writableIn(folder)).sort() : [];
532
+ return _jsxs("section", { "data-boring": "file-tree", "data-variant": variant, "data-dropping": dropping || undefined, "data-drop-target": dropTarget === '' ? 'true' : undefined, className: className, "aria-label": labels.region, onDragEnter: event => { rootDrop.onDragEnter?.(event); if (canUpload && event.dataTransfer.types.includes('Files')) {
520
533
  event.preventDefault();
521
534
  dragDepth.current++;
522
535
  setDropping(true);
523
- } }, onDragOver: event => { rootDrop.onDragOver?.(event); if (upload && event.dataTransfer.types.includes('Files')) {
536
+ } }, onDragOver: event => { rootDrop.onDragOver?.(event); if (canUpload && event.dataTransfer.types.includes('Files')) {
524
537
  event.preventDefault();
525
538
  event.dataTransfer.dropEffect = 'copy';
526
- } }, onDragLeave: event => { rootDrop.onDragLeave?.(event); if (upload && event.dataTransfer.types.includes('Files')) {
539
+ } }, onDragLeave: event => { rootDrop.onDragLeave?.(event); if (canUpload && event.dataTransfer.types.includes('Files')) {
527
540
  dragDepth.current = Math.max(0, dragDepth.current - 1);
528
541
  if (!dragDepth.current)
529
542
  setDropping(false);
530
- } }, onDrop: event => { rootDrop.onDrop?.(event); if (upload && event.dataTransfer.types.includes('Files')) {
543
+ } }, onDrop: event => { rootDrop.onDrop?.(event); if (canUpload && event.dataTransfer.types.includes('Files')) {
531
544
  event.preventDefault();
532
545
  dragDepth.current = 0;
533
546
  setDropping(false);
534
547
  void uploadFiles(Array.from(event.dataTransfer.files));
535
- } }, children: [list && _jsxs("header", { className: "file-tree-header", children: [_jsx("h2", { children: title ?? labels.library }), _jsxs("span", { className: "file-tree-count", "aria-label": labels.shownItems(rows.length), children: [rows.length, paginated ? '+' : ''] }), upload && uploadButton, onClose && _jsx(Button, { type: "button", variant: "ghost", size: "icon", "data-file-close": true, "aria-label": labels.close, onClick: onClose, children: _jsx(X, { "aria-hidden": "true" }) })] }), _jsxs("div", { className: "file-tree-toolbar", children: [_jsxs("div", { className: "file-tree-search", children: [list && _jsx(Search, { "aria-hidden": "true" }), _jsx(Input, { type: "search", "aria-label": labels.search, placeholder: labels.searchPlaceholder, value: state.query, onChange: event => { void controller.search(event.currentTarget.value); } })] }), refresh && _jsx(Button, { type: "button", variant: "ghost", "aria-label": labels.refresh, onClick: () => { void (state.query ? controller.search(state.query) : controller.refresh()); }, children: labels.refresh }), !list && upload && uploadButton] }), upload && _jsx(Input, { ref: fileInput, "aria-label": labels.uploadFiles, type: "file", multiple: true, onChange: event => {
548
+ } }, children: [list && _jsxs("header", { className: "file-tree-header", children: [_jsx("h2", { children: title ?? labels.library }), _jsxs("span", { className: "file-tree-count", "aria-label": labels.shownItems(rows.length), children: [rows.length, paginated ? '+' : ''] }), canUpload && uploadButton, onClose && _jsx(Button, { type: "button", variant: "ghost", size: "icon", "data-file-close": true, "aria-label": labels.close, onClick: onClose, children: _jsx(X, { "aria-hidden": "true" }) })] }), _jsxs("div", { className: "file-tree-toolbar", children: [_jsxs("div", { className: "file-tree-search", children: [list && _jsx(Search, { "aria-hidden": "true" }), _jsx(Input, { type: "search", "aria-label": labels.search, placeholder: labels.searchPlaceholder, value: state.query, onChange: event => { void controller.search(event.currentTarget.value); } })] }), refresh && _jsx(Button, { type: "button", variant: "ghost", "aria-label": labels.refresh, onClick: () => { void (state.query ? controller.search(state.query) : controller.refresh()); }, children: labels.refresh }), !list && canUpload && uploadButton] }), canUpload && _jsx(Input, { ref: fileInput, "aria-label": labels.uploadFiles, type: "file", multiple: true, onChange: event => {
536
549
  const files = Array.from(event.currentTarget.files ?? []);
537
550
  event.currentTarget.value = '';
538
551
  void uploadFiles(files);
539
552
  } }), 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]) => {
540
553
  const percent = item.progress && item.progress.total > 0 ? Math.min(100, Math.round(item.progress.loaded / item.progress.total * 100)) : null;
541
554
  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);
542
- }) }), _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.reason] }, upload.operationId)), [...localFailures].map(([path, failure]) => _jsxs("li", { children: [path, ": ", failure.reason] }, path)), state.mutations.map(item => _jsxs("li", { "data-operation": item.operationId, children: [item.path, ": ", mutationReason(item)] }, item.operationId))] }), Dialog && _jsx(Dialog, { open: asking !== undefined, onOpenChange: open => { if (!open)
555
+ }) }), _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.reason] }, upload.operationId)), [...localFailures].map(([path, failure]) => _jsxs("li", { children: [path, ": ", failure.reason] }, path)), state.mutations.map(item => _jsxs("li", { "data-operation": item.operationId, children: [item.path, ": ", mutationReason(item)] }, item.operationId))] }), Dialog && _jsx(Dialog, { open: askingAllowed, onOpenChange: open => { if (!open)
543
556
  closeAsking(); }, title: asking?.kind === 'move' ? labels.moveTitle(baseName(dialogPath)) : labels.deleteTitle(baseName(dialogPath)), description: asking?.kind === 'move' ? labels.moveDescription : dialogIsFolder ? labels.deleteFolderDescription : labels.deleteDescription, footer: asking?.kind === 'delete' ? _jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: "outline", "data-confirm": "cancel", onClick: closeAsking, children: labels.cancel }), _jsxs(Button, { type: "button", variant: "destructive", "data-confirm": "delete", onClick: () => {
544
557
  const path = dialogPath;
545
558
  setAsking(undefined);
546
- void controller.remove(path).then(report).catch(refused(path));
559
+ if (pathWritable(path, dialogIsFolder))
560
+ void controller.remove(path).then(report).catch(refused(path));
547
561
  }, children: [_jsx(Trash2, { "aria-hidden": "true" }), labels.delete] })] }) : _jsx(Button, { type: "button", variant: "outline", "data-confirm": "cancel", onClick: closeAsking, children: labels.cancel }), children: asking?.kind === 'move' && _jsx("ul", { className: "file-tree-destinations", "aria-label": labels.moveTitle(baseName(dialogPath)), children: folders.map(folder => _jsx("li", { children: _jsxs(Button, { type: "button", variant: "ghost", "data-destination": folder, disabled: folder === parentPath(dialogPath), onClick: () => moveTo(dialogPath, folder), children: [folder ? _jsx(Folder, { "aria-hidden": "true" }) : _jsx(FolderOpen, { "aria-hidden": "true" }), _jsx("span", { className: "file-tree-name", children: folder || labels.root })] }) }, folder)) }) }), history && historyPath !== undefined && _jsx(FileHistory, { components: components, locale: locale, controller: controller, path: historyPath, labels: labels, reload: historyKey, onClose: () => setHistoryPath(undefined) }, historyPath)] });
548
562
  }
549
563
  /** The message of a host refusal `{ error: { code, message } }` (or an Error's), if any. */
@@ -1,16 +1,23 @@
1
1
  import type { PublicationResult, ResourceRef } from '@hachej/boring-files';
2
2
  import type { FileEntry, RevisionProvider, SavedRevision } from '@hachej/boring-files/revision';
3
+ /**
4
+ * `writable`: the host's word on writing in this folder (`FilePage.writable`); `false` also when the host refused the listing (403). A
5
+ * reload or a failed read keeps the last word, so a read-only folder never regains its controls while it is read again.
6
+ */
3
7
  export type FileListing = {
4
8
  readonly kind: 'loading';
5
9
  readonly entries: readonly FileEntry[];
10
+ readonly writable?: boolean;
6
11
  } | {
7
12
  readonly kind: 'ready';
8
13
  readonly entries: readonly FileEntry[];
9
14
  readonly cursor?: string;
15
+ readonly writable?: boolean;
10
16
  } | {
11
17
  readonly kind: 'error';
12
18
  readonly entries: readonly FileEntry[];
13
19
  readonly reason: string;
20
+ readonly writable?: boolean;
14
21
  };
15
22
  export interface FileUpload {
16
23
  readonly operationId: string;
@@ -84,6 +91,11 @@ export interface FileTreeController {
84
91
  readonly dismiss: (operationId: string) => void;
85
92
  readonly dispose: () => void;
86
93
  }
94
+ /**
95
+ * Whether the host lets the caller write in `folder` (`''` is the root): the flag of its own listing, or of the nearest listed folder
96
+ * above it while it is not listed. True when no listing says otherwise, so a host that sends no flag keeps every control.
97
+ */
98
+ export declare function folderWritable(directories: ReadonlyMap<string, FileListing>, folder: string): boolean;
87
99
  /** Browsing state belongs to one borrowed binding. Disposal fences late results; it never closes the workspace. */
88
100
  export declare function createFileTreeController({ revisionProvider }: {
89
101
  readonly revisionProvider: RevisionProvider;
package/dist/file-tree.js CHANGED
@@ -1,6 +1,31 @@
1
1
  import { parsePublicationResult, PublicationNotDispatchedError, publicationDigest, publicationSnapshot } from '@hachej/boring-files/publication';
2
2
  import { randomUUID } from '@hachej/boring-files/platform';
3
3
  const message = (error) => error instanceof Error ? error.message : 'Files could not be loaded';
4
+ /**
5
+ * What a folder listing's failure means for the tree, read by shape (a hand-built binding may throw a `RevisionError` from another copy
6
+ * of the package): the host's 403 makes the folder read-only, its 404 makes it gone. A `denied` without that 403 (a 409 binding
7
+ * mismatch, an answer for another workspace) stays an error.
8
+ */
9
+ function refusal(error) {
10
+ if (!(error instanceof Error) || error.name !== 'RevisionError' || !('kind' in error))
11
+ return undefined;
12
+ if (error.kind === 'missing')
13
+ return 'gone';
14
+ return error.kind === 'denied' && 'status' in error && error.status === 403 ? 'read-only' : undefined;
15
+ }
16
+ /**
17
+ * Whether the host lets the caller write in `folder` (`''` is the root): the flag of its own listing, or of the nearest listed folder
18
+ * above it while it is not listed. True when no listing says otherwise, so a host that sends no flag keeps every control.
19
+ */
20
+ export function folderWritable(directories, folder) {
21
+ for (let path = folder;; path = path.slice(0, Math.max(0, path.lastIndexOf('/')))) {
22
+ const listing = directories.get(path);
23
+ if (listing?.writable !== undefined)
24
+ return listing.writable;
25
+ if (!path)
26
+ return true;
27
+ }
28
+ }
4
29
  const empty = { kind: 'ready', entries: [] };
5
30
  const parentOf = (path) => path.slice(0, Math.max(0, path.lastIndexOf('/')));
6
31
  const nameOf = (path) => path.slice(path.lastIndexOf('/') + 1);
@@ -49,18 +74,30 @@ export function createFileTreeController({ revisionProvider }) {
49
74
  const signal = AbortSignal.any([abort.signal, lifetime.signal]);
50
75
  const query = state.query;
51
76
  const set = (value) => search ? update({ ...state, search: value }) : directory(path, value);
52
- set({ kind: 'loading', entries: previous.entries });
77
+ const known = search || previous.writable === undefined ? {} : { writable: previous.writable };
78
+ set({ kind: 'loading', entries: previous.entries, ...known });
53
79
  try {
54
80
  const page = await (search ? revisionProvider.search({ query, ...(cursor ? { cursor } : {}) }, signal)
55
81
  : revisionProvider.list({ directory: path, ...(cursor ? { cursor } : {}) }, signal));
56
82
  if (signal.aborted || loads.get(key) !== abort)
57
83
  return;
58
84
  const entries = append ? [...new Map([...previous.entries, ...page.entries].map(entry => [entry.path, entry])).values()] : [...page.entries];
59
- set({ kind: 'ready', entries, ...(page.cursor === undefined ? {} : { cursor: page.cursor }) });
85
+ // A next page without the flag keeps the first page's word; a fresh read takes the host's word as it is now.
86
+ const writable = search ? {} : typeof page.writable === 'boolean' ? { writable: page.writable } : append ? known : {};
87
+ set({ kind: 'ready', entries, ...(page.cursor === undefined ? {} : { cursor: page.cursor }), ...writable });
60
88
  }
61
89
  catch (error) {
62
- if (!signal.aborted && loads.get(key) === abort)
63
- set({ kind: 'error', entries: previous.entries, reason: message(error) });
90
+ if (signal.aborted || loads.get(key) !== abort)
91
+ return;
92
+ const kind = search || !path ? undefined : refusal(error);
93
+ // A folder the host refuses to list (403) is shown read-only and empty, with no error; one it says is not there (404) leaves the
94
+ // tree: its row, its listing and its open state. The root keeps its error, the workspace itself being out of reach.
95
+ if (kind === 'read-only')
96
+ set({ kind: 'ready', entries: [], writable: false });
97
+ else if (kind === 'gone')
98
+ gone(path);
99
+ else
100
+ set({ kind: 'error', entries: previous.entries, reason: message(error), ...known });
64
101
  }
65
102
  finally {
66
103
  if (loads.get(key) === abort)
@@ -68,6 +105,21 @@ export function createFileTreeController({ revisionProvider }) {
68
105
  }
69
106
  }
70
107
  const refresh = (path = '') => load(path, false);
108
+ /** A folder the host says is not there: removed with everything under it (listings, pending reads, search rows) and from its parent. */
109
+ function gone(path) {
110
+ const under = (item) => item === path || item.startsWith(`${path}/`);
111
+ for (const [key, pending] of loads)
112
+ if (key.startsWith('directory:') && under(key.slice('directory:'.length))) {
113
+ pending.abort();
114
+ loads.delete(key);
115
+ }
116
+ const directories = new Map([...state.directories].filter(([folder]) => !under(folder)));
117
+ const parent = directories.get(parentOf(path));
118
+ if (parent)
119
+ directories.set(parentOf(path), { ...parent, entries: parent.entries.filter(entry => entry.path !== path) });
120
+ const search = state.search.entries.some(entry => under(entry.path)) ? { ...state.search, entries: state.search.entries.filter(entry => !under(entry.path)) } : state.search;
121
+ update({ ...state, directories, search, expanded: new Set([...state.expanded].filter(folder => !under(folder))) });
122
+ }
71
123
  function uploadState(operationId, result) {
72
124
  const attempt = attempts.get(operationId);
73
125
  if (!attempt)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-ui-kit",
3
- "version": "0.4.51",
3
+ "version": "0.4.52",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -154,7 +154,7 @@
154
154
  }
155
155
  },
156
156
  "peerDependencies": {
157
- "@hachej/boring-files": "^0.4.51",
157
+ "@hachej/boring-files": "^0.4.52",
158
158
  "@earendil-works/pi-durable": "~1.4.0",
159
159
  "react": "^19.0.0",
160
160
  "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.51",
8
- "@hachej/boring-feedback@^0.4.51",
7
+ "@hachej/boring-ui-kit@^0.4.52",
8
+ "@hachej/boring-feedback@^0.4.52",
9
9
  "@earendil-works/pi-durable@~1.4.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.51/r/app-shell.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/pi-chat.json"
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/app-shell.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/pi-chat.json"
16
16
  ],
17
17
  "files": [
18
18
  {
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.51",
8
- "@hachej/boring-ui-kit@^0.4.51",
7
+ "@hachej/boring-feedback@^0.4.52",
8
+ "@hachej/boring-ui-kit@^0.4.52",
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.51/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/pi-chat-plugins.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/pi-chat-voice.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/dock.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/feedback.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/utils.json",
21
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/button.json"
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/pi-chat-plugins.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/pi-chat-voice.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/dock.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/feedback.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/utils.json",
21
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/button.json"
22
22
  ],
23
23
  "files": [
24
24
  {
@@ -59,7 +59,7 @@
59
59
  },
60
60
  {
61
61
  "path": "registry/app-shell/app-shell.tsx",
62
- "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 { useEffect, useState, useSyncExternalStore } from 'react';\nimport { afterStacksBack } from '../utils/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 { NoPanelLayout } from './shell-panel';\nimport type { ShellPanel } from './shell-panel';\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 /** A panel beside the app, opened from the account menu. The product manager is `pmPanel` from the Factory's `platform-ui/pm-panel`. */\n readonly panel?: ShellPanel;\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 * `false`: no backdrop blur behind dialogs, sheets and drawers (blurring the page costs frames on slow phones), only their dim.\n * Default `true`. Overlays live in a portal, so the shell marks `<html data-overlay-blur=\"off\">` while it is mounted; the app's\n * own CSS drops the blur there (the rule is in registry/README.md: an item's CSS stays inside `.app-shell`).\n */\n readonly overlayBlur?: boolean;\n}\n\n/** `<html data-overlay-blur=\"off\">` while a shell asks for it. */\nfunction useOverlayBlur(blur: boolean) {\n useEffect(() => {\n if (blur || typeof document === 'undefined') return;\n const root = document.documentElement;\n root.dataset.overlayBlur = 'off';\n return () => { delete root.dataset.overlayBlur; };\n }, [blur]);\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, panel, 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 panel: ShellPanel | undefined; readonly account: AccountSpec | undefined; readonly route: AppShellProps['route']; readonly text: AppShellText }) {\n const improve = panel ? panel.label ?? text.improve : undefined;\n const Layout = panel?.Layout ?? NoPanelLayout;\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 <Layout screen={active.label} navigation={compact && !sheet ? <TabBar items={nav} activeId={active.id} onChange={choose} text={text} /> : undefined}>\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 </Layout>\n </div>\n </AgentDockLayout>\n <AgentLayer />\n </SidebarProvider>;\n}\n\nconst never = () => () => {};\n\nexport function AppShell(props: AppShellProps) {\n const { layout, rail = 'icon', compactNav = 'tabs', agent, panel, 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 `panel` for the product manager)');\n const text = appShellText(agent?.chatProps?.locale ?? (panel?.locale as AppShellLocale | undefined) ?? props.locale);\n useOverlayBlur(props.overlayBlur ?? true);\n const alone = useSyncExternalStore(never, () => panel?.page?.matches() ?? false, () => false);\n if (alone && panel?.page) return <panel.page.Page config={panel.config} appName={title} locale={props.locale} />;\n return <AgentLayerProvider agent={agent} panel={panel} locale={props.locale} agentLink={props.agentLink} appName={props.title}>\n <HeaderProvider><Shell compactNav={compactNav} layout={layout} rail={rail} title={title} nav={nav} agent={agent} account={account} route={route} panel={panel} text={text} /></HeaderProvider>\n </AgentLayerProvider>;\n}\n",
62
+ "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 { useEffect, useState, useSyncExternalStore } from 'react';\nimport { afterStacksBack } from '../utils/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 { NoPanelLayout } from './shell-panel';\nimport type { ShellPanel } from './shell-panel';\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 /** A panel beside the app, opened from the account menu. The product manager is `pmPanel` from the Factory's `platform-ui/pm-panel`. */\n readonly panel?: ShellPanel;\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 * `false`: no backdrop blur behind dialogs, sheets and drawers (blurring the page costs frames on slow phones), only their dim.\n * Default `true`. Overlays live in a portal, so the shell marks `<html data-overlay-blur=\"off\">` while it is mounted; the app's\n * own CSS drops the blur there (the rule is in registry/README.md: an item's CSS stays inside `.app-shell`).\n */\n readonly overlayBlur?: boolean;\n}\n\n/** `<html data-overlay-blur=\"off\">` while a shell asks for it. */\nfunction useOverlayBlur(blur: boolean) {\n useEffect(() => {\n if (blur || typeof document === 'undefined') return;\n const root = document.documentElement;\n root.dataset.overlayBlur = 'off';\n return () => { delete root.dataset.overlayBlur; };\n }, [blur]);\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, panel, 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 panel: ShellPanel | undefined; readonly account: AccountSpec | undefined; readonly route: AppShellProps['route']; readonly text: AppShellText }) {\n const improve = panel && panel.menu !== false ? panel.label ?? text.improve : undefined;\n const Layout = panel?.Layout ?? NoPanelLayout;\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 <Layout screen={active.label} navigation={compact && !sheet ? <TabBar items={nav} activeId={active.id} onChange={choose} text={text} /> : undefined}>\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 </Layout>\n </div>\n </AgentDockLayout>\n <AgentLayer />\n </SidebarProvider>;\n}\n\nconst never = () => () => {};\n\nexport function AppShell(props: AppShellProps) {\n const { layout, rail = 'icon', compactNav = 'tabs', agent, panel, 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 `panel` for the product manager)');\n const text = appShellText(agent?.chatProps?.locale ?? (panel?.locale as AppShellLocale | undefined) ?? props.locale);\n useOverlayBlur(props.overlayBlur ?? true);\n const alone = useSyncExternalStore(never, () => panel?.page?.matches() ?? false, () => false);\n if (alone && panel?.page) return <panel.page.Page config={panel.config} appName={title} locale={props.locale} />;\n return <AgentLayerProvider agent={agent} panel={panel} locale={props.locale} agentLink={props.agentLink} appName={props.title}>\n <HeaderProvider><Shell compactNav={compactNav} layout={layout} rail={rail} title={title} nav={nav} agent={agent} account={account} route={route} panel={panel} text={text} /></HeaderProvider>\n </AgentLayerProvider>;\n}\n",
63
63
  "type": "registry:component",
64
64
  "target": "components/app-shell/app-shell.tsx"
65
65
  },
@@ -107,7 +107,7 @@
107
107
  },
108
108
  {
109
109
  "path": "registry/app-shell/shell-panel.tsx",
110
- "content": "'use client';\n\n// The shell's panel place: a second agent or tool beside the app, opened from the account menu. The shell knows only this shape, so an app\n// plugs in what it wants (the product manager is `pmPanel` from the Factory's `platform-ui/pm-panel`) and an app without one installs nothing more.\nimport type { ComponentType, ReactNode } from 'react';\n\nexport interface ShellPanelProviderProps {\n /** `ShellPanel.config`, as the panel wrote it. */\n readonly config: unknown;\n readonly open: boolean;\n readonly close: () => void;\n readonly appName?: string | undefined;\n readonly locale?: string | undefined;\n /** The panel took the whole screen, or gave it back; `collapse` is what Back runs. */\n readonly onFull?: ((full: boolean, collapse: () => void) => void) | undefined;\n readonly children: ReactNode;\n}\n\nexport interface ShellPanelLayoutProps {\n readonly children: ReactNode;\n /** The phone tab bar. The layout hides it while the panel covers the page. */\n readonly navigation?: ReactNode;\n /** The label of the screen on show. */\n readonly screen?: string;\n}\n\nexport interface ShellPanelPageProps {\n readonly config: unknown;\n readonly appName: string;\n readonly locale?: string | undefined;\n}\n\n/** Keep `Provider`, `Layout` and `page.Page` the same across renders: a new component remounts the app. */\nexport interface ShellPanel {\n /** Passed back to `Provider` and `page.Page`. */\n readonly config: unknown;\n /** Wraps the whole shell, so the panel's state outlives its closing. */\n readonly Provider: ComponentType<ShellPanelProviderProps>;\n /** Wraps the screen and draws the panel beside it. */\n readonly Layout: ComponentType<ShellPanelLayoutProps>;\n /** The account menu's row and the layer's title. Default: the shell's « Améliorer l'app ». */\n readonly label?: string;\n /** The panel's language, for the shell's own words when the app has no agent to say it. */\n readonly locale?: string | undefined;\n /** Changes when the panel's conversation changes; a launch link's focus waits on it. */\n readonly identity?: unknown;\n /** The address that shows only the panel, without the shell. */\n readonly page?: { readonly matches: () => boolean; readonly Page: ComponentType<ShellPanelPageProps> };\n}\n\n/** The screen when the app has no panel. */\nexport function NoPanelLayout({ children, navigation }: ShellPanelLayoutProps) {\n return <div className=\"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden\">\n <div className=\"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden\">{children}</div>\n <div className=\"shrink-0 md:hidden\">{navigation}</div>\n </div>;\n}\n",
110
+ "content": "'use client';\n\n// The shell's panel place: a second agent or tool beside the app, opened from the account menu. The shell knows only this shape, so an app\n// plugs in what it wants (the product manager is `pmPanel` from the Factory's `platform-ui/pm-panel`) and an app without one installs nothing more.\nimport type { ComponentType, ReactNode } from 'react';\n\nexport interface ShellPanelProviderProps {\n /** `ShellPanel.config`, as the panel wrote it. */\n readonly config: unknown;\n readonly open: boolean;\n readonly close: () => void;\n readonly appName?: string | undefined;\n readonly locale?: string | undefined;\n /** The panel took the whole screen, or gave it back; `collapse` is what Back runs. */\n readonly onFull?: ((full: boolean, collapse: () => void) => void) | undefined;\n readonly children: ReactNode;\n}\n\nexport interface ShellPanelLayoutProps {\n readonly children: ReactNode;\n /** The phone tab bar. The layout hides it while the panel covers the page. */\n readonly navigation?: ReactNode;\n /** The label of the screen on show. */\n readonly screen?: string;\n}\n\nexport interface ShellPanelPageProps {\n readonly config: unknown;\n readonly appName: string;\n readonly locale?: string | undefined;\n}\n\n/** Keep `Provider`, `Layout` and `page.Page` the same across renders: a new component remounts the app. */\nexport interface ShellPanel {\n /** Passed back to `Provider` and `page.Page`. */\n readonly config: unknown;\n /** Wraps the whole shell, so the panel's state outlives its closing. */\n readonly Provider: ComponentType<ShellPanelProviderProps>;\n /** Wraps the screen and draws the panel beside it. */\n readonly Layout: ComponentType<ShellPanelLayoutProps>;\n /** The account menu's row and the layer's title. Default: the shell's « Améliorer l'app ». */\n readonly label?: string;\n /** `false`: no account menu row for the panel, which still mounts (a host that already shows the panel, around a framed app). Default: the row. */\n readonly menu?: false;\n /** The panel's language, for the shell's own words when the app has no agent to say it. */\n readonly locale?: string | undefined;\n /** Changes when the panel's conversation changes; a launch link's focus waits on it. */\n readonly identity?: unknown;\n /** The address that shows only the panel, without the shell. */\n readonly page?: { readonly matches: () => boolean; readonly Page: ComponentType<ShellPanelPageProps> };\n}\n\n/** The screen when the app has no panel. */\nexport function NoPanelLayout({ children, navigation }: ShellPanelLayoutProps) {\n return <div className=\"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden\">\n <div className=\"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden\">{children}</div>\n <div className=\"shrink-0 md:hidden\">{navigation}</div>\n </div>;\n}\n",
111
111
  "type": "registry:component",
112
112
  "target": "components/app-shell/shell-panel.tsx"
113
113
  },
@@ -4,17 +4,17 @@
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.51",
8
- "@hachej/boring-files@^0.4.51",
7
+ "@hachej/boring-ui-kit@^0.4.52",
8
+ "@hachej/boring-files@^0.4.52",
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.51/r/maquette-viewer.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/mockup-viewer.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/pi-artifacts.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/utils.json"
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/maquette-viewer.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/mockup-viewer.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/pi-artifacts.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/utils.json"
18
18
  ],
19
19
  "files": [
20
20
  {
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.51/r/utils.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/utils.json"
13
13
  ],
14
14
  "files": [
15
15
  {
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.51/r/utils.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/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.51",
8
- "@hachej/boring-files@^0.4.51",
7
+ "@hachej/boring-ui-kit@^0.4.52",
8
+ "@hachej/boring-files@^0.4.52",
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.51/r/utils.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/button.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/pi-artifacts.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/pi-app.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/pi-app-kinds.json",
18
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/app-shell.json",
19
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/file-tree.json",
20
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/viewer-frame.json",
21
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/viewer-image.json",
22
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/viewer-pdf.json"
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/utils.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/button.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/pi-artifacts.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/pi-app.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/pi-app-kinds.json",
18
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/app-shell.json",
19
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/file-tree.json",
20
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/viewer-frame.json",
21
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/viewer-image.json",
22
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/viewer-pdf.json"
23
23
  ],
24
24
  "files": [
25
25
  {
@@ -4,7 +4,7 @@
4
4
  "title": "Drawing viewer",
5
5
  "description": "DrawingViewer, drawingKind and describeDrawing: an Excalidraw (.excalidraw) or draw.io (.drawio) file, read-only, drawn by the tool's own renderer on a white page fitted to the shapes: Excalidraw's exportToSvg (its fonts served by the app, never its CDN) and the official diagrams.net viewer, vendored in the kit (@hachej/boring-ui-kit/drawio-viewer), in a sandboxed frame without network. Shown as an image, so nothing in the file runs; an unreadable file shows an inline error. onRead hands the shapes and arrows the renderer read (kind, place, size, label, colours, from and to). Each renderer loads the first time a drawing of its format opens; the kind is not in the pi-app-kinds default list, so apps without drawings install nothing.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.51",
7
+ "@hachej/boring-ui-kit@^0.4.52",
8
8
  "react@^19.0.0",
9
9
  "lucide-react@^1.51.0",
10
10
  "@excalidraw/excalidraw@0.18.1"
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.51",
8
- "@hachej/boring-ui-kit@^0.4.51",
9
- "@hachej/boring-files@^0.4.51",
7
+ "@hachej/boring-feedback@^0.4.52",
8
+ "@hachej/boring-ui-kit@^0.4.52",
9
+ "@hachej/boring-files@^0.4.52",
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.51/r/pi-chat.json",
16
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/pi-chat-voice.json",
17
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.51/r/utils.json"
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/pi-chat.json",
16
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/pi-chat-voice.json",
17
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.52/r/utils.json"
18
18
  ],
19
19
  "files": [
20
20
  {