@hachej/boring-ui-kit 0.4.57 → 0.4.59

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 (59) hide show
  1. package/bin/boring-ui-blocks.mjs +49 -4
  2. package/dist/chat-react-conversations.d.ts +7 -1
  3. package/dist/chat-react-conversations.js +58 -18
  4. package/package.json +6 -6
  5. package/r/_shared.json +19 -0
  6. package/r/agent-target.json +4 -4
  7. package/r/app-shell.json +17 -17
  8. package/r/board-viewer.json +10 -10
  9. package/r/button.json +4 -4
  10. package/r/data-table.json +21 -0
  11. package/r/diagram-viewer.json +6 -4
  12. package/r/dock.json +1 -1
  13. package/r/documents.json +13 -14
  14. package/r/drawing-viewer.json +6 -4
  15. package/r/empty-state.json +22 -0
  16. package/r/feedback.json +14 -14
  17. package/r/file-tree.json +4 -4
  18. package/r/highlights.json +3 -3
  19. package/r/html-viewer.json +3 -3
  20. package/r/kpi-stat.json +22 -0
  21. package/r/list-row.json +21 -0
  22. package/r/looks-viewer.json +6 -6
  23. package/r/maquette-viewer.json +7 -6
  24. package/r/markdown-editor.json +10 -9
  25. package/r/mockup-viewer.json +5 -5
  26. package/r/page-header.json +22 -0
  27. package/r/pi-ambient.json +12 -12
  28. package/r/pi-app-kinds.json +10 -10
  29. package/r/pi-app.json +17 -17
  30. package/r/pi-artifacts.json +4 -4
  31. package/r/pi-chat-approval.json +4 -4
  32. package/r/pi-chat-attachments.json +4 -4
  33. package/r/pi-chat-markdown.json +3 -3
  34. package/r/pi-chat-mentions.json +4 -4
  35. package/r/pi-chat-pickers.json +4 -4
  36. package/r/pi-chat-plugins.json +11 -11
  37. package/r/pi-chat-question.json +3 -3
  38. package/r/pi-chat-slash.json +5 -5
  39. package/r/pi-chat-subagent.json +4 -4
  40. package/r/pi-chat-voice.json +7 -7
  41. package/r/pi-chat.json +20 -20
  42. package/r/pi-workspace.json +3 -3
  43. package/r/placed-agent.json +7 -7
  44. package/r/provider-setup.json +4 -4
  45. package/r/record-viewer.json +5 -3
  46. package/r/registry.json +494 -280
  47. package/r/shared-chat.json +11 -11
  48. package/r/spaces.json +7 -5
  49. package/r/task-list-viewer.json +28 -3
  50. package/r/theme.json +115 -50
  51. package/r/version-history.json +2 -2
  52. package/r/viewer-code.json +5 -5
  53. package/r/viewer-frame.json +9 -9
  54. package/r/viewer-html.json +7 -7
  55. package/r/viewer-image.json +5 -5
  56. package/r/viewer-markdown.json +7 -7
  57. package/r/viewer-pdf.json +6 -6
  58. package/r/viewers.json +6 -6
  59. package/r/windows.json +4 -4
package/r/theme.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
3
  "name": "theme",
4
4
  "title": "Theme",
5
- "description": "The shadcn tokens every Tailwind item is styled with (neutral base, light and dark) and their Tailwind v4 `@theme inline` bindings. The CLI adds only the tokens the host does not define yet. The examples compile the same values (lab/studio/tailwind.mjs).",
5
+ "description": "Optional default theme tokens and Tailwind v4 bindings: original grey canvas, flat white surface roles, compact type and semantic statuses. Explicit shadcn add overwrites matching host tokens; blocks never install this theme automatically. Font families are declarations only; hosts load the fonts and own primitive geometry and icons.",
6
6
  "cssVars": {
7
7
  "theme": {
8
8
  "color-background": "var(--background)",
@@ -20,6 +20,7 @@
20
20
  "color-accent": "var(--accent)",
21
21
  "color-accent-foreground": "var(--accent-foreground)",
22
22
  "color-destructive": "var(--destructive)",
23
+ "color-success": "var(--success)",
23
24
  "color-border": "var(--border)",
24
25
  "color-input": "var(--input)",
25
26
  "color-ring": "var(--ring)",
@@ -28,6 +29,16 @@
28
29
  "color-chart-3": "var(--chart-3)",
29
30
  "color-chart-4": "var(--chart-4)",
30
31
  "color-chart-5": "var(--chart-5)",
32
+ "color-sidebar": "var(--sidebar)",
33
+ "color-sidebar-foreground": "var(--sidebar-foreground)",
34
+ "color-sidebar-primary": "var(--sidebar-primary)",
35
+ "color-sidebar-primary-foreground": "var(--sidebar-primary-foreground)",
36
+ "color-sidebar-accent": "var(--sidebar-accent)",
37
+ "color-sidebar-accent-foreground": "var(--sidebar-accent-foreground)",
38
+ "color-sidebar-border": "var(--sidebar-border)",
39
+ "color-sidebar-ring": "var(--sidebar-ring)",
40
+ "text-xs": "var(--text-body)",
41
+ "text-sm": "var(--text-title)",
31
42
  "radius-sm": "calc(var(--radius) - 4px)",
32
43
  "radius-md": "calc(var(--radius) - 2px)",
33
44
  "radius-lg": "var(--radius)",
@@ -67,34 +78,57 @@
67
78
  "radius-pm-popover": "var(--pm-radius-popover)",
68
79
  "radius-pm-send": "var(--pm-radius-send)",
69
80
  "shadow-pm-popover": "var(--pm-shadow-popover)",
70
- "color-pm-label": "var(--pm-label)"
81
+ "color-pm-label": "var(--pm-label)",
82
+ "font-sans": "var(--font-sans)",
83
+ "font-mono": "var(--font-mono)",
84
+ "color-success-soft": "var(--success-soft)",
85
+ "color-success-soft-foreground": "var(--success-soft-foreground)",
86
+ "color-warning": "var(--warning)",
87
+ "color-warning-soft": "var(--warning-soft)",
88
+ "color-warning-soft-foreground": "var(--warning-soft-foreground)",
89
+ "color-info": "var(--info)",
90
+ "color-info-soft": "var(--info-soft)",
91
+ "color-info-soft-foreground": "var(--info-soft-foreground)",
92
+ "color-destructive-soft": "var(--destructive-soft)",
93
+ "color-destructive-soft-foreground": "var(--destructive-soft-foreground)"
71
94
  },
72
95
  "light": {
73
- "radius": "0.75rem",
74
- "background": "oklch(1 0 0)",
75
- "foreground": "oklch(0.141 0.005 285.823)",
96
+ "radius": "0.5rem",
97
+ "background": "oklch(0.97 0 0)",
98
+ "foreground": "oklch(0.2 0 0)",
76
99
  "card": "oklch(1 0 0)",
77
- "card-foreground": "oklch(0.141 0.005 285.823)",
78
- "popover": "oklch(1 0 0)",
79
- "popover-foreground": "oklch(0.141 0.005 285.823)",
100
+ "card-foreground": "var(--foreground)",
101
+ "popover": "var(--card)",
102
+ "popover-foreground": "var(--foreground)",
80
103
  "primary": "oklch(0.21 0.006 285.885)",
81
104
  "primary-foreground": "oklch(0.985 0 0)",
82
- "secondary": "oklch(0.967 0.001 286.375)",
83
- "secondary-foreground": "oklch(0.21 0.006 285.885)",
84
- "muted": "oklch(0.967 0.001 286.375)",
85
- "muted-foreground": "oklch(0.52 0.016 285.938)",
86
- "accent": "oklch(0.955 0.003 286.375)",
87
- "accent-foreground": "oklch(0.21 0.006 285.885)",
88
- "destructive": "oklch(0.577 0.245 27.325)",
89
- "border": "oklch(0.92 0.004 286.32)",
90
- "input": "oklch(0.89 0.005 286.32)",
91
- "ring": "oklch(0.55 0.02 286.067)",
92
- "chart-1": "oklch(0.646 0.222 41.116)",
93
- "chart-2": "oklch(0.6 0.118 184.704)",
94
- "chart-3": "oklch(0.398 0.07 227.392)",
95
- "chart-4": "oklch(0.828 0.189 84.429)",
96
- "chart-5": "oklch(0.769 0.188 70.08)",
97
- "shell": "oklch(0.975 0.002 286)",
105
+ "secondary": "var(--muted)",
106
+ "secondary-foreground": "var(--foreground)",
107
+ "muted": "oklch(0.95 0 0)",
108
+ "muted-foreground": "color-mix(in oklab, var(--foreground) 57%, var(--background))",
109
+ "accent": "oklch(0.94 0 0)",
110
+ "accent-foreground": "var(--foreground)",
111
+ "destructive": "oklch(0.577 0.2 27)",
112
+ "success": "oklch(0.527 0.154 150)",
113
+ "border": "oklch(0.91 0 0)",
114
+ "input": "var(--border)",
115
+ "ring": "oklch(0.55 0.02 286)",
116
+ "chart-1": "oklch(0.55 0.17 264)",
117
+ "chart-2": "oklch(0.58 0.15 160)",
118
+ "chart-3": "oklch(0.63 0.15 75)",
119
+ "chart-4": "oklch(0.6 0.18 15)",
120
+ "chart-5": "oklch(0.57 0.12 300)",
121
+ "shell": "var(--background)",
122
+ "text-body": "0.75rem",
123
+ "text-title": "0.875rem",
124
+ "sidebar": "var(--background)",
125
+ "sidebar-foreground": "var(--foreground)",
126
+ "sidebar-primary": "var(--primary)",
127
+ "sidebar-primary-foreground": "var(--primary-foreground)",
128
+ "sidebar-accent": "var(--accent)",
129
+ "sidebar-accent-foreground": "var(--accent-foreground)",
130
+ "sidebar-border": "var(--border)",
131
+ "sidebar-ring": "var(--ring)",
98
132
  "shadow-xs": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
99
133
  "shadow-sm": "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
100
134
  "shadow-md": "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
@@ -129,33 +163,54 @@
129
163
  "pm-shadow-popover": "0 0 0 1px var(--border), 0 8px 24px rgb(0 0 0 / 0.14)",
130
164
  "pm-label": "var(--foreground)",
131
165
  "pm-mark-line": "35%",
132
- "pm-mark-width": "2px"
166
+ "pm-mark-width": "2px",
167
+ "warning": "oklch(0.6 0.15 65)",
168
+ "info": "oklch(0.5 0.2 264)",
169
+ "font-sans": "'Geist', ui-sans-serif, system-ui, sans-serif",
170
+ "font-mono": "'Geist Mono', ui-monospace, Menlo, monospace",
171
+ "success-soft": "color-mix(in oklab, var(--success) 14%, var(--card))",
172
+ "success-soft-foreground": "var(--success)",
173
+ "warning-soft": "color-mix(in oklab, var(--warning) 16%, var(--card))",
174
+ "warning-soft-foreground": "var(--warning)",
175
+ "info-soft": "color-mix(in oklab, var(--info) 12%, var(--card))",
176
+ "info-soft-foreground": "var(--info)",
177
+ "destructive-soft": "color-mix(in oklab, var(--destructive) 12%, var(--card))",
178
+ "destructive-soft-foreground": "var(--destructive)"
133
179
  },
134
180
  "dark": {
135
- "background": "oklch(0.175 0.006 285.885)",
136
- "foreground": "oklch(0.985 0 0)",
137
- "card": "oklch(0.205 0.006 285.885)",
138
- "card-foreground": "oklch(0.985 0 0)",
139
- "popover": "oklch(0.205 0.006 285.885)",
140
- "popover-foreground": "oklch(0.985 0 0)",
141
- "primary": "oklch(0.92 0.004 286.32)",
142
- "primary-foreground": "oklch(0.21 0.006 285.885)",
143
- "secondary": "oklch(0.274 0.006 286.033)",
144
- "secondary-foreground": "oklch(0.985 0 0)",
145
- "muted": "oklch(0.255 0.006 286.033)",
146
- "muted-foreground": "oklch(0.72 0.015 286.067)",
147
- "accent": "oklch(0.3 0.006 286.033)",
148
- "accent-foreground": "oklch(0.985 0 0)",
149
- "destructive": "oklch(0.704 0.191 22.216)",
150
- "border": "oklch(1 0 0 / 11%)",
151
- "input": "oklch(1 0 0 / 16%)",
152
- "ring": "oklch(0.65 0.02 286.067)",
153
- "chart-1": "oklch(0.488 0.243 264.376)",
154
- "chart-2": "oklch(0.696 0.17 162.48)",
155
- "chart-3": "oklch(0.769 0.188 70.08)",
156
- "chart-4": "oklch(0.627 0.265 303.9)",
157
- "chart-5": "oklch(0.645 0.246 16.439)",
158
- "shell": "oklch(0.13 0.005 285.823)",
181
+ "background": "oklch(0.18 0 0)",
182
+ "foreground": "oklch(0.96 0 0)",
183
+ "card": "oklch(0.23 0 0)",
184
+ "card-foreground": "var(--foreground)",
185
+ "popover": "var(--card)",
186
+ "popover-foreground": "var(--foreground)",
187
+ "primary": "oklch(0.92 0 0)",
188
+ "primary-foreground": "oklch(0.2 0 0)",
189
+ "secondary": "var(--muted)",
190
+ "secondary-foreground": "var(--foreground)",
191
+ "muted": "oklch(0.27 0 0)",
192
+ "muted-foreground": "color-mix(in oklab, var(--foreground) 62%, var(--background))",
193
+ "accent": "oklch(0.28 0 0)",
194
+ "accent-foreground": "var(--foreground)",
195
+ "destructive": "oklch(0.7 0.19 22)",
196
+ "success": "oklch(0.7 0.17 150)",
197
+ "border": "oklch(1 0 0 / 10%)",
198
+ "input": "var(--border)",
199
+ "ring": "oklch(0.65 0.02 286)",
200
+ "chart-1": "oklch(0.72 0.15 264)",
201
+ "chart-2": "oklch(0.75 0.15 160)",
202
+ "chart-3": "oklch(0.78 0.15 75)",
203
+ "chart-4": "oklch(0.73 0.16 15)",
204
+ "chart-5": "oklch(0.74 0.12 300)",
205
+ "shell": "var(--background)",
206
+ "sidebar": "var(--background)",
207
+ "sidebar-foreground": "var(--foreground)",
208
+ "sidebar-primary": "var(--primary)",
209
+ "sidebar-primary-foreground": "var(--primary-foreground)",
210
+ "sidebar-accent": "var(--accent)",
211
+ "sidebar-accent-foreground": "var(--accent-foreground)",
212
+ "sidebar-border": "var(--border)",
213
+ "sidebar-ring": "var(--ring)",
159
214
  "shadow-xs": "0 1px 2px 0 rgb(0 0 0 / 0.05)",
160
215
  "shadow-sm": "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
161
216
  "shadow-md": "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
@@ -190,7 +245,17 @@
190
245
  "pm-shadow-popover": "0 0 0 1px var(--border), 0 8px 24px rgb(0 0 0 / 0.14)",
191
246
  "pm-label": "var(--foreground)",
192
247
  "pm-mark-line": "35%",
193
- "pm-mark-width": "2px"
248
+ "pm-mark-width": "2px",
249
+ "warning": "oklch(0.78 0.15 75)",
250
+ "info": "oklch(0.72 0.14 254)",
251
+ "success-soft": "color-mix(in oklab, var(--success) 22%, var(--card))",
252
+ "success-soft-foreground": "var(--success)",
253
+ "warning-soft": "color-mix(in oklab, var(--warning) 22%, var(--card))",
254
+ "warning-soft-foreground": "var(--warning)",
255
+ "info-soft": "color-mix(in oklab, var(--info) 22%, var(--card))",
256
+ "info-soft-foreground": "var(--info)",
257
+ "destructive-soft": "color-mix(in oklab, var(--destructive) 22%, var(--card))",
258
+ "destructive-soft-foreground": "var(--destructive)"
194
259
  }
195
260
  },
