@hachej/boring-ui-kit 0.3.2 → 0.4.1

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 (57) hide show
  1. package/README.md +46 -18
  2. package/THIRD_PARTY_NOTICES.md +35 -0
  3. package/bin/boring-ui-blocks.mjs +286 -0
  4. package/dist/canvas-editor-native.d.ts +4 -1
  5. package/dist/canvas-editor-native.js +8 -6
  6. package/dist/canvas-editor.js +1 -1
  7. package/dist/canvas-proposals.d.ts +3 -1
  8. package/dist/canvas-proposals.js +4 -3
  9. package/dist/experience-document-viewer.d.ts +3 -0
  10. package/dist/experience-document-viewer.js +6 -5
  11. package/dist/experience.d.ts +3 -0
  12. package/dist/experience.js +2 -1
  13. package/dist/file-tree-view.d.ts +76 -3
  14. package/dist/file-tree-view.js +172 -39
  15. package/dist/file-tree.d.ts +3 -0
  16. package/dist/file-tree.js +17 -2
  17. package/dist/html-viewer.d.ts +4 -0
  18. package/dist/html-viewer.js +5 -3
  19. package/dist/markdown-autosave.d.ts +13 -0
  20. package/dist/markdown-autosave.js +94 -0
  21. package/dist/markdown-editor.d.ts +16 -0
  22. package/dist/markdown-editor.js +81 -17
  23. package/dist/markdown-extensions.js +9 -1
  24. package/dist/markdown-proposal-diff.d.ts +2 -1
  25. package/dist/markdown-proposal-diff.js +3 -2
  26. package/dist/markdown-safety.js +3 -1
  27. package/dist/native-chat.js +1 -1
  28. package/dist/remote-chat.d.ts +18 -0
  29. package/dist/remote-chat.js +149 -7
  30. package/dist/text-buffer.js +3 -2
  31. package/dist/text-draft-controls.d.ts +3 -1
  32. package/dist/text-draft-controls.js +3 -2
  33. package/dist/viewer-components.d.ts +14 -0
  34. package/dist/viewer-components.js +1 -0
  35. package/package.json +8 -4
  36. package/r/app-shell.json +39 -20
  37. package/r/button.json +19 -11
  38. package/r/dock.json +2 -2
  39. package/r/documents.json +17 -14
  40. package/r/feedback-preview.json +8 -2
  41. package/r/feedback.json +46 -325
  42. package/r/file-tree.json +14 -6
  43. package/r/html-viewer.json +8 -42
  44. package/r/markdown-editor.json +38 -87
  45. package/r/pi-ambient.json +15 -8
  46. package/r/pi-app.json +30 -24
  47. package/r/pi-chat.json +101 -70
  48. package/r/pi-workspace.json +7 -5
  49. package/r/provider-setup.json +9 -4
  50. package/r/registry.json +471 -893
  51. package/r/task-list-viewer.json +11 -21
  52. package/r/theme.json +23 -4
  53. package/r/version-history.json +24 -0
  54. package/r/viewers.json +139 -320
  55. package/styles/file-tree.css +41 -42
  56. package/dist/file-tree-shadcn.d.ts +0 -8
  57. package/dist/file-tree-shadcn.js +0 -14
package/r/viewers.json CHANGED
@@ -4,8 +4,8 @@
4
4
  "title": "Viewers",
5
5
  "description": "The standard viewer top bar (title, status, viewer controls, Refresh, Share, Copy, Download, Open in new tab, Close, overflow menu) with Tailwind panes over it: the Markdown editor with a formatting toolbar and prose styling, the passive HTML preview and source editor, an image viewer with zoom and pan, and a PDF viewer. Owned, editable source over the borrowed controllers; no AI SDK.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.3.2",
8
- "@hachej/boring-files@^0.3.2",
7
+ "@hachej/boring-ui-kit@^0.4.1",
8
+ "@hachej/boring-files@^0.4.1",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "marked@^17.0.6",
@@ -21,12 +21,21 @@
21
21
  "@tiptap/extension-highlight@^3.31.4",
22
22
  "@tiptap/extension-bubble-menu@^3.31.4",
23
23
  "@tiptap/extension-floating-menu@^3.31.4",
24
- "lucide-react@^1.51.0"
24
+ "lucide-react@^1.51.0",
25
+ "pdfjs-dist@6.3.289"
25
26
  ],
26
27
  "registryDependencies": [
27
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/theme.json",
28
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/utils.json",
29
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.3.2/r/button.json"
28
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/theme.json",
29
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/utils.json",
30
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/button.json",
31
+ "button",
32
+ "toggle",
33
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.1/r/version-history.json",
34
+ "toggle-group",
35
+ "dialog",
36
+ "textarea",
37
+ "badge",
38
+ "dropdown-menu"
30
39
  ],
