@hachej/boring-ui-kit 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -45,7 +45,7 @@ export default function NativeCanvas({ controller, assetUrls, title = 'Canvas',
45
45
  setError(null);
46
46
  try {
47
47
  const result = await action();
48
- if (mounted.current && (result.kind === 'unavailable' || result.kind === 'denied' || result.kind === 'conflict' || result.kind === 'unknown'))
48
+ if (mounted.current && (result.kind === 'unavailable' || result.kind === 'denied' || result.kind === 'conflict' || result.kind === 'unknown' || result.kind === 'file_too_large'))
49
49
  setError(result.reason);
50
50
  }
51
51
  catch (cause) {
@@ -75,7 +75,7 @@ export default function NativeCanvas({ controller, assetUrls, title = 'Canvas',
75
75
  if (!unavailable && !pending && !uncertain && !busy && !state.problem)
76
76
  void save();
77
77
  }
78
- }, 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("button", { type: "button", disabled: unavailable || !state.dirty || pending || uncertain || busy || state.problem !== null, onClick: () => void save(), children: "Save" }), _jsx("button", { 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("button", { type: "button", disabled: busy, onClick: () => void run(controller.actions.reconcile), children: "Check save outcome" }), _jsx("button", { 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("button", { 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') && _jsx("p", { role: "alert", children: outcome.reason }), error && _jsx("p", { role: "alert", children: error }), _jsx(CanvasProposals, { 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'))
78
+ }, 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("button", { type: "button", disabled: unavailable || !state.dirty || pending || uncertain || busy || state.problem !== null, onClick: () => void save(), children: "Save" }), _jsx("button", { 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("button", { type: "button", disabled: busy, onClick: () => void run(controller.actions.reconcile), children: "Check save outcome" }), _jsx("button", { 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("button", { 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, { 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'))
79
79
  event.preventDefault(); }, onAuxClickCapture: event => { if (event.target instanceof Element && event.target.closest('a'))
80
80
  event.preventDefault(); }, children: _jsxs(Tldraw, { store: controller.store, hideUi: true, locale: "en", autoFocus: false, assetUrls: assets, ...(licenseKey === undefined ? {} : { licenseKey }), onMount: attach, children: [_jsx(CanvasTools, { controller: controller }), _jsx(MountedCommands, { controller: controller, revokeTools: revokeTools, ...(onMountedTools ? { onMountedTools } : {}) })] }) })] });
81
81
  }
@@ -18,7 +18,7 @@ function MountedDocument({ controller, cells, canView, onRegenerate, title = 'Ex
18
18
  const perform = (action) => {
19
19
  setError(undefined);
20
20
  void Promise.resolve().then(action).then(result => {
21
- if (active.current && (result.kind === 'denied' || result.kind === 'unavailable'))
21
+ if (active.current && (result.kind === 'denied' || result.kind === 'unavailable' || result.kind === 'file_too_large'))
22
22
  setError(result.reason);
23
23
  }).catch(() => { if (active.current)
24
24
  setError('The layout operation failed. The draft has been retained.'); });
@@ -53,6 +53,8 @@ export interface FileTreeProps {
53
53
  readonly history?: boolean;
54
54
  /** Reload the open folders and the current search whenever this value changes (for example after the agent saves a file). */
55
55
  readonly refreshKey?: unknown;
56
+ /** Reload the open history panel whenever this value changes (for example the seq of the workspace's latest change event). */
57
+ readonly historyKey?: unknown;
56
58
  }
57
59
  /** One borrowed binding powers browsing, search, upload and history. Historical previews never replace an editor's draft. */
58
60
  export declare function FileTree(props: FileTreeProps): import("react").JSX.Element;
@@ -60,5 +62,5 @@ export interface FileTreeViewProps extends Omit<FileTreeProps, 'revisionProvider
60
62
  readonly controller: FileTreeController;
61
63
  }
62
64
  /** Renders a borrowed controller, for a workspace whose chat and tree share upload state. */
63
- export declare function FileTreeView({ controller, onOpen, selectedPath, className, uploadDirectory, locale, labels: given, upload, history, refreshKey }: FileTreeViewProps): import("react").JSX.Element;
65
+ export declare function FileTreeView({ controller, onOpen, selectedPath, className, uploadDirectory, locale, labels: given, upload, history, refreshKey, historyKey }: FileTreeViewProps): import("react").JSX.Element;
64
66
  //# sourceMappingURL=file-tree-view.d.ts.map
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import * as Collapsible from '@radix-ui/react-collapsible';
3
+ import { Collapsible } from 'radix-ui';
4
4
  import { ChevronRight, File, Folder } from 'lucide-react';
5
5
  import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub } from './file-tree-shadcn.js';
6
6
  import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
@@ -85,7 +85,7 @@ function BoundFileTree(props) {
85
85
  return owned?.provider === props.revisionProvider ? _jsx(FileTreeView, { ...props, controller: owned.controller }) : _jsx("p", { role: "status", children: props.labels?.loading ?? LOCALES[props.locale ?? 'en'].loading });
86
86
  }
87
87
  /** Renders a borrowed controller, for a workspace whose chat and tree share upload state. */
88
- export function FileTreeView({ controller, onOpen, selectedPath, className, uploadDirectory = 'uploads', locale = 'en', labels: given, upload = true, history = true, refreshKey }) {
88
+ export function FileTreeView({ controller, onOpen, selectedPath, className, uploadDirectory = 'uploads', locale = 'en', labels: given, upload = true, history = true, refreshKey, historyKey }) {
89
89
  const labels = { ...LOCALES[locale], ...given };
90
90
  const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
91
91
  const shownKey = useRef(refreshKey);
@@ -210,9 +210,9 @@ export function FileTreeView({ controller, onOpen, selectedPath, className, uplo
210
210
  if (errors.length)
211
211
  setUploadError(errors.join(' '));
212
212
  })();
213
- } })] })] }), uploadError && _jsx("p", { role: "alert", children: uploadError }), _jsx(SidebarMenu, { ref: tree, role: "tree", "aria-label": labels.tree, className: "m-0 list-none p-2", children: state.query ? _jsxs(_Fragment, { children: [state.search.entries.map(entry => renderEntry(entry, 1)), listingStatus(state.search, '', true)] }) : renderEntries('', 1) }), state.uploads.length > 0 && _jsx("ul", { "aria-label": labels.uploads, className: "list-none p-2 text-sm", children: state.uploads.map(upload => _jsxs("li", { "data-operation": upload.operationId, children: [_jsxs("span", { 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.state.kind === 'settled' && upload.state.result.kind === 'unknown' && _jsx("button", { type: "button", onClick: () => { void controller.reconcile(upload.operationId).catch(error => setUploadError(error instanceof Error ? error.message : labels.uploadStatusUnavailable)); }, children: labels.checkUpload })] }, upload.operationId)) }), history && historyPath !== undefined && _jsx(FileHistory, { controller: controller, path: historyPath, labels: labels, onClose: () => setHistoryPath(undefined) }, historyPath)] });
213
+ } })] })] }), uploadError && _jsx("p", { role: "alert", children: uploadError }), _jsx(SidebarMenu, { ref: tree, role: "tree", "aria-label": labels.tree, className: "m-0 list-none p-2", children: state.query ? _jsxs(_Fragment, { children: [state.search.entries.map(entry => renderEntry(entry, 1)), listingStatus(state.search, '', true)] }) : renderEntries('', 1) }), state.uploads.length > 0 && _jsx("ul", { "aria-label": labels.uploads, className: "list-none p-2 text-sm", children: state.uploads.map(upload => _jsxs("li", { "data-operation": upload.operationId, children: [_jsxs("span", { 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.state.kind === 'settled' && upload.state.result.kind === 'unknown' && _jsx("button", { type: "button", onClick: () => { void controller.reconcile(upload.operationId).catch(error => setUploadError(error instanceof Error ? error.message : labels.uploadStatusUnavailable)); }, children: labels.checkUpload })] }, upload.operationId)) }), history && historyPath !== undefined && _jsx(FileHistory, { controller: controller, path: historyPath, labels: labels, reload: historyKey, onClose: () => setHistoryPath(undefined) }, historyPath)] });
214
214
  }