196
261
  "type": "registry:theme"
@@ -4,11 +4,11 @@
4
4
  "title": "Version history rows",
5
5
  "description": "Shared view-only version rows and menu metadata with relative time, provenance and Current. No file reads, publication or restoration.",
6
6
  "dependencies": [
7
- "@hachej/boring-files@^0.4.57",
7
+ "@hachej/boring-files@^0.4.59",
8
8
  "react@^19.0.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/button.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/button.json"
12
12
  ],
13
13
  "files": [
14
14
  {
@@ -4,12 +4,12 @@
4
4
  "title": "Code viewers",
5
5
  "description": "The SVG and CSV kinds: `svgKind` (a preview from a blob URL, or its source) and `csvKind` (a table), drawn inside the artifact panel's bar. Each view loads the first time its artifact opens; other code stays in the panel's code block.",
6
6
  "dependencies": [
7
- "react@^19.0.0",
8
- "lucide-react@^1.51.0"
7
+ "react@^19.0.0"
9
8
  ],
10
9
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/viewer-frame.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/pi-chat.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/viewer-frame.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/pi-chat.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
13
13
  ],
14
14
  "files": [
15
15
  {
@@ -20,7 +20,7 @@
20
20
  },
21
21
  {
22
22
  "path": "registry/viewer-code/svg-view.tsx",
23
- "content": "'use client';\n\nimport { useEffect, useMemo, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { CodeXmlIcon, EyeIcon } from 'lucide-react';\nimport { CodeBlock } from '../pi-chat/code-block';\nimport { ViewerToggle } from '../viewer-frame/viewer-frame';\nimport { useViewerText } from '../viewer-frame/viewer-window';\n\n/** What the code kinds' views read of the panel's kind props (structurally `ArtifactKindViewProps` of pi-artifacts). */\nexport interface CodeViewProps {\n readonly text: string;\n readonly artifact: { readonly title: string };\n /** The panel bar's controls slot (the preview and source toggle goes there). */\n readonly setControls?: ((controls: ReactNode) => void) | undefined;\n}\n\n/** An SVG artifact: a preview, or its source. The preview is an image from a blob URL: it never becomes markup in this page, so its scripts cannot run. */\nexport function SvgView({ text, artifact, setControls }: CodeViewProps) {\n const { labels } = useViewerText();\n const [mode, setMode] = useState<'preview' | 'source'>('preview');\n const url = useMemo(() => URL.createObjectURL(new Blob([text], { type: 'image/svg+xml' })), [text]);\n useEffect(() => () => URL.revokeObjectURL(url), [url]);\n useEffect(() => {\n setControls?.(<ViewerToggle label={labels.view} value={mode} onChange={setMode} options={[\n { id: 'preview', label: labels.preview, text: labels.preview, icon: <EyeIcon className=\"size-4\" aria-hidden=\"true\" />, testId: 'artifact-mode' },\n { id: 'source', label: labels.source, text: labels.source, icon: <CodeXmlIcon className=\"size-4\" aria-hidden=\"true\" />, testId: 'artifact-mode' }]} />);\n return () => setControls?.(undefined);\n }, [setControls, mode, labels.view, labels.preview, labels.source]);\n return mode === 'preview'\n ? <div className=\"boring-viewer-checker flex h-full min-h-0 items-center justify-center overflow-auto p-6\"><img data-testid=\"artifact-svg\" src={url} alt={artifact.title} className=\"max-h-full max-w-full\" /></div>\n : <div className=\"min-h-0 p-3\"><CodeBlock code={text} language=\"svg\" /></div>;\n}\n",
23
+ "content": "'use client';\n\nimport { useEffect, useMemo, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { CodeXmlIcon, EyeIcon } from '../_shared/icons';\nimport { CodeBlock } from '../pi-chat/code-block';\nimport { ViewerToggle } from '../viewer-frame/viewer-frame';\nimport { useViewerText } from '../viewer-frame/viewer-window';\n\n/** What the code kinds' views read of the panel's kind props (structurally `ArtifactKindViewProps` of pi-artifacts). */\nexport interface CodeViewProps {\n readonly text: string;\n readonly artifact: { readonly title: string };\n /** The panel bar's controls slot (the preview and source toggle goes there). */\n readonly setControls?: ((controls: ReactNode) => void) | undefined;\n}\n\n/** An SVG artifact: a preview, or its source. The preview is an image from a blob URL: it never becomes markup in this page, so its scripts cannot run. */\nexport function SvgView({ text, artifact, setControls }: CodeViewProps) {\n const { labels } = useViewerText();\n const [mode, setMode] = useState<'preview' | 'source'>('preview');\n const url = useMemo(() => URL.createObjectURL(new Blob([text], { type: 'image/svg+xml' })), [text]);\n useEffect(() => () => URL.revokeObjectURL(url), [url]);\n useEffect(() => {\n setControls?.(<ViewerToggle label={labels.view} value={mode} onChange={setMode} options={[\n { id: 'preview', label: labels.preview, text: labels.preview, icon: <EyeIcon className=\"size-4\" aria-hidden=\"true\" />, testId: 'artifact-mode' },\n { id: 'source', label: labels.source, text: labels.source, icon: <CodeXmlIcon className=\"size-4\" aria-hidden=\"true\" />, testId: 'artifact-mode' }]} />);\n return () => setControls?.(undefined);\n }, [setControls, mode, labels.view, labels.preview, labels.source]);\n return mode === 'preview'\n ? <div className=\"boring-viewer-checker flex h-full min-h-0 items-center justify-center overflow-auto p-6\"><img data-testid=\"artifact-svg\" src={url} alt={artifact.title} className=\"max-h-full max-w-full\" /></div>\n : <div className=\"min-h-0 p-3\"><CodeBlock code={text} language=\"svg\" /></div>;\n}\n",
24
24
  "type": "registry:component",
25
25
  "target": "components/viewer-code/svg-view.tsx"
26
26
  },
@@ -4,15 +4,15 @@
4
4
  "title": "Viewer frame",
5
5
  "description": "The standard viewer top bar every viewer draws in (title, status, viewer controls, Refresh, Share, Copy, Download, Open in new tab, Close, overflow menu), its window labels, download, share and media helpers and the sandboxed interactive HTML frame. The viewers' styles live here. Each viewer is its own item (viewer-markdown, viewer-html, viewer-image, viewer-pdf, viewer-code) with a cheap kind module and a view loaded on first open.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.57",
7
+ "@hachej/boring-ui-kit@^0.4.59",
8
8
  "react@^19.0.0",
9
- "react-dom@^19.0.0",
10
- "lucide-react@^1.51.0"
9
+ "react-dom@^19.0.0"
11
10
  ],
12
11
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/utils.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/button.json",
15
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/version-history.json"
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/utils.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/button.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/version-history.json",
15
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
16
16
  ],