31
40
  "files": [
32
41
  {
@@ -37,25 +46,25 @@
37
46
  },
38
47
  {
39
48
  "path": "registry/viewers/html-pane.tsx",
40
- "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 { IconButton } from '../button/button';\nimport { downloadFile, fileNameFor } from './download';\nimport { copyText } from '../utils/utils';\nimport { InteractiveFrame } from './interactive-html';\nimport type { InteractiveHtml } from './interactive-html';\nimport { ViewerFrame, ViewerToggle } from './viewer-frame';\nimport type { ViewerShare, ViewerStatus } from './viewer-frame';\nimport { defaultViewerLabels, useViewerText } from './viewer-window';\nimport type { ViewerLabels } from './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 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 };\n if (state.save.kind === 'settled' && state.save.result.kind === 'unknown') return { label: labels.saveUnconfirmed, tone: 'warning' };\n if (state.remote !== null || state.save.kind === 'settled' && state.save.result.kind === 'conflict') return { label: labels.changedElsewhere, tone: 'warning' };\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, 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} 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 className=\"size-4\" aria-hidden=\"true\" />, testId: `${testId}-mode-preview` },\n { id: 'source', label: labels.htmlSource, text: labels.source, icon: <CodeXmlIcon className=\"size-4\" aria-hidden=\"true\" />, testId: `${testId}-mode-source` }]} />\n {!state.readOnly && state.dirty && <IconButton label={labels.save} variant=\"default\" data-testid={`${testId}-save`} disabled={disposed || pending || unconfirmed} onClick={() => perform(() => controller.flush(controller.actions.selection()))}><SaveIcon className=\"size-4\" aria-hidden=\"true\" /></IconButton>}\n {unconfirmed && <IconButton label={labels.checkSave} onClick={() => perform(controller.actions.reconcile)}><SearchCheckIcon className=\"size-4\" aria-hidden=\"true\" /></IconButton>}\n {unconfirmed && <IconButton label={labels.abandon} onClick={() => perform(controller.actions.abandon)}><RotateCcwIcon className=\"size-4\" aria-hidden=\"true\" /></IconButton>}\n {state.remote !== null && <IconButton label={labels.discardLocal} disabled={pending || unconfirmed} onClick={() => perform(controller.actions.discardToRemote)}><RotateCcwIcon className=\"size-4\" aria-hidden=\"true\" /></IconButton>}\n {controls}\n </>}>\n {error && <p role=\"alert\" className=\"m-0 border-b border-border bg-destructive/10 px-3 py-2 text-xs text-destructive\">{error}</p>}\n {interactive && mode === 'preview'\n ? <InteractiveFrame text={state.text} sources={interactive.scriptSources} reload={reload} title={`${title} (running)`} testId={testId} />\n : <HtmlViewer controller={controller} title={title} header={false} mode={mode === 'source' ? 'source' : 'preview'} onModeChange={next => setMode(next)} className=\"boring-html-pane\" />}\n </ViewerFrame>;\n}\n",
49
+ "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 './download';\nimport { copyText } from '../utils/utils';\nimport { InteractiveFrame } from './interactive-html';\nimport type { InteractiveHtml } from './interactive-html';\nimport { ViewerFrame, ViewerToggle } from './viewer-frame';\nimport type { ViewerShare, ViewerStatus } from './viewer-frame';\nimport { defaultViewerLabels, useViewerText } from './viewer-window';\nimport type { ViewerLabels } from './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 };\n if (state.save.kind === 'settled' && state.save.result.kind === 'unknown') return { label: labels.saveUnconfirmed, tone: 'warning' };\n if (state.remote !== null || state.save.kind === 'settled' && state.save.result.kind === 'conflict') return { label: labels.changedElsewhere, tone: 'warning' };\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",
41
50
  "type": "registry:component",
42
51
  "target": "components/viewers/html-pane.tsx"
43
52
  },
44
53
  {
45
54
  "path": "registry/viewers/image-pane.tsx",
46
- "content": "'use client';\n\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport type { PointerEvent, ReactNode } from 'react';\nimport { MaximizeIcon, ScanIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react';\nimport { Button } from '../button/button';\nimport { downloadFile } from './download';\nimport { formatBytes, useMediaUrl } from './media';\nimport type { MediaSource } from './media';\nimport { cn } from '../utils/utils';\nimport { useViewerBar, ViewerFrame, ViewerIconButton } from './viewer-frame';\nimport type { ViewerShare, ViewerStatus } from './viewer-frame';\nimport { useViewerText } from './viewer-window';\n\nexport const IMAGE_TYPES: Readonly<Record<string, string>> = { 'image/png': 'PNG', 'image/jpeg': 'JPEG', 'image/gif': 'GIF', 'image/webp': 'WebP', 'image/svg+xml': 'SVG' };\nconst STEPS = [0.1, 0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4, 6, 8];\n\nexport interface ImagePaneProps extends MediaSource {\n readonly name: string;\n readonly mediaType: string;\n readonly subtitle?: ReactNode;\n readonly status?: ViewerStatus;\n readonly target?: unknown;\n readonly revision?: string;\n readonly controls?: ReactNode;\n /** Re-reads the file; the host passes the new bytes, blob or URL back in. */\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}\n\n/** Zoom steps and the level; on a narrow bar only the steps stay (the stage also zooms with + - 0). Fit and Actual size are in the \"…\" menu. */\nfunction ZoomControls({ testId, percent, ready, onStep, onFit }: { readonly testId: string; readonly percent: string; readonly ready: boolean; readonly onStep: (direction: 1 | -1) => void; readonly onFit: () => void }) {\n const { tight } = useViewerBar();\n const { labels } = useViewerText();\n return <div role=\"group\" aria-label={labels.zoom} className=\"flex items-center gap-0.5\">\n <ViewerIconButton label={labels.zoomOut} data-testid={`${testId}-zoom-out`} disabled={!ready} onClick={() => onStep(-1)}><ZoomOutIcon className=\"size-4\" aria-hidden=\"true\" /></ViewerIconButton>\n {!tight && <Button variant=\"quiet\" size=\"bar\" data-testid={`${testId}-zoom-level`} aria-label={labels.zoomLevel(percent)} title={labels.fitToView} disabled={!ready} className=\"min-w-10 px-1 tabular-nums\" onClick={onFit}>{percent}</Button>}\n <ViewerIconButton label={labels.zoomIn} data-testid={`${testId}-zoom-in`} disabled={!ready} onClick={() => onStep(1)}><ZoomInIcon className=\"size-4\" aria-hidden=\"true\" /></ViewerIconButton>\n </div>;\n}\n\ntype Zoom = { readonly kind: 'fit' } | { readonly kind: 'scale'; readonly value: number };\n\n/**\n * A read-only image viewer in the standard frame: fit to view by default, zoom, actual size, drag to pan when zoomed, a checkerboard\n * behind transparency. Every type, SVG included, is shown through an `<img>` from an object URL, never as markup.\n */\nexport function ImagePane({ name, mediaType, bytes, blob, url: hostUrl, subtitle, status, target, revision, controls, onRefresh, onShare, onOpenInNewTab, onClose, testId = 'viewer', titleTestId, className }: ImagePaneProps) {\n const source = useMemo<MediaSource>(() => ({ ...(bytes ? { bytes } : {}), ...(blob ? { blob } : {}), ...(hostUrl ? { url: hostUrl } : {}) }), [bytes, blob, hostUrl]);\n const { url, size, blob: made } = useMediaUrl(source, mediaType);\n const supported = mediaType in IMAGE_TYPES;\n const { labels } = useViewerText();\n const [zoom, setZoom] = useState<Zoom>({ kind: 'fit' });\n const [natural, setNatural] = useState<{ width: number; height: number } | undefined>(undefined);\n const [failed, setFailed] = useState(false);\n const [fitScale, setFitScale] = useState(1);\n const stage = useRef<HTMLDivElement>(null);\n const image = useRef<HTMLImageElement>(null);\n const drag = useRef<{ x: number; y: number; left: number; top: number } | undefined>(undefined);\n useEffect(() => { setNatural(undefined); setFailed(false); }, [url]);\n const measure = () => {\n const box = stage.current, img = image.current;\n if (box && img && img.naturalWidth) setFitScale(Math.min(1, (box.clientWidth - 32) / img.naturalWidth, (box.clientHeight - 32) / img.naturalHeight));\n };\n useEffect(() => {\n const box = stage.current;\n if (!box || typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(measure);\n observer.observe(box);\n return () => observer.disconnect();\n }, [url]);\n const current = zoom.kind === 'fit' ? fitScale : zoom.value;\n const step = (direction: 1 | -1) => {\n const now = Number(current.toFixed(3));\n const next = direction > 0 ? STEPS.find(value => value > now + 0.001) : [...STEPS].reverse().find(value => value < now - 0.001);\n if (next) setZoom({ kind: 'scale', value: next });\n };\n const label = IMAGE_TYPES[mediaType] ?? mediaType;\n const facts = [label, natural && `${natural.width} × ${natural.height}`, size !== undefined && formatBytes(size)].filter(Boolean) as string[];\n const panning = zoom.kind === 'scale' && natural !== undefined && (natural.width * zoom.value > (stage.current?.clientWidth ?? 0) || natural.height * zoom.value > (stage.current?.clientHeight ?? 0));\n const begin = (event: PointerEvent<HTMLDivElement>) => {\n const box = stage.current;\n if (!panning || !box || event.button !== 0) return;\n drag.current = { x: event.clientX, y: event.clientY, left: box.scrollLeft, top: box.scrollTop };\n box.setPointerCapture?.(event.pointerId);\n };\n const move = (event: PointerEvent<HTMLDivElement>) => {\n const box = stage.current, start = drag.current;\n if (!box || !start) return;\n box.scrollLeft = start.left - (event.clientX - start.x);\n box.scrollTop = start.top - (event.clientY - start.y);\n };\n const end = () => { drag.current = undefined; };\n const downloadName = name.split('/').pop() || 'image';\n const download = () => { const data = made; if (data) downloadFile(downloadName, data, mediaType); else if (url) { const link = Object.assign(document.createElement('a'), { href: url, download: downloadName }); document.body.append(link); link.click(); link.remove(); } };\n const percent = `${Math.round(current * 100)}%`;\n return <ViewerFrame title={name.split('/').pop() || name} subtitle={<>{subtitle}{subtitle && facts.length > 0 && <span aria-hidden=\"true\">·</span>}{facts.join(' · ')}</>} status={status} target={target} revision={revision} testId={testId} {...(titleTestId ? { titleTestId } : {})} className={className}\n onRefresh={onRefresh} onShare={onShare} onDownload={url ? download : undefined} onOpenInNewTab={onOpenInNewTab ?? (url ? () => { window.open(url, '_blank', 'noopener,noreferrer'); } : undefined)} onClose={onClose}\n menu={[\n { id: 'zoom-fit', label: labels.fitToView, icon: <ScanIcon className=\"size-4\" />, disabled: !natural, onSelect: () => setZoom({ kind: 'fit' }) },\n { id: 'zoom-actual', label: labels.actualSize, icon: <MaximizeIcon className=\"size-4\" />, disabled: !natural, onSelect: () => setZoom({ kind: 'scale', value: 1 }) }]}\n controls={<>\n <ZoomControls testId={testId} percent={percent} ready={Boolean(natural)} onStep={step} onFit={() => setZoom({ kind: 'fit' })} />\n {controls}\n </>}>\n <div ref={stage} data-testid={`${testId}-stage`} data-zoom={zoom.kind === 'fit' ? 'fit' : String(zoom.value)} data-panning={panning || undefined}\n onPointerDown={begin} onPointerMove={move} onPointerUp={end} onPointerCancel={end}\n className={cn('boring-viewer-stage relative min-h-0 flex-1 overflow-auto', panning && 'cursor-grab active:cursor-grabbing')} tabIndex={0} aria-label={labels.imageStage(name)}\n onKeyDown={event => { if (event.key === '+' || event.key === '=') step(1); else if (event.key === '-') step(-1); else if (event.key === '0') setZoom({ kind: 'fit' }); }}>\n {!supported ? <p role=\"alert\" className=\"m-auto p-6 text-center text-sm text-muted-foreground\">{labels.imageUnsupported(mediaType)}</p>\n : failed ? <p role=\"alert\" className=\"m-auto p-6 text-center text-sm text-muted-foreground\">{labels.imageUnreadable}</p>\n : url ? <div className=\"flex min-h-full min-w-full items-center justify-center p-4\">\n <img ref={image} src={url} alt={name} data-testid={`${testId}-image`} draggable={false} decoding=\"async\" referrerPolicy=\"no-referrer\"\n onLoad={event => { setNatural({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight }); measure(); }} onError={() => setFailed(true)}\n style={natural ? { width: natural.width * current, height: natural.height * current, maxWidth: 'none' } : undefined}\n className=\"boring-viewer-checker block shrink-0 select-none rounded-sm shadow-sm outline outline-1 outline-border\" />\n </div> : <p role=\"status\" className=\"m-auto p-6 text-sm text-muted-foreground\">{labels.loading}</p>}\n </div>\n </ViewerFrame>;\n}\n",
55
+ "content": "'use client';\n\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport type { PointerEvent, ReactNode } from 'react';\nimport { MaximizeIcon, ScanIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { downloadFile } from './download';\nimport { formatBytes, useMediaUrl } from './media';\nimport type { MediaSource } from './media';\nimport { cn } from '../utils/utils';\nimport { useViewerBar, ViewerFrame } from './viewer-frame';\nimport type { ViewerShare, ViewerStatus } from './viewer-frame';\nimport { useViewerText } from './viewer-window';\n\nexport const IMAGE_TYPES: Readonly<Record<string, string>> = { 'image/png': 'PNG', 'image/jpeg': 'JPEG', 'image/gif': 'GIF', 'image/webp': 'WebP', 'image/svg+xml': 'SVG' };\nconst STEPS = [0.1, 0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4, 6, 8];\n\nexport interface ImagePaneProps extends MediaSource {\n readonly name: string;\n readonly mediaType: string;\n readonly subtitle?: ReactNode;\n readonly status?: ViewerStatus;\n readonly target?: unknown;\n readonly revision?: string;\n /** Leading header content, before the title. */\n readonly headerStart?: ReactNode;\n readonly controls?: ReactNode;\n /** Re-reads the file; the host passes the new bytes, blob or URL back in. */\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}\n\n/** Zoom steps and the level; on a narrow bar only the steps stay (the stage also zooms with + - 0). Fit and Actual size are in the \"…\" menu. */\nexport function ZoomControls({ testId, percent, ready, onStep, onFit }: { readonly testId: string; readonly percent: string; readonly ready: boolean; readonly onStep: (direction: 1 | -1) => void; readonly onFit: () => void }) {\n const { tight } = useViewerBar();\n const { labels } = useViewerText();\n return <div role=\"group\" aria-label={labels.zoom} className=\"flex items-center gap-0.5\">\n <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.zoomOut} title={labels.zoomOut} data-testid={`${testId}-zoom-out`} disabled={!ready} onClick={() => onStep(-1)}><ZoomOutIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n {!tight && <Button variant=\"ghost\" size=\"sm\" data-testid={`${testId}-zoom-level`} aria-label={labels.zoomLevel(percent)} title={labels.fitToView} disabled={!ready} className=\"min-w-10\" onClick={onFit}>{percent}</Button>}\n <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.zoomIn} title={labels.zoomIn} data-testid={`${testId}-zoom-in`} disabled={!ready} onClick={() => onStep(1)}><ZoomInIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>\n </div>;\n}\n\ntype Zoom = { readonly kind: 'fit' } | { readonly kind: 'scale'; readonly value: number };\n\n/**\n * A read-only image viewer in the standard frame: fit to view by default, zoom, actual size, drag to pan when zoomed, a checkerboard\n * behind transparency. Every type, SVG included, is shown through an `<img>` from an object URL, never as markup.\n */\nexport function ImagePane({ name, mediaType, bytes, blob, url: hostUrl, subtitle, status, target, revision, headerStart, controls, onRefresh, onShare, onOpenInNewTab, onClose, testId = 'viewer', titleTestId, className }: ImagePaneProps) {\n const source = useMemo<MediaSource>(() => ({ ...(bytes ? { bytes } : {}), ...(blob ? { blob } : {}), ...(hostUrl ? { url: hostUrl } : {}) }), [bytes, blob, hostUrl]);\n const { url, size, blob: made } = useMediaUrl(source, mediaType);\n const supported = mediaType in IMAGE_TYPES;\n const { labels } = useViewerText();\n const [zoom, setZoom] = useState<Zoom>({ kind: 'fit' });\n const [natural, setNatural] = useState<{ width: number; height: number } | undefined>(undefined);\n const [failed, setFailed] = useState(false);\n const [fitScale, setFitScale] = useState(1);\n const stage = useRef<HTMLDivElement>(null);\n const image = useRef<HTMLImageElement>(null);\n const drag = useRef<{ x: number; y: number; left: number; top: number } | undefined>(undefined);\n useEffect(() => { setNatural(undefined); setFailed(false); }, [url]);\n const measure = () => {\n const box = stage.current, img = image.current;\n if (box && img && img.naturalWidth) setFitScale(Math.min(1, (box.clientWidth - 32) / img.naturalWidth, (box.clientHeight - 32) / img.naturalHeight));\n };\n useEffect(() => {\n const box = stage.current;\n if (!box || typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(measure);\n observer.observe(box);\n return () => observer.disconnect();\n }, [url]);\n const current = zoom.kind === 'fit' ? fitScale : zoom.value;\n const step = (direction: 1 | -1) => {\n const now = Number(current.toFixed(3));\n const next = direction > 0 ? STEPS.find(value => value > now + 0.001) : [...STEPS].reverse().find(value => value < now - 0.001);\n if (next) setZoom({ kind: 'scale', value: next });\n };\n const label = IMAGE_TYPES[mediaType] ?? mediaType;\n const facts = [label, natural && `${natural.width} × ${natural.height}`, size !== undefined && formatBytes(size)].filter(Boolean) as string[];\n const panning = zoom.kind === 'scale' && natural !== undefined && (natural.width * zoom.value > (stage.current?.clientWidth ?? 0) || natural.height * zoom.value > (stage.current?.clientHeight ?? 0));\n const begin = (event: PointerEvent<HTMLDivElement>) => {\n const box = stage.current;\n if (!panning || !box || event.button !== 0) return;\n drag.current = { x: event.clientX, y: event.clientY, left: box.scrollLeft, top: box.scrollTop };\n box.setPointerCapture?.(event.pointerId);\n };\n const move = (event: PointerEvent<HTMLDivElement>) => {\n const box = stage.current, start = drag.current;\n if (!box || !start) return;\n box.scrollLeft = start.left - (event.clientX - start.x);\n box.scrollTop = start.top - (event.clientY - start.y);\n };\n const end = () => { drag.current = undefined; };\n const downloadName = name.split('/').pop() || 'image';\n const download = () => { const data = made; if (data) downloadFile(downloadName, data, mediaType); else if (url) { const link = Object.assign(document.createElement('a'), { href: url, download: downloadName }); document.body.append(link); link.click(); link.remove(); } };\n const percent = `${Math.round(current * 100)}%`;\n return <ViewerFrame title={name.split('/').pop() || name} subtitle={<>{subtitle}{subtitle && facts.length > 0 && <span aria-hidden=\"true\">·</span>}{facts.join(' · ')}</>} status={status} target={target} revision={revision} headerStart={headerStart} testId={testId} {...(titleTestId ? { titleTestId } : {})} className={className}\n onRefresh={onRefresh} onShare={onShare} onDownload={url ? download : undefined} onOpenInNewTab={onOpenInNewTab ?? (url ? () => { window.open(url, '_blank', 'noopener,noreferrer'); } : undefined)} onClose={onClose}\n menu={[\n { id: 'zoom-fit', label: labels.fitToView, icon: <ScanIcon className=\"size-4\" />, disabled: !natural, onSelect: () => setZoom({ kind: 'fit' }) },\n { id: 'zoom-actual', label: labels.actualSize, icon: <MaximizeIcon className=\"size-4\" />, disabled: !natural, onSelect: () => setZoom({ kind: 'scale', value: 1 }) }]}\n controls={<>\n <ZoomControls testId={testId} percent={percent} ready={Boolean(natural)} onStep={step} onFit={() => setZoom({ kind: 'fit' })} />\n {controls}\n </>}>\n <div ref={stage} data-testid={`${testId}-stage`} data-zoom={zoom.kind === 'fit' ? 'fit' : String(zoom.value)} data-panning={panning || undefined}\n onPointerDown={begin} onPointerMove={move} onPointerUp={end} onPointerCancel={end}\n className={cn('boring-viewer-stage relative min-h-0 flex-1 overflow-auto', panning && 'cursor-grab active:cursor-grabbing')} tabIndex={0} aria-label={labels.imageStage(name)}\n onKeyDown={event => { if (event.key === '+' || event.key === '=') step(1); else if (event.key === '-') step(-1); else if (event.key === '0') setZoom({ kind: 'fit' }); }}>\n {!supported ? <p role=\"alert\" className=\"m-auto p-6 text-center text-sm text-muted-foreground\">{labels.imageUnsupported(mediaType)}</p>\n : failed ? <p role=\"alert\" className=\"m-auto p-6 text-center text-sm text-muted-foreground\">{labels.imageUnreadable}</p>\n : url ? <div className=\"flex min-h-full min-w-full items-center justify-center p-4\">\n <img ref={image} src={url} alt={name} data-testid={`${testId}-image`} draggable={false} decoding=\"async\" referrerPolicy=\"no-referrer\"\n onLoad={event => { setNatural({ width: event.currentTarget.naturalWidth, height: event.currentTarget.naturalHeight }); measure(); }} onError={() => setFailed(true)}\n style={natural ? { width: natural.width * current, height: natural.height * current, maxWidth: 'none' } : undefined}\n className=\"boring-viewer-checker block shrink-0 select-none outline outline-1 outline-border\" />\n </div> : <p role=\"status\" className=\"m-auto p-6 text-sm text-muted-foreground\">{labels.loading}</p>}\n </div>\n </ViewerFrame>;\n}\n",
47
56
  "type": "registry:component",
48
57
  "target": "components/viewers/image-pane.tsx"
49
58
  },
50
59
  {
51
60
  "path": "registry/viewers/interactive-html.tsx",
52
- "content": "'use client';\n\nimport type { ReactNode } from 'react';\n\n/**\n * Host opt-in for running an HTML document's own scripts. `scriptSources` are the HTTPS origins the page may load libraries, styles and fonts\n * from (for example `https://cdnjs.cloudflare.com`). Everything else is closed: no network requests from the page, no frames, no forms.\n * Pinning library versions is the page's responsibility.\n */\nexport interface InteractiveHtml { readonly scriptSources: readonly string[] }\n\n/** An origin only: `https://host` with an optional port, no path, no wildcard, nothing that could add a second CSP directive. */\nconst ORIGIN = /^https:\\/\\/[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(:\\d{1,5})?$/i;\n\nexport function allowedOrigins(sources: readonly string[]): string[] {\n return [...new Set(sources.map(source => source.trim().toLowerCase()).filter(source => ORIGIN.test(source)))];\n}\n\n/** The Content-Security-Policy the running page gets: inline script and style, the allow-listed origins for libraries, nothing else. */\nexport function interactivePolicy(sources: readonly string[]): string {\n const origins = allowedOrigins(sources).join(' ');\n const list = (...extra: string[]) => [...extra, ...(origins ? [origins] : [])].join(' ');\n return [\n \"default-src 'none'\",\n `script-src ${list(\"'unsafe-inline'\")}`,\n `style-src ${list(\"'unsafe-inline'\")}`,\n 'img-src data: blob:',\n `font-src ${list('data:')}`,\n \"connect-src 'none'\",\n \"frame-src 'none'\",\n \"base-uri 'none'\",\n \"form-action 'none'\",\n ].join('; ');\n}\n\n/**\n * The document the frame loads: the policy and a no-referrer rule come first, so they apply before any of the page's own markup is parsed.\n * A second doctype or head in the page is ignored by the parser; a meta policy of the page's own can only tighten this one.\n */\nexport function interactiveDocument(text: string, sources: readonly string[]): string {\n return `<!doctype html><meta http-equiv=\"Content-Security-Policy\" content=\"${interactivePolicy(sources).replaceAll('\"', '&quot;')}\"><meta name=\"referrer\" content=\"no-referrer\">${text}`;\n}\n\n/**\n * The page running in a sandboxed frame: `allow-scripts` only. There is no `allow-same-origin`, so the page has an opaque origin (no cookies,\n * storage or access to this page), and no top navigation, popups, forms, downloads or modals. `allow=\"\"` withholds camera, microphone, location\n * and the other powerful features. Changing `reload` loads the page again from the start.\n */\nexport function InteractiveFrame({ text, sources, reload = 0, title = 'HTML page (running)', testId = 'viewer', children }: {\n readonly text: string;\n readonly sources: readonly string[];\n readonly reload?: number;\n readonly title?: string;\n readonly testId?: string;\n readonly children?: ReactNode;\n}) {\n return <div className=\"boring-html-run flex min-h-0 flex-1 flex-col bg-white\" data-testid={`${testId}-run`}>\n <iframe key={reload} title={title} sandbox=\"allow-scripts\" allow=\"\" referrerPolicy=\"no-referrer\" srcDoc={interactiveDocument(text, sources)}\n data-testid={`${testId}-run-frame`} className=\"size-full min-h-0 flex-1 border-0 bg-white\" />\n {children}\n </div>;\n}\n",
61
+ "content": "'use client';\n\nimport type { ReactNode } from 'react';\n\n/**\n * Host opt-in for running an HTML document's own scripts. `scriptSources` are the HTTPS origins the page may load libraries, styles and fonts\n * from (for example `https://cdnjs.cloudflare.com`). Everything else is closed: no network requests from the page, no frames, no forms.\n * Pinning library versions is the page's responsibility.\n */\nexport interface InteractiveHtml { readonly scriptSources: readonly string[] }\n\n/** An origin only: `https://host` with an optional port, no path, no wildcard, nothing that could add a second CSP directive. */\nconst ORIGIN = /^https:\\/\\/[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(:\\d{1,5})?$/i;\n\nexport function allowedOrigins(sources: readonly string[]): string[] {\n return [...new Set(sources.map(source => source.trim().toLowerCase()).filter(source => ORIGIN.test(source)))];\n}\n\n/** The Content-Security-Policy the running page gets: inline script and style, the allow-listed origins for libraries, nothing else. */\nexport function interactivePolicy(sources: readonly string[]): string {\n const origins = allowedOrigins(sources).join(' ');\n const list = (...extra: string[]) => [...extra, ...(origins ? [origins] : [])].join(' ');\n return [\n \"default-src 'none'\",\n `script-src ${list(\"'unsafe-inline'\")}`,\n `style-src ${list(\"'unsafe-inline'\")}`,\n 'img-src data: blob:',\n `font-src ${list('data:')}`,\n \"connect-src 'none'\",\n \"frame-src 'none'\",\n \"base-uri 'none'\",\n \"form-action 'none'\",\n ].join('; ');\n}\n\n/**\n * The document the frame loads: the policy and a no-referrer rule come first, so they apply before any of the page's own markup is parsed.\n * A second doctype or head in the page is ignored by the parser; a meta policy of the page's own can only tighten this one.\n */\nexport function interactiveDocument(text: string, sources: readonly string[]): string {\n return `<!doctype html><meta http-equiv=\"Content-Security-Policy\" content=\"${interactivePolicy(sources).replaceAll('\"', '&quot;')}\"><meta name=\"referrer\" content=\"no-referrer\">${text}`;\n}\n\n/**\n * The page running in a sandboxed frame: `allow-scripts` only. There is no `allow-same-origin`, so the page has an opaque origin (no cookies,\n * storage or access to this page), and no top navigation, popups, forms, downloads or modals. `allow=\"\"` withholds camera, microphone, location\n * and the other powerful features. Changing `reload` loads the page again from the start.\n */\nexport function InteractiveFrame({ text, sources, reload = 0, title = 'HTML page (running)', testId = 'viewer', children }: {\n readonly text: string;\n readonly sources: readonly string[];\n readonly reload?: number;\n readonly title?: string;\n readonly testId?: string;\n readonly children?: ReactNode;\n}) {\n return <div className=\"boring-html-run flex min-h-0 flex-1 flex-col\" data-testid={`${testId}-run`}>\n <iframe key={reload} title={title} sandbox=\"allow-scripts\" allow=\"\" referrerPolicy=\"no-referrer\" srcDoc={interactiveDocument(text, sources)}\n data-testid={`${testId}-run-frame`} className=\"size-full min-h-0 flex-1\" />\n {children}\n </div>;\n}\n",
53
62
  "type": "registry:component",
54
63
  "target": "components/viewers/interactive-html.tsx"
55
64
  },
56
65
  {
57
66
  "path": "registry/viewers/markdown-pane.tsx",
58
- "content": "'use client';\n\nimport { useMemo, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { CodeXmlIcon, PencilLineIcon, SaveIcon } from 'lucide-react';\nimport { MarkdownEditor } from '@hachej/boring-ui-kit/markdown-editor';\nimport type { MarkdownImageResolver, MarkdownMountedTools, MarkdownRichSafety } from '@hachej/boring-ui-kit/markdown-editor';\nimport type { MarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport { IconButton } from '../button/button';\nimport { downloadFile, fileNameFor } from './download';\nimport { copyText } from '../utils/utils';\nimport { ViewerFrame, ViewerToggle } from './viewer-frame';\nimport type { ViewerShare, ViewerStatus } from './viewer-frame';\nimport { defaultViewerLabels, useViewerText } from './viewer-window';\nimport type { ViewerLabels } from './viewer-window';\n\nexport interface MarkdownPaneProps {\n /** Borrowed: the host owns the controller and disposes it. */\n readonly controller: MarkdownController;\n readonly title?: string;\n readonly subtitle?: ReactNode;\n readonly target?: unknown;\n readonly revision?: string;\n readonly initialMode?: 'rich' | 'source';\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: 'rich' | 'source') => void;\n readonly placeholder?: string;\n /** Extra viewer controls, for example a version switcher. */\n readonly controls?: ReactNode;\n /** Defaults to the controller's refresh: a clean document follows the saved one, unsaved edits are kept and shown as changed elsewhere. */\n readonly onRefresh?: () => unknown;\n readonly onShare?: ViewerShare;\n readonly onOpenInNewTab?: () => unknown;\n readonly onClose?: () => unknown;\n /** Host mapping from an image address in the document to a loadable URL. Images stay inert without it. */\n readonly resolveImage?: MarkdownImageResolver;\n readonly onMountedTools?: (tools: MarkdownMountedTools | null) => void;\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\n/** The status chip for a Markdown controller: Saved, Unsaved, Saving, Changed elsewhere, Read-only. */\nexport function markdownStatus(state: ReturnType<MarkdownController['getSnapshot']>, labels: ViewerLabels = defaultViewerLabels): ViewerStatus {\n if (state.lifecycle === 'disposed') return { label: labels.closed };\n if (state.save.kind === 'pending') return { label: labels.saving };\n if (state.save.kind === 'settled' && state.save.result.kind === 'unknown') return { label: labels.saveUnconfirmed, tone: 'warning' };\n if (state.save.kind === 'settled' && state.save.result.kind === 'conflict' || state.remote !== null) return { label: labels.changedElsewhere, tone: 'warning' };\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 Markdown editor in the standard viewer frame: mode toggle, Save while there are edits, Share, and Reload, Copy and Download under the \"…\" menu. */\nexport function MarkdownPane({ controller, title: givenTitle, labels: ownLabels, subtitle, target, revision, initialMode = 'rich', onModeChange, placeholder, controls, onRefresh, onShare, onOpenInNewTab, onClose, resolveImage, onMountedTools, testId = 'viewer', titleTestId, className }: MarkdownPaneProps) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels } = useViewerText(ownLabels);\n const title = givenTitle ?? labels.document;\n const [requested, setRequestedState] = useState<'rich' | 'source'>(initialMode);\n const setRequested = (next: 'rich' | 'source') => { setRequestedState(next); onModeChange?.(next); };\n const [safety, setSafety] = useState<MarkdownRichSafety>({ safe: true });\n const mode = safety.safe ? requested : 'source';\n const unavailable = state.readOnly || state.lifecycle !== 'active';\n const pending = state.save.kind === 'pending';\n const unconfirmed = state.save.kind === 'settled' && state.save.result.kind === 'unknown';\n const [refused, setRefused] = useState<{ readonly version: number; readonly label: string } | null>(null);\n const save = () => {\n setRefused(null);\n const version = controller.getSnapshot().bufferVersion;\n void controller.flush(controller.actions.selection()).then(result => {\n if (result.kind === 'denied' || result.kind === 'conflict' || result.kind === 'unavailable' || result.kind === 'file_too_large') {\n const reason = result.reason ?? (result.kind === 'conflict' ? labels.changedElsewhereReason : result.kind);\n setRefused({ version, label: labels.notSaved(reason.length > 48 ? `${reason.slice(0, 47)}…` : reason) });\n }\n }, () => setRefused({ version, label: labels.notSaved(labels.saveFailedReason) }));\n };\n const filename = useMemo(() => fileNameFor(title, 'md'), [title]);\n return <ViewerFrame title={title} subtitle={subtitle} status={refused && refused.version === state.bufferVersion && state.save.kind !== 'pending' ? { label: refused.label, tone: 'warning' } : markdownStatus(state, labels)} target={target} labels={labels} revision={revision} testId={testId} {...(titleTestId ? { titleTestId } : {})} className={className}\n onRefresh={onRefresh ?? (() => controller.actions.refresh())} onShare={onShare} onCopy={() => copyText(controller.getSnapshot().text)}\n onDownload={() => downloadFile(filename, controller.getSnapshot().text, 'text/markdown;charset=utf-8')} onOpenInNewTab={onOpenInNewTab} onClose={onClose}\n controls={<>\n <ViewerToggle label={labels.editorMode} value={mode} onChange={setRequested} options={[\n { id: 'rich', label: labels.richText, text: labels.rich, icon: <PencilLineIcon className=\"size-4\" aria-hidden=\"true\" />, disabled: !safety.safe, testId: `${testId}-mode-rich` },\n { id: 'source', label: labels.markdownSource, text: labels.source, icon: <CodeXmlIcon className=\"size-4\" aria-hidden=\"true\" />, testId: `${testId}-mode-source` }]} />\n {!state.readOnly && state.dirty && <IconButton label={labels.save} variant=\"default\" data-testid={`${testId}-save`} disabled={unavailable || pending || unconfirmed} onClick={save}><SaveIcon className=\"size-4\" aria-hidden=\"true\" /></IconButton>}\n {controls}\n </>}>\n <MarkdownEditor controller={controller} title={title} header={false} mode={mode} onModeChange={setRequested} onRichSafety={setSafety}\n {...(placeholder === undefined ? {} : { placeholder })} {...(resolveImage ? { resolveImage } : {})} {...(onMountedTools ? { onMountedTools } : {})} className=\"boring-markdown-editor\" />\n </ViewerFrame>;\n}\n",
67
+ "content": "'use client';\n\nimport { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ReactNode } from 'react';\nimport { CodeXmlIcon, PencilLineIcon, SaveIcon } from 'lucide-react';\nimport { MarkdownEditor } from '@hachej/boring-ui-kit/markdown-editor';\nimport type { MarkdownEditorProps, MarkdownImageResolver, MarkdownMountedTools, MarkdownRichSafety } from '@hachej/boring-ui-kit/markdown-editor';\nimport type { MarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { editorControls } from '../button/editor-controls';\nimport { downloadFile, fileNameFor } from './download';\nimport { copyText } from '../utils/utils';\nimport { ViewerFrame, ViewerToggle } from './viewer-frame';\nimport type { ViewerShare, ViewerStatus } from './viewer-frame';\nimport { defaultViewerLabels, useViewerText, useViewerWindow } from './viewer-window';\nimport type { ViewerLabels } from './viewer-window';\n\nexport interface MarkdownPaneProps {\n /** Borrowed: the host owns the controller and disposes it. */\n readonly controller: MarkdownController;\n readonly title?: string;\n readonly subtitle?: ReactNode;\n readonly target?: unknown;\n readonly revision?: string;\n readonly initialMode?: 'rich' | 'source';\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: 'rich' | 'source') => void;\n readonly placeholder?: string;\n /** Defaults to 1200ms idle autosave; false keeps the Save button. */\n readonly autosave?: MarkdownEditorProps['autosave'];\n /** Registers the flush for owner teardown; this is not a navigation guard. */\n readonly onAutosaveChange?: MarkdownEditorProps['onAutosaveChange'];\n /** Registers with a host navigation guard whose onClose handles refused saves and deliberate discard. */\n readonly onBeforeLeaveChange?: MarkdownEditorProps['onAutosaveChange'];\n /** Pinned by default on desktop. Phones always use the selection bubble. */\n readonly formattingToolbar?: MarkdownEditorProps['formattingToolbar'];\n /** Leading header content, before the title. */\n readonly headerStart?: ReactNode;\n /** Extra viewer controls, for example a version switcher. */\n readonly controls?: ReactNode;\n /** Defaults to the controller's refresh: a clean document follows the saved one, unsaved edits are kept and shown as changed elsewhere. */\n readonly onRefresh?: () => unknown;\n readonly onShare?: ViewerShare;\n readonly onOpenInNewTab?: () => unknown;\n readonly onClose?: () => unknown;\n /** Host mapping from an image address in the document to a loadable URL. Images stay inert without it. */\n readonly resolveImage?: MarkdownImageResolver;\n readonly onMountedTools?: (tools: MarkdownMountedTools | null) => void;\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\n/** The status chip for a Markdown controller: Saved, Unsaved, Saving, Changed elsewhere, Read-only. */\nexport function markdownStatus(state: ReturnType<MarkdownController['getSnapshot']>, labels: ViewerLabels = defaultViewerLabels): ViewerStatus {\n if (state.lifecycle === 'disposed') return { label: labels.closed };\n if (state.save.kind === 'pending') return { label: labels.saving };\n if (state.save.kind === 'settled' && state.save.result.kind === 'unknown') return { label: labels.saveUnconfirmed, tone: 'warning' };\n if (state.save.kind === 'settled' && state.save.result.kind === 'conflict' || state.remote !== null) return { label: labels.changedElsewhere, tone: 'warning' };\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 Markdown editor in the standard viewer frame: mode toggle, Save while there are edits, Share, and Reload, Copy and Download under the \"…\" menu. */\nexport function MarkdownPane({ controller, title: givenTitle, labels: ownLabels, subtitle, target, revision, initialMode = 'rich', onModeChange, placeholder, formattingToolbar = 'pinned', autosave: ownAutosave, onAutosaveChange, onBeforeLeaveChange, headerStart, controls, onRefresh, onShare, onOpenInNewTab, onClose, resolveImage, onMountedTools, testId = 'viewer', titleTestId, className }: MarkdownPaneProps) {\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const { labels } = useViewerText(ownLabels);\n const window = useViewerWindow();\n const autosave = ownAutosave ?? window?.autosave ?? true;\n const flush = useRef<(() => Promise<boolean>) | null>(null);\n const registerAutosave = useCallback((save: (() => Promise<boolean>) | null) => { flush.current = save; onAutosaveChange?.(save); onBeforeLeaveChange?.(save); }, [onAutosaveChange, onBeforeLeaveChange]);\n const close = onClose ? async () => {\n if (autosave !== false && flush.current && !await flush.current() && !onBeforeLeaveChange) return false;\n return onClose();\n } : undefined;\n const title = givenTitle ?? labels.document;\n const [requested, setRequestedState] = useState<'rich' | 'source'>(initialMode);\n const setRequested = (next: 'rich' | 'source') => { setRequestedState(next); onModeChange?.(next); };\n const [toolbar, setToolbar] = useState(formattingToolbar);\n const [safety, setSafety] = useState<MarkdownRichSafety>({ safe: true });\n const mode = safety.safe ? requested : 'source';\n const unavailable = state.readOnly || state.lifecycle !== 'active';\n const pending = state.save.kind === 'pending';\n const unconfirmed = state.save.kind === 'settled' && state.save.result.kind === 'unknown';\n const [refused, setRefused] = useState<{ readonly save: typeof state.save; readonly label: string } | null>(null);\n const report: NonNullable<MarkdownEditorProps['onSaveResult']> = result => {\n if (result.kind === 'saved' || result.kind === 'conflict') setRefused(null);\n else if (result.kind === 'denied' || result.kind === 'unavailable' || result.kind === 'file_too_large') {\n const reason = result.reason ?? result.kind;\n setRefused({ save: controller.getSnapshot().save, label: labels.notSaved(reason.length > 48 ? `${reason.slice(0, 47)}…` : reason) });\n }\n };\n const save = () => {\n setRefused(null);\n void controller.flush(controller.actions.selection()).then(report, () => setRefused({ save: controller.getSnapshot().save, label: labels.notSaved(labels.saveFailedReason) }));\n };\n const filename = useMemo(() => fileNameFor(title, 'md'), [title]);\n return <ViewerFrame showSavedStatus title={title} subtitle={subtitle} headerStart={headerStart} status={refused && refused.save === state.save && state.save.kind !== 'pending' ? { label: refused.label, tone: 'warning' } : markdownStatus(state, labels)} target={target} labels={labels} revision={revision} testId={testId} {...(titleTestId ? { titleTestId } : {})} className={className}\n onRefresh={onRefresh ?? (() => controller.actions.refresh())} onShare={onShare} onCopy={() => copyText(controller.getSnapshot().text)}\n onDownload={() => downloadFile(filename, controller.getSnapshot().text, 'text/markdown;charset=utf-8')} onOpenInNewTab={onOpenInNewTab} onClose={close}\n controls={<>\n {mode === 'rich' && <Toggle size=\"sm\" className=\"max-[640px]:hidden\" aria-label=\"Pin formatting toolbar\" title=\"Pin formatting toolbar\" pressed={toolbar === 'pinned'}\n onPressedChange={pressed => setToolbar(pressed ? 'pinned' : 'selection')}>Aa</Toggle>}\n <ViewerToggle label={labels.editorMode} value={mode} onChange={setRequested} options={[\n { id: 'rich', label: labels.richText, text: labels.rich, icon: <PencilLineIcon data-icon=\"inline-start\" aria-hidden=\"true\" />, disabled: !safety.safe, testId: `${testId}-mode-rich` },\n { id: 'source', label: labels.markdownSource, text: labels.source, icon: <CodeXmlIcon data-icon=\"inline-start\" aria-hidden=\"true\" />, testId: `${testId}-mode-source` }]} />\n {autosave === false && !state.readOnly && state.dirty && <Button variant=\"ghost\" size=\"icon-sm\" aria-label={labels.save} title={labels.save} data-testid={`${testId}-save`} disabled={unavailable || pending || unconfirmed} onClick={save}><SaveIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>}\n {controls}\n </>}>\n <MarkdownEditor components={editorControls} controller={controller} title={title} header={false} autosave={autosave} onAutosaveChange={registerAutosave} onSaveResult={report} formattingToolbar={toolbar} mode={mode} onModeChange={setRequested} onRichSafety={setSafety}\n {...(placeholder === undefined ? {} : { placeholder })} {...(resolveImage ? { resolveImage } : {})} {...(onMountedTools ? { onMountedTools } : {})} className=\"boring-markdown-editor\" />\n </ViewerFrame>;\n}\n",
59
68
  "type": "registry:component",
60
69
  "target": "components/viewers/markdown-pane.tsx"
61
70
  },
@@ -67,16 +76,22 @@
67
76
  },