215
- function FileHistory({ controller, path, labels, onClose }) {
215
+ function FileHistory({ controller, path, labels, reload, onClose }) {
216
216
  const [versions, setVersions] = useState();
217
217
  const [failure, setFailure] = useState('');
218
218
  const [selected, setSelected] = useState();
@@ -223,7 +223,7 @@ function FileHistory({ controller, path, labels, onClose }) {
223
223
  setVersions(value); }, error => { if (!abort.signal.aborted)
224
224
  setFailure(error instanceof Error ? error.message : labels.historyUnavailable); });
225
225
  return () => abort.abort();
226
- }, [controller, path]);
226
+ }, [controller, path, reload]);
227
227
  useEffect(() => {
228
228
  if (selected === undefined)
229
229
  return;
@@ -48,7 +48,7 @@ function MountedHtmlViewer({ controller, title = 'HTML document', className, hea
48
48
  const perform = (action) => {
49
49
  setError(undefined);
50
50
  void Promise.resolve().then(action).then(result => {
51
- if (active.current && (result.kind === 'denied' || result.kind === 'unavailable'))
51
+ if (active.current && (result.kind === 'denied' || result.kind === 'unavailable' || result.kind === 'file_too_large'))
52
52
  setError(result.reason);
53
53
  }).catch(() => { if (active.current)
54
54
  setError('The document operation failed. Your source has been retained.'); });
@@ -171,7 +171,7 @@ function MarkdownEditorSession({ controller, title = 'Document', initialMode = '
171
171
  if (active.current !== controller || !result || typeof result !== 'object')
172
172
  return;
173
173
  const { kind, reason } = result;
174
- if (kind === 'denied' || kind === 'conflict' || kind === 'unavailable')
174
+ if (kind === 'denied' || kind === 'conflict' || kind === 'unavailable' || kind === 'file_too_large')
175
175
  setError(typeof reason === 'string' && reason ? reason : kind === 'conflict' ? 'The document changed elsewhere. Your text is still available.' : 'The document could not be saved. Your text is still available.');
176
176
  };
177
177
  const run = (operation) => {
@@ -222,7 +222,7 @@ function MarkdownEditorSession({ controller, title = 'Document', initialMode = '
222
222
  setError(result.reason);
223
223
  }).catch(() => { if (active.current === controller)
224
224
  setError('The heading could not be selected'); });
225
- }, children: heading.text || 'Untitled heading' }) }, heading.position)) }) }), uncertain && _jsxs("div", { role: "alert", className: "boring-markdown-alert", children: ["Save acknowledgement was lost. Keep editing while its outcome is checked.", _jsx("button", { type: "button", onClick: () => run(controller.actions.reconcile), children: "Check save outcome" }), _jsx("button", { type: "button", onClick: () => run(controller.actions.abandon), children: "Stop checking and refresh, keeping my draft" })] }), conflict && _jsxs("div", { role: "alert", className: "boring-markdown-alert", children: ["The saved document changed. Your local text has been kept.", _jsx("button", { type: "button", disabled: pending || uncertain, onClick: () => run(controller.actions.refresh), children: "Check saved version" }), _jsx("button", { type: "button", disabled: pending || uncertain, onClick: () => run(controller.actions.discardToRemote), children: "Discard local changes and reload" })] }), (outcome?.kind === 'denied' || outcome?.kind === 'unavailable') && _jsx("p", { role: "alert", className: "boring-markdown-alert", children: outcome.reason }), error && _jsx("p", { role: "alert", className: "boring-markdown-alert", children: error }), state.proposals.map(proposal => _jsxs("article", { "data-boring": "proposal", className: "boring-markdown-proposal", children: [_jsx("h3", { children: proposal.summary || 'Proposed edit' }), _jsxs("details", { children: [_jsx("summary", { children: "Review text changes" }), _jsx(MarkdownProposalDiff, { proposal: proposal }), _jsxs("details", { children: [_jsx("summary", { children: "Exact before and after" }), _jsx("h4", { children: "Before" }), _jsx("pre", { children: proposal.before }), _jsx("h4", { children: "After" }), _jsx("pre", { children: proposal.after })] })] }), _jsx("button", { type: "button", disabled: unavailable || pending || uncertain || proposal.adopted, onClick: () => run(() => controller.actions.accept(proposal.id)), children: proposal.adopted ? 'Adopted' : 'Accept and save' }), _jsx("button", { type: "button", onClick: () => controller.actions.reject(proposal.id), children: "Dismiss proposal" })] }, proposal.id)), mode === 'source'
225
+ }, children: heading.text || 'Untitled heading' }) }, heading.position)) }) }), uncertain && _jsxs("div", { role: "alert", className: "boring-markdown-alert", children: ["Save acknowledgement was lost. Keep editing while its outcome is checked.", _jsx("button", { type: "button", onClick: () => run(controller.actions.reconcile), children: "Check save outcome" }), _jsx("button", { type: "button", onClick: () => run(controller.actions.abandon), children: "Stop checking and refresh, keeping my draft" })] }), conflict && _jsxs("div", { role: "alert", className: "boring-markdown-alert", children: ["The saved document changed. Your local text has been kept.", _jsx("button", { type: "button", disabled: pending || uncertain, onClick: () => run(controller.actions.refresh), children: "Check saved version" }), _jsx("button", { type: "button", disabled: pending || uncertain, onClick: () => run(controller.actions.discardToRemote), children: "Discard local changes and reload" })] }), (outcome?.kind === 'denied' || outcome?.kind === 'unavailable' || outcome?.kind === 'file_too_large') && _jsx("p", { role: "alert", className: "boring-markdown-alert", children: outcome.reason }), error && _jsx("p", { role: "alert", className: "boring-markdown-alert", children: error }), state.proposals.map(proposal => _jsxs("article", { "data-boring": "proposal", className: "boring-markdown-proposal", children: [_jsx("h3", { children: proposal.summary || 'Proposed edit' }), _jsxs("details", { children: [_jsx("summary", { children: "Review text changes" }), _jsx(MarkdownProposalDiff, { proposal: proposal }), _jsxs("details", { children: [_jsx("summary", { children: "Exact before and after" }), _jsx("h4", { children: "Before" }), _jsx("pre", { children: proposal.before }), _jsx("h4", { children: "After" }), _jsx("pre", { children: proposal.after })] })] }), _jsx("button", { type: "button", disabled: unavailable || pending || uncertain || proposal.adopted, onClick: () => run(() => controller.actions.accept(proposal.id)), children: proposal.adopted ? 'Adopted' : 'Accept and save' }), _jsx("button", { type: "button", onClick: () => controller.actions.reject(proposal.id), children: "Dismiss proposal" })] }, proposal.id)), mode === 'source'
226
226
  ? _jsx("textarea", { ref: sourceInput, className: "boring-markdown-source", "aria-label": `${title} Markdown source`, value: state.text, readOnly: unavailable, spellCheck: false, onChange: event => controller.actions.edit(event.currentTarget.value) })