17
17
  "files": [
18
18
  {
@@ -35,7 +35,7 @@
35
35
  },
36
36
  {
37
37
  "path": "registry/viewer-frame/menu.tsx",
38
- "content": "'use client';\n\nimport { useState } from 'react';\nimport { HistoryIcon } from 'lucide-react';\nimport type { ReactNode } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { ActionMenuContent, useMenuPortal } from '../button/actions';\nimport type { MenuItem } from '../button/actions';\nimport { versionHistoryItems } from '../version-history/version-history';\nimport type { VersionHistoryEntry } from '../version-history/version-history';\nimport { useViewerText } from './viewer-window';\n\nexport type ViewerMenuItem = MenuItem;\nexport interface ViewerActionsProps {\n readonly label: string;\n readonly icon: ReactNode;\n readonly items: readonly MenuItem[];\n readonly testId?: string;\n readonly align?: 'start' | 'end';\n readonly className?: string;\n readonly children?: ReactNode;\n readonly onOpenChange?: (open: boolean) => void;\n}\nexport type ViewerVersion = VersionHistoryEntry;\n\n/** The consumer's shadcn Base UI dropdown supplies positioning, dismissal and keyboard navigation. */\nexport function ViewerActions({ label, icon, items, testId = 'viewer-menu', align = 'end', className, children, onOpenChange }: ViewerActionsProps) {\n const { ref: portalRef, container: portalContainer } = useMenuPortal();\n return <DropdownMenu onOpenChange={open => onOpenChange?.(open)}>\n <DropdownMenuTrigger render={<Button ref={portalRef} variant=\"ghost\" size=\"icon-sm\" aria-label={label} title={label} data-testid={testId} className={className}>{icon}</Button>} />\n <ActionMenuContent label={label} testId={testId} items={items} align={align} container={portalContainer}>{children}</ActionMenuContent>\n </DropdownMenu>;\n}\n\n/** Saved versions are view-only choices. Current is the displayed revision, not necessarily the latest one. */\nexport function ViewerVersions({ versions, current, onSelect, testId = 'viewer-versions', label }: {\n readonly versions: readonly ViewerVersion[];\n readonly current: string;\n readonly onSelect: (id: string) => void;\n readonly testId?: string;\n readonly label?: string;\n}) {\n const { labels } = useViewerText();\n const [now, setNow] = useState(Date.now);\n return <ViewerActions label={label ?? labels.versionHistory} testId={testId} align=\"end\" icon={<HistoryIcon data-icon=\"inline-start\" aria-hidden=\"true\" />} onOpenChange={open => { if (open) setNow(Date.now()); }}\n items={versionHistoryItems({ versions, current, onSelect, labels, now, testId })} />;\n}\n",
38
+ "content": "'use client';\n\nimport { useState } from 'react';\nimport { HistoryIcon } from '../_shared/icons';\nimport type { ReactNode } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { DropdownMenu, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { ActionMenuContent, useMenuPortal } from '../button/actions';\nimport type { MenuItem } from '../button/actions';\nimport { versionHistoryItems } from '../version-history/version-history';\nimport type { VersionHistoryEntry } from '../version-history/version-history';\nimport { useViewerText } from './viewer-window';\n\nexport type ViewerMenuItem = MenuItem;\nexport interface ViewerActionsProps {\n readonly label: string;\n readonly icon: ReactNode;\n readonly items: readonly MenuItem[];\n readonly testId?: string;\n readonly align?: 'start' | 'end';\n readonly className?: string;\n readonly children?: ReactNode;\n readonly onOpenChange?: (open: boolean) => void;\n}\nexport type ViewerVersion = VersionHistoryEntry;\n\n/** The consumer's shadcn Base UI dropdown supplies positioning, dismissal and keyboard navigation. */\nexport function ViewerActions({ label, icon, items, testId = 'viewer-menu', align = 'end', className, children, onOpenChange }: ViewerActionsProps) {\n const { ref: portalRef, container: portalContainer } = useMenuPortal();\n return <DropdownMenu onOpenChange={open => onOpenChange?.(open)}>\n <DropdownMenuTrigger render={<Button ref={portalRef} variant=\"ghost\" size=\"icon-sm\" aria-label={label} title={label} data-testid={testId} className={className}>{icon}</Button>} />\n <ActionMenuContent label={label} testId={testId} items={items} align={align} container={portalContainer}>{children}</ActionMenuContent>\n </DropdownMenu>;\n}\n\n/** Saved versions are view-only choices. Current is the displayed revision, not necessarily the latest one. */\nexport function ViewerVersions({ versions, current, onSelect, testId = 'viewer-versions', label }: {\n readonly versions: readonly ViewerVersion[];\n readonly current: string;\n readonly onSelect: (id: string) => void;\n readonly testId?: string;\n readonly label?: string;\n}) {\n const { labels } = useViewerText();\n const [now, setNow] = useState(Date.now);\n return <ViewerActions label={label ?? labels.versionHistory} testId={testId} align=\"end\" icon={<HistoryIcon data-icon=\"inline-start\" aria-hidden=\"true\" />} onOpenChange={open => { if (open) setNow(Date.now()); }}\n items={versionHistoryItems({ versions, current, onSelect, labels, now, testId })} />;\n}\n",
39
39
  "type": "registry:component",
40
40
  "target": "components/viewer-frame/menu.tsx"
41
41
  },
@@ -47,13 +47,13 @@
47
47
  },