68
77
  {
69
78
  "path": "registry/viewers/menu.tsx",
70
- "content": "'use client';\n\nimport { HistoryIcon } from 'lucide-react';\nimport { ActionMenu } from '../button/actions';\nimport type { ActionMenuProps, MenuItem } from '../button/actions';\nimport { useViewerText } from './viewer-window';\n\n/** One row of a viewer menu. With `checked` set (true or false) the row is a radio choice, otherwise a plain action. */\nexport type ViewerMenuItem = MenuItem;\nexport type ViewerMenuProps = Omit<ActionMenuProps, 'size'>;\n\n/**\n * An icon button that opens a small menu (the shared `ActionMenu` of the button item): arrow keys, Home/End and roving focus, Escape\n * (focus returns to the trigger), Tab and an outside press close it. Used for the viewer's overflow actions and the version history.\n */\nexport function ViewerMenu({ testId = 'viewer-menu', ...props }: ViewerMenuProps) {\n return <ActionMenu testId={testId} {...props} />;\n}\n\n/** One saved version in a history menu. */\nexport interface ViewerVersion { readonly id: string; readonly label: string; readonly latest?: boolean }\n\n/**\n * The version history of a document: an icon button (tooltip \"Version history\") that opens the list of versions, newest first, with the\n * latest marked and the one on display checked.\n */\nexport function ViewerVersions({ versions, current, onSelect, testId = 'viewer-versions', label, latest }: {\n readonly versions: readonly ViewerVersion[];\n readonly current: string;\n readonly onSelect: (id: string) => void;\n readonly testId?: string;\n readonly label?: string;\n /** The hint on the newest version. */\n readonly latest?: string;\n}) {\n const { labels } = useViewerText();\n return <ViewerMenu label={label ?? labels.versionHistory} testId={testId} align=\"end\" icon={<HistoryIcon className=\"size-4\" aria-hidden=\"true\" />}\n items={versions.map(version => ({ id: version.id, label: version.label, ...(version.latest ? { hint: latest ?? labels.latest } : {}), checked: version.id === current, testId: `${testId}-item`, onSelect: () => onSelect(version.id) }))} />;\n}\n",
79
+ "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 portal = useMenuPortal();\n return <DropdownMenu onOpenChange={open => onOpenChange?.(open)}>\n <DropdownMenuTrigger render={<Button ref={portal.ref} 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={portal.container}>{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",
71
80
  "type": "registry:component",
72
81
  "target": "components/viewers/menu.tsx"
73
82
  },