227
227
  : _jsx("div", { className: "boring-markdown-surface", onKeyDownCapture: markInteraction, onBeforeInputCapture: markInteraction, onInputCapture: markInteraction, onPasteCapture: markInteraction, onDropCapture: markInteraction, onChangeCapture: markInteraction, onClickCapture: event => { markInteraction(); if (event.target instanceof Element && event.target.closest('a'))
228
228
  event.preventDefault(); }, onAuxClickCapture: event => { if (event.target instanceof Element && event.target.closest('a'))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-ui-kit",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -11,6 +11,7 @@
11
11
  "files": [
12
12
  "dist/**/*.js",
13
13
  "dist/**/*.d.ts",
14
+ "r/*.json",
14
15
  "README.md",
15
16
  "LICENSE",
16
17
  "THIRD_PARTY_NOTICES.md",
@@ -109,34 +110,34 @@
109
110
  "./file-tree.css": "./styles/file-tree.css"
110
111
  },
111
112
  "peerDependencies": {
112
- "@hachej/boring-files": "0.2.0",
113
- "@earendil-works/pi-durable": "1.1.0",
114
- "react": "19.3.0",
115
- "react-dom": "19.3.0",
116
- "marked": "17.0.6",
117
- "@tiptap/core": "3.31.4",
118
- "@tiptap/react": "3.31.4",
119
- "@tiptap/pm": "3.31.4",
120
- "@tiptap/starter-kit": "3.31.4",
121
- "@tiptap/markdown": "3.31.4",
122
- "@tiptap/extensions": "3.31.4",
123
- "@tiptap/extension-list": "3.31.4",
124
- "@tiptap/extension-table": "3.31.4",
125
- "@tiptap/extension-image": "3.31.4",
126
- "@tiptap/extension-highlight": "3.31.4",
127
- "@tiptap/extension-bubble-menu": "3.31.4",
128
- "@tiptap/extension-floating-menu": "3.31.4",
129
- "@earendil-works/chord": "1.1.0",
130
- "@earendil-works/pi-ai": "1.1.0",
131
- "tldraw": "5.5.2",
132
- "@tldraw/editor": "5.5.2",
133
- "@tldraw/store": "5.5.2",
134
- "@json-render/core": "0.21.0",
135
- "@json-render/react": "0.21.0",
136
- "zod": "4.6.5",
137
- "@tldraw/tlschema": "5.5.2",
138
- "@radix-ui/react-collapsible": "1.1.20",
139
- "lucide-react": "1.51.0"
113
+ "@hachej/boring-files": "^0.2.2",
114
+ "@earendil-works/pi-durable": "^1.1.0",
115
+ "react": "^19.0.0",
116
+ "react-dom": "^19.0.0",
117
+ "marked": "^17.0.6",
118
+ "@tiptap/core": "^3.31.4",
119
+ "@tiptap/react": "^3.31.4",
120
+ "@tiptap/pm": "^3.31.4",
121
+ "@tiptap/starter-kit": "^3.31.4",
122
+ "@tiptap/markdown": "^3.31.4",
123
+ "@tiptap/extensions": "^3.31.4",
124
+ "@tiptap/extension-list": "^3.31.4",
125
+ "@tiptap/extension-table": "^3.31.4",
126
+ "@tiptap/extension-image": "^3.31.4",
127
+ "@tiptap/extension-highlight": "^3.31.4",
128
+ "@tiptap/extension-bubble-menu": "^3.31.4",
129
+ "@tiptap/extension-floating-menu": "^3.31.4",
130
+ "@earendil-works/chord": "^1.1.0",
131
+ "@earendil-works/pi-ai": "^1.1.0",
132
+ "tldraw": "^5.5.2",
133
+ "@tldraw/editor": "^5.5.2",
134
+ "@tldraw/store": "^5.5.2",
135
+ "@json-render/core": "^0.21.0",
136
+ "@json-render/react": "^0.21.0",
137
+ "zod": "^4.6.5",
138
+ "@tldraw/tlschema": "^5.5.2",
139
+ "lucide-react": "^1.51.0",
140
+ "radix-ui": "^1.8.0"
140
141
  },
141
142
  "peerDependenciesMeta": {
142
143
  "@hachej/boring-files": {
@@ -217,10 +218,10 @@
217
218
  "@tldraw/tlschema": {
218
219
  "optional": true
219
220
  },
220
- "@radix-ui/react-collapsible": {
221
+ "lucide-react": {
221
222
  "optional": true
222
223
  },
223
- "lucide-react": {
224
+ "radix-ui": {
224
225
  "optional": true
225
226
  }
226
227
  },
package/r/button.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "button",
4
+ "title": "Button",
5
+ "description": "The one shadcn-style button of the Tailwind items (chat and viewer bar sizes, touch targets of 40-44px), IconButton, and the host actions every block takes (BlockAction, BlockActions, the shared ActionMenu), installed in components/button/ so it never overwrites the host's own button.",
6
+ "dependencies": [
7
+ "react@^19.0.0",
8
+ "lucide-react@^1.51.0"
9
+ ],
10
+ "registryDependencies": [
11
+ "@boring-ui/theme",
12
+ "@boring-ui/utils"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "registry/button/button.tsx",
17
+ "content": "import type { ButtonHTMLAttributes, ReactNode, Ref } from 'react';\nimport { cn } from '../utils/utils';\n\nconst variants = {\n default: 'bg-primary text-primary-foreground hover:bg-primary/90',\n secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',\n outline: 'border border-border bg-background hover:bg-muted',\n ghost: 'hover:bg-muted hover:text-foreground',\n /** A ghost button whose resting text is muted (viewer bars). */\n quiet: 'text-muted-foreground hover:bg-muted hover:text-foreground',\n destructive: 'bg-destructive text-white hover:bg-destructive/90',\n} as const;\n// Touch targets: on a phone or any coarse pointer every button is at least 40px (icon buttons 44px), whatever its desktop size.\n// `bar` and `icon-bar` are the compact viewer-bar sizes: 44px on touch, with the tighter radius of a toolbar.\nconst sizes = {\n sm: 'h-7 gap-1 px-2.5 text-xs max-sm:h-10 pointer-coarse:h-10',\n md: 'h-9 gap-1.5 px-3.5 text-sm max-sm:h-11 pointer-coarse:h-11',\n icon: 'size-9 max-sm:size-11 pointer-coarse:size-11',\n 'icon-sm': 'size-7 max-sm:size-10 pointer-coarse:size-10',\n bar: 'h-8 gap-1.5 rounded-md px-3 text-xs max-sm:h-11 pointer-coarse:h-11',\n 'icon-bar': 'size-8 rounded-md max-sm:size-11 pointer-coarse:size-11',\n} as const;\n\nexport interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {\n readonly variant?: keyof typeof variants;\n readonly size?: keyof typeof sizes;\n readonly ref?: Ref<HTMLButtonElement>;\n}\n\n/** shadcn-style button shared by every item. Always `type=\"button\"` unless a form submit is requested. */\nexport function Button({ className, variant = 'ghost', size = 'md', type = 'button', ...props }: ButtonProps) {\n return <button type={type} className={cn(\n 'inline-flex shrink-0 cursor-pointer items-center justify-center rounded-lg font-medium whitespace-nowrap transition-colors outline-none',\n 'focus-visible:ring-2 focus-visible:ring-ring/60 focus-visible:ring-offset-1 focus-visible:ring-offset-background',\n 'disabled:pointer-events-none disabled:opacity-45 motion-reduce:transition-none',\n variants[variant], sizes[size], className)} {...props} />;\n}\n\n/** A bar button: an icon with an accessible name and a tooltip (quiet and bar-sized unless told otherwise). */\nexport function IconButton({ label, children, variant = 'quiet', size = 'icon-bar', ...props }: { readonly label: string; readonly children: ReactNode } & Omit<ButtonProps, 'children'>) {\n return <Button variant={variant} size={size} aria-label={label} title={label} {...props}>{children}</Button>;\n}\n",
18
+ "type": "registry:ui",
19
+ "target": "components/button/button.tsx"
20
+ },
21
+ {
22
+ "path": "registry/button/actions.tsx",
23
+ "content": "'use client';\n\nimport { useEffect, useId, useRef, useState } from 'react';\nimport type { ComponentType, KeyboardEvent, ReactNode } from 'react';\nimport { CheckIcon, EllipsisIcon } from 'lucide-react';\nimport { Button, IconButton } from './button';\nimport { cn } from '../utils/utils';\n\n/** An icon the host may pass for any of the blocks' icons: any component that takes a class name (every lucide icon does). */\nexport type BlockIcon = ComponentType<{ readonly className?: string | undefined; readonly 'aria-hidden'?: boolean | 'true' | 'false' | undefined }>;\n\n/**\n * A host action added to a block's bar or row (the chat header, the floating chat's bar, the artifact panel, a conversation row, a reply),\n * next to the block's own buttons. `header` (the default) is a button in the bar: icon only when `icon` is given (the label is then its\n * accessible name and tooltip), otherwise the label. `menu` puts it in the block's \"…\" menu. Its test id is `<surface>-<id>` (see each block).\n */\nexport interface BlockAction {\n readonly id: string;\n readonly label: string;\n readonly icon?: BlockIcon | undefined;\n readonly onSelect: () => void;\n readonly placement?: 'header' | 'menu' | undefined;\n readonly disabled?: boolean | undefined;\n}\n\n/** One row of a menu. With `checked` set (true or false) the row is a radio choice, otherwise a plain action. */\nexport interface MenuItem {\n readonly id: string;\n readonly label: string;\n readonly icon?: ReactNode;\n /** Small text after the label, for example \"Latest\". */\n readonly hint?: string;\n readonly checked?: boolean;\n readonly disabled?: boolean;\n readonly testId?: string;\n readonly onSelect: () => void;\n}\n\nexport interface ActionMenuProps {\n /** Accessible name and tooltip of the trigger; also the name of the menu. */\n readonly label: string;\n readonly icon: ReactNode;\n readonly items: readonly MenuItem[];\n readonly testId?: string;\n /** Which edge of the trigger the menu lines up with. */\n readonly align?: 'start' | 'end';\n /** The trigger's size: the viewer bar's (default) or a small chat button. */\n readonly size?: 'icon-bar' | 'icon-sm';\n readonly className?: string;\n}\n\nconst ITEM = 'flex h-9 w-full cursor-pointer items-center gap-2 rounded-md px-2.5 text-left text-sm outline-none hover:bg-muted focus-visible:bg-muted disabled:pointer-events-none disabled:opacity-45 max-sm:h-11 pointer-coarse:h-11';\n\n/**\n * An icon button that opens a small menu: arrow keys, Home/End and roving focus, Escape (focus returns to the trigger), Tab and an outside\n * press close it. The one menu of the blocks' \"…\" buttons and the viewers' version history.\n */\nexport function ActionMenu({ label, icon, items, testId = 'menu', align = 'end', size = 'icon-bar', className }: ActionMenuProps) {\n const [open, setOpen] = useState(false);\n const root = useRef<HTMLDivElement>(null);\n const trigger = useRef<HTMLButtonElement>(null);\n const menuId = useId();\n const radio = items.some(item => item.checked !== undefined);\n\n useEffect(() => {\n if (!open) return;\n const list = root.current?.querySelectorAll<HTMLElement>('[role^=menuitem]:not(:disabled)');\n const wanted = root.current?.querySelector<HTMLElement>('[aria-checked=true]:not(:disabled)') ?? list?.[0];\n wanted?.focus();\n const away = (event: Event) => { if (!(event.target instanceof Node) || !root.current?.contains(event.target)) setOpen(false); };\n document.addEventListener('pointerdown', away);\n return () => document.removeEventListener('pointerdown', away);\n }, [open]);\n\n const close = (refocus: boolean) => { setOpen(false); if (refocus) trigger.current?.focus(); };\n const keys = (event: KeyboardEvent<HTMLElement>) => {\n const rows = [...(root.current?.querySelectorAll<HTMLElement>('[role^=menuitem]:not(:disabled)') ?? [])];\n const at = rows.indexOf(document.activeElement as HTMLElement);\n const go = (index: number) => { event.preventDefault(); rows[(index + rows.length) % rows.length]?.focus(); };\n if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(true); }\n else if (event.key === 'Tab') close(false);\n else if (event.key === 'ArrowDown') go(at + 1);\n else if (event.key === 'ArrowUp') go(at < 0 ? -1 : at - 1);\n else if (event.key === 'Home') go(0);\n else if (event.key === 'End') go(-1);\n };\n\n return <div ref={root} data-viewer-menu className={cn('relative', className)} onKeyDown={event => { if (open) keys(event); }}>\n <IconButton ref={trigger} label={label} size={size} data-testid={testId} aria-haspopup=\"menu\" aria-expanded={open} aria-controls={open ? menuId : undefined}\n onClick={() => setOpen(value => !value)}\n onKeyDown={event => { if (!open && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) { event.preventDefault(); setOpen(true); } }}>{icon}</IconButton>\n {open && <div id={menuId} role=\"menu\" aria-label={label} data-testid={`${testId}-list`}\n className={cn('absolute top-full z-50 mt-1 grid max-h-72 min-w-48 gap-0.5 overflow-y-auto rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg', 'max-sm:fixed max-sm:inset-x-2 max-sm:top-auto max-sm:bottom-2 max-sm:mt-0 max-sm:max-h-[70vh] max-sm:rounded-xl max-sm:p-1.5', align === 'end' ? 'right-0' : 'left-0')}>\n {items.map(item => <button key={item.id} type=\"button\" tabIndex={-1} role={radio ? 'menuitemradio' : 'menuitem'} {...(radio ? { 'aria-checked': item.checked === true } : {})}\n disabled={item.disabled} data-testid={item.testId ?? `${testId}-${item.id}`} data-item={item.id} className={ITEM}\n onClick={() => { close(true); item.onSelect(); }}>\n {item.icon ?? (radio ? <span className=\"size-4 shrink-0\">{item.checked && <CheckIcon className=\"size-4\" aria-hidden=\"true\" />}</span> : null)}\n <span className=\"min-w-0 flex-1 truncate\">{item.label}</span>\n {item.hint && <span className=\"shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground\">{item.hint}</span>}\n </button>)}\n </div>}\n </div>;\n}\n\n/** A host action as a menu row (for a block that already has a \"…\" menu of its own). */\nexport function actionMenuItem(action: BlockAction, testId: string): MenuItem {\n const Icon = action.icon;\n return { id: action.id, label: action.label, ...(Icon ? { icon: <Icon className=\"size-4\" aria-hidden=\"true\" /> } : {}), ...(action.disabled ? { disabled: true } : {}), testId: `${testId}-${action.id}`, onSelect: action.onSelect };\n}\n\n/** The `header` actions of a list. */\nexport const headerActions = (actions: readonly BlockAction[] | undefined): readonly BlockAction[] => (actions ?? []).filter(action => action.placement !== 'menu');\n/** The `menu` actions of a list. */\nexport const menuActions = (actions: readonly BlockAction[] | undefined): readonly BlockAction[] => (actions ?? []).filter(action => action.placement === 'menu');\n\n/** One `header` action as a bar button: the icon with the label as its name and tooltip, or the label as text. */\nexport function ActionButton({ action, testId, size = 'icon-sm', className }: { readonly action: BlockAction; readonly testId: string; readonly size?: 'icon-sm' | 'icon-bar'; readonly className?: string }) {\n const Icon = action.icon;\n if (Icon) return <IconButton label={action.label} size={size} data-testid={`${testId}-${action.id}`} data-action={action.id} disabled={action.disabled} onClick={action.onSelect} className={className}>\n <Icon className=\"size-4\" aria-hidden=\"true\" /></IconButton>;\n return <Button size=\"sm\" variant=\"ghost\" data-testid={`${testId}-${action.id}`} data-action={action.id} disabled={action.disabled} onClick={action.onSelect} title={action.label}\n className={cn('max-w-40 min-w-0 text-muted-foreground', className)}><span className=\"truncate\">{action.label}</span></Button>;\n}\n\n/**\n * A block's host actions where it has no \"…\" menu of its own: the `header` ones as buttons, then one \"…\" menu (test id `<testId>-more`)\n * holding the `menu` ones. Each action's test id is `<testId>-<id>`. Nothing is rendered for an empty list.\n */\nexport function BlockActions({ actions, testId, menuLabel, size = 'icon-sm', className }: {\n readonly actions: readonly BlockAction[] | undefined;\n readonly testId: string;\n /** The \"…\" button's name. */\n readonly menuLabel: string;\n readonly size?: 'icon-sm' | 'icon-bar';\n readonly className?: string;\n}) {\n const buttons = headerActions(actions), menu = menuActions(actions);\n if (!buttons.length && !menu.length) return null;\n return <div data-boring=\"block-actions\" data-actions={testId} className={cn('flex shrink-0 items-center gap-0.5', className)}>\n {buttons.map(action => <ActionButton key={action.id} action={action} testId={testId} size={size} />)}\n {menu.length > 0 && <ActionMenu label={menuLabel} size={size} testId={`${testId}-more`} icon={<EllipsisIcon className=\"size-4\" aria-hidden=\"true\" />}\n items={menu.map(action => actionMenuItem(action, testId))} />}\n </div>;\n}\n",
24
+ "type": "registry:ui",
25
+ "target": "components/button/actions.tsx"
26
+ }
27
+ ],
28
+ "type": "registry:ui"
29
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "feedback-preview",
4
+ "title": "Feedback preview",
5
+ "description": "PreviewBanner: a live, reversible preview of a change in the page (Approve / Discard) over `@hachej/boring-feedback/preview`, whose subagent calls models through a host gateway. Separate from `feedback` so annotation-only apps do not install the model SDK; install `feedback` first (its styles cover the banner).",
6
+ "dependencies": [
7
+ "@hachej/boring-feedback@^0.2.2",
8
+ "@earendil-works/pi-ai@^1.1.0",
9
+ "react@^19.0.0"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "registry/feedback-preview/preview-banner.tsx",
14
+ "content": "// The preview banner (FEEDBACK.md, \"Preview\"): while the in-page preview subagent changes the live page for the builder's\n// `browser_preview` call, a fixed, clearly labelled banner says nothing is saved, lists the changes, lets the person keep talking to\n// the subagent (\"darker\"), and offers Approve and Discard. Everything it shows and does is the headless `createPreviewSession` from\n// `@hachej/boring-feedback/preview`; this file is how it looks. It is `data-feedback-ignore`, so the preview tools and every snapshot skip it.\nimport { useState, useSyncExternalStore, type FormEvent } from 'react';\nimport { readableElement } from '@hachej/boring-feedback/format';\nimport type { PreviewSession, PreviewSnapshot } from '@hachej/boring-feedback/preview';\n\nconst STATUS: Readonly<Record<PreviewSnapshot['status'], string>> = {\n starting: 'Preparing the preview…', working: 'Previewing…', ready: 'Preview ready', failed: 'Preview failed',\n answering: 'Sending…', approved: 'Approved', discarded: 'Discarded',\n};\n\nexport interface PreviewBannerProps {\n readonly session: PreviewSession;\n}\n\nexport function PreviewBanner({ session }: PreviewBannerProps) {\n const state = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot);\n const [text, setText] = useState('');\n const busy = state.status === 'starting' || state.status === 'working' || state.status === 'answering';\n const done = state.status === 'approved' || state.status === 'discarded';\n const submit = (event: FormEvent) => {\n event.preventDefault();\n const said = text.trim();\n if (!said || busy) return;\n setText('');\n void session.say(said);\n };\n return <section data-boring=\"feedback\" data-feedback-ignore=\"\" role=\"region\" aria-label=\"Preview of a change\" aria-live=\"polite\"\n data-testid=\"preview-banner\" data-status={state.status} className=\"boring-feedback-preview\">\n <div className=\"boring-feedback-preview-head\">\n <strong className=\"boring-feedback-preview-title\">Preview</strong>\n <span className=\"boring-feedback-preview-notice\">these changes are not saved</span>\n <span className=\"boring-feedback-preview-status\" data-testid=\"preview-status\">{STATUS[state.status]}</span>\n </div>\n {state.summary && <p className=\"boring-feedback-preview-summary\" data-testid=\"preview-summary\">{state.summary}</p>}\n {state.changes.length > 0 && <ul className=\"boring-feedback-preview-changes\" data-testid=\"preview-changes\">\n {state.changes.map((change, index) => {\n const element = readableElement(change.element, change.source);\n return <li key={index} data-testid=\"preview-change\">\n <span title={element.path}>{element.name}{element.file ? ` · ${element.file}` : ''}</span>{' '}\n <code>{change.text ? 'text' : change.property}</code> {change.from || '—'} → <strong>{change.to}</strong>\n </li>;\n })}\n </ul>}\n {state.error && <p className=\"boring-feedback-preview-error\" role=\"alert\" data-testid=\"preview-error\">{state.error}</p>}\n {!done && <form className=\"boring-feedback-preview-actions\" onSubmit={submit}>\n <input className=\"boring-feedback-preview-input\" data-testid=\"preview-input\" value={text} maxLength={300} disabled={busy}\n aria-label=\"Tell the preview what to change\" placeholder=\"Adjust it… (e.g. darker)\" onChange={event => setText(event.currentTarget.value)} />\n <button type=\"submit\" className=\"boring-feedback-button\" data-testid=\"preview-say\" disabled={busy || !text.trim()}>Send</button>\n <button type=\"button\" className=\"boring-feedback-button\" data-variant=\"primary\" data-testid=\"preview-approve\" disabled={busy || state.changes.length === 0}\n onClick={() => { void session.approve(); }}>Approve</button>\n <button type=\"button\" className=\"boring-feedback-button\" data-testid=\"preview-discard\" disabled={state.status === 'answering'}\n onClick={() => { void session.discard(); }}>Discard</button>\n </form>}\n </section>;\n}\n",
15
+ "type": "registry:component",
16
+ "target": "components/feedback-preview/preview-banner.tsx"
17
+ }
18
+ ],
19
+ "type": "registry:component"
20
+ }