48
48
  {
49
49
  "path": "registry/viewer-frame/viewer-frame.tsx",
50
- "content": "'use client';\n\nimport { useMedia, usePhone } from '../utils/use-media';\n\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowLeftIcon, CheckIcon, CircleIcon, CopyIcon, DownloadIcon, ExternalLinkIcon, RefreshCwIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';\nimport { Badge } from '@/components/ui/badge';\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';\nimport { Textarea } from '@/components/ui/textarea';\nimport { DropdownMenuSeparator } from '@/components/ui/dropdown-menu';\nimport { ActionButton, actionMenuItem, headerActions, menuActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { ViewerActions } from './menu';\nimport type { ViewerMenuItem } from './menu';\nimport { useViewerText, useViewerWindow } from './viewer-window';\nimport type { ViewerIcons, ViewerLabels } from './viewer-window';\nimport { cn } from '../utils/utils';\nimport type { ManualCopyError } from '../utils/utils';\nimport type { ViewerShare } from './share';\n\nexport type { ViewerShare, ViewerShareRequest, ViewerShareResult } from './share';\nexport { ViewerWindowProvider, defaultViewerIcons, defaultViewerLabels, useViewerText } from './viewer-window';\nexport type { ViewerIcons, ViewerLabels, ViewerWindow } from './viewer-window';\n\nexport type ViewerTone = 'neutral' | 'success' | 'warning' | 'danger';\n/**\n * `kind` says what the status is whatever its (translated) label: the bar hides a plain `saved` (unless `showSavedStatus`) and shows\n * `read-only` as a fact. The bar shows a dot in the default shadcn roles: `success` primary, `warning` and `neutral` muted-foreground, `danger` destructive. Only a\n * `danger` status also shows its words, because the person must act on it; the others keep the label in the tooltip and for screen readers.\n */\nexport interface ViewerStatus { readonly label: string; readonly tone?: ViewerTone; readonly kind?: 'saved' | 'read-only' | undefined }\n\nexport interface ViewerFrameProps {\n /** What is shown, for example the file name. */\n readonly title: string;\n /** Type, version, path, size: short facts under the title. */\n readonly subtitle?: ReactNode | undefined;\n /** Saved, Unsaved, Saving, Changed elsewhere, Read-only. */\n readonly status?: ViewerStatus | undefined;\n /** Keep a quiet saved acknowledgement visible, for autosaving editors. */\n readonly showSavedStatus?: boolean | undefined;\n /** Opaque identifiers handed to `onShare` so the host can build a link to this exact thing. */\n readonly target?: unknown | undefined;\n readonly revision?: string | undefined;\n /** Leading header content, for example Back before the file title. */\n readonly headerStart?: ReactNode | undefined;\n /** Viewer-specific controls that stay visible: a mode toggle, Save while there are edits, zoom steps, a version switcher when there are several. */\n readonly controls?: ReactNode | undefined;\n /**\n * The one control that matters most here (Comment on a version to try, for example), kept in the bar at every width. With the compact\n * header a phone folds `controls` into the \"…\" menu; this one stays beside it, so the main action is one tap away. Test id `<testId>-main`.\n */\n readonly mainControl?: ReactNode | undefined;\n /** Viewer-specific secondary actions, listed in the \"…\" menu after the standard ones. */\n readonly menu?: readonly ViewerMenuItem[] | undefined;\n /** Viewer-specific native dropdown groups, before the standard secondary actions. */\n readonly menuContent?: ReactNode | undefined;\n /**\n * The host's actions for this bar, after the window's (`ViewerWindow.actions`): `header` ones as buttons beside Share, `menu` ones at the\n * end of the \"…\" menu. Test ids `<testId>-<id>`.\n */\n readonly actions?: readonly BlockAction[] | undefined;\n /** This bar's words and icons, over the window's and the defaults (`defaultViewerLabels`, `defaultViewerIcons`). */\n readonly labels?: Partial<ViewerLabels> | undefined;\n readonly icons?: Partial<ViewerIcons> | undefined;\n /** Each standard action appears only when its handler is supplied. Share is always visible; Reload, Copy, Download and Open in new tab live in the \"…\" menu. */\n readonly onRefresh?: (() => unknown) | undefined;\n readonly onShare?: ViewerShare | undefined;\n readonly onCopy?: (() => unknown) | undefined;\n readonly onDownload?: (() => unknown) | undefined;\n readonly onOpenInNewTab?: (() => unknown) | undefined;\n readonly onClose?: (() => unknown) | undefined;\n /** Prefix of the `data-testid` of the bar and its actions (`viewer-share`, `viewer-more`, and `viewer-refresh` for the menu's Reload, ...). */\n readonly testId?: string | undefined;\n readonly titleTestId?: string | undefined;\n /** Quiet one-row bar with secondary actions in the menu. False retains the former visible Share/fullscreen/close layout. */\n readonly compactHeader?: boolean | undefined;\n readonly className?: string | undefined;\n readonly children?: ReactNode | undefined;\n}\n\n\n/** Narrow panes drop optional controls. */\nconst TIGHT_WIDTH = { touch: 440, pointer: 340 };\n\n/** How much room the bar has, for a pane that wants to drop optional controls (the zoom percentage, for example) on a narrow panel. */\nexport interface ViewerBarSpace { readonly tight: boolean }\nconst ViewerBarContext = createContext<ViewerBarSpace>({ tight: false });\nexport const useViewerBar = (): ViewerBarSpace => useContext(ViewerBarContext);\nconst NOTICE_MS = 2600;\n\n/**\n * One choice of a mode toggle, for example Rich | Source. Icon only: `label` is the accessible name and the tooltip, `text` the short word\n * for hosts that want it (shown when there is no icon). A disabled choice shows `reason` as its tooltip.\n */\nexport interface ViewerToggleOption<T extends string> { readonly id: T; readonly label: string; readonly text: string; readonly icon?: ReactNode; readonly disabled?: boolean; readonly reason?: string | undefined; readonly testId?: string }\n\n/** A segmented control over the consumer's outlined ToggleGroup: the pressed segment is filled with a full-contrast icon, the others muted. */\nexport function ViewerToggle<T extends string>({ label, value, options, onChange }: { readonly label: string; readonly value: T; readonly options: readonly ViewerToggleOption<T>[]; readonly onChange: (id: T) => void }) {\n return <ToggleGroup aria-label={label} variant=\"outline\" size=\"sm\" spacing={1} value={[value]} onValueChange={values => {\n const selected = options.find(option => option.id === values[0] && !option.disabled);\n if (selected && selected.id !== value) onChange(selected.id);\n }}>\n {options.map(option => <ToggleGroupItem key={option.id} value={option.id} aria-label={option.label} title={option.disabled && option.reason ? option.reason : option.label} disabled={option.disabled}\n className=\"disabled:pointer-events-auto disabled:cursor-not-allowed\" data-testid={option.testId} data-mode={option.id}>\n <span className={cn('inline-flex items-center', value !== option.id && 'text-muted-foreground')}>{option.icon ?? option.text}</span></ToggleGroupItem>)}\n </ToggleGroup>;\n}\n\n/** The copy helper throws this when the browser allowed no automatic copy at all; the person copies by hand instead. */\nconst needsManualCopy = (error: unknown): error is ManualCopyError => (error as { name?: string } | null)?.name === 'ManualCopyError' && typeof (error as { text?: unknown }).text === 'string';\n\n/** Last resort for Copy and Share: the text, already selected, so Ctrl/Cmd+C or the long-press menu finishes the job. */\nfunction ManualCopy({ testId, link, text, onClose, labels }: { readonly testId: string; readonly link: boolean; readonly text: string; readonly onClose: () => void; readonly labels: ViewerLabels }) {\n const field = useRef<HTMLTextAreaElement>(null);\n return <Dialog open onOpenChange={open => { if (!open) onClose(); }}>\n <DialogContent showCloseButton={false} className=\"boring-viewer\" data-testid={`${testId}-manual-copy`} initialFocus={field}>\n <DialogHeader><DialogTitle>{labels.manualCopy(link)}</DialogTitle></DialogHeader>\n <Textarea ref={field} readOnly rows={Math.min(6, Math.max(2, Math.ceil(text.length / 48)))} value={text} data-testid={`${testId}-manual-copy-text`} onFocus={event => event.currentTarget.select()} />\n <div className=\"flex justify-end\"><Button variant=\"ghost\" size=\"sm\" data-testid={`${testId}-manual-copy-close`} onClick={onClose}>{labels.done}</Button></div>\n </DialogContent>\n </Dialog>;\n}\n\n/** Dot colours: default shadcn roles only. */\nconst DOTS: Record<ViewerTone, string> = {\n neutral: 'fill-muted-foreground',\n success: 'fill-primary',\n warning: 'fill-muted-foreground',\n danger: 'fill-destructive',\n};\n\n/** The save status as a coloured dot; the label is the tooltip and the screen-reader text, and is shown only when the person must act (`danger`). */\nfunction StatusMark({ status, testId }: { readonly status: ViewerStatus; readonly testId: string }) {\n const tone = status.tone ?? 'neutral';\n return <span role=\"status\" aria-label={status.label} title={status.label} data-testid={`${testId}-status`} data-tone={tone} data-kind={status.kind}\n className=\"inline-flex h-6 min-w-6 shrink-0 cursor-default items-center justify-center gap-1.5 text-xs\">\n <CircleIcon aria-hidden=\"true\" strokeWidth={0} className={cn('size-2 shrink-0', DOTS[tone])} />\n <span data-testid={`${testId}-status-text`} className={tone === 'danger' ? 'max-w-48 truncate text-destructive' : 'sr-only'}>{status.label}</span>\n </span>;\n}\n\n/** The one top bar of every viewer, always a single row: title and facts, viewer controls, a status dot when something needs attention, Share, full screen, Close and the \"…\" menu. */\nexport function ViewerFrame({ title, subtitle, status, showSavedStatus = false, target, revision, headerStart, controls, mainControl, menu, menuContent, actions, labels: ownLabels, icons: ownIcons, onRefresh, onShare, onCopy, onDownload, onOpenInNewTab, onClose, testId = 'viewer', titleTestId, compactHeader = true, className, children }: ViewerFrameProps) {\n const bar = useRef<HTMLElement>(null);\n const phone = usePhone(), coarse = useMedia('(pointer: coarse)');\n const tightWidth = phone || coarse ? TIGHT_WIDTH.touch : TIGHT_WIDTH.pointer;\n const [space, setSpace] = useState<ViewerBarSpace>({ tight: false });\n const [busy, setBusy] = useState<'refresh' | 'share' | undefined>();\n const [notice, setNotice] = useState<{ readonly text: string; readonly tone: ViewerTone } | undefined>();\n const [copied, setCopied] = useState(false);\n const [manual, setManual] = useState<{ readonly text: string; readonly link: boolean } | undefined>();\n const host = useViewerWindow();\n const { labels, icons } = useViewerText(ownLabels, ownIcons);\n useLayoutEffect(() => {\n const element = bar.current;\n if (!element || typeof ResizeObserver === 'undefined') return;\n const measure = () => {\n const width = element.getBoundingClientRect().width;\n const next = { tight: width > 0 && width < tightWidth };\n setSpace(now => now.tight === next.tight ? now : next);\n };\n measure();\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, [host?.renderHeader, tightWidth]);\n useEffect(() => {\n if (!notice) return;\n const timer = setTimeout(() => setNotice(undefined), NOTICE_MS);\n return () => clearTimeout(timer);\n }, [notice]);\n\n // A result after unmount updates nothing: React ignores a state update on an unmounted component.\n const guarded = useCallback(async (kind: 'refresh' | 'share', run: () => unknown, failed: string) => {\n setBusy(kind);\n setNotice(undefined);\n try { return await run(); }\n catch (error) { if (needsManualCopy(error)) setManual({ text: error.text, link: kind === 'share' }); else setNotice({ text: failed, tone: 'danger' }); return undefined; }\n finally { setBusy(undefined); }\n }, []);\n const refresh = () => { if (onRefresh) void guarded('refresh', onRefresh, labels.refreshFailed); };\n const share = () => {\n if (!onShare) return;\n void guarded('share', () => onShare({ title, target, revision }), labels.shareFailed).then(result => {\n if (result === 'copied') setNotice({ text: labels.linkCopied, tone: 'success' });\n else if (result === 'shared') setNotice({ text: labels.shared, tone: 'success' });\n });\n };\n const copy = () => {\n void Promise.resolve().then(onCopy).then(() => { setCopied(true); setNotice({ text: labels.copied, tone: 'success' }); setTimeout(() => setCopied(false), 1500); })\n .catch(error => { if (needsManualCopy(error)) setManual({ text: error.text, link: false }); else setNotice({ text: labels.copyFailed, tone: 'danger' }); });\n };\n const run = (action: (() => unknown) | undefined) => { void Promise.resolve().then(action).catch(() => setNotice({ text: labels.actionFailed, tone: 'danger' })); };\n // The host's actions: the window's (every viewer in the panel), then this frame's. Each runs through `run`, so a failure shows as a notice.\n const hostActions = [...host?.actions ?? [], ...actions ?? []].map(action => ({ ...action, onSelect: () => run(action.onSelect) }));\n\n const reading = status?.kind === 'read-only' || status?.label === labels.readOnly;\n const menuItems: ViewerMenuItem[] = ([\n ...(compactHeader && onShare ? [{ id: 'share', label: labels.share, icon: <icons.share className=\"size-4\" />, onSelect: share }] : []),\n ...(compactHeader && host?.onFullscreenChange ? [{ id: 'fullscreen', label: host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen, icon: <icons.enterFullscreen className=\"size-4\" />, onSelect: () => host.onFullscreenChange!(!host.fullscreen) }] : []),\n ...(onRefresh ? [{ id: 'refresh', separatorBefore: true, label: labels.reload, icon: <RefreshCwIcon className=\"size-4\" />, disabled: busy === 'refresh', onSelect: refresh }] : []),\n ...(onCopy ? [{ id: 'copy', label: labels.copy, icon: copied ? <CheckIcon className=\"size-4\" /> : <CopyIcon className=\"size-4\" />, onSelect: copy }] : []),\n ...(onDownload ? [{ id: 'download', label: labels.download, icon: <DownloadIcon className=\"size-4\" />, onSelect: () => run(onDownload) }] : []),\n ...(onOpenInNewTab ? [{ id: 'open', label: labels.openInNewTab, icon: <ExternalLinkIcon className=\"size-4\" />, onSelect: () => run(onOpenInNewTab) }] : []),\n ...(menu ?? []).map((item, index) => ({ ...item, separatorBefore: index === 0 || item.separatorBefore })),\n ...menuActions(hostActions).map((action, index) => ({ ...actionMenuItem(action, testId), separatorBefore: index === 0 })),\n ] as ViewerMenuItem[]).map(entry => ({ ...entry, testId: entry.testId ?? `${testId}-${entry.id}` }));\n\n const stacked = !compactHeader && host?.headerLayout === 'stacked';\n const toolbar = controls && <div data-testid={`${testId}-controls`} className={cn('flex shrink-0 items-center gap-1', compactHeader && 'max-md:hidden', stacked && 'flex-wrap px-3 py-2')}>{controls}</div>;\n const titlebar = <>\n {compactHeader && !headerStart && onClose && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.close} title={labels.close} data-testid={`${testId}-close`} onClick={() => run(onClose)}><ArrowLeftIcon data-icon=\"inline-start\" className=\"md:hidden\" aria-hidden=\"true\" /><icons.close data-icon=\"inline-start\" className=\"hidden md:block\" aria-hidden=\"true\" /></Button>}\n {headerStart}\n <div className=\"group/title relative min-w-0 flex-1 basis-0 text-left\">\n <h2 data-testid={titleTestId ?? `${testId}-title`} title={title} className=\"m-0 truncate text-sm leading-5 font-semibold tracking-tight\">{title}</h2>\n {(subtitle || reading) && <p data-testid={`${testId}-subtitle`} className={cn('m-0 flex min-w-0 items-center gap-x-1.5 text-xs leading-4 whitespace-nowrap text-muted-foreground', compactHeader ? 'hidden group-hover/title:flex max-md:hidden' : 'overflow-hidden')}>{subtitle}{subtitle && reading && <span aria-hidden=\"true\">·</span>}{reading && <span data-testid={`${testId}-readonly`}>{labels.readOnly}</span>}</p>}\n </div>\n {!stacked && toolbar}\n {mainControl && <div data-testid={`${testId}-main`} className=\"flex shrink-0 items-center\">{mainControl}</div>}\n {status && !reading && (showSavedStatus || status.kind !== 'saved' && status.label !== labels.saved) && <StatusMark status={status} testId={testId} />}\n <div className=\"flex shrink-0 items-center gap-0.5\">\n {headerActions(hostActions).map(action => <ActionButton key={action.id} action={action} testId={testId} size=\"icon-sm\" />)}\n {!compactHeader && onShare && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.share} title={labels.share} data-testid={`${testId}-share`} disabled={busy === 'share'} onClick={share}><icons.share data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {(menuItems.length > 0 || controls || menuContent) && <ViewerActions label={labels.moreActions} testId={`${testId}-more`} icon={<icons.more data-icon=\"inline-start\" aria-hidden=\"true\" />} items={menuItems}>{menuContent}{menuContent && menuItems.length > 0 && <DropdownMenuSeparator />}{compactHeader && controls && <div className=\"boring-viewer flex flex-wrap items-center gap-2 p-2 md:hidden\">{controls}</div>}</ViewerActions>}\n {!compactHeader && host?.onFullscreenChange && <Toggle size=\"sm\" aria-label={host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen} title={host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen} data-testid={`${testId}-fullscreen`} pressed={host.fullscreen === true}\n onPressedChange={pressed => host.onFullscreenChange!(pressed)}>{host.fullscreen ? <icons.exitFullscreen data-icon=\"inline-start\" aria-hidden=\"true\" /> : <icons.enterFullscreen data-icon=\"inline-start\" aria-hidden=\"true\" />}</Toggle>}\n {(!compactHeader || headerStart) && onClose && <Button variant=\"ghost\" size=\"icon-sm\" className={compactHeader ? 'max-md:hidden' : undefined} aria-label={labels.close} title={labels.close} data-testid={`${testId}-close`} onClick={() => run(onClose)}><icons.close data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n </div>\n </>;\n const header = <header ref={bar} data-testid={`${testId}-bar`} data-layout={stacked ? 'stacked' : undefined}\n className={cn('relative flex shrink-0', stacked ? 'flex-col' : 'h-13 flex-nowrap items-center gap-x-1.5 px-2 md:px-3')}>\n {stacked ? <><div data-testid={`${testId}-titlebar`} className=\"flex min-h-14 min-w-0 items-center gap-x-1.5 px-3 py-1.5\">{titlebar}</div>{toolbar}</> : titlebar}\n {notice && <Badge role=\"status\" aria-live=\"polite\" data-testid={`${testId}-notice`} data-tone={notice.tone} variant={notice.tone === 'danger' ? 'destructive' : 'secondary'}>{notice.text}</Badge>}\n {manual && <ManualCopy testId={testId} link={manual.link} text={manual.text} labels={labels} onClose={() => setManual(undefined)} />}\n </header>;\n return <ViewerBarContext.Provider value={space}>\n <section data-boring=\"viewer-frame\" data-testid={`${testId}-frame`} data-tight={space.tight || undefined}\n className={cn('boring-viewer isolate flex h-full min-h-0 min-w-0 flex-col bg-background text-foreground', className)}>\n {host?.renderHeader ? host.renderHeader(header) : header}\n <div data-testid={`${testId}-body`} className=\"flex min-h-0 min-w-0 flex-1 flex-col\">{children}</div>\n </section>\n </ViewerBarContext.Provider>;\n}\n",
50
+ "content": "'use client';\n\nimport { useMedia, usePhone } from '../utils/use-media';\n\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowLeftIcon, CheckIcon, CircleIcon, CopyIcon, DownloadIcon, ExternalLinkIcon, RefreshCwIcon } from '../_shared/icons';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';\nimport { Badge } from '@/components/ui/badge';\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';\nimport { Textarea } from '@/components/ui/textarea';\nimport { DropdownMenuSeparator } from '@/components/ui/dropdown-menu';\nimport { ActionButton, actionMenuItem, headerActions, menuActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { ViewerActions } from './menu';\nimport type { ViewerMenuItem } from './menu';\nimport { useViewerText, useViewerWindow } from './viewer-window';\nimport type { ViewerIcons, ViewerLabels } from './viewer-window';\nimport { cn } from '../utils/utils';\nimport type { ManualCopyError } from '../utils/utils';\nimport type { ViewerShare } from './share';\n\nexport type { ViewerShare, ViewerShareRequest, ViewerShareResult } from './share';\nexport { ViewerWindowProvider, defaultViewerIcons, defaultViewerLabels, useViewerText } from './viewer-window';\nexport type { ViewerIcons, ViewerLabels, ViewerWindow } from './viewer-window';\n\nexport type ViewerTone = 'neutral' | 'success' | 'warning' | 'danger';\n/**\n * `kind` says what the status is whatever its (translated) label: the bar hides a plain `saved` (unless `showSavedStatus`) and shows\n * `read-only` as a fact. The bar shows a dot in the default shadcn roles: `success` primary, `warning` and `neutral` muted-foreground, `danger` destructive. Only a\n * `danger` status also shows its words, because the person must act on it; the others keep the label in the tooltip and for screen readers.\n */\nexport interface ViewerStatus { readonly label: string; readonly tone?: ViewerTone; readonly kind?: 'saved' | 'read-only' | undefined }\n\nexport interface ViewerFrameProps {\n /** What is shown, for example the file name. */\n readonly title: string;\n /** Type, version, path, size: short facts under the title. */\n readonly subtitle?: ReactNode | undefined;\n /** Saved, Unsaved, Saving, Changed elsewhere, Read-only. */\n readonly status?: ViewerStatus | undefined;\n /** Keep a quiet saved acknowledgement visible, for autosaving editors. */\n readonly showSavedStatus?: boolean | undefined;\n /** Opaque identifiers handed to `onShare` so the host can build a link to this exact thing. */\n readonly target?: unknown | undefined;\n readonly revision?: string | undefined;\n /** Leading header content, for example Back before the file title. */\n readonly headerStart?: ReactNode | undefined;\n /** Viewer-specific controls that stay visible: a mode toggle, Save while there are edits, zoom steps, a version switcher when there are several. */\n readonly controls?: ReactNode | undefined;\n /**\n * The one control that matters most here (Comment on a version to try, for example), kept in the bar at every width. With the compact\n * header a phone folds `controls` into the \"…\" menu; this one stays beside it, so the main action is one tap away. Test id `<testId>-main`.\n */\n readonly mainControl?: ReactNode | undefined;\n /** Viewer-specific secondary actions, listed in the \"…\" menu after the standard ones. */\n readonly menu?: readonly ViewerMenuItem[] | undefined;\n /** Viewer-specific native dropdown groups, before the standard secondary actions. */\n readonly menuContent?: ReactNode | undefined;\n /**\n * The host's actions for this bar, after the window's (`ViewerWindow.actions`): `header` ones as buttons beside Share, `menu` ones at the\n * end of the \"…\" menu. Test ids `<testId>-<id>`.\n */\n readonly actions?: readonly BlockAction[] | undefined;\n /** This bar's words and icons, over the window's and the defaults (`defaultViewerLabels`, `defaultViewerIcons`). */\n readonly labels?: Partial<ViewerLabels> | undefined;\n readonly icons?: Partial<ViewerIcons> | undefined;\n /** Each standard action appears only when its handler is supplied. Share is always visible; Reload, Copy, Download and Open in new tab live in the \"…\" menu. */\n readonly onRefresh?: (() => unknown) | undefined;\n readonly onShare?: ViewerShare | undefined;\n readonly onCopy?: (() => unknown) | undefined;\n readonly onDownload?: (() => unknown) | undefined;\n readonly onOpenInNewTab?: (() => unknown) | undefined;\n readonly onClose?: (() => unknown) | undefined;\n /** Prefix of the `data-testid` of the bar and its actions (`viewer-share`, `viewer-more`, and `viewer-refresh` for the menu's Reload, ...). */\n readonly testId?: string | undefined;\n readonly titleTestId?: string | undefined;\n /** Quiet one-row bar with secondary actions in the menu. False retains the former visible Share/fullscreen/close layout. */\n readonly compactHeader?: boolean | undefined;\n readonly className?: string | undefined;\n readonly children?: ReactNode | undefined;\n}\n\n\n/** Narrow panes drop optional controls. */\nconst TIGHT_WIDTH = { touch: 440, pointer: 340 };\n\n/** How much room the bar has, for a pane that wants to drop optional controls (the zoom percentage, for example) on a narrow panel. */\nexport interface ViewerBarSpace { readonly tight: boolean }\nconst ViewerBarContext = createContext<ViewerBarSpace>({ tight: false });\nexport const useViewerBar = (): ViewerBarSpace => useContext(ViewerBarContext);\nconst NOTICE_MS = 2600;\n\n/**\n * One choice of a mode toggle, for example Rich | Source. Icon only: `label` is the accessible name and the tooltip, `text` the short word\n * for hosts that want it (shown when there is no icon). A disabled choice shows `reason` as its tooltip.\n */\nexport interface ViewerToggleOption<T extends string> { readonly id: T; readonly label: string; readonly text: string; readonly icon?: ReactNode; readonly disabled?: boolean; readonly reason?: string | undefined; readonly testId?: string }\n\n/** A segmented control over the consumer's outlined ToggleGroup: the pressed segment is filled with a full-contrast icon, the others muted. */\nexport function ViewerToggle<T extends string>({ label, value, options, onChange }: { readonly label: string; readonly value: T; readonly options: readonly ViewerToggleOption<T>[]; readonly onChange: (id: T) => void }) {\n return <ToggleGroup aria-label={label} variant=\"outline\" size=\"sm\" spacing={1} value={[value]} onValueChange={values => {\n const selected = options.find(option => option.id === values[0] && !option.disabled);\n if (selected && selected.id !== value) onChange(selected.id);\n }}>\n {options.map(option => <ToggleGroupItem key={option.id} value={option.id} aria-label={option.label} title={option.disabled && option.reason ? option.reason : option.label} disabled={option.disabled}\n className=\"disabled:pointer-events-auto disabled:cursor-not-allowed\" data-testid={option.testId} data-mode={option.id}>\n <span className={cn('inline-flex items-center', value !== option.id && 'text-muted-foreground')}>{option.icon ?? option.text}</span></ToggleGroupItem>)}\n </ToggleGroup>;\n}\n\n/** The copy helper throws this when the browser allowed no automatic copy at all; the person copies by hand instead. */\nconst needsManualCopy = (error: unknown): error is ManualCopyError => (error as { name?: string } | null)?.name === 'ManualCopyError' && typeof (error as { text?: unknown }).text === 'string';\n\n/** Last resort for Copy and Share: the text, already selected, so Ctrl/Cmd+C or the long-press menu finishes the job. */\nfunction ManualCopy({ testId, link, text, onClose, labels }: { readonly testId: string; readonly link: boolean; readonly text: string; readonly onClose: () => void; readonly labels: ViewerLabels }) {\n const field = useRef<HTMLTextAreaElement>(null);\n return <Dialog open onOpenChange={open => { if (!open) onClose(); }}>\n <DialogContent showCloseButton={false} className=\"boring-viewer\" data-testid={`${testId}-manual-copy`} initialFocus={field}>\n <DialogHeader><DialogTitle>{labels.manualCopy(link)}</DialogTitle></DialogHeader>\n <Textarea ref={field} readOnly rows={Math.min(6, Math.max(2, Math.ceil(text.length / 48)))} value={text} data-testid={`${testId}-manual-copy-text`} onFocus={event => event.currentTarget.select()} />\n <div className=\"flex justify-end\"><Button variant=\"ghost\" size=\"sm\" data-testid={`${testId}-manual-copy-close`} onClick={onClose}>{labels.done}</Button></div>\n </DialogContent>\n </Dialog>;\n}\n\n/** Dot colours: default shadcn roles only. */\nconst DOTS: Record<ViewerTone, string> = {\n neutral: 'fill-muted-foreground',\n success: 'fill-primary',\n warning: 'fill-muted-foreground',\n danger: 'fill-destructive',\n};\n\n/** The save status as a coloured dot; the label is the tooltip and the screen-reader text, and is shown only when the person must act (`danger`). */\nfunction StatusMark({ status, testId }: { readonly status: ViewerStatus; readonly testId: string }) {\n const tone = status.tone ?? 'neutral';\n return <span role=\"status\" aria-label={status.label} title={status.label} data-testid={`${testId}-status`} data-tone={tone} data-kind={status.kind}\n className=\"inline-flex h-6 min-w-6 shrink-0 cursor-default items-center justify-center gap-1.5 text-xs\">\n <CircleIcon aria-hidden=\"true\" strokeWidth={0} className={cn('size-2 shrink-0', DOTS[tone])} />\n <span data-testid={`${testId}-status-text`} className={tone === 'danger' ? 'max-w-48 truncate text-destructive' : 'sr-only'}>{status.label}</span>\n </span>;\n}\n\n/** The one top bar of every viewer, always a single row: title and facts, viewer controls, a status dot when something needs attention, Share, full screen, Close and the \"…\" menu. */\nexport function ViewerFrame({ title, subtitle, status, showSavedStatus = false, target, revision, headerStart, controls, mainControl, menu, menuContent, actions, labels: ownLabels, icons: ownIcons, onRefresh, onShare, onCopy, onDownload, onOpenInNewTab, onClose, testId = 'viewer', titleTestId, compactHeader = true, className, children }: ViewerFrameProps) {\n const bar = useRef<HTMLElement>(null);\n const phone = usePhone(), coarse = useMedia('(pointer: coarse)');\n const tightWidth = phone || coarse ? TIGHT_WIDTH.touch : TIGHT_WIDTH.pointer;\n const [space, setSpace] = useState<ViewerBarSpace>({ tight: false });\n const [busy, setBusy] = useState<'refresh' | 'share' | undefined>();\n const [notice, setNotice] = useState<{ readonly text: string; readonly tone: ViewerTone } | undefined>();\n const [copied, setCopied] = useState(false);\n const [manual, setManual] = useState<{ readonly text: string; readonly link: boolean } | undefined>();\n const host = useViewerWindow();\n const { labels, icons } = useViewerText(ownLabels, ownIcons);\n useLayoutEffect(() => {\n const element = bar.current;\n if (!element || typeof ResizeObserver === 'undefined') return;\n const measure = () => {\n const width = element.getBoundingClientRect().width;\n const next = { tight: width > 0 && width < tightWidth };\n setSpace(now => now.tight === next.tight ? now : next);\n };\n measure();\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, [host?.renderHeader, tightWidth]);\n useEffect(() => {\n if (!notice) return;\n const timer = setTimeout(() => setNotice(undefined), NOTICE_MS);\n return () => clearTimeout(timer);\n }, [notice]);\n\n // A result after unmount updates nothing: React ignores a state update on an unmounted component.\n const guarded = useCallback(async (kind: 'refresh' | 'share', run: () => unknown, failed: string) => {\n setBusy(kind);\n setNotice(undefined);\n try { return await run(); }\n catch (error) { if (needsManualCopy(error)) setManual({ text: error.text, link: kind === 'share' }); else setNotice({ text: failed, tone: 'danger' }); return undefined; }\n finally { setBusy(undefined); }\n }, []);\n const refresh = () => { if (onRefresh) void guarded('refresh', onRefresh, labels.refreshFailed); };\n const share = () => {\n if (!onShare) return;\n void guarded('share', () => onShare({ title, target, revision }), labels.shareFailed).then(result => {\n if (result === 'copied') setNotice({ text: labels.linkCopied, tone: 'success' });\n else if (result === 'shared') setNotice({ text: labels.shared, tone: 'success' });\n });\n };\n const copy = () => {\n void Promise.resolve().then(onCopy).then(() => { setCopied(true); setNotice({ text: labels.copied, tone: 'success' }); setTimeout(() => setCopied(false), 1500); })\n .catch(error => { if (needsManualCopy(error)) setManual({ text: error.text, link: false }); else setNotice({ text: labels.copyFailed, tone: 'danger' }); });\n };\n const run = (action: (() => unknown) | undefined) => { void Promise.resolve().then(action).catch(() => setNotice({ text: labels.actionFailed, tone: 'danger' })); };\n // The host's actions: the window's (every viewer in the panel), then this frame's. Each runs through `run`, so a failure shows as a notice.\n const hostActions = [...host?.actions ?? [], ...actions ?? []].map(action => ({ ...action, onSelect: () => run(action.onSelect) }));\n\n const reading = status?.kind === 'read-only' || status?.label === labels.readOnly;\n const menuItems: ViewerMenuItem[] = ([\n ...(compactHeader && onShare ? [{ id: 'share', label: labels.share, icon: <icons.share className=\"size-4\" />, onSelect: share }] : []),\n ...(compactHeader && host?.onFullscreenChange ? [{ id: 'fullscreen', label: host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen, icon: <icons.enterFullscreen className=\"size-4\" />, onSelect: () => host.onFullscreenChange!(!host.fullscreen) }] : []),\n ...(onRefresh ? [{ id: 'refresh', separatorBefore: true, label: labels.reload, icon: <RefreshCwIcon className=\"size-4\" />, disabled: busy === 'refresh', onSelect: refresh }] : []),\n ...(onCopy ? [{ id: 'copy', label: labels.copy, icon: copied ? <CheckIcon className=\"size-4\" /> : <CopyIcon className=\"size-4\" />, onSelect: copy }] : []),\n ...(onDownload ? [{ id: 'download', label: labels.download, icon: <DownloadIcon className=\"size-4\" />, onSelect: () => run(onDownload) }] : []),\n ...(onOpenInNewTab ? [{ id: 'open', label: labels.openInNewTab, icon: <ExternalLinkIcon className=\"size-4\" />, onSelect: () => run(onOpenInNewTab) }] : []),\n ...(menu ?? []).map((item, index) => ({ ...item, separatorBefore: index === 0 || item.separatorBefore })),\n ...menuActions(hostActions).map((action, index) => ({ ...actionMenuItem(action, testId), separatorBefore: index === 0 })),\n ] as ViewerMenuItem[]).map(entry => ({ ...entry, testId: entry.testId ?? `${testId}-${entry.id}` }));\n\n const stacked = !compactHeader && host?.headerLayout === 'stacked';\n const toolbar = controls && <div data-testid={`${testId}-controls`} className={cn('flex shrink-0 items-center gap-1', compactHeader && 'max-md:hidden', stacked && 'flex-wrap px-3 py-2')}>{controls}</div>;\n const titlebar = <>\n {compactHeader && !headerStart && onClose && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.close} title={labels.close} data-testid={`${testId}-close`} onClick={() => run(onClose)}><ArrowLeftIcon data-icon=\"inline-start\" className=\"md:hidden\" aria-hidden=\"true\" /><icons.close data-icon=\"inline-start\" className=\"hidden md:block\" aria-hidden=\"true\" /></Button>}\n {headerStart}\n <div className=\"group/title relative min-w-0 flex-1 basis-0 text-left\">\n <h2 data-testid={titleTestId ?? `${testId}-title`} title={title} className=\"m-0 truncate text-sm leading-5 font-semibold tracking-tight\">{title}</h2>\n {(subtitle || reading) && <p data-testid={`${testId}-subtitle`} className={cn('m-0 flex min-w-0 items-center gap-x-1.5 text-xs leading-4 whitespace-nowrap text-muted-foreground', compactHeader ? 'hidden group-hover/title:flex max-md:hidden' : 'overflow-hidden')}>{subtitle}{subtitle && reading && <span aria-hidden=\"true\">·</span>}{reading && <span data-testid={`${testId}-readonly`}>{labels.readOnly}</span>}</p>}\n </div>\n {!stacked && toolbar}\n {mainControl && <div data-testid={`${testId}-main`} className=\"flex shrink-0 items-center\">{mainControl}</div>}\n {status && !reading && (showSavedStatus || status.kind !== 'saved' && status.label !== labels.saved) && <StatusMark status={status} testId={testId} />}\n <div className=\"flex shrink-0 items-center gap-0.5\">\n {headerActions(hostActions).map(action => <ActionButton key={action.id} action={action} testId={testId} size=\"icon-sm\" />)}\n {!compactHeader && onShare && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.share} title={labels.share} data-testid={`${testId}-share`} disabled={busy === 'share'} onClick={share}><icons.share data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {(menuItems.length > 0 || controls || menuContent) && <ViewerActions label={labels.moreActions} testId={`${testId}-more`} icon={<icons.more data-icon=\"inline-start\" aria-hidden=\"true\" />} items={menuItems}>{menuContent}{menuContent && menuItems.length > 0 && <DropdownMenuSeparator />}{compactHeader && controls && <div className=\"boring-viewer flex flex-wrap items-center gap-2 p-2 md:hidden\">{controls}</div>}</ViewerActions>}\n {!compactHeader && host?.onFullscreenChange && <Toggle size=\"sm\" aria-label={host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen} title={host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen} data-testid={`${testId}-fullscreen`} pressed={host.fullscreen === true}\n onPressedChange={pressed => host.onFullscreenChange!(pressed)}>{host.fullscreen ? <icons.exitFullscreen data-icon=\"inline-start\" aria-hidden=\"true\" /> : <icons.enterFullscreen data-icon=\"inline-start\" aria-hidden=\"true\" />}</Toggle>}\n {(!compactHeader || headerStart) && onClose && <Button variant=\"ghost\" size=\"icon-sm\" className={compactHeader ? 'max-md:hidden' : undefined} aria-label={labels.close} title={labels.close} data-testid={`${testId}-close`} onClick={() => run(onClose)}><icons.close data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n </div>\n </>;\n const header = <header ref={bar} data-testid={`${testId}-bar`} data-layout={stacked ? 'stacked' : undefined}\n className={cn('relative flex shrink-0', stacked ? 'flex-col' : 'h-13 flex-nowrap items-center gap-x-1.5 px-2 md:px-3')}>\n {stacked ? <><div data-testid={`${testId}-titlebar`} className=\"flex min-h-14 min-w-0 items-center gap-x-1.5 px-3 py-1.5\">{titlebar}</div>{toolbar}</> : titlebar}\n {notice && <Badge role=\"status\" aria-live=\"polite\" data-testid={`${testId}-notice`} data-tone={notice.tone} variant={notice.tone === 'danger' ? 'destructive' : 'secondary'}>{notice.text}</Badge>}\n {manual && <ManualCopy testId={testId} link={manual.link} text={manual.text} labels={labels} onClose={() => setManual(undefined)} />}\n </header>;\n return <ViewerBarContext.Provider value={space}>\n <section data-boring=\"viewer-frame\" data-testid={`${testId}-frame`} data-tight={space.tight || undefined}\n className={cn('boring-viewer isolate flex h-full min-h-0 min-w-0 flex-col bg-background text-foreground', className)}>\n {host?.renderHeader ? host.renderHeader(header) : header}\n <div data-testid={`${testId}-body`} className=\"flex min-h-0 min-w-0 flex-1 flex-col\">{children}</div>\n </section>\n </ViewerBarContext.Provider>;\n}\n",
51
51
  "type": "registry:component",
52
52
  "target": "components/viewer-frame/viewer-frame.tsx"
53
53
  },