74
83
  {
75
84
  "path": "registry/viewers/pdf-pane.tsx",
76
- "content": "'use client';\n\nimport { useMemo } from 'react';\nimport type { ReactNode } from 'react';\nimport { DownloadIcon, FileTextIcon } from 'lucide-react';\nimport { Button } from '../button/button';\nimport { downloadFile } from './download';\nimport { formatBytes, useMediaUrl } from './media';\nimport type { MediaSource } from './media';\nimport { ViewerFrame } from './viewer-frame';\nimport type { ViewerShare, ViewerStatus } from './viewer-frame';\nimport { useViewerText } from './viewer-window';\n\nexport interface PdfPaneProps extends MediaSource {\n readonly name: string;\n readonly subtitle?: ReactNode;\n readonly status?: ViewerStatus;\n readonly target?: unknown;\n readonly revision?: string;\n readonly controls?: ReactNode;\n readonly onRefresh?: () => unknown;\n readonly onShare?: ViewerShare;\n readonly onOpenInNewTab?: () => unknown;\n readonly onClose?: () => unknown;\n /** Force the fallback (for a host that knows its browser cannot render PDFs). Defaults to the browser's own report. */\n readonly inline?: boolean;\n readonly testId?: string;\n /** Overrides the title's `data-testid` (default `<testId>-title`). */\n readonly titleTestId?: string;\n readonly className?: string;\n}\n\n/** The browser reports whether it has a built-in PDF viewer. Unknown (older browsers) is treated as yes: the frame is tried. */\nexport function browserShowsPdf(): boolean {\n const nav = globalThis.navigator as (Navigator & { pdfViewerEnabled?: boolean }) | undefined;\n return nav?.pdfViewerEnabled !== false;\n}\n\n/**\n * A read-only PDF viewer in the standard frame, as in boring-ui v2: the browser's own PDF viewer in a frame over an object URL\n * (so its page navigation, zoom, search and print come with it). Where the browser cannot render PDFs inline, a clear message with\n * Download and Open in new tab replaces the frame.\n */\nexport function PdfPane({ name, bytes, blob, url: hostUrl, subtitle, status, target, revision, controls, onRefresh, onShare, onOpenInNewTab, onClose, inline, testId = 'viewer', titleTestId, className }: PdfPaneProps) {\n const source = useMemo<MediaSource>(() => ({ ...(bytes ? { bytes } : {}), ...(blob ? { blob } : {}), ...(hostUrl ? { url: hostUrl } : {}) }), [bytes, blob, hostUrl]);\n const { url, size, blob: made } = useMediaUrl(source, 'application/pdf');\n const canShow = inline ?? browserShowsPdf();\n const { labels } = useViewerText();\n const downloadName = name.split('/').pop() || 'document.pdf';\n const download = () => { if (made) downloadFile(downloadName, made, 'application/pdf'); else if (url) { const link = Object.assign(document.createElement('a'), { href: url, download: downloadName }); document.body.append(link); link.click(); link.remove(); } };\n const open = onOpenInNewTab ?? (url ? () => { window.open(url, '_blank', 'noopener,noreferrer'); } : undefined);\n return <ViewerFrame title={downloadName} subtitle={<>{subtitle}{subtitle && size !== undefined && <span aria-hidden=\"true\">·</span>}{['PDF', size !== undefined && formatBytes(size)].filter(Boolean).join(' · ')}</>} status={status} target={target} revision={revision} testId={testId} {...(titleTestId ? { titleTestId } : {})} className={className}\n onRefresh={onRefresh} onShare={onShare} onDownload={url ? download : undefined} onOpenInNewTab={open} onClose={onClose} controls={controls}>\n <div data-testid={`${testId}-stage`} data-pdf={url ? (canShow ? 'inline' : 'fallback') : 'loading'} className=\"boring-viewer-stage relative flex min-h-0 flex-1\">\n {!url ? <p role=\"status\" className=\"m-auto p-6 text-sm text-muted-foreground\">{labels.loading}</p>\n : canShow ? <iframe src={url} title={`${downloadName} (PDF)`} data-testid={`${testId}-frame-pdf`} className=\"size-full min-h-0 flex-1 border-0 bg-background\" />\n : <div role=\"alert\" data-testid={`${testId}-pdf-fallback`} className=\"m-auto flex max-w-sm flex-col items-center gap-3 p-6 text-center\">\n <span className=\"grid size-12 place-items-center rounded-full bg-muted text-muted-foreground\"><FileTextIcon className=\"size-6\" aria-hidden=\"true\" /></span>\n <p className=\"m-0 text-sm font-medium\">{labels.pdfUnsupported}</p>\n <p className=\"m-0 text-xs text-muted-foreground\">{labels.pdfHint(downloadName, Boolean(open))}</p>\n <div className=\"flex flex-wrap justify-center gap-2\">\n <Button variant=\"default\" size=\"bar\" data-testid={`${testId}-pdf-download`} onClick={download}><DownloadIcon className=\"size-3.5\" aria-hidden=\"true\" />{labels.download}</Button>\n {open && <Button variant=\"outline\" size=\"bar\" onClick={() => open()}>{labels.openInNewTab}</Button>}\n </div>\n </div>}\n </div>\n </ViewerFrame>;\n}\n",
85
+ "content": "'use client';\n\nimport { useEffect, useMemo, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { DownloadIcon, FileTextIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Button } from '@/components/ui/button';\nimport { downloadFile } from './download';\nimport { ZoomControls } from './image-pane';\nimport { formatBytes, useMediaUrl } from './media';\nimport { PdfPages } from './pdf-pages';\nimport type { MediaSource } from './media';\nimport { ViewerFrame } from './viewer-frame';\nimport type { ViewerShare, ViewerStatus } from './viewer-frame';\nimport { useViewerText } from './viewer-window';\n\nexport interface PdfPaneProps extends MediaSource {\n readonly name: string;\n readonly subtitle?: ReactNode;\n readonly status?: ViewerStatus;\n readonly target?: unknown;\n readonly revision?: string;\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 /** `false` draws the pages with pdf.js (for a host that knows its browser cannot render PDFs), `true` uses the browser's own viewer. Defaults to the browser's own report. */\n readonly inline?: boolean;\n /** How the browser's viewer opens the page: `width` (default) fits the page to the pane's width, `page` fits the whole page, `none` leaves the browser's own zoom. */\n readonly fit?: 'width' | 'page' | 'none';\n readonly testId?: string;\n /** Overrides the title's `data-testid` (default `<testId>-title`). */\n readonly titleTestId?: string;\n readonly className?: string;\n}\n\n/** The browser reports whether it has a built-in PDF viewer. Unknown (older browsers) is treated as yes: the frame is tried. */\nexport function browserShowsPdf(): boolean {\n const nav = globalThis.navigator as (Navigator & { pdfViewerEnabled?: boolean }) | undefined;\n return nav?.pdfViewerEnabled !== false;\n}\n\nconst STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4];\n\n/**\n * A read-only PDF viewer in the standard frame, as in boring-ui v2: the browser's own PDF viewer in a frame over an object URL\n * (so its page navigation, zoom, search and print come with it). Where the browser cannot render PDFs inline (phones), pdf.js, loaded\n * only then, draws the pages with zoom and a page indicator; if that fails too (a damaged or password-protected file, no worker), a clear\n * message with Download and Open in new tab replaces it.\n */\nexport function PdfPane({ name, bytes, blob, url: hostUrl, subtitle, status, target, revision, headerStart, controls, onRefresh, onShare, onOpenInNewTab, onClose, inline, fit = 'width', testId = 'viewer', titleTestId, className }: PdfPaneProps) {\n const source = useMemo<MediaSource>(() => ({ ...(bytes ? { bytes } : {}), ...(blob ? { blob } : {}), ...(hostUrl ? { url: hostUrl } : {}) }), [bytes, blob, hostUrl]);\n const { url, size, blob: made } = useMediaUrl(source, 'application/pdf');\n const canShow = inline ?? browserShowsPdf();\n const { labels } = useViewerText();\n const downloadName = name.split('/').pop() || 'document.pdf';\n const download = () => { if (made) downloadFile(downloadName, made, 'application/pdf'); else if (url) { const link = Object.assign(document.createElement('a'), { href: url, download: downloadName }); document.body.append(link); link.click(); link.remove(); } };\n const open = onOpenInNewTab ?? (url ? () => { window.open(url, '_blank', 'noopener,noreferrer'); } : undefined);\n const [zoom, setZoom] = useState(1);\n const [pages, setPages] = useState<{ readonly count: number; readonly current: number }>();\n const [failed, setFailed] = useState(false);\n useEffect(() => { setPages(undefined); setFailed(false); }, [url]);\n const step = (direction: 1 | -1) => setZoom(now => (direction > 0 ? STEPS.find(value => value > now) : [...STEPS].reverse().find(value => value < now)) ?? now);\n const drawn = Boolean(url) && !canShow && !failed;\n return <ViewerFrame title={downloadName} subtitle={<>{subtitle}{subtitle && size !== undefined && <span aria-hidden=\"true\">·</span>}{['PDF', size !== undefined && formatBytes(size)].filter(Boolean).join(' · ')}</>} status={status} target={target} revision={revision} headerStart={headerStart} testId={testId} {...(titleTestId ? { titleTestId } : {})} className={className}\n onRefresh={onRefresh} onShare={onShare} onDownload={url ? download : undefined} onOpenInNewTab={open} onClose={onClose}\n controls={drawn && pages ? <><ZoomControls testId={testId} percent={`${Math.round(zoom * 100)}%`} ready onStep={step} onFit={() => setZoom(1)} />{controls}</> : controls}>\n <div data-testid={`${testId}-stage`} data-pdf={url ? (canShow ? 'inline' : drawn ? 'pages' : 'fallback') : 'loading'} className=\"boring-viewer-stage relative flex min-h-0 flex-1\">\n {!url ? <p role=\"status\" className=\"m-auto p-6 text-sm text-muted-foreground\">{labels.loading}</p>\n : canShow ? <iframe src={fit === 'none' ? url : `${url}#view=${fit === 'page' ? 'Fit' : 'FitH'}`} title={`${downloadName} (PDF)`} data-testid={`${testId}-frame-pdf`} className=\"size-full min-h-0 flex-1 bg-background\" />\n : drawn ? <><PdfPages key={url} url={url} name={labels.pdfDocument(downloadName)} zoom={zoom} testId={testId} onLoad={count => setPages({ count, current: 1 })}\n onPage={current => setPages(now => now && now.current !== current ? { ...now, current } : now)} onError={() => setFailed(true)}\n onKey={key => { if (key === '+' || key === '=') step(1); else if (key === '-') step(-1); else if (key === '0') setZoom(1); }} />\n {pages && <Badge variant=\"secondary\" data-testid={`${testId}-pdf-page-indicator`} aria-live=\"polite\" className=\"pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2\">{labels.pdfPage(pages.current, pages.count)}</Badge>}</>\n : <div role=\"alert\" data-testid={`${testId}-pdf-fallback`} className=\"m-auto flex max-w-sm flex-col items-center gap-3 p-6 text-center\">\n <span className=\"grid size-12 place-items-center bg-muted text-muted-foreground\"><FileTextIcon className=\"size-6\" aria-hidden=\"true\" /></span>\n <p className=\"m-0 text-sm font-medium\">{labels.pdfUnsupported}</p>\n <p className=\"m-0 text-xs text-muted-foreground\">{labels.pdfHint(downloadName, Boolean(open))}</p>\n <div className=\"flex flex-wrap justify-center gap-2\">\n <Button variant=\"default\" size=\"sm\" data-testid={`${testId}-pdf-download`} onClick={download}><DownloadIcon data-icon=\"inline-start\" aria-hidden=\"true\" />{labels.download}</Button>\n {open && <Button variant=\"outline\" size=\"sm\" onClick={() => open()}>{labels.openInNewTab}</Button>}\n </div>\n </div>}\n </div>\n </ViewerFrame>;\n}\n",
77
86
  "type": "registry:component",
78
87
  "target": "components/viewers/pdf-pane.tsx"
79
88
  },