54
54
  {
55
55
  "path": "registry/viewer-frame/viewer-window.ts",
56
- "content": "/*\n * The window a viewer sits in, and the viewers' words and icons as plain data. A host passes a partial `labels` (and `icons`) object to\n * `ViewerWindowProvider` (every viewer below reads it) or to one `ViewerFrame`; what it leaves out keeps the default below.\n */\nimport { createContext, useContext, useMemo } from 'react';\nimport { EllipsisIcon, Maximize2Icon, Minimize2Icon, Share2Icon, XIcon } from 'lucide-react';\nimport type { ReactNode } from 'react';\nimport type { MarkdownEditorProps } from '@hachej/boring-ui-kit/markdown-editor';\nimport type { BlockAction, BlockIcon } from '../button/actions';\nimport { withDefaults } from '../utils/utils';\nimport { defaultVersionHistoryLabels } from '../version-history/version-history';\n\nexport const defaultViewerLabels = {\n share: 'Share',\n moreActions: 'More actions',\n reload: 'Reload',\n copy: 'Copy',\n download: 'Download',\n openInNewTab: 'Open in new tab',\n enterFullscreen: 'Enter full screen',\n exitFullscreen: 'Exit full screen',\n close: 'Close',\n readOnly: 'Read-only',\n refreshFailed: 'Refresh failed',\n shareFailed: 'Could not share',\n linkCopied: 'Link copied',\n shared: 'Shared',\n copied: 'Copied',\n copyFailed: 'Could not copy',\n actionFailed: 'The action failed',\n /** Shown when the browser allowed no automatic copy: `link` is true for Share, false for Copy. */\n manualCopy: (link: boolean) => `This browser blocked automatic copying. Copy the ${link ? 'link' : 'text'} below.`,\n done: 'Done',\n ...defaultVersionHistoryLabels,\n versionViewOnly: 'Viewing an older version. Restore is not available in this viewer.',\n latest: 'Latest',\n loading: 'Loading…',\n // Save status\n saved: 'Saved',\n unsaved: 'Unsaved',\n saving: 'Saving…',\n saveUnconfirmed: 'Save unconfirmed',\n changedElsewhere: 'Changed elsewhere',\n closed: 'Closed',\n save: 'Save',\n checkSave: 'Check save outcome',\n abandon: 'Abandon and refresh, keeping my draft',\n discardLocal: 'Discard local edits',\n // Modes\n view: 'View',\n preview: 'Preview',\n source: 'Source',\n htmlPreview: 'HTML preview',\n htmlSource: 'HTML source',\n editorMode: 'Editor mode',\n rich: 'Rich',\n richText: 'Rich text',\n markdownSource: 'Markdown source',\n formatted: 'Formatted',\n markdown: 'Markdown',\n formatting: 'Formatting',\n showOutline: 'Show outline',\n /** Tooltip of the disabled Text mode: what the rich editor could not keep exactly. */\n richUnavailable: (reasons: string) => `Formatted is off: this document contains ${reasons}, which it could not keep exactly`,\n document: 'Document',\n // Images and PDFs\n zoom: 'Zoom',\n zoomIn: 'Zoom in',\n zoomOut: 'Zoom out',\n zoomLevel: (percent: string) => `Zoom ${percent}, fit to view`,\n fitToView: 'Fit to view',\n actualSize: 'Actual size',\n noPreview: 'No preview available. You can still download this file.',\n imageUnreadable: 'This file could not be read as an image.',\n imageUnsupported: (mediaType: string) => `This image type (${mediaType || 'unknown'}) cannot be shown here. You can still download it.`,\n imageStage: (name: string) => `${name} image`,\n pdfUnsupported: 'This browser cannot show PDFs inline.',\n pdfDocument: (name: string) => `${name} pages`,\n /** The page indicator of the drawn pages, for example `3 / 12`. */\n pdfPage: (current: number, count: number) => `${current} / ${count}`,\n pdfHint: (name: string, canOpen: boolean) => `Download ${name} to read it in a PDF app${canOpen ? ', or open it in a new tab' : ''}.`,\n notSaved: (reason: string) => `Not saved: ${reason}`,\n saveFailedReason: 'the save failed',\n changedElsewhereReason: 'changed elsewhere',\n operationFailed: 'The document operation failed. Your source has been retained.',\n};\nexport type ViewerLabels = typeof defaultViewerLabels;\n\nexport const defaultViewerIcons = {\n share: Share2Icon as BlockIcon,\n more: EllipsisIcon as BlockIcon,\n close: XIcon as BlockIcon,\n enterFullscreen: Maximize2Icon as BlockIcon,\n exitFullscreen: Minimize2Icon as BlockIcon,\n};\nexport type ViewerIcons = typeof defaultViewerIcons;\n\n/**\n * The window a viewer sits in, when the host lays viewers out in a panel (pi-app's `AgentWorkspace`, the ambient window). With\n * `onFullscreenChange`, every `ViewerFrame` below shows an \"Enter full screen\" / \"Exit full screen\" button; `actions` adds the host's\n * actions to every viewer bar (`header` ones as buttons, `menu` ones in its \"…\" menu, test ids `<frame testId>-<id>`); `labels` and\n * `icons` reach every viewer below. Without a provider nothing changes.\n */\nexport interface ViewerWindow {\n /** Markdown autosave policy for every pane below. Defaults to true, with a 1200ms idle delay. */\n readonly autosave?: MarkdownEditorProps['autosave'];\n /** Place the existing bar in a host-owned shared header (for example with createPortal). Default: above the viewer body. */\n readonly renderHeader?: ((header: ReactNode) => ReactNode) | undefined;\n /** `stacked` keeps the title and window actions above a separate content toolbar. Default: `inline`, one row. */\n readonly headerLayout?: 'inline' | 'stacked' | undefined;\n readonly fullscreen?: boolean | undefined;\n readonly onFullscreenChange?: ((next: boolean) => void) | undefined;\n readonly actions?: readonly BlockAction[] | undefined;\n readonly labels?: Partial<ViewerLabels> | undefined;\n readonly icons?: Partial<ViewerIcons> | undefined;\n}\nconst ViewerWindowContext = createContext<ViewerWindow | undefined>(undefined);\nexport const ViewerWindowProvider = ViewerWindowContext.Provider;\nexport const useViewerWindow = (): ViewerWindow | undefined => useContext(ViewerWindowContext);\n\n/** The viewers' labels and icons: the defaults, then the window's, then the frame's own. */\nexport function useViewerText(labels?: Partial<ViewerLabels>, icons?: Partial<ViewerIcons>): { readonly labels: ViewerLabels; readonly icons: ViewerIcons } {\n const host = useViewerWindow();\n return useMemo(() => ({ labels: withDefaults(defaultViewerLabels, host?.labels, labels), icons: withDefaults(defaultViewerIcons, host?.icons, icons) }), [host?.labels, host?.icons, labels, icons]);\n}\n",
56
+ "content": "/*\n * The window a viewer sits in, and the viewers' words and icons as plain data. A host passes a partial `labels` (and `icons`) object to\n * `ViewerWindowProvider` (every viewer below reads it) or to one `ViewerFrame`; what it leaves out keeps the default below.\n */\nimport { createContext, useContext, useMemo } from 'react';\nimport { EllipsisIcon, Maximize2Icon, Minimize2Icon, Share2Icon, XIcon } from '../_shared/icons';\nimport type { ReactNode } from 'react';\nimport type { MarkdownEditorProps } from '@hachej/boring-ui-kit/markdown-editor';\nimport type { BlockAction, BlockIcon } from '../button/actions';\nimport { withDefaults } from '../utils/utils';\nimport { defaultVersionHistoryLabels } from '../version-history/version-history';\n\nexport const defaultViewerLabels = {\n share: 'Share',\n moreActions: 'More actions',\n reload: 'Reload',\n copy: 'Copy',\n download: 'Download',\n openInNewTab: 'Open in new tab',\n enterFullscreen: 'Enter full screen',\n exitFullscreen: 'Exit full screen',\n close: 'Close',\n readOnly: 'Read-only',\n refreshFailed: 'Refresh failed',\n shareFailed: 'Could not share',\n linkCopied: 'Link copied',\n shared: 'Shared',\n copied: 'Copied',\n copyFailed: 'Could not copy',\n actionFailed: 'The action failed',\n /** Shown when the browser allowed no automatic copy: `link` is true for Share, false for Copy. */\n manualCopy: (link: boolean) => `This browser blocked automatic copying. Copy the ${link ? 'link' : 'text'} below.`,\n done: 'Done',\n ...defaultVersionHistoryLabels,\n versionViewOnly: 'Viewing an older version. Restore is not available in this viewer.',\n latest: 'Latest',\n loading: 'Loading…',\n // Save status\n saved: 'Saved',\n unsaved: 'Unsaved',\n saving: 'Saving…',\n saveUnconfirmed: 'Save unconfirmed',\n changedElsewhere: 'Changed elsewhere',\n closed: 'Closed',\n save: 'Save',\n checkSave: 'Check save outcome',\n abandon: 'Abandon and refresh, keeping my draft',\n discardLocal: 'Discard local edits',\n // Modes\n view: 'View',\n preview: 'Preview',\n source: 'Source',\n htmlPreview: 'HTML preview',\n htmlSource: 'HTML source',\n editorMode: 'Editor mode',\n rich: 'Rich',\n richText: 'Rich text',\n markdownSource: 'Markdown source',\n formatted: 'Formatted',\n markdown: 'Markdown',\n formatting: 'Formatting',\n showOutline: 'Show outline',\n /** Tooltip of the disabled Text mode: what the rich editor could not keep exactly. */\n richUnavailable: (reasons: string) => `Formatted is off: this document contains ${reasons}, which it could not keep exactly`,\n document: 'Document',\n // Images and PDFs\n zoom: 'Zoom',\n zoomIn: 'Zoom in',\n zoomOut: 'Zoom out',\n zoomLevel: (percent: string) => `Zoom ${percent}, fit to view`,\n fitToView: 'Fit to view',\n actualSize: 'Actual size',\n noPreview: 'No preview available. You can still download this file.',\n imageUnreadable: 'This file could not be read as an image.',\n imageUnsupported: (mediaType: string) => `This image type (${mediaType || 'unknown'}) cannot be shown here. You can still download it.`,\n imageStage: (name: string) => `${name} image`,\n pdfUnsupported: 'This browser cannot show PDFs inline.',\n pdfDocument: (name: string) => `${name} pages`,\n /** The page indicator of the drawn pages, for example `3 / 12`. */\n pdfPage: (current: number, count: number) => `${current} / ${count}`,\n pdfHint: (name: string, canOpen: boolean) => `Download ${name} to read it in a PDF app${canOpen ? ', or open it in a new tab' : ''}.`,\n notSaved: (reason: string) => `Not saved: ${reason}`,\n saveFailedReason: 'the save failed',\n changedElsewhereReason: 'changed elsewhere',\n operationFailed: 'The document operation failed. Your source has been retained.',\n};\nexport type ViewerLabels = typeof defaultViewerLabels;\n\nexport const defaultViewerIcons = {\n share: Share2Icon as BlockIcon,\n more: EllipsisIcon as BlockIcon,\n close: XIcon as BlockIcon,\n enterFullscreen: Maximize2Icon as BlockIcon,\n exitFullscreen: Minimize2Icon as BlockIcon,\n};\nexport type ViewerIcons = typeof defaultViewerIcons;\n\n/**\n * The window a viewer sits in, when the host lays viewers out in a panel (pi-app's `AgentWorkspace`, the ambient window). With\n * `onFullscreenChange`, every `ViewerFrame` below shows an \"Enter full screen\" / \"Exit full screen\" button; `actions` adds the host's\n * actions to every viewer bar (`header` ones as buttons, `menu` ones in its \"…\" menu, test ids `<frame testId>-<id>`); `labels` and\n * `icons` reach every viewer below. Without a provider nothing changes.\n */\nexport interface ViewerWindow {\n /** Markdown autosave policy for every pane below. Defaults to true, with a 1200ms idle delay. */\n readonly autosave?: MarkdownEditorProps['autosave'];\n /** Place the existing bar in a host-owned shared header (for example with createPortal). Default: above the viewer body. */\n readonly renderHeader?: ((header: ReactNode) => ReactNode) | undefined;\n /** `stacked` keeps the title and window actions above a separate content toolbar. Default: `inline`, one row. */\n readonly headerLayout?: 'inline' | 'stacked' | undefined;\n readonly fullscreen?: boolean | undefined;\n readonly onFullscreenChange?: ((next: boolean) => void) | undefined;\n readonly actions?: readonly BlockAction[] | undefined;\n readonly labels?: Partial<ViewerLabels> | undefined;\n readonly icons?: Partial<ViewerIcons> | undefined;\n}\nconst ViewerWindowContext = createContext<ViewerWindow | undefined>(undefined);\nexport const ViewerWindowProvider = ViewerWindowContext.Provider;\nexport const useViewerWindow = (): ViewerWindow | undefined => useContext(ViewerWindowContext);\n\n/** The viewers' labels and icons: the defaults, then the window's, then the frame's own. */\nexport function useViewerText(labels?: Partial<ViewerLabels>, icons?: Partial<ViewerIcons>): { readonly labels: ViewerLabels; readonly icons: ViewerIcons } {\n const host = useViewerWindow();\n return useMemo(() => ({ labels: withDefaults(defaultViewerLabels, host?.labels, labels), icons: withDefaults(defaultViewerIcons, host?.icons, icons) }), [host?.labels, host?.icons, labels, icons]);\n}\n",
57
57
  "type": "registry:lib",
58
58
  "target": "components/viewer-frame/viewer-window.ts"
59
59
  },
@@ -4,14 +4,14 @@
4
4
  "title": "HTML viewer",
5
5
  "description": "The HTML kind: `htmlKind` (match, loader) and `HtmlPane`, the passive HTML preview (sandboxed when the host opts in) and source editor, in the viewer frame. The pane loads the first time an HTML file opens.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.57",
8
- "react@^19.0.0",
9
- "lucide-react@^1.51.0"
7
+ "@hachej/boring-ui-kit@^0.4.59",
8
+ "react@^19.0.0"
10
9
  ],
11
10
  "registryDependencies": [
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/viewer-frame.json",
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/utils.json",
14
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/button.json"
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/viewer-frame.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/utils.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/button.json",
14
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
15
15
  ],
16
16
  "files": [
17
17
  {
@@ -22,7 +22,7 @@
22
22
  },
23
23
  {
24
24
  "path": "registry/viewer-html/html-pane.tsx",
25
- "content": "'use client';\n\nimport { useMemo, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { CodeXmlIcon, EyeIcon, RotateCcwIcon, SaveIcon, SearchCheckIcon } from 'lucide-react';\nimport { HtmlViewer } from '@hachej/boring-ui-kit/html-viewer';\nimport type { HtmlController } from '@hachej/boring-ui-kit/html';\nimport { Button } from '@/components/ui/button';\nimport { editorControls } from '../button/editor-controls';\nimport { downloadFile, fileNameFor } from '../viewer-frame/download';\nimport { copyText } from '../utils/utils';\nimport { InteractiveFrame } from '../viewer-frame/interactive-html';\nimport type { InteractiveHtml } from '../viewer-frame/interactive-html';\nimport { ViewerFrame, ViewerToggle } from '../viewer-frame/viewer-frame';\nimport type { ViewerShare, ViewerStatus } from '../viewer-frame/viewer-frame';\nimport { defaultViewerLabels, useViewerText } from '../viewer-frame/viewer-window';\nimport type { ViewerLabels } from '../viewer-frame/viewer-window';\n\nexport type HtmlPaneMode = 'preview' | 'source';\n\nexport interface HtmlPaneProps {\n /** Borrowed: the host owns the controller and disposes it. */\n readonly controller: HtmlController;\n readonly title?: string;\n readonly subtitle?: ReactNode;\n readonly target?: unknown;\n readonly revision?: string;\n readonly initialMode?: HtmlPaneMode;\n /** Reports the person's choice, so a host that remounts the pane (for a new revision) can start it in the same mode. */\n readonly onModeChange?: (mode: HtmlPaneMode) => void;\n /**\n * Host opt-in (off by default): the preview then runs the page's own scripts in a sandboxed frame with an opaque origin and a\n * Content-Security-Policy limited to inline code and `scriptSources`. See registry/README.md. Without it the preview is the passive,\n * script-free rendering of `@hachej/boring-ui-kit/html-viewer`.\n */\n readonly interactive?: InteractiveHtml;\n /** Leading header content, before the title. */\n readonly headerStart?: ReactNode;\n readonly controls?: ReactNode;\n readonly onRefresh?: () => unknown;\n readonly onShare?: ViewerShare;\n readonly onOpenInNewTab?: () => unknown;\n readonly onClose?: () => unknown;\n readonly testId?: string;\n /** Overrides the title's `data-testid` (default `<testId>-title`). */\n readonly titleTestId?: string;\n readonly className?: string;\n /** The pane's and its bar's words, over the window's and `defaultViewerLabels`. */\n readonly labels?: Partial<ViewerLabels> | undefined;\n}\n\nexport function htmlStatus(state: ReturnType<HtmlController['getSnapshot']>, labels: ViewerLabels = defaultViewerLabels): ViewerStatus {\n if (state.lifecycle === 'disposed') return { label: labels.closed };\n if (state.save.kind === 'pending') return { label: labels.saving, tone: 'warning' };\n if (state.save.kind === 'settled' && state.save.result.kind === 'unknown') return { label: labels.saveUnconfirmed, tone: 'danger' };\n if (state.remote !== null || state.save.kind === 'settled' && state.save.result.kind === 'conflict') return { label: labels.changedElsewhere, tone: 'danger' };\n if (state.readOnly) return { label: labels.readOnly, kind: 'read-only' };\n return state.dirty ? { label: labels.unsaved, tone: 'warning' } : { label: labels.saved, tone: 'success', kind: 'saved' };\n}\n\n/** The HTML safe preview and source editor in the standard viewer frame. The preview never runs scripts; a host-enabled interactive preview runs them, in a sandbox. */\nexport function HtmlPane({ controller, labels: ownLabels, title = 'HTML document', subtitle, target, revision, initialMode = 'preview', onModeChange, interactive, headerStart, controls, onRefresh, onShare, onOpenInNewTab, onClose, testId = 'viewer', titleTestId, className }: HtmlPaneProps) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels } = useViewerText(ownLabels);\n const [mode, setModeState] = useState<HtmlPaneMode>(initialMode);\n const setMode = (next: HtmlPaneMode) => { setModeState(next); onModeChange?.(next); };\n const [reload, setReload] = useState(0);\n const [error, setError] = useState<string>();\n const pending = state.save.kind === 'pending';\n const unconfirmed = state.save.kind === 'settled' && state.save.result.kind === 'unknown';\n const disposed = state.lifecycle === 'disposed';\n const perform = (action: () => Promise<{ readonly kind: string; readonly reason?: string }>) => {\n setError(undefined);\n void Promise.resolve().then(action).then(result => { if ((result.kind === 'denied' || result.kind === 'unavailable' || result.kind === 'file_too_large') && result.reason) setError(result.reason); })\n .catch(() => setError(labels.operationFailed));\n };\n const filename = useMemo(() => fileNameFor(title, 'html'), [title]);\n return <ViewerFrame title={title} subtitle={subtitle} headerStart={headerStart} status={htmlStatus(state, labels)} labels={labels} target={target} revision={revision} testId={testId} {...(titleTestId ? { titleTestId } : {})} className={className}\n onRefresh={() => { if (interactive && mode === 'preview') setReload(count => count + 1); return (onRefresh ?? (() => controller.actions.refresh()))(); }} onShare={onShare} onCopy={() => copyText(controller.getSnapshot().text)}\n onDownload={() => downloadFile(filename, controller.getSnapshot().text, 'text/html;charset=utf-8')} onOpenInNewTab={onOpenInNewTab} onClose={onClose}\n controls={<>\n <ViewerToggle label={labels.view} value={mode} onChange={setMode} options={[\n { id: 'preview', label: labels.htmlPreview, text: labels.preview, icon: <EyeIcon data-icon=\"inline-start\" aria-hidden=\"true\" />, testId: `${testId}-mode-preview` },\n { id: 'source', label: labels.htmlSource, text: labels.source, icon: <CodeXmlIcon data-icon=\"inline-start\" aria-hidden=\"true\" />, testId: `${testId}-mode-source` }]} />\n {!state.readOnly && state.dirty && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.save} title={labels.save} data-testid={`${testId}-save`} disabled={disposed || pending || unconfirmed} onClick={() => perform(() => controller.flush(controller.actions.selection()))}><SaveIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {unconfirmed && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.checkSave} title={labels.checkSave} onClick={() => perform(controller.actions.reconcile)}><SearchCheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {unconfirmed && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.abandon} title={labels.abandon} onClick={() => perform(controller.actions.abandon)}><RotateCcwIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {state.remote !== null && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.discardLocal} title={labels.discardLocal} disabled={pending || unconfirmed} onClick={() => perform(controller.actions.discardToRemote)}><RotateCcwIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {controls}\n </>}>\n {error && <p role=\"alert\" className=\"m-0 px-3 py-2\">{error}</p>}\n {interactive && mode === 'preview'\n ? <InteractiveFrame text={state.text} sources={interactive.scriptSources} reload={reload} title={`${title} (running)`} testId={testId} />\n : <HtmlViewer components={editorControls} controller={controller} title={title} header={false} mode={mode === 'source' ? 'source' : 'preview'} onModeChange={next => setMode(next)} className=\"boring-html-pane\" />}\n </ViewerFrame>;\n}\n",
25
+ "content": "'use client';\n\nimport { useMemo, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { CodeXmlIcon, EyeIcon, RotateCcwIcon, SaveIcon, SearchCheckIcon } from '../_shared/icons';\nimport { HtmlViewer } from '@hachej/boring-ui-kit/html-viewer';\nimport type { HtmlController } from '@hachej/boring-ui-kit/html';\nimport { Button } from '@/components/ui/button';\nimport { editorControls } from '../button/editor-controls';\nimport { downloadFile, fileNameFor } from '../viewer-frame/download';\nimport { copyText } from '../utils/utils';\nimport { InteractiveFrame } from '../viewer-frame/interactive-html';\nimport type { InteractiveHtml } from '../viewer-frame/interactive-html';\nimport { ViewerFrame, ViewerToggle } from '../viewer-frame/viewer-frame';\nimport type { ViewerShare, ViewerStatus } from '../viewer-frame/viewer-frame';\nimport { defaultViewerLabels, useViewerText } from '../viewer-frame/viewer-window';\nimport type { ViewerLabels } from '../viewer-frame/viewer-window';\n\nexport type HtmlPaneMode = 'preview' | 'source';\n\nexport interface HtmlPaneProps {\n /** Borrowed: the host owns the controller and disposes it. */\n readonly controller: HtmlController;\n readonly title?: string;\n readonly subtitle?: ReactNode;\n readonly target?: unknown;\n readonly revision?: string;\n readonly initialMode?: HtmlPaneMode;\n /** Reports the person's choice, so a host that remounts the pane (for a new revision) can start it in the same mode. */\n readonly onModeChange?: (mode: HtmlPaneMode) => void;\n /**\n * Host opt-in (off by default): the preview then runs the page's own scripts in a sandboxed frame with an opaque origin and a\n * Content-Security-Policy limited to inline code and `scriptSources`. See registry/README.md. Without it the preview is the passive,\n * script-free rendering of `@hachej/boring-ui-kit/html-viewer`.\n */\n readonly interactive?: InteractiveHtml;\n /** Leading header content, before the title. */\n readonly headerStart?: ReactNode;\n readonly controls?: ReactNode;\n readonly onRefresh?: () => unknown;\n readonly onShare?: ViewerShare;\n readonly onOpenInNewTab?: () => unknown;\n readonly onClose?: () => unknown;\n readonly testId?: string;\n /** Overrides the title's `data-testid` (default `<testId>-title`). */\n readonly titleTestId?: string;\n readonly className?: string;\n /** The pane's and its bar's words, over the window's and `defaultViewerLabels`. */\n readonly labels?: Partial<ViewerLabels> | undefined;\n}\n\nexport function htmlStatus(state: ReturnType<HtmlController['getSnapshot']>, labels: ViewerLabels = defaultViewerLabels): ViewerStatus {\n if (state.lifecycle === 'disposed') return { label: labels.closed };\n if (state.save.kind === 'pending') return { label: labels.saving, tone: 'warning' };\n if (state.save.kind === 'settled' && state.save.result.kind === 'unknown') return { label: labels.saveUnconfirmed, tone: 'danger' };\n if (state.remote !== null || state.save.kind === 'settled' && state.save.result.kind === 'conflict') return { label: labels.changedElsewhere, tone: 'danger' };\n if (state.readOnly) return { label: labels.readOnly, kind: 'read-only' };\n return state.dirty ? { label: labels.unsaved, tone: 'warning' } : { label: labels.saved, tone: 'success', kind: 'saved' };\n}\n\n/** The HTML safe preview and source editor in the standard viewer frame. The preview never runs scripts; a host-enabled interactive preview runs them, in a sandbox. */\nexport function HtmlPane({ controller, labels: ownLabels, title = 'HTML document', subtitle, target, revision, initialMode = 'preview', onModeChange, interactive, headerStart, controls, onRefresh, onShare, onOpenInNewTab, onClose, testId = 'viewer', titleTestId, className }: HtmlPaneProps) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels } = useViewerText(ownLabels);\n const [mode, setModeState] = useState<HtmlPaneMode>(initialMode);\n const setMode = (next: HtmlPaneMode) => { setModeState(next); onModeChange?.(next); };\n const [reload, setReload] = useState(0);\n const [error, setError] = useState<string>();\n const pending = state.save.kind === 'pending';\n const unconfirmed = state.save.kind === 'settled' && state.save.result.kind === 'unknown';\n const disposed = state.lifecycle === 'disposed';\n const perform = (action: () => Promise<{ readonly kind: string; readonly reason?: string }>) => {\n setError(undefined);\n void Promise.resolve().then(action).then(result => { if ((result.kind === 'denied' || result.kind === 'unavailable' || result.kind === 'file_too_large') && result.reason) setError(result.reason); })\n .catch(() => setError(labels.operationFailed));\n };\n const filename = useMemo(() => fileNameFor(title, 'html'), [title]);\n return <ViewerFrame title={title} subtitle={subtitle} headerStart={headerStart} status={htmlStatus(state, labels)} labels={labels} target={target} revision={revision} testId={testId} {...(titleTestId ? { titleTestId } : {})} className={className}\n onRefresh={() => { if (interactive && mode === 'preview') setReload(count => count + 1); return (onRefresh ?? (() => controller.actions.refresh()))(); }} onShare={onShare} onCopy={() => copyText(controller.getSnapshot().text)}\n onDownload={() => downloadFile(filename, controller.getSnapshot().text, 'text/html;charset=utf-8')} onOpenInNewTab={onOpenInNewTab} onClose={onClose}\n controls={<>\n <ViewerToggle label={labels.view} value={mode} onChange={setMode} options={[\n { id: 'preview', label: labels.htmlPreview, text: labels.preview, icon: <EyeIcon data-icon=\"inline-start\" aria-hidden=\"true\" />, testId: `${testId}-mode-preview` },\n { id: 'source', label: labels.htmlSource, text: labels.source, icon: <CodeXmlIcon data-icon=\"inline-start\" aria-hidden=\"true\" />, testId: `${testId}-mode-source` }]} />\n {!state.readOnly && state.dirty && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.save} title={labels.save} data-testid={`${testId}-save`} disabled={disposed || pending || unconfirmed} onClick={() => perform(() => controller.flush(controller.actions.selection()))}><SaveIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {unconfirmed && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.checkSave} title={labels.checkSave} onClick={() => perform(controller.actions.reconcile)}><SearchCheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {unconfirmed && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.abandon} title={labels.abandon} onClick={() => perform(controller.actions.abandon)}><RotateCcwIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {state.remote !== null && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.discardLocal} title={labels.discardLocal} disabled={pending || unconfirmed} onClick={() => perform(controller.actions.discardToRemote)}><RotateCcwIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {controls}\n </>}>\n {error && <p role=\"alert\" className=\"m-0 px-3 py-2\">{error}</p>}\n {interactive && mode === 'preview'\n ? <InteractiveFrame text={state.text} sources={interactive.scriptSources} reload={reload} title={`${title} (running)`} testId={testId} />\n : <HtmlViewer components={editorControls} controller={controller} title={title} header={false} mode={mode === 'source' ? 'source' : 'preview'} onModeChange={next => setMode(next)} className=\"boring-html-pane\" />}\n </ViewerFrame>;\n}\n",
26
26
  "type": "registry:component",
27
27
  "target": "components/viewer-html/html-pane.tsx"
28
28
  }