89
+ {
90
+ "path": "registry/viewers/pdf-pages.tsx",
91
+ "content": "'use client';\n\nimport { useEffect, useRef, useState } from 'react';\nimport type { PDFDocumentProxy, PDFWorker, RenderTask } from 'pdfjs-dist';\nimport { useViewerText } from './viewer-window';\n\n/** Device pixels per CSS pixel are capped (3) and so is one page's canvas (about 8 megapixels) so a zoomed page stays within a phone's memory. */\nconst MAX_RATIO = 3;\nconst MAX_PIXELS = 8 * 1024 * 1024;\n\n/**\n * pdf.js's own worker, from the package's worker entry. `new Worker(new URL(<package path>, import.meta.url))` is the form Vite and\n * webpack (Next) bundle as a worker; a host bundler that leaves the URL as is (esbuild) serves that path itself. Resolves once the\n * worker reports ready and rejects when it cannot load, so a missing worker shows the Download fallback instead of a spinner forever.\n */\nfunction startWorker(signal: AbortSignal): Promise<Worker> {\n return new Promise((resolve, reject) => {\n const worker = new Worker(new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url), { type: 'module' });\n const fail = () => { worker.terminate(); reject(new Error('The PDF worker could not start.')); };\n worker.addEventListener('message', () => { signal.removeEventListener('abort', fail); resolve(worker); }, { once: true });\n worker.addEventListener('error', fail, { once: true });\n signal.addEventListener('abort', fail, { once: true });\n });\n}\n\n/** Opens the document with pdf.js (loaded on demand) in its own worker. `close` destroys the document and stops the worker. */\nasync function openPdf(url: string, signal: AbortSignal): Promise<{ readonly document: PDFDocumentProxy; readonly close: () => void }> {\n const started = startWorker(signal);\n let worker: PDFWorker | undefined, task: { destroy(): Promise<void> } | undefined;\n const close = () => { void task?.destroy(); worker?.destroy(); void started.then(port => port.terminate(), () => {}); };\n try {\n const [pdfjs, port] = await Promise.all([import('pdfjs-dist'), started]);\n if (signal.aborted) throw signal.reason;\n worker = pdfjs.PDFWorker.create({ port });\n const loading = pdfjs.getDocument({ url, worker, verbosity: 0 });\n task = loading;\n const document = await loading.promise;\n if (signal.aborted) throw signal.reason;\n return { document, close };\n } catch (error) { close(); throw error; }\n}\n\nexport interface PdfPagesProps {\n readonly url: string;\n readonly name: string;\n /** 1 fits the page to the pane's width. */\n readonly zoom: number;\n readonly testId: string;\n readonly onLoad: (pages: number) => void;\n readonly onPage: (page: number) => void;\n readonly onError: (error: unknown) => void;\n readonly onKey?: (key: string) => void;\n}\n\n/**\n * The pages of a PDF drawn by pdf.js, for a browser without a PDF viewer of its own (phones): stacked, fit to the pane's width times\n * `zoom`, rendered only near the viewport, crisp at the device's pixel ratio. Unmounting cancels rendering and destroys the document.\n */\nexport function PdfPages({ url, name, zoom, testId, onLoad, onPage, onError, onKey }: PdfPagesProps) {\n const { labels } = useViewerText();\n const scroller = useRef<HTMLDivElement>(null);\n const [document, setDocument] = useState<PDFDocumentProxy>();\n const [ratio, setRatio] = useState(Math.SQRT2);\n const [width, setWidth] = useState(0);\n const latest = useRef({ onLoad, onError });\n latest.current = { onLoad, onError };\n useEffect(() => {\n const abort = new AbortController();\n let close: (() => void) | undefined;\n openPdf(url, abort.signal).then(async opened => {\n close = opened.close;\n if (abort.signal.aborted) { close(); return; }\n const first = (await opened.document.getPage(1)).getViewport({ scale: 1 });\n if (abort.signal.aborted) return;\n setRatio(first.height / first.width);\n setDocument(opened.document);\n latest.current.onLoad(opened.document.numPages);\n }).catch(error => { if (!abort.signal.aborted) latest.current.onError(error); });\n return () => { abort.abort(); close?.(); };\n }, [url]);\n useEffect(() => {\n const box = scroller.current;\n if (!box) return;\n const measure = () => setWidth(Math.max(0, box.clientWidth - 16));\n measure();\n if (typeof ResizeObserver === 'undefined') return;\n const observer = new ResizeObserver(measure);\n observer.observe(box);\n return () => observer.disconnect();\n }, []);\n const scrolled = () => {\n const box = scroller.current;\n if (!box) return;\n const line = box.scrollTop + box.clientHeight / 3;\n let page = 1;\n for (const element of box.querySelectorAll<HTMLElement>('[data-page]')) if (element.offsetTop <= line) page = Number(element.dataset.page);\n onPage(page);\n };\n const pageWidth = Math.floor(width * zoom);\n return <div ref={scroller} data-testid={`${testId}-pdf-pages`} aria-label={name} role=\"document\" tabIndex={0} onScroll={scrolled}\n onKeyDown={event => onKey?.(event.key)} className=\"relative min-h-0 min-w-0 flex-1 overflow-auto p-2\">\n {!document ? <p role=\"status\" className=\"m-0 p-6 text-center text-sm text-muted-foreground\">{labels.loading}</p>\n : <div className=\"flex w-max min-w-full flex-col items-center gap-2\">\n {Array.from({ length: document.numPages }, (_, index) => <PdfPage key={index} document={document} number={index + 1} width={pageWidth} ratio={ratio} root={scroller.current} testId={testId} />)}\n </div>}\n </div>;\n}\n\nfunction PdfPage({ document, number, width, ratio, root, testId }: { readonly document: PDFDocumentProxy; readonly number: number; readonly width: number; readonly ratio: number; readonly root: Element | null; readonly testId: string }) {\n const box = useRef<HTMLDivElement>(null);\n const canvas = useRef<HTMLCanvasElement>(null);\n const [near, setNear] = useState(false);\n const [own, setOwn] = useState<number>();\n const [rendered, setRendered] = useState(false);\n useEffect(() => {\n const element = box.current;\n if (!element || typeof IntersectionObserver === 'undefined') { setNear(true); return; }\n const observer = new IntersectionObserver(entries => { if (entries.some(entry => entry.isIntersecting)) setNear(true); }, { root, rootMargin: '100% 0px' });\n observer.observe(element);\n return () => observer.disconnect();\n }, [root]);\n useEffect(() => {\n const element = canvas.current;\n if (!near || !width || !element) return;\n let cancelled = false, task: RenderTask | undefined;\n void document.getPage(number).then(page => {\n if (cancelled) return;\n const base = page.getViewport({ scale: 1 });\n setOwn(base.height / base.width);\n const viewport = page.getViewport({ scale: width / base.width });\n const scale = Math.min(globalThis.devicePixelRatio || 1, MAX_RATIO, Math.sqrt(MAX_PIXELS / (viewport.width * viewport.height)));\n element.width = Math.floor(viewport.width * scale);\n element.height = Math.floor(viewport.height * scale);\n task = page.render({ canvas: element, viewport, ...(scale === 1 ? {} : { transform: [scale, 0, 0, scale, 0, 0] }) });\n return task.promise.then(() => { if (!cancelled) setRendered(true); });\n }).catch(() => {});\n return () => { cancelled = true; task?.cancel(); };\n }, [document, number, width, near]);\n return <div ref={box} data-page={number} data-testid={`${testId}-pdf-page`} data-rendered={rendered || undefined} className=\"relative shrink-0 bg-background outline outline-1 outline-border\"\n style={{ width, height: Math.round(width * (own ?? ratio)) }}>\n <canvas ref={canvas} data-testid={`${testId}-pdf-canvas`} className=\"absolute inset-0 size-full\" />\n </div>;\n}\n",
92
+ "type": "registry:component",
93
+ "target": "components/viewers/pdf-pages.tsx"
94
+ },
80
95
  {
81
96
  "path": "registry/viewers/share.ts",
82
97
  "content": "import { copyText } from '../utils/utils';\n\n/** What the frame hands the host when the person presses Share. `target` and `revision` are the host's own identifiers for what is shown. */\nexport interface ViewerShareRequest {\n readonly title: string;\n readonly target: unknown;\n readonly revision: string | undefined;\n}\n\n/** `shared`: the system share sheet took it. `copied`: a link is on the clipboard (the frame says so). Nothing: the host confirmed it its own way. */\nexport type ViewerShareResult = 'shared' | 'copied' | undefined | void;\nexport type ViewerShare = (request: ViewerShareRequest) => ViewerShareResult | Promise<ViewerShareResult>;\n\n/**\n * The ready-made Share action. `link` turns the request into the URL a person can open to see the same thing. Uses the Web Share\n * API where the browser offers it (phones), otherwise copies the link (see `copyText`) and lets the frame show \"Link copied\". A cancelled share\n * sheet is not an error.\n */\nexport function createLinkShare(link: (request: ViewerShareRequest) => string): ViewerShare {\n return async request => {\n const url = link(request);\n const nav = globalThis.navigator;\n if (typeof nav?.share === 'function' && (typeof nav.canShare !== 'function' || nav.canShare({ title: request.title, url }))) {\n try { await nav.share({ title: request.title, url }); return 'shared'; }\n catch (error) { if ((error as { name?: string })?.name === 'AbortError') return undefined; /* fall back to the clipboard */ }\n }\n // Clipboard API, then execCommand; when neither works copyText throws ManualCopyError and the frame shows the link selected.\n await copyText(url);\n return 'copied';\n };\n}\n",
@@ -85,47 +100,38 @@
85
100
  },
86
101
  {
87
102
  "path": "registry/viewers/viewer-frame.tsx",
88
- "content": "'use client';\n\nimport { createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { CheckIcon, CopyIcon, DownloadIcon, ExternalLinkIcon, RefreshCwIcon } from 'lucide-react';\nimport { Button, IconButton } from '../button/button';\nimport { ActionButton, actionMenuItem, headerActions, menuActions } from '../button/actions';\nimport type { BlockAction } from '../button/actions';\nimport { ViewerMenu } 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/** `kind` says what the status is whatever its (translated) label: the bar hides a plain `saved` and shows `read-only` as a fact. */\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;\n /** Saved, Unsaved, Saving, Changed elsewhere, Read-only. */\n readonly status?: ViewerStatus | undefined;\n /** Opaque identifiers handed to `onShare` so the host can build a link to this exact thing. */\n readonly target?: unknown;\n readonly revision?: string | 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;\n /** Viewer-specific secondary actions, listed in the \"…\" menu after the standard ones. */\n readonly menu?: readonly ViewerMenuItem[];\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;\n readonly onShare?: ViewerShare;\n readonly onCopy?: () => unknown;\n readonly onDownload?: () => unknown;\n readonly onOpenInNewTab?: () => unknown;\n readonly onClose?: () => unknown;\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;\n readonly titleTestId?: string;\n readonly className?: string;\n readonly children?: ReactNode;\n}\n\n\nconst TONES: Record<ViewerTone, string> = {\n neutral: 'bg-muted text-muted-foreground',\n success: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300',\n warning: 'bg-amber-500/15 text-amber-800 dark:text-amber-300',\n danger: 'bg-destructive/12 text-destructive',\n};\nconst DOTS: Record<ViewerTone, string> = {\n neutral: 'bg-muted-foreground/60',\n success: 'bg-emerald-500',\n warning: 'bg-amber-500',\n danger: 'bg-destructive',\n};\n/** Buttons are 40-44px on a phone or touch screen (see Button), 32px with a mouse: below this width a pane drops its optional bar controls. */\nconst TIGHT_WIDTH = { touch: 440, pointer: 340 };\nconst touchScreen = () => typeof matchMedia === 'function' && matchMedia('(max-width: 639px), (pointer: coarse)').matches;\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/** A bar button: an icon with an accessible name and a tooltip. */\nexport const ViewerIconButton = IconButton;\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 for hosts that want it. */\nexport interface ViewerToggleOption<T extends string> { readonly id: T; readonly label: string; readonly text: string; readonly icon?: ReactNode; readonly disabled?: boolean; readonly testId?: string }\n\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 const move = (event: React.KeyboardEvent<HTMLDivElement>) => {\n const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;\n if (!step) return;\n const buttons = [...event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')];\n const at = buttons.indexOf(document.activeElement as HTMLButtonElement);\n if (at < 0) return;\n event.preventDefault();\n buttons[(at + step + buttons.length) % buttons.length]?.focus();\n };\n return <div role=\"group\" aria-label={label} onKeyDown={move} className=\"inline-flex shrink-0 rounded-lg border border-border bg-muted/40 p-0.5\">\n {options.map(option => <button key={option.id} type=\"button\" aria-label={option.label} title={option.label} aria-pressed={value === option.id} disabled={option.disabled}\n data-testid={option.testId} data-mode={option.id} onClick={() => onChange(option.id)}\n className={cn('inline-flex size-7 cursor-pointer items-center justify-center rounded-md outline-none max-sm:size-10 pointer-coarse:size-10 focus-visible:ring-2 focus-visible:ring-ring/60 disabled:pointer-events-none disabled:opacity-45',\n value === option.id ? 'bg-background text-foreground shadow-xs' : 'text-muted-foreground hover:text-foreground')}>\n {option.icon ?? option.text}</button>)}\n </div>;\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 const labelId = useId();\n useEffect(() => { const element = field.current; if (element) { element.focus(); element.select(); } }, [text]);\n return <div role=\"dialog\" aria-labelledby={labelId} data-testid={`${testId}-manual-copy`}\n onKeyDown={event => { if (event.key === 'Escape') { event.stopPropagation(); onClose(); } }}\n className=\"absolute top-full right-3 left-3 z-50 mt-1 grid gap-2 rounded-lg border border-border bg-popover p-3 text-popover-foreground shadow-lg sm:left-auto sm:w-96\">\n <p id={labelId} className=\"m-0 text-xs font-medium\">{labels.manualCopy(link)}</p>\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 className=\"w-full resize-none rounded-md border border-border bg-background p-2 font-mono text-xs break-all outline-none focus-visible:ring-2 focus-visible:ring-ring/60\" />\n <div className=\"flex justify-end\"><Button variant=\"quiet\" size=\"bar\" data-testid={`${testId}-manual-copy-close`} onClick={onClose}>{labels.done}</Button></div>\n </div>;\n}\n\n/** The status as a small dot with the words in a tooltip and for screen readers. The frame shows it only when the state is not plain \"Saved\". */\nfunction StatusMark({ status, testId }: { readonly status: ViewerStatus; readonly testId: string }) {\n const tone = status.tone ?? 'neutral';\n return <span role=\"status\" data-testid={`${testId}-status`} data-tone={tone} title={status.label} className=\"inline-flex size-6 shrink-0 cursor-default items-center justify-center rounded-full\">\n <span aria-hidden=\"true\" className={cn('size-2 rounded-full', DOTS[tone], tone === 'warning' && status.label !== 'Unsaved' && 'animate-pulse motion-reduce:animate-none')} />\n <span className=\"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, target, revision, controls, menu, actions, labels: ownLabels, icons: ownIcons, onRefresh, onShare, onCopy, onDownload, onOpenInNewTab, onClose, testId = 'viewer', titleTestId, className, children }: ViewerFrameProps) {\n const bar = useRef<HTMLElement>(null);\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 const mounted = useRef(true);\n useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\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 < (touchScreen() ? TIGHT_WIDTH.touch : TIGHT_WIDTH.pointer) };\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 }, []);\n useEffect(() => {\n if (!notice) return;\n const timer = setTimeout(() => setNotice(undefined), NOTICE_MS);\n return () => clearTimeout(timer);\n }, [notice]);\n\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 (mounted.current) { if (needsManualCopy(error)) setManual({ text: error.text, link: kind === 'share' }); else setNotice({ text: failed, tone: 'danger' }); } return undefined; }\n finally { if (mounted.current) 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 (!mounted.current) return;\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(() => { if (mounted.current) { setCopied(true); setNotice({ text: labels.copied, tone: 'success' }); setTimeout(() => { if (mounted.current) setCopied(false); }, 1500); } })\n .catch(error => { if (!mounted.current) return; 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(() => { if (mounted.current) 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 ...(onRefresh ? [{ id: 'refresh', 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 ?? []),\n ...menuActions(hostActions).map(action => actionMenuItem(action, testId)),\n ] as ViewerMenuItem[]).map(entry => ({ ...entry, testId: entry.testId ?? `${testId}-${entry.id}` }));\n\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 flex h-full min-h-0 min-w-0 flex-col bg-background text-foreground', className)}>\n <header ref={bar} data-testid={`${testId}-bar`} className=\"relative flex shrink-0 flex-nowrap items-center gap-x-1.5 border-b border-border px-3 py-1.5\">\n <div className=\"min-w-0 flex-1 basis-0\">\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=\"m-0 flex min-w-0 items-center gap-x-1.5 overflow-hidden text-xs leading-4 whitespace-nowrap text-muted-foreground\">{subtitle}{subtitle && reading && <span aria-hidden=\"true\">·</span>}{reading && <span data-testid={`${testId}-readonly`}>{labels.readOnly}</span>}</p>}\n </div>\n {controls && <div data-testid={`${testId}-controls`} className=\"flex shrink-0 items-center gap-1\">{controls}</div>}\n {status && !reading && 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-bar\" />)}\n {onShare && <ViewerIconButton label={labels.share} data-testid={`${testId}-share`} disabled={busy === 'share'} onClick={share}><icons.share className=\"size-4\" aria-hidden=\"true\" /></ViewerIconButton>}\n {menuItems.length > 0 && <ViewerMenu label={labels.moreActions} testId={`${testId}-more`} icon={<icons.more className=\"size-4\" aria-hidden=\"true\" />} items={menuItems} />}\n {host?.onFullscreenChange && <ViewerIconButton label={host.fullscreen ? labels.exitFullscreen : labels.enterFullscreen} data-testid={`${testId}-fullscreen`} aria-pressed={host.fullscreen === true}\n onClick={() => host.onFullscreenChange!(!host.fullscreen)}>{host.fullscreen ? <icons.exitFullscreen className=\"size-4\" aria-hidden=\"true\" /> : <icons.enterFullscreen className=\"size-4\" aria-hidden=\"true\" />}</ViewerIconButton>}\n {onClose && <ViewerIconButton label={labels.close} data-testid={`${testId}-close`} onClick={() => run(onClose)}><icons.close className=\"size-4\" aria-hidden=\"true\" /></ViewerIconButton>}\n </div>\n {notice && <span role=\"status\" aria-live=\"polite\" data-testid={`${testId}-notice`} data-tone={notice.tone} className={cn('absolute top-full right-3 z-40 mt-1 rounded-full border border-border px-2.5 py-1 text-xs font-medium shadow-md', TONES[notice.tone])}>{notice.text}</span>}\n {manual && <ManualCopy testId={testId} link={manual.link} text={manual.text} labels={labels} onClose={() => setManual(undefined)} />}\n </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",
103
+ "content": "'use client';\n\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { ArrowLeftIcon, CheckIcon, 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 { 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/** `kind` says what the status is whatever its (translated) label: the bar hides a plain `saved` and shows `read-only` as a fact. */\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;\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;\n readonly revision?: string | undefined;\n /** Leading header content, for example Back before the file title. */\n readonly headerStart?: ReactNode;\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;\n /** Viewer-specific secondary actions, listed in the \"…\" menu after the standard ones. */\n readonly menu?: readonly ViewerMenuItem[];\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;\n readonly onShare?: ViewerShare;\n readonly onCopy?: () => unknown;\n readonly onDownload?: () => unknown;\n readonly onOpenInNewTab?: () => unknown;\n readonly onClose?: () => unknown;\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;\n readonly titleTestId?: string;\n /** Quiet one-row bar with secondary actions in the menu. False retains the former visible Share/fullscreen/close layout. */\n readonly compactHeader?: boolean;\n readonly className?: string;\n readonly children?: ReactNode;\n}\n\n\n/** Narrow panes drop optional controls. */\nconst TIGHT_WIDTH = { touch: 440, pointer: 340 };\nconst touchScreen = () => typeof matchMedia === 'function' && matchMedia('(max-width: 639px), (pointer: coarse)').matches;\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/** One choice of a mode toggle, for example Rich | Source. Icon only: `label` is the accessible name and the tooltip, `text` the short word for hosts that want it. */\nexport interface ViewerToggleOption<T extends string> { readonly id: T; readonly label: string; readonly text: string; readonly icon?: ReactNode; readonly disabled?: boolean; readonly testId?: string }\n\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} size=\"sm\" 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.label} disabled={option.disabled}\n data-testid={option.testId} data-mode={option.id}>\n {option.icon ?? option.text}</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/** Status uses the consumer's Badge; the frame decides whether a saved acknowledgement is shown. */\nfunction StatusMark({ status, testId }: { readonly status: ViewerStatus; readonly testId: string }) {\n return <Badge role=\"status\" title={status.label} data-testid={`${testId}-status`} data-tone={status.tone ?? 'neutral'} variant={status.tone === 'danger' ? 'destructive' : 'secondary'}>{status.label}</Badge>;\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, menu, 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 [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 const mounted = useRef(true);\n useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);\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 < (touchScreen() ? TIGHT_WIDTH.touch : TIGHT_WIDTH.pointer) };\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]);\n useEffect(() => {\n if (!notice) return;\n const timer = setTimeout(() => setNotice(undefined), NOTICE_MS);\n return () => clearTimeout(timer);\n }, [notice]);\n\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 (mounted.current) { if (needsManualCopy(error)) setManual({ text: error.text, link: kind === 'share' }); else setNotice({ text: failed, tone: 'danger' }); } return undefined; }\n finally { if (mounted.current) 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 (!mounted.current) return;\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(() => { if (mounted.current) { setCopied(true); setNotice({ text: labels.copied, tone: 'success' }); setTimeout(() => { if (mounted.current) setCopied(false); }, 1500); } })\n .catch(error => { if (!mounted.current) return; 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(() => { if (mounted.current) 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-[641px]: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=\"min-[641px]:hidden\" aria-hidden=\"true\" /><icons.close data-icon=\"inline-start\" className=\"hidden min-[641px]: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-[641px]: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 {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) && <ViewerActions label={labels.moreActions} testId={`${testId}-more`} icon={<icons.more data-icon=\"inline-start\" aria-hidden=\"true\" />} items={menuItems}>{compactHeader && controls && <div className=\"boring-viewer flex flex-wrap items-center gap-2 p-2 min-[641px]: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-[641px]: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 sm: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",
89
104
  "type": "registry:component",
90
105
  "target": "components/viewers/viewer-frame.tsx"
91
106
  },
92
107
  {
93
108
  "path": "registry/viewers/viewer-window.ts",
94
- "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 { BlockAction, BlockIcon } from '../button/actions';\nimport { withDefaults } from '../utils/utils';\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 versionHistory: 'Version history',\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 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 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 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 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",
109
+ "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 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 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",
95
110
  "type": "registry:lib",
96
111
  "target": "components/viewers/viewer-window.ts"
97
112
  },
98
113
  {
99
114
  "path": "registry/viewers/viewer-labels-fr.ts",
100
- "content": "/* The viewers' words in French, the `locale=\"fr\"` base of `AgentWorkspace`'s viewers. Typed as `ViewerLabels`, so a key added in English must be added here too. */\nimport type { ViewerLabels } from './viewer-window';\n\nexport const frenchViewerLabels: ViewerLabels = {\n share: 'Partager',\n moreActions: 'Plus d’actions',\n reload: 'Recharger',\n copy: 'Copier',\n download: 'Télécharger',\n openInNewTab: 'Ouvrir dans un nouvel onglet',\n enterFullscreen: 'Plein écran',\n exitFullscreen: 'Quitter le plein écran',\n close: 'Fermer',\n readOnly: 'Lecture seule',\n refreshFailed: 'Échec de l’actualisation',\n shareFailed: 'Partage impossible',\n linkCopied: 'Lien copié',\n shared: 'Partagé',\n copied: 'Copié',\n copyFailed: 'Copie impossible',\n actionFailed: 'L’action a échoué',\n manualCopy: link => `Ce navigateur a bloqué la copie automatique. Copiez ${link ? 'le lien' : 'le texte'} ci-dessous.`,\n done: 'Terminé',\n versionHistory: 'Historique des versions',\n latest: 'Dernière version',\n loading: 'Chargement…',\n saved: 'Enregistré',\n unsaved: 'Non enregistré',\n saving: 'Enregistrement',\n saveUnconfirmed: 'Enregistrement non confirmé',\n changedElsewhere: 'Modifié ailleurs',\n closed: 'Fermé',\n save: 'Enregistrer',\n checkSave: 'Vérifier l’enregistrement',\n abandon: 'Abandonner et actualiser, en gardant mon brouillon',\n discardLocal: 'Annuler mes modifications',\n view: 'Affichage',\n preview: 'Aperçu',\n source: 'Source',\n htmlPreview: 'Aperçu HTML',\n htmlSource: 'Source HTML',\n editorMode: 'Mode d’édition',\n rich: 'Mise en forme',\n richText: 'Texte mis en forme',\n markdownSource: 'Source Markdown',\n document: 'Document',\n zoom: 'Zoom',\n zoomIn: 'Agrandir',\n zoomOut: 'Réduire',\n zoomLevel: percent => `Zoom ${percent}, ajuster à la vue`,\n fitToView: 'Ajuster à la vue',\n actualSize: 'Taille réelle',\n imageUnreadable: 'Ce fichier n’a pas pu être lu comme une image.',\n imageUnsupported: mediaType => `Ce type d’image (${mediaType || 'inconnu'}) ne peut pas être affiché ici. Vous pouvez toujours le télécharger.`,\n imageStage: name => `Image ${name}`,\n pdfUnsupported: 'Ce navigateur ne peut pas afficher les PDF directement.',\n pdfHint: (name, canOpen) => `Téléchargez ${name} pour le lire dans une application PDF${canOpen ? ', ou ouvrez-le dans un nouvel onglet' : ''}.`,\n notSaved: reason => `Non enregistré : ${reason}`,\n saveFailedReason: 'l’enregistrement a échoué',\n changedElsewhereReason: 'modifié ailleurs',\n operationFailed: 'L’opération sur le document a échoué. Votre source est conservée.',\n};\n",
115
+ "content": "/* The viewers' words in French, the `locale=\"fr\"` base of `AgentWorkspace`'s viewers. Typed as `ViewerLabels`, so a key added in English must be added here too. */\nimport type { ViewerLabels } from './viewer-window';\nimport { frenchVersionHistoryLabels } from '../version-history/version-history';\n\nexport const frenchViewerLabels: ViewerLabels = {\n share: 'Partager',\n moreActions: 'Plus d’actions',\n reload: 'Recharger',\n copy: 'Copier',\n download: 'Télécharger',\n openInNewTab: 'Ouvrir dans un nouvel onglet',\n enterFullscreen: 'Plein écran',\n exitFullscreen: 'Quitter le plein écran',\n close: 'Fermer',\n readOnly: 'Lecture seule',\n refreshFailed: 'Échec de l’actualisation',\n shareFailed: 'Partage impossible',\n linkCopied: 'Lien copié',\n shared: 'Partagé',\n copied: 'Copié',\n copyFailed: 'Copie impossible',\n actionFailed: 'L’action a échoué',\n manualCopy: link => `Ce navigateur a bloqué la copie automatique. Copiez ${link ? 'le lien' : 'le texte'} ci-dessous.`,\n done: 'Terminé',\n ...frenchVersionHistoryLabels,\n versionViewOnly: 'Ancienne version en lecture seule. La restauration n’est pas disponible dans cette vue.',\n latest: 'Dernière version',\n loading: 'Chargement…',\n saved: 'Enregistré',\n unsaved: 'Non enregistré',\n saving: 'Enregistrement',\n saveUnconfirmed: 'Enregistrement non confirmé',\n changedElsewhere: 'Modifié ailleurs',\n closed: 'Fermé',\n save: 'Enregistrer',\n checkSave: 'Vérifier l’enregistrement',\n abandon: 'Abandonner et actualiser, en gardant mon brouillon',\n discardLocal: 'Annuler mes modifications',\n view: 'Affichage',\n preview: 'Aperçu',\n source: 'Source',\n htmlPreview: 'Aperçu HTML',\n htmlSource: 'Source HTML',\n editorMode: 'Mode d’édition',\n rich: 'Mise en forme',\n richText: 'Texte mis en forme',\n markdownSource: 'Source Markdown',\n document: 'Document',\n zoom: 'Zoom',\n zoomIn: 'Agrandir',\n zoomOut: 'Réduire',\n zoomLevel: percent => `Zoom ${percent}, ajuster à la vue`,\n fitToView: 'Ajuster à la vue',\n actualSize: 'Taille réelle',\n imageUnreadable: 'Ce fichier n’a pas pu être lu comme une image.',\n imageUnsupported: mediaType => `Ce type d’image (${mediaType || 'inconnu'}) ne peut pas être affiché ici. Vous pouvez toujours le télécharger.`,\n imageStage: name => `Image ${name}`,\n pdfUnsupported: 'Ce navigateur ne peut pas afficher les PDF directement.',\n pdfDocument: name => `Pages de ${name}`,\n pdfPage: (current, count) => `${current} / ${count}`,\n pdfHint: (name, canOpen) => `Téléchargez ${name} pour le lire dans une application PDF${canOpen ? ', ou ouvrez-le dans un nouvel onglet' : ''}.`,\n notSaved: reason => `Non enregistré : ${reason}`,\n saveFailedReason: 'l’enregistrement a échoué',\n changedElsewhereReason: 'modifié ailleurs',\n operationFailed: 'L’opération sur le document a échoué. Votre source est conservée.',\n};\n",
101
116
  "type": "registry:lib",
102
117
  "target": "components/viewers/viewer-labels-fr.ts"
103
118
  }
104
119
  ],
105
120
  "css": {
106
- "@layer base": {
107
- ".boring-viewer, .boring-viewer *, .boring-viewer *::before, .boring-viewer *::after": {
108
- "box-sizing": "border-box"
109
- },
110
- ".boring-viewer :where(button, input, textarea)": {
111
- "font": "inherit",
112
- "color": "inherit",
113
- "letter-spacing": "inherit"
114
- },
115
- ".boring-viewer :where(button)": {
116
- "background-color": "transparent",
117
- "border": "0",
118
- "padding": "0"
119
- },
120
- ".boring-viewer :where(textarea, input)": {
121
- "margin": "0"
122
- },
123
- ".boring-viewer [hidden]": {
124
- "display": "none"
121
+ "@layer utilities": {
122
+ "@media (max-width: 640px), (pointer: coarse)": {
123
+ ".boring-viewer :is([data-slot=\"button\"], [data-slot=\"toggle\"], [data-slot=\"toggle-group-item\"], [class~=\"group/button\"], [class~=\"group/toggle\"])": {
124
+ "min-height": "2.75rem",
125
+ "min-width": "2.75rem"
126
+ },
127
+ ".boring-viewer.boring-markdown-bubble :is([data-slot=\"button\"], [data-slot=\"toggle\"])": {
128
+ "min-height": "2.75rem",
129
+ "min-width": "2.75rem"
130
+ }
125
131
  }
126
132
  },
127
133
  "@layer components": {
128
- ".boring-viewer .boring-markdown-editor": {
134
+ ".boring-viewer .boring-markdown-editor, .boring-viewer .boring-html-pane": {
129
135
  "display": "flex",
130
136
  "flex-direction": "column",
131
137
  "flex": "1 1 auto",
@@ -136,156 +142,60 @@
136
142
  ".boring-viewer .boring-markdown-surface": {
137
143
  "flex": "1 1 auto",
138
144
  "min-height": "0",
139
- "overflow": "auto",
140
- "scrollbar-width": "thin",
141
- "scrollbar-color": "color-mix(in oklab, var(--foreground) 22%, transparent) transparent"
145
+ "overflow": "auto"
142
146
  },
143
- ".boring-viewer .boring-markdown-surface:focus-within": {
144
- "box-shadow": "inset 0 0 0 1px color-mix(in oklab, var(--ring) 55%, transparent)"
145
- },
146
- ".boring-viewer .boring-markdown-toolbar": {
147
+ ".boring-viewer .boring-markdown-toolbar, .boring-viewer.boring-markdown-bubble": {
147
148
  "display": "flex",
148
149
  "flex": "none",
149
150
  "align-items": "center",
150
- "gap": "0.125rem",
151
- "padding": "0.375rem 0.75rem",
152
- "border-bottom": "1px solid var(--border)",
153
- "background": "var(--background)",
151
+ "gap": "0.25rem",
152
+ "max-width": "calc(100vw - 24px)"
153
+ },
154
+ ".boring-viewer .boring-markdown-toolbar": {
155
+ "padding": "0.375rem 1.5rem 0.375rem 0.75rem",
154
156
  "overflow-x": "auto",
155
- "scrollbar-width": "none"
157
+ "scrollbar-width": "none",
158
+ "mask-image": "linear-gradient(to right, black calc(100% - 2rem), transparent)"
156
159
  },
157
160
  ".boring-viewer .boring-markdown-toolbar::-webkit-scrollbar": {
158
161
  "display": "none"
159
162
  },
160
163
  ".boring-viewer .boring-markdown-tool": {
161
- "display": "inline-flex",
162
- "flex": "none",
163
- "align-items": "center",
164
- "justify-content": "center",
165
- "width": "2rem",
166
- "height": "2rem",
167
- "border-radius": "0.375rem",
168
- "color": "var(--muted-foreground)",
169
- "cursor": "pointer",
170
- "transition": "background-color 0.12s, color 0.12s"
171
- },
172
- ".boring-viewer .boring-markdown-tool:hover:not(:disabled)": {
173
- "background": "var(--muted)",
174
- "color": "var(--foreground)"
175
- },
176
- ".boring-viewer .boring-markdown-tool[aria-pressed=\"true\"]": {
177
- "background": "color-mix(in oklab, var(--primary) 12%, transparent)",
178
- "color": "var(--foreground)",
179
- "box-shadow": "inset 0 0 0 1px color-mix(in oklab, var(--primary) 22%, transparent)"
180
- },
181
- ".boring-viewer .boring-markdown-tool:disabled": {
182
- "opacity": "0.4",
183
- "cursor": "not-allowed"
184
- },
185
- ".boring-viewer .boring-markdown-tool:focus-visible, .boring-viewer .boring-markdown-link button:focus-visible, .boring-viewer .boring-markdown-alert button:focus-visible, .boring-viewer .boring-markdown-proposal button:focus-visible": {
186
- "outline": "2px solid color-mix(in oklab, var(--ring) 70%, transparent)",
187
- "outline-offset": "1px"
164
+ "flex": "none"
188
165
  },
189
166
  ".boring-viewer .boring-markdown-separator": {
190
167
  "flex": "none",
191
- "width": "1px",
192
- "height": "1rem",
193
- "margin": "0 0.375rem",
194
- "background": "var(--border)"
168
+ "margin-inline": "0.25rem"
169
+ },
170
+ ".boring-viewer span.boring-markdown-separator": {
171
+ "width": "0.5rem",
172
+ "margin": "0"
195
173
  },
196
174
  ".boring-viewer .boring-markdown-icon": {
197
175
  "display": "block"
198
176
  },
199
- ".boring-viewer .boring-markdown-link": {
177
+ ".boring-viewer .boring-markdown-link, .boring-viewer .boring-markdown-alert, .boring-viewer .boring-markdown-proposal, .boring-viewer [data-boring=\"text-draft-controls\"]": {
200
178
  "display": "flex",
201
179
  "flex": "none",
202
180
  "flex-wrap": "wrap",
203
181
  "align-items": "center",
204
182
  "gap": "0.5rem",
205
- "padding": "0.5rem 0.75rem",
206
- "border-bottom": "1px solid var(--border)",
207
- "background": "var(--muted)"
183
+ "padding": "0.5rem 0.75rem"
208
184
  },
209
- ".boring-viewer .boring-markdown-link input": {
185
+ ".boring-viewer .boring-markdown-link [data-slot=\"input\"]": {
210
186
  "flex": "1 1 14rem",
211
- "min-width": "0",
212
- "height": "2rem",
213
- "padding": "0 0.625rem",
214
- "border": "1px solid var(--input)",
215
- "border-radius": "0.375rem",
216
- "background": "var(--background)",
217
- "font-size": "0.875rem"
218
- },
219
- ".boring-viewer .boring-markdown-link input:focus-visible": {
220
- "outline": "2px solid color-mix(in oklab, var(--ring) 70%, transparent)",
221
- "outline-offset": "0"
222
- },
223
- ".boring-viewer .boring-markdown-link button, .boring-viewer .boring-markdown-alert button, .boring-viewer .boring-markdown-proposal > button": {
224
- "height": "2rem",
225
- "padding": "0 0.75rem",
226
- "border": "1px solid var(--border)",
227
- "border-radius": "0.375rem",
228
- "background": "var(--background)",
229
- "font-size": "0.8125rem",
230
- "font-weight": "500",
231
- "cursor": "pointer"
232
- },
233
- ".boring-viewer .boring-markdown-link button:hover:not(:disabled), .boring-viewer .boring-markdown-alert button:hover:not(:disabled), .boring-viewer .boring-markdown-proposal > button:hover:not(:disabled)": {
234
- "background": "var(--muted)"
235
- },
236
- ".boring-viewer .boring-markdown-alert button:disabled, .boring-viewer .boring-markdown-proposal > button:disabled": {
237
- "opacity": "0.45",
238
- "cursor": "not-allowed"
239
- },
240
- ".boring-viewer .boring-markdown-notice, .boring-viewer .boring-markdown-alert, .boring-viewer .boring-markdown-proposal": {
241
- "flex": "none",
242
- "margin": "0.75rem 0.75rem 0",
243
- "padding": "0.625rem 0.75rem",
244
- "border": "1px solid var(--border)",
245
- "border-radius": "0.5rem",
246
- "background": "var(--muted)",
247
- "font-size": "0.8125rem",
248
- "line-height": "1.5"
187
+ "min-width": "0"
249
188
  },
250
189
  ".boring-viewer .boring-markdown-notice": {
251
- "border-left": "3px solid color-mix(in oklab, var(--primary) 55%, transparent)"
252
- },
253
- ".boring-viewer .boring-markdown-alert": {
254
- "display": "flex",
255
- "flex-wrap": "wrap",
256
- "align-items": "center",
257
- "gap": "0.5rem",
258
- "border-color": "color-mix(in oklab, var(--destructive) 40%, var(--border))",
259
- "background": "color-mix(in oklab, var(--destructive) 8%, var(--background))"
260
- },
261
- ".boring-viewer .boring-markdown-proposal": {
262
- "display": "flex",
263
- "flex-wrap": "wrap",
264
- "align-items": "center",
265
- "gap": "0.5rem",
266
- "background": "var(--background)"
267
- },
268
- ".boring-viewer .boring-markdown-proposal h3": {
269
- "flex": "1 1 100%",
270
- "margin": "0",
271
- "font-size": "0.875rem"
190
+ "margin": "0.75rem"
272
191
  },
273
- ".boring-viewer .boring-markdown-proposal details": {
192
+ ".boring-viewer .boring-markdown-proposal :is(h3, details)": {
274
193
  "flex": "1 1 100%",
275
194
  "min-width": "0"
276
195
  },
277
- ".boring-viewer .boring-markdown-proposal summary": {
278
- "cursor": "pointer",
279
- "color": "var(--muted-foreground)"
280
- },
281
196
  ".boring-viewer .boring-markdown-proposal pre": {
282
- "margin": "0.25rem 0",
283
197
  "max-height": "12rem",
284
198
  "overflow": "auto",
285
- "padding": "0.5rem",
286
- "border-radius": "0.375rem",
287
- "background": "var(--muted)",
288
- "font": "0.75rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace",
289
199
  "white-space": "pre-wrap"
290
200
  },
291
201
  ".boring-viewer .boring-markdown-editor nav[aria-label=\"Document outline\"]": {
@@ -295,70 +205,35 @@
295
205
  ".boring-viewer .boring-markdown-editor nav[aria-label=\"Document outline\"]": {
296
206
  "display": "block",
297
207
  "flex": "none",
298
- "padding": "0.375rem 0.75rem",
299
- "border-bottom": "1px solid var(--border)",
300
- "overflow-x": "auto"
208
+ "overflow-x": "auto",
209
+ "padding": "0.375rem 0.75rem"
301
210
  },
302
211
  ".boring-viewer .boring-markdown-editor nav[aria-label=\"Document outline\"] ol": {
303
212
  "display": "flex",
304
213
  "gap": "0.25rem",
214
+ "list-style": "none",
305
215
  "margin": "0",
306
- "padding": "0",
307
- "list-style": "none"
308
- },
309
- ".boring-viewer .boring-markdown-editor nav[aria-label=\"Document outline\"] button": {
310
- "max-width": "14rem",
311
- "padding": "0.125rem 0.5rem",
312
- "overflow": "hidden",
313
- "border-radius": "999px",
314
- "color": "var(--muted-foreground)",
315
- "font-size": "0.75rem",
316
- "text-overflow": "ellipsis",
317
- "white-space": "nowrap",
318
- "cursor": "pointer"
319
- },
320
- ".boring-viewer .boring-markdown-editor nav[aria-label=\"Document outline\"] button:hover": {
321
- "background": "var(--muted)",
322
- "color": "var(--foreground)"
323
- },
324
- ".boring-viewer .boring-markdown-editor nav[aria-label=\"Document outline\"] [data-heading-level=\"1\"] button": {
325
- "font-weight": "600"
216
+ "padding": "0"
326
217
  }
327
218
  },
328
219
  ".boring-viewer .boring-markdown-source, .boring-viewer .boring-html-viewer__source": {
329
- "display": "block",
330
220
  "flex": "1 1 auto",
331
221
  "width": "100%",
332
222
  "min-height": "0",
333
- "margin": "0",
334
- "padding": "1rem 1.25rem",
335
- "border": "0",
336
- "border-radius": "0",
337
- "outline": "none",
338
- "resize": "none",
339
- "background": "var(--background)",
340
- "color": "var(--foreground)",
341
- "font": "0.8125rem/1.65 ui-monospace, SFMono-Regular, \"SF Mono\", Menlo, Consolas, monospace",
342
- "tab-size": "2",
343
- "caret-color": "var(--foreground)"
344
- },
345
- ".boring-viewer .boring-markdown-source:focus-visible, .boring-viewer .boring-html-viewer__source:focus-visible": {
346
- "box-shadow": "inset 0 0 0 1px color-mix(in oklab, var(--ring) 55%, transparent)"
347
- },
348
- ".boring-viewer .boring-markdown-source::selection, .boring-viewer .boring-html-viewer__source::selection, .boring-viewer .boring-markdown-content ::selection, .boring-viewer .boring-markdown-content::selection": {
349
- "background": "color-mix(in oklab, var(--primary) 24%, transparent)"
223
+ "resize": "none"
350
224
  },
351
225
  ".boring-viewer .boring-markdown-content": {
352
- "max-width": "46rem",
226
+ "max-width": "68ch",
353
227
  "min-height": "100%",
354
228
  "margin": "0 auto",
355
- "padding": "1.25rem 1.25rem 3rem",
356
- "outline": "none",
229
+ "padding": "1.5rem 1.5rem 3rem",
230
+ "overflow-wrap": "anywhere",
357
231
  "color": "var(--foreground)",
358
- "font-size": "0.9375rem",
359
- "line-height": "1.7",
360
- "letter-spacing": "-0.005em",
361
- "overflow-wrap": "anywhere"
232
+ "font-size": "var(--text-base)",
233
+ "line-height": "var(--leading-relaxed)"
234
+ },
235
+ ".boring-viewer .boring-markdown-content:focus": {
236
+ "outline": "none"
362
237
  },
363
238
  ".boring-viewer .boring-markdown-content > :first-child": {
364
239
  "margin-top": "0"
@@ -371,32 +246,27 @@
371
246
  "float": "left",
372
247
  "height": "0",
373
248
  "color": "var(--muted-foreground)",
374
- "opacity": "0.8",
375
249
  "pointer-events": "none"
376
250
  },
377
251
  ".boring-viewer .boring-markdown-content :is(h1, h2, h3, h4, h5, h6)": {
378
- "color": "var(--foreground)",
379
- "font-weight": "600",
380
- "letter-spacing": "-0.02em",
381
- "line-height": "1.25"
252
+ "font-weight": "var(--font-weight-semibold)",
253
+ "line-height": "var(--leading-tight)"
382
254
  },
383
255
  ".boring-viewer .boring-markdown-content h1": {
384
256
  "margin": "1.75rem 0 0.75rem",
385
- "font-size": "1.875rem"
257
+ "font-size": "var(--text-2xl)"
386
258
  },
387
259
  ".boring-viewer .boring-markdown-content h2": {
388
- "margin": "1.5rem 0 0.625rem",
389
- "padding-bottom": "0.25rem",
390
- "border-bottom": "1px solid color-mix(in oklab, var(--border) 70%, transparent)",
391
- "font-size": "1.375rem"
260
+ "margin": "2rem 0 0.75rem",
261
+ "font-size": "var(--text-xl)"
392
262
  },
393
263
  ".boring-viewer .boring-markdown-content h3": {
394
264
  "margin": "1.25rem 0 0.5rem",
395
- "font-size": "1.125rem"
265
+ "font-size": "var(--text-lg)"
396
266
  },
397
267
  ".boring-viewer .boring-markdown-content :is(h4, h5, h6)": {
398
268
  "margin": "1rem 0 0.4rem",
399
- "font-size": "1rem"
269
+ "font-size": "var(--text-base)"
400
270
  },
401
271
  ".boring-viewer .boring-markdown-content :is(ul, ol)": {
402
272
  "margin": "0.75rem 0",
@@ -405,27 +275,12 @@
405
275
  ".boring-viewer .boring-markdown-content ul": {
406
276
  "list-style": "disc"
407
277
  },
408
- ".boring-viewer .boring-markdown-content ul ul": {
409
- "list-style": "circle"
410
- },
411
- ".boring-viewer .boring-markdown-content ul ul ul": {
412
- "list-style": "square"
413
- },
414
278
  ".boring-viewer .boring-markdown-content ol": {
415
279
  "list-style": "decimal"
416
280
  },
417
- ".boring-viewer .boring-markdown-content ol ol": {
418
- "list-style": "lower-alpha"
419
- },
420
- ".boring-viewer .boring-markdown-content ol ol ol": {
421
- "list-style": "lower-roman"
422
- },
423
281
  ".boring-viewer .boring-markdown-content li": {
424
282
  "margin": "0.25rem 0"
425
283
  },
426
- ".boring-viewer .boring-markdown-content li::marker": {
427
- "color": "var(--muted-foreground)"
428
- },
429
284
  ".boring-viewer .boring-markdown-content li > p": {
430
285
  "margin": "0"
431
286
  },
@@ -457,27 +312,29 @@
457
312
  "flex": "1 1 auto",
458
313
  "min-width": "0"
459
314
  },
460
- ".boring-viewer .boring-markdown-content ul[data-type=\"taskList\"] li[data-checked=\"true\"] > div": {
315
+ ".boring-viewer .boring-markdown-content li[data-checked=\"true\"] > div": {
461
316
  "color": "var(--muted-foreground)",
462
317
  "text-decoration": "line-through"
463
318
  },
464
319
  ".boring-viewer .boring-markdown-content strong": {
465
- "font-weight": "600"
320
+ "font-weight": "var(--font-weight-semibold)"
466
321
  },
467
322
  ".boring-viewer .boring-markdown-content code": {
468
323
  "padding": "0.1em 0.35em",
469
- "border-radius": "0.25rem",
470
- "background": "color-mix(in oklab, var(--foreground) 7%, transparent)",
471
- "font": "0.88em ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
324
+ "border-radius": "var(--radius-sm)",
325
+ "background": "var(--muted)",
326
+ "font-family": "var(--font-mono)",
327
+ "font-size": "var(--text-sm)"
472
328
  },
473
329
  ".boring-viewer .boring-markdown-content pre": {
474
330
  "margin": "1rem 0",
475
331
  "padding": "0.875rem 1rem",
476
332
  "overflow-x": "auto",
477
- "border": "1px solid color-mix(in oklab, var(--border) 80%, transparent)",
478
- "border-radius": "0.5rem",
479
- "background": "color-mix(in oklab, var(--foreground) 4%, transparent)",
480
- "font": "0.8125rem/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
333
+ "border": "var(--default-border-width) solid var(--border)",
334
+ "border-radius": "var(--radius-lg)",
335
+ "background": "var(--muted)",
336
+ "font-family": "var(--font-mono)",
337
+ "font-size": "var(--text-sm)"
481
338
  },
482
339
  ".boring-viewer .boring-markdown-content pre code": {
483
340
  "padding": "0",
@@ -487,35 +344,22 @@
487
344
  ".boring-viewer .boring-markdown-content blockquote": {
488
345
  "margin": "1rem 0",
489
346
  "padding": "0.5rem 1rem",
490
- "border-left": "3px solid color-mix(in oklab, var(--primary) 35%, transparent)",
491
- "border-radius": "0 0.375rem 0.375rem 0",
492
- "background": "color-mix(in oklab, var(--foreground) 4%, transparent)",
493
- "color": "color-mix(in oklab, var(--foreground) 85%, transparent)"
494
- },
495
- ".boring-viewer .boring-markdown-content blockquote > :first-child": {
496
- "margin-top": "0"
497
- },
498
- ".boring-viewer .boring-markdown-content blockquote > :last-child": {
499
- "margin-bottom": "0"
347
+ "border-inline-start": "var(--default-border-width) solid var(--border)",
348
+ "color": "var(--muted-foreground)"
500
349
  },
501
350
  ".boring-viewer .boring-markdown-content a": {
502
351
  "color": "var(--foreground)",
503
352
  "text-decoration": "underline",
504
- "text-decoration-thickness": "1px",
505
- "text-underline-offset": "3px",
506
- "text-decoration-color": "color-mix(in oklab, var(--foreground) 45%, transparent)",
507
353
  "cursor": "text"
508
354
  },
509
355
  ".boring-viewer .boring-markdown-content hr": {
510
356
  "margin": "1.5rem 0",
511
357
  "border": "0",
512
- "border-top": "1px solid color-mix(in oklab, var(--border) 85%, transparent)"
358
+ "border-top": "var(--default-border-width) solid var(--border)"
513
359
  },
514
360
  ".boring-viewer .boring-markdown-content mark": {
515
- "padding": "0 0.15em",
516
- "border-radius": "0.2em",
517
- "background": "color-mix(in oklab, #facc15 40%, transparent)",
518
- "color": "inherit"
361
+ "background": "var(--accent)",
362
+ "color": "var(--accent-foreground)"
519
363
  },
520
364
  ".boring-viewer .boring-markdown-content .tableWrapper": {
521
365
  "margin": "1rem 0",
@@ -524,84 +368,49 @@
524
368
  ".boring-viewer .boring-markdown-content table": {
525
369
  "width": "100%",
526
370
  "border-collapse": "collapse",
527
- "font-size": "0.875rem"
371
+ "font-size": "var(--text-sm)"
528
372
  },
529
373
  ".boring-viewer .boring-markdown-content :is(th, td)": {
530
374
  "min-width": "4rem",
531
375
  "padding": "0.5rem 0.75rem",
532
- "border": "1px solid color-mix(in oklab, var(--border) 90%, transparent)",
376
+ "border": "var(--default-border-width) solid var(--border)",
533
377
  "text-align": "left",
534
378
  "vertical-align": "top"
535
379
  },
536
380
  ".boring-viewer .boring-markdown-content th": {
537
- "background": "color-mix(in oklab, var(--foreground) 5%, transparent)",
538
- "font-weight": "600"
381
+ "background": "var(--muted)",
382
+ "font-weight": "var(--font-weight-semibold)"
539
383
  },
540
384
  ".boring-viewer .boring-markdown-content :is(th, td) > p": {
541
385
  "margin": "0"
542
386
  },
543
387
  ".boring-viewer .boring-markdown-content .selectedCell": {
544
- "background": "color-mix(in oklab, var(--primary) 12%, transparent)"
388
+ "background": "var(--accent)"
545
389
  },
546
390
  ".boring-viewer .boring-markdown-content img": {
547
391
  "display": "block",
548
392
  "max-width": "100%",
549
393
  "height": "auto",
550
- "margin": "1rem 0",
551
- "border-radius": "0.5rem"
394
+ "margin": "1rem 0"
552
395
  },
553
396
  ".boring-viewer .boring-markdown-content .boring-markdown-image": {
554
397
  "display": "inline-flex",
555
- "align-items": "center",
556
- "gap": "0.4rem",
557
398
  "max-width": "100%",
558
399
  "margin": "0.125rem 0",
559
400
  "padding": "0.125rem 0.5rem",
560
- "border": "1px dashed var(--border)",
561
- "border-radius": "0.375rem",
562
401
  "background": "var(--muted)",
563
- "color": "var(--muted-foreground)",
564
- "font-size": "0.85em",
565
- "line-height": "1.5"
566
- },
567
- ".boring-viewer .boring-markdown-content .boring-markdown-image::before": {
568
- "content": "\"\"",
569
- "flex": "none",
570
- "width": "0.85em",
571
- "height": "0.85em",
572
- "border": "1.5px solid currentColor",
573
- "border-radius": "0.15em",
574
- "opacity": "0.7"
575
- },
576
- ".boring-viewer .boring-markdown-content .ProseMirror-selectednode": {
577
- "outline": "2px solid color-mix(in oklab, var(--ring) 70%, transparent)",
578
- "outline-offset": "2px"
579
- },
580
- ".boring-viewer .boring-html-pane": {
581
- "display": "flex",
582
- "flex": "1 1 auto",
583
- "flex-direction": "column",
584
- "min-height": "0",
585
- "min-width": "0"
402
+ "color": "var(--muted-foreground)"
586
403
  },
587
404
  ".boring-viewer .boring-html-viewer__preview": {
588
405
  "display": "flex",
589
406
  "flex": "1 1 auto",
590
407
  "flex-direction": "column",
591
- "min-height": "0",
592
- "background": "var(--muted)"
408
+ "min-height": "0"
593
409
  },
594
410
  ".boring-viewer .boring-html-viewer__preview > p": {
595
411
  "flex": "none",
596
412
  "margin": "0",
597
- "padding": "0.375rem 0.75rem",
598
- "border-bottom": "1px solid var(--border)",
599
- "background": "var(--background)",
600
- "color": "var(--muted-foreground)",
601
- "font-size": "0.75rem"
602
- },
603
- ".boring-viewer .boring-html-viewer__preview > p[role=\"alert\"]": {
604
- "color": "var(--destructive)"
413
+ "padding": "0.375rem 0.75rem"
605
414
  },
606
415
  ".boring-viewer .boring-html-viewer__preview > div": {
607
416
  "display": "flex",
@@ -613,38 +422,48 @@
613
422
  "width": "100%",
614
423
  "height": "100%",
615
424
  "min-height": "0",
616
- "border": "0",
617
- "background": "#fff",
618
- "color-scheme": "light"
425
+ "border": "0"
619
426
  },
620
427
  ".boring-viewer .boring-viewer-checker": {
621
- "background-color": "#fff",
622
- "background-image": "linear-gradient(45deg, #d4d4d8 25%, transparent 25%, transparent 75%, #d4d4d8 75%), linear-gradient(45deg, #d4d4d8 25%, transparent 25%, transparent 75%, #d4d4d8 75%)",
428
+ "background-color": "var(--background)",
429
+ "background-image": "linear-gradient(45deg, var(--muted) 25%, transparent 25%, transparent 75%, var(--muted) 75%), linear-gradient(45deg, var(--muted) 25%, transparent 25%, transparent 75%, var(--muted) 75%)",
623
430
  "background-position": "0 0, 8px 8px",
624
431
  "background-size": "16px 16px"
625
432
  },
626
433
  ".boring-viewer .boring-viewer-stage": {
627
- "background": "var(--muted)",
628
- "scrollbar-width": "thin"
629
- },
630
- ".boring-viewer .boring-viewer-stage:focus-visible": {
631
- "outline": "2px solid color-mix(in oklab, var(--ring) 60%, transparent)",
632
- "outline-offset": "-2px"
434
+ "overflow": "auto"
633
435
  },
634
- "@media (pointer: coarse)": {
635
- ".boring-viewer .boring-markdown-tool": {
636
- "width": "2.75rem",
637
- "height": "2.75rem"
436
+ "@media (max-width: 640px)": {
437
+ ".boring-viewer .boring-markdown-content": {
438
+ "padding": "1.25rem 1.25rem 3rem"
439
+ },
440
+ ".boring-viewer .boring-markdown-toolbar": {
441
+ "display": "none"
442
+ },
443
+ ".boring-viewer.boring-markdown-dock": {
444
+ "display": "flex",
445
+ "align-items": "center",
446
+ "gap": "0.125rem",
447
+ "overflow-x": "auto",
448
+ "scrollbar-width": "none",
449
+ "position": "fixed",
450
+ "inset-inline": "0",
451
+ "z-index": "50",
452
+ "padding": "0.25rem 1.5rem 0.25rem 0.5rem",
453
+ "padding-bottom": "calc(0.25rem + env(safe-area-inset-bottom, 0px))",
454
+ "background": "var(--background)",
455
+ "mask-image": "linear-gradient(to right, black calc(100% - 2rem), transparent)"
638
456
  },
639
- ".boring-viewer .boring-markdown-link input, .boring-viewer .boring-markdown-link button, .boring-viewer .boring-markdown-alert button, .boring-viewer .boring-markdown-proposal > button": {
640
- "height": "2.75rem"
457
+ ".boring-viewer.boring-markdown-dock[data-keyboard]": {
458
+ "padding-bottom": "0.25rem"
641
459
  },
642
- ".boring-viewer .boring-markdown-link input, .boring-viewer .boring-markdown-source, .boring-viewer .boring-html-viewer__source": {
643
- "font-size": "1rem"
460
+ ".boring-viewer.boring-markdown-dock > .boring-markdown-dock-rule": {
461
+ "position": "absolute",
462
+ "inset-inline": "0",
463
+ "top": "0"
644
464
  },
645
- ".boring-viewer .boring-markdown-content ul[data-type=\"taskList\"] li > label input[type=\"checkbox\"]": {
646
- "width": "1.25rem",
647
- "height": "1.25rem"
465
+ ".boring-viewer [data-boring=\"markdown-editor\"][data-dock] .boring-markdown-content": {
466
+ "padding-bottom": "6rem"
648
467
  }
649
468
  }
650
469
  }