@hachej/boring-ui-kit 0.4.12 → 0.4.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/bin/boring-ui-blocks.mjs +8 -1
- package/dist/file-tree-view.d.ts +48 -3
- package/dist/file-tree-view.js +202 -12
- package/dist/file-tree.d.ts +31 -0
- package/dist/file-tree.js +122 -1
- package/dist/voice-capture.d.ts +10 -0
- package/dist/voice-capture.js +235 -39
- package/package.json +2 -2
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +9 -9
- package/r/board-viewer.json +12 -6
- package/r/button.json +1 -1
- package/r/diagram-viewer.json +8 -2
- package/r/dock.json +1 -1
- package/r/documents.json +14 -12
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +6 -6
- package/r/file-tree.json +8 -7
- package/r/highlights.json +3 -3
- package/r/html-viewer.json +3 -3
- package/r/looks-viewer.json +9 -3
- package/r/maquette-viewer.json +9 -3
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +14 -2
- package/r/pi-ambient.json +10 -10
- package/r/pi-app.json +31 -20
- package/r/pi-artifacts.json +3 -3
- package/r/pi-chat-approval.json +1 -1
- package/r/pi-chat-attachments.json +1 -1
- package/r/pi-chat-markdown.json +2 -2
- package/r/pi-chat-mentions.json +1 -1
- package/r/pi-chat-pickers.json +1 -1
- package/r/pi-chat-plugins.json +11 -11
- package/r/pi-chat-question.json +1 -1
- package/r/pi-chat-slash.json +1 -1
- package/r/pi-chat-subagent.json +1 -1
- package/r/pi-chat-voice.json +3 -3
- package/r/pi-chat.json +5 -5
- package/r/pi-workspace.json +4 -4
- package/r/placed-agent.json +4 -4
- package/r/provider-setup.json +2 -2
- package/r/record-viewer.json +8 -2
- package/r/registry.json +398 -204
- package/r/requests-board.json +1 -1
- package/r/shared-chat.json +6 -6
- package/r/task-list-viewer.json +2 -2
- package/r/version-history.json +3 -3
- package/r/viewer-code.json +36 -0
- package/r/viewer-frame.json +447 -0
- package/r/viewer-html.json +32 -0
- package/r/viewer-image.json +36 -0
- package/r/viewer-markdown.json +47 -0
- package/r/viewer-pdf.json +45 -0
- package/r/viewers.json +8 -491
- package/r/windows.json +1 -1
- package/styles/file-tree.css +13 -0
package/r/diagram-viewer.json
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"lucide-react@^1.51.0"
|
|
9
9
|
],
|
|
10
10
|
"registryDependencies": [
|
|
11
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/theme.json",
|
|
12
12
|
"alert",
|
|
13
13
|
"badge"
|
|
14
14
|
],
|
|
@@ -33,9 +33,15 @@
|
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
35
|
"path": "registry/diagram-viewer/diagram-viewer.tsx",
|
|
36
|
-
"content": "'use client';\n\n// The diagram viewer: an agent's `*.diagram.json` (format v1) drawn by `DiagramView`, with its highlighted path and note. `diagramKind` is\n// the kind the pi-app artifact panel uses by default for `.diagram.json` files; an app's own kind in `artifactKinds` wins. The maquette\n// viewer draws its workflow (\"How it works\") with the same `DiagramView`. It draws data only: nothing in the file runs.\nimport { useMemo } from 'react';\nimport
|
|
36
|
+
"content": "'use client';\n\n// The diagram viewer: an agent's `*.diagram.json` (format v1) drawn by `DiagramView`, with its highlighted path and note. `diagramKind` (diagram-kind.ts) is\n// the kind the pi-app artifact panel uses by default for `.diagram.json` files; an app's own kind in `artifactKinds` wins. The maquette\n// viewer draws its workflow (\"How it works\") with the same `DiagramView`. It draws data only: nothing in the file runs.\nimport { useMemo } from 'react';\nimport { InfoIcon, TriangleAlertIcon } from 'lucide-react';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport { DIAGRAM_FORMAT_VERSION, parseDiagram } from './diagram';\nimport { diagramLabelsFor } from './diagram-labels';\nimport type { DiagramLabels, DiagramLocale } from './diagram-labels';\nimport { DiagramView } from './diagram-view';\n\nexport { DIAGRAM_FORMAT_VERSION, isDiagramPath, parseDiagram } from './diagram';\nexport type { Diagram, DiagramEdge, DiagramFileV1, DiagramGroup, DiagramNode, DiagramParse } from './diagram';\nexport { DiagramView } from './diagram-view';\nexport type { DiagramViewLabels } from './diagram-view';\nexport { defaultDiagramLabels, diagramLabelsFor, frenchDiagramLabels } from './diagram-labels';\nexport type { DiagramLabels, DiagramLocale } from './diagram-labels';\n\nexport interface DiagramViewerProps {\n /** The file's text (JSON). */\n readonly text: string;\n /** The base words: English by default. */\n readonly locale?: DiagramLocale | undefined;\n /** Any word over the locale's. */\n readonly labels?: Partial<DiagramLabels> | undefined;\n}\n\n/** A diagram file; a newer format version shows a notice, an unreadable file an inline error. */\nexport function DiagramViewer({ text, locale, labels: given }: DiagramViewerProps) {\n const labels = useMemo(() => diagramLabelsFor(locale, given), [locale, given]);\n const parsed = useMemo(() => parseDiagram(text), [text]);\n const newer = parsed.version !== undefined && parsed.version > DIAGRAM_FORMAT_VERSION;\n return <section data-boring=\"diagram-viewer\" data-state={parsed.kind} aria-label={parsed.kind === 'ok' ? parsed.diagram.title || labels.kind : labels.kind}\n className=\"mx-auto flex w-full max-w-5xl min-w-0 flex-col gap-3 px-3 pt-2 pb-4 sm:px-6\">\n {newer && <Alert data-testid=\"diagram-newer\"><InfoIcon aria-hidden=\"true\" /><AlertTitle>{labels.newerTitle}</AlertTitle><AlertDescription>{labels.newer(parsed.version!)}</AlertDescription></Alert>}\n {parsed.kind === 'ok' ? <DiagramView diagram={parsed.diagram} labels={labels} />\n : <Alert variant=\"destructive\" data-testid=\"diagram-error\" data-reason={parsed.reason}><TriangleAlertIcon aria-hidden=\"true\" /><AlertTitle>{labels.errorTitle}</AlertTitle>\n <AlertDescription>{parsed.reason === 'json' ? labels.invalidJson : labels.invalidShape}</AlertDescription></Alert>}\n </section>;\n}\n",
|
|
37
37
|
"type": "registry:component",
|
|
38
38
|
"target": "components/diagram-viewer/diagram-viewer.tsx"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"path": "registry/diagram-viewer/diagram-kind.tsx",
|
|
42
|
+
"content": "'use client';\n\nimport { lazy } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { WorkflowIcon } from 'lucide-react';\nimport { isDiagramPath } from './diagram';\nimport { diagramLabelsFor } from './diagram-labels';\nimport type { DiagramLabels, DiagramLocale } from './diagram-labels';\n\nconst DiagramViewer = lazy(() => import('./diagram-viewer').then(module => ({ default: module.DiagramViewer })));\n\n/**\n * The artifact kind of a diagram file (structurally an `ArtifactKind` of pi-artifacts): it matches `*.diagram.json`, names it, gives its icon\n * and draws it with `DiagramViewer`, loaded on first open. Pass it, or your own, in `artifactKinds`; kinds an app passes are consulted first.\n */\nexport interface DiagramKind {\n readonly match: (target: { readonly resource: { readonly path: string } }) => boolean;\n readonly label: string;\n readonly icon: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }>;\n readonly renderer: (props: { readonly text: string }) => ReactNode;\n}\n\n/** A diagram kind in `locale` (\"Diagram\" in English, \"Diagramme\" in French) with any word overridden. */\nexport function createDiagramKind({ locale, labels }: { readonly locale?: DiagramLocale | undefined; readonly labels?: Partial<DiagramLabels> | undefined } = {}): DiagramKind {\n return {\n match: target => isDiagramPath(target.resource.path),\n label: diagramLabelsFor(locale, labels).kind,\n icon: WorkflowIcon,\n renderer: ({ text }) => <DiagramViewer text={text} locale={locale} labels={labels} />,\n };\n}\n\n/** The built-in diagram kind, in English. */\nexport const diagramKind: DiagramKind = createDiagramKind();\n",
|
|
43
|
+
"type": "registry:component",
|
|
44
|
+
"target": "components/diagram-viewer/diagram-kind.tsx"
|
|
39
45
|
}
|
|
40
46
|
],
|
|
41
47
|
"type": "registry:component"
|
package/r/dock.json
CHANGED
package/r/documents.json
CHANGED
|
@@ -4,20 +4,22 @@
|
|
|
4
4
|
"title": "Documents: the Library, a file and its revisions",
|
|
5
5
|
"description": "The workspace's Library as the list, the chosen file in its viewer and its retained revisions beside it (an older one opens read-only). DocumentSheet opens one document over the app for a host's « Voir la fiche », with an optional « Toutes les fiches ». Every read goes through one revisionProvider binding.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.14",
|
|
8
|
+
"@hachej/boring-files@^0.4.14",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"lucide-react@^1.51.0"
|
|
11
11
|
],
|
|
12
12
|
"registryDependencies": [
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
17
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
18
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
19
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
20
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/theme.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/utils.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/button.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/pi-artifacts.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/pi-app.json",
|
|
18
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/app-shell.json",
|
|
19
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/file-tree.json",
|
|
20
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/viewer-frame.json",
|
|
21
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/viewer-image.json",
|
|
22
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/viewer-pdf.json",
|
|
21
23
|
"drawer",
|
|
22
24
|
"button",
|
|
23
25
|
"scroll-area",
|
|
@@ -26,7 +28,7 @@
|
|
|
26
28
|
"files": [
|
|
27
29
|
{
|
|
28
30
|
"path": "registry/documents/documents.tsx",
|
|
29
|
-
"content": "'use client';\n\n// The documents screen: the workspace's Library is the list (`ListDetail`: beside the file on a wide screen, one pane at a time on a phone);\n// the chosen file opens in the same viewer the Library uses (editable where it edits); its Revisions sit beside it. Every revision row opens\n// that exact revision read-only, and « Back to editing » returns to the file. The editor stays mounted while a revision is inspected, so its\n// unsaved edits survive; leaving the file with unsaved edits asks first. Every read goes through the one `revisionProvider` the host binds.\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { createFileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport type { FileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport { FileTreeView } from '../file-tree/file-tree';\nimport type { RevisionProvider } from '@hachej/boring-files/revision';\nimport { mediaTypeOf, viewerKindOf } from '@hachej/boring-files/file-types';\nimport { checkBlobBase, withBlobPointers } from '@hachej/boring-files/blob';\nimport type { ViewerKind } from '@hachej/boring-files/file-types';\nimport { ListDetail } from '../app-shell/list-detail';\nimport { ArtifactPanel } from '../pi-app/artifact-panel';\nimport type { ArtifactDescriptor } from '../pi-artifacts/artifact';\nimport type { CustomViewers, ViewerOptions } from '../pi-app/artifact-panel';\nimport { FileViewer } from '../pi-app/file-viewer';\nimport { ImagePane } from '../viewers/image-pane';\nimport { PdfPane } from '../viewers/pdf-pane';\nimport { ViewerFrame } from '../viewers/viewer-frame';\nimport { useViewerText } from '../viewers/viewer-window';\nimport { cn } from '../utils/utils';\nimport { Revisions } from './revisions';\nimport type { DocumentsLabels } from './documents-labels';\nimport { documentsLabels, DocumentsWords } from './words';\nimport type { DocumentsLocale } from './words';\n\nexport interface DocumentsProps {\n /** The workspace's revision provider (the files guide's `connectRevisionProvider`, or a host's own binding). Borrowed: the host disposes it. */\n readonly provider: RevisionProvider;\n readonly locale?: DocumentsLocale | undefined;\n /** The block's own words, over `documentsLabels` (the viewers' words follow `locale`). */\n readonly labels?: Partial<DocumentsLabels> | undefined;\n /** Host viewers by kind, as for the Library (for example a canvas). */\n readonly viewers?: CustomViewers | undefined;\n readonly share?: ViewerOptions['share'];\n /** Where the host serves the bytes of large files (blob pointers): a same-origin path starting with `/`, default `/_files/`. */\n readonly blobBase?: string | undefined;\n}\n\nconst nameOf = (path: string) => path.split('/').pop() || path;\n\n/** The workspace's change feed as a counter: it moves on every change, so the listings and the revisions are read again. */\nfunction useWorkspaceVersion(provider: RevisionProvider): number {\n const [version, setVersion] = useState(0);\n useEffect(() => {\n if (!provider.changes) return undefined;\n const abort = new AbortController();\n void (async () => {\n try { for await (const change of provider.changes!({ signal: abort.signal })) { void change; setVersion(current => current + 1); } } catch { /* the binding ended the feed: the listings stay as they are */ }\n })();\n return () => abort.abort();\n }, [provider]);\n return version;\n}\n\n/** The Library's tree over the provider: created and disposed with the binding, read again when the workspace changes. */\nfunction useFileTree(provider: RevisionProvider, version: number, blobBase: string): FileTreeController | undefined {\n const [tree, setTree] = useState<FileTreeController>();\n useEffect(() => {\n const next = createFileTreeController({ revisionProvider: withBlobPointers(provider, blobBase) });\n setTree(next);\n void next.refresh();\n return () => next.dispose();\n }, [provider, blobBase]);\n useEffect(() => {\n if (!tree || version === 0) return;\n const { query, expanded } = tree.getSnapshot();\n if (query) void tree.search(query);\n else for (const directory of ['', ...expanded]) void tree.refresh(directory);\n }, [tree, version]);\n return tree;\n}\n\n/**\n * The provider as it reads one revision: every read of the returned client is the exact `revision`, over the same provider (a reload in a\n * pinned view re-reads that revision, never the latest). It is for read-only views only.\n */\nfunction pinnedProviderOf(provider: RevisionProvider, revision: string): RevisionProvider {\n return { ...provider, read: (request, signal) => provider.read({ target: request.target, revision: { kind: 'exact', value: revision } }, signal) };\n}\n\n/** An image or PDF revision, read exactly and shown in the viewer's read-only pane. */\nfunction BinaryRevision({ provider, path, revision, kind, labels, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly revision: string;\n readonly kind: 'image' | 'pdf';\n readonly labels: DocumentsLabels;\n readonly onClose: () => void;\n}) {\n const [state, setState] = useState<{ readonly kind: 'loading' } | { readonly kind: 'failed' } | { readonly kind: 'ready'; readonly bytes: Uint8Array; readonly mediaType: string }>({ kind: 'loading' });\n const { labels: viewer } = useViewerText();\n useEffect(() => {\n const abort = new AbortController();\n setState({ kind: 'loading' });\n provider.read({ target: provider.locate(path), revision: { kind: 'exact', value: revision } }, abort.signal).then(result => {\n if (!abort.signal.aborted) setState(result.kind === 'available' ? { kind: 'ready', bytes: result.snapshot.bytes, mediaType: result.snapshot.mediaType } : { kind: 'failed' });\n }, () => { if (!abort.signal.aborted) setState({ kind: 'failed' }); });\n return () => abort.abort();\n }, [provider, path, revision]);\n const status = { label: viewer.readOnly, kind: 'read-only' as const };\n if (state.kind !== 'ready') return <ViewerFrame title={nameOf(path)} status={status} onClose={onClose}>\n <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{state.kind === 'loading' ? labels.revisionsLoading : labels.revisionLoadFailed}</p>\n </ViewerFrame>;\n return kind === 'image'\n ? <ImagePane name={nameOf(path)} mediaType={state.mediaType} bytes={state.bytes} status={status} onClose={onClose} />\n : <PdfPane name={nameOf(path)} bytes={state.bytes} status={status} onClose={onClose} />;\n}\n\n/** One revision of the file, read-only, with « Back to editing ». Text, Markdown and HTML use the artifact panel's pinned view. */\nfunction OlderVersion({ provider, path, revision, options, labels, onBack, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly revision: string;\n readonly options: ViewerOptions;\n readonly labels: DocumentsLabels;\n readonly onBack: () => void;\n readonly onClose: () => void;\n}) {\n const kind: ViewerKind = viewerKindOf(path);\n const pinned = useMemo(() => pinnedProviderOf(provider, revision), [provider, revision]);\n const type = kind === 'markdown' ? 'markdown' : kind === 'html' ? 'html' : kind === 'text' ? 'code' : undefined;\n const artifact: ArtifactDescriptor | undefined = type === undefined ? undefined\n : { schema: 'boring.artifact', version: 1, title: nameOf(path), type, mediaType: mediaTypeOf(path), target: provider.locate(path), revision };\n const bar = <>\n <div className=\"flex shrink-0 items-center gap-2 px-3 py-2\">\n <span role=\"status\" className=\"min-w-0 flex-1 truncate text-sm font-medium\">{labels.readOnlyRevision}</span>\n <Button variant=\"ghost\" data-testid=\"back-to-editing\" onClick={onBack}>{labels.backToEditing}</Button>\n </div>\n <Separator />\n </>;\n let body;\n if (artifact) body = <ArtifactPanel active={{ descriptor: artifact, follow: false }} versions={[]} options={{ ...options, client: pinned }} onSelect={onBack} onClose={onClose} />;\n else if (kind === 'image' || kind === 'pdf') body = <BinaryRevision provider={provider} path={path} revision={revision} kind={kind} labels={labels} onClose={onClose} />;\n else body = <ViewerFrame title={nameOf(path)} onClose={onClose}><p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.unsupportedPreview}</p></ViewerFrame>;\n return <div data-testid=\"revision-view\" data-kind={kind} data-revision={revision} className=\"flex h-full min-h-0 flex-1 flex-col\">\n {bar}\n <div className=\"flex min-h-0 flex-1 flex-col\">{body}</div>\n </div>;\n}\n\nfunction DocumentDetail({ provider, path, version, options, labels, locale, older, onDirty, onOpenRevision, onBack, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly version: number;\n readonly options: ViewerOptions;\n readonly labels: DocumentsLabels;\n readonly locale: DocumentsLocale;\n /** The older revision on show, `undefined` for the editable file. */\n readonly older: string | undefined;\n readonly onDirty: (unsaved: boolean) => void;\n readonly onOpenRevision: (revision: string) => void;\n readonly onBack: () => void;\n readonly onClose: () => void;\n}) {\n return <div data-testid=\"documents-detail\" className=\"flex h-full min-h-0 flex-col md:flex-row\">\n <div className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n {/* The editor stays mounted (hidden) while a revision is on show, so its unsaved edits are kept. */}\n <div className={cn('min-h-0 flex-1 flex-col', older === undefined ? 'flex' : 'hidden')}>\n <FileViewer path={path} locator={provider.locate(path)} options={options} onClose={onClose} onUnsavedChange={onDirty} />\n </div>\n {older !== undefined && <OlderVersion provider={provider} path={path} revision={older} options={options} labels={labels} onBack={onBack} onClose={onClose} />}\n </div>\n <Separator className=\"md:hidden\" />\n <Separator orientation=\"vertical\" className=\"max-md:hidden\" />\n <aside className=\"flex max-h-[40%] min-h-0 shrink-0 flex-col overflow-hidden md:max-h-none md:w-72\">\n <Revisions provider={provider} path={path} version={version} selected={older} locale={locale} labels={labels} onOpen={onOpenRevision} />\n </aside>\n </div>;\n}\n\nexport function Documents({ provider, locale, labels: given, viewers, share, blobBase: givenBase }: DocumentsProps) {\n const labels = documentsLabels(locale, given);\n const language: DocumentsLocale = locale === 'fr' ? 'fr' : 'en';\n const version = useWorkspaceVersion(provider);\n const blobBase = checkBlobBase(givenBase);\n const tree = useFileTree(provider, version, blobBase);\n const [path, setPath] = useState<string>();\n // An older revision of the file on show (`undefined`: the editable file).\n const [pinned, setPinned] = useState<{ readonly path: string; readonly revision: string }>();\n // The file's unsaved edits, as its viewer reports them. A change of file or a close that would drop them asks first.\n const dirty = useRef(false);\n const onDirty = useCallback((unsaved: boolean) => { dirty.current = unsaved; }, []);\n const mayLeave = () => !dirty.current || window.confirm(labels.discardChanges);\n const options = useMemo<ViewerOptions>(() => ({ client: provider, identity: provider.identity, blobBase, ...(viewers ? { viewers } : {}), ...(share ? { share } : {}) }), [provider, viewers, share, blobBase]);\n const choose = (next: string) => {\n if (next === path || !mayLeave()) return;\n setPath(next);\n setPinned(undefined);\n };\n const closeFile = () => { if (!mayLeave()) return; setPath(undefined); setPinned(undefined); };\n const list = tree ? <div className=\"min-h-0\">\n <FileTreeView controller={tree} onOpen={choose} selectedPath={path} locale={language} upload={false} history={false} />\n </div> : null;\n const older = path !== undefined && pinned?.path === path ? pinned.revision : undefined;\n // `null` (nothing chosen) is what lets a phone show the list alone: ListDetail stacks the detail only when there is one.\n const detail = path === undefined ? null\n : <DocumentDetail key={path} provider={provider} path={path} version={version} options={options} labels={labels} locale={language} older={older}\n onDirty={onDirty} onOpenRevision={revision => setPinned({ path, revision })} onBack={() => setPinned(undefined)} onClose={closeFile} />;\n return <DocumentsWords locale={locale}>\n <ListDetail list={list} detail={detail} onBack={closeFile} />\n </DocumentsWords>;\n}\n",
|
|
31
|
+
"content": "'use client';\n\n// The documents screen: the workspace's Library is the list (`ListDetail`: beside the file on a wide screen, one pane at a time on a phone);\n// the chosen file opens in the same viewer the Library uses (editable where it edits); its Revisions sit beside it. Every revision row opens\n// that exact revision read-only, and « Back to editing » returns to the file. The editor stays mounted while a revision is inspected, so its\n// unsaved edits survive; leaving the file with unsaved edits asks first. Every read goes through the one `revisionProvider` the host binds.\nimport { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Separator } from '@/components/ui/separator';\nimport { createFileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport type { FileTreeController } from '@hachej/boring-ui-kit/file-tree';\nimport { FileTreeView } from '../file-tree/file-tree';\nimport type { RevisionProvider } from '@hachej/boring-files/revision';\nimport { mediaTypeOf, viewerKindOf } from '@hachej/boring-files/file-types';\nimport { checkBlobBase, withBlobPointers } from '@hachej/boring-files/blob';\nimport type { ViewerKind } from '@hachej/boring-files/file-types';\nimport { ListDetail } from '../app-shell/list-detail';\nimport { ArtifactPanel } from '../pi-app/artifact-panel';\nimport type { ArtifactDescriptor } from '../pi-artifacts/artifact';\nimport type { CustomViewers, ViewerOptions } from '../pi-app/artifact-panel';\nimport { FileViewer } from '../pi-app/file-viewer';\nimport { lazyView, ViewLoading } from '../pi-app/viewer-kinds';\nimport { imageKind } from '../viewer-image/image-kind';\nimport { pdfKind } from '../viewer-pdf/pdf-kind';\nimport { ViewerFrame } from '../viewer-frame/viewer-frame';\nimport { useViewerText } from '../viewer-frame/viewer-window';\nimport { cn } from '../utils/utils';\nimport { Revisions } from './revisions';\nimport type { DocumentsLabels } from './documents-labels';\nimport { documentsLabels, DocumentsWords } from './words';\nimport type { DocumentsLocale } from './words';\n\nexport interface DocumentsProps {\n /** The workspace's revision provider (the files guide's `connectRevisionProvider`, or a host's own binding). Borrowed: the host disposes it. */\n readonly provider: RevisionProvider;\n readonly locale?: DocumentsLocale | undefined;\n /** The block's own words, over `documentsLabels` (the viewers' words follow `locale`). */\n readonly labels?: Partial<DocumentsLabels> | undefined;\n /** Host viewers by kind, as for the Library (for example a canvas). */\n readonly viewers?: CustomViewers | undefined;\n readonly share?: ViewerOptions['share'];\n /** Where the host serves the bytes of large files (blob pointers): a same-origin path starting with `/`, default `/_files/`. */\n readonly blobBase?: string | undefined;\n}\n\nconst nameOf = (path: string) => path.split('/').pop() || path;\n\n/** The workspace's change feed as a counter: it moves on every change, so the listings and the revisions are read again. */\nfunction useWorkspaceVersion(provider: RevisionProvider): number {\n const [version, setVersion] = useState(0);\n useEffect(() => {\n if (!provider.changes) return undefined;\n const abort = new AbortController();\n void (async () => {\n try { for await (const change of provider.changes!({ signal: abort.signal })) { void change; setVersion(current => current + 1); } } catch { /* the binding ended the feed: the listings stay as they are */ }\n })();\n return () => abort.abort();\n }, [provider]);\n return version;\n}\n\n/** The Library's tree over the provider: created and disposed with the binding, read again when the workspace changes. */\nfunction useFileTree(provider: RevisionProvider, version: number, blobBase: string): FileTreeController | undefined {\n const [tree, setTree] = useState<FileTreeController>();\n useEffect(() => {\n const next = createFileTreeController({ revisionProvider: withBlobPointers(provider, blobBase) });\n setTree(next);\n void next.refresh();\n return () => next.dispose();\n }, [provider, blobBase]);\n useEffect(() => {\n if (!tree || version === 0) return;\n const { query, expanded } = tree.getSnapshot();\n if (query) void tree.search(query);\n else for (const directory of ['', ...expanded]) void tree.refresh(directory);\n }, [tree, version]);\n return tree;\n}\n\n/**\n * The provider as it reads one revision: every read of the returned client is the exact `revision`, over the same provider (a reload in a\n * pinned view re-reads that revision, never the latest). It is for read-only views only.\n */\nfunction pinnedProviderOf(provider: RevisionProvider, revision: string): RevisionProvider {\n return { ...provider, read: (request, signal) => provider.read({ target: request.target, revision: { kind: 'exact', value: revision } }, signal) };\n}\n\n/** An image or PDF revision, read exactly and shown in the viewer's read-only pane. */\nfunction BinaryRevision({ provider, path, revision, kind, labels, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly revision: string;\n readonly kind: 'image' | 'pdf';\n readonly labels: DocumentsLabels;\n readonly onClose: () => void;\n}) {\n const [state, setState] = useState<{ readonly kind: 'loading' } | { readonly kind: 'failed' } | { readonly kind: 'ready'; readonly bytes: Uint8Array; readonly mediaType: string }>({ kind: 'loading' });\n const { labels: viewer } = useViewerText();\n useEffect(() => {\n const abort = new AbortController();\n setState({ kind: 'loading' });\n provider.read({ target: provider.locate(path), revision: { kind: 'exact', value: revision } }, abort.signal).then(result => {\n if (!abort.signal.aborted) setState(result.kind === 'available' ? { kind: 'ready', bytes: result.snapshot.bytes, mediaType: result.snapshot.mediaType } : { kind: 'failed' });\n }, () => { if (!abort.signal.aborted) setState({ kind: 'failed' }); });\n return () => abort.abort();\n }, [provider, path, revision]);\n const status = { label: viewer.readOnly, kind: 'read-only' as const };\n if (state.kind !== 'ready') return <ViewerFrame title={nameOf(path)} status={status} onClose={onClose}>\n <p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{state.kind === 'loading' ? labels.revisionsLoading : labels.revisionLoadFailed}</p>\n </ViewerFrame>;\n // The panes load the first time an image or a PDF revision opens (D-257), like the file viewer's.\n const ImagePane = lazyView(imageKind.load), PdfPane = lazyView(pdfKind.load);\n return <Suspense fallback={<ViewerFrame testId=\"viewer-pending\" title={nameOf(path)} status={status} onClose={onClose}><ViewLoading label={viewer.loading} /></ViewerFrame>}>\n {kind === 'image'\n ? <ImagePane name={nameOf(path)} mediaType={state.mediaType} bytes={state.bytes} status={status} onClose={onClose} />\n : <PdfPane name={nameOf(path)} bytes={state.bytes} status={status} onClose={onClose} />}\n </Suspense>;\n}\n\n/** One revision of the file, read-only, with « Back to editing ». Text, Markdown and HTML use the artifact panel's pinned view. */\nfunction OlderVersion({ provider, path, revision, options, labels, onBack, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly revision: string;\n readonly options: ViewerOptions;\n readonly labels: DocumentsLabels;\n readonly onBack: () => void;\n readonly onClose: () => void;\n}) {\n const kind: ViewerKind = viewerKindOf(path);\n const pinned = useMemo(() => pinnedProviderOf(provider, revision), [provider, revision]);\n const type = kind === 'markdown' ? 'markdown' : kind === 'html' ? 'html' : kind === 'text' ? 'code' : undefined;\n const artifact: ArtifactDescriptor | undefined = type === undefined ? undefined\n : { schema: 'boring.artifact', version: 1, title: nameOf(path), type, mediaType: mediaTypeOf(path), target: provider.locate(path), revision };\n const bar = <>\n <div className=\"flex shrink-0 items-center gap-2 px-3 py-2\">\n <span role=\"status\" className=\"min-w-0 flex-1 truncate text-sm font-medium\">{labels.readOnlyRevision}</span>\n <Button variant=\"ghost\" data-testid=\"back-to-editing\" onClick={onBack}>{labels.backToEditing}</Button>\n </div>\n <Separator />\n </>;\n let body;\n if (artifact) body = <ArtifactPanel active={{ descriptor: artifact, follow: false }} versions={[]} options={{ ...options, client: pinned }} onSelect={onBack} onClose={onClose} />;\n else if (kind === 'image' || kind === 'pdf') body = <BinaryRevision provider={provider} path={path} revision={revision} kind={kind} labels={labels} onClose={onClose} />;\n else body = <ViewerFrame title={nameOf(path)} onClose={onClose}><p role=\"status\" className=\"m-0 p-4 text-sm text-muted-foreground\">{labels.unsupportedPreview}</p></ViewerFrame>;\n return <div data-testid=\"revision-view\" data-kind={kind} data-revision={revision} className=\"flex h-full min-h-0 flex-1 flex-col\">\n {bar}\n <div className=\"flex min-h-0 flex-1 flex-col\">{body}</div>\n </div>;\n}\n\nfunction DocumentDetail({ provider, path, version, options, labels, locale, older, onDirty, onOpenRevision, onBack, onClose }: {\n readonly provider: RevisionProvider;\n readonly path: string;\n readonly version: number;\n readonly options: ViewerOptions;\n readonly labels: DocumentsLabels;\n readonly locale: DocumentsLocale;\n /** The older revision on show, `undefined` for the editable file. */\n readonly older: string | undefined;\n readonly onDirty: (unsaved: boolean) => void;\n readonly onOpenRevision: (revision: string) => void;\n readonly onBack: () => void;\n readonly onClose: () => void;\n}) {\n return <div data-testid=\"documents-detail\" className=\"flex h-full min-h-0 flex-col md:flex-row\">\n <div className=\"flex min-h-0 min-w-0 flex-1 flex-col\">\n {/* The editor stays mounted (hidden) while a revision is on show, so its unsaved edits are kept. */}\n <div className={cn('min-h-0 flex-1 flex-col', older === undefined ? 'flex' : 'hidden')}>\n <FileViewer path={path} locator={provider.locate(path)} options={options} onClose={onClose} onUnsavedChange={onDirty} />\n </div>\n {older !== undefined && <OlderVersion provider={provider} path={path} revision={older} options={options} labels={labels} onBack={onBack} onClose={onClose} />}\n </div>\n <Separator className=\"md:hidden\" />\n <Separator orientation=\"vertical\" className=\"max-md:hidden\" />\n <aside className=\"flex max-h-[40%] min-h-0 shrink-0 flex-col overflow-hidden md:max-h-none md:w-72\">\n <Revisions provider={provider} path={path} version={version} selected={older} locale={locale} labels={labels} onOpen={onOpenRevision} />\n </aside>\n </div>;\n}\n\nexport function Documents({ provider, locale, labels: given, viewers, share, blobBase: givenBase }: DocumentsProps) {\n const labels = documentsLabels(locale, given);\n const language: DocumentsLocale = locale === 'fr' ? 'fr' : 'en';\n const version = useWorkspaceVersion(provider);\n const blobBase = checkBlobBase(givenBase);\n const tree = useFileTree(provider, version, blobBase);\n const [path, setPath] = useState<string>();\n // An older revision of the file on show (`undefined`: the editable file).\n const [pinned, setPinned] = useState<{ readonly path: string; readonly revision: string }>();\n // The file's unsaved edits, as its viewer reports them. A change of file or a close that would drop them asks first.\n const dirty = useRef(false);\n const onDirty = useCallback((unsaved: boolean) => { dirty.current = unsaved; }, []);\n const mayLeave = () => !dirty.current || window.confirm(labels.discardChanges);\n const options = useMemo<ViewerOptions>(() => ({ client: provider, identity: provider.identity, blobBase, ...(viewers ? { viewers } : {}), ...(share ? { share } : {}) }), [provider, viewers, share, blobBase]);\n const choose = (next: string) => {\n if (next === path || !mayLeave()) return;\n setPath(next);\n setPinned(undefined);\n };\n const closeFile = () => { if (!mayLeave()) return; setPath(undefined); setPinned(undefined); };\n // The file on show was renamed, moved or deleted from the list: follow it to its new path, or close it (its old path is gone).\n const renamed = (from: string, to: string) => { setPath(current => current === from ? to : current); setPinned(undefined); };\n const deleted = (gone: string) => { setPath(current => { if (current !== gone) return current; dirty.current = false; return undefined; }); setPinned(undefined); };\n const list = tree ? <div className=\"min-h-0\">\n <FileTreeView controller={tree} onOpen={choose} onRenamed={renamed} onDeleted={deleted} selectedPath={path} locale={language} upload={false} history={false} />\n </div> : null;\n const older = path !== undefined && pinned?.path === path ? pinned.revision : undefined;\n // `null` (nothing chosen) is what lets a phone show the list alone: ListDetail stacks the detail only when there is one.\n const detail = path === undefined ? null\n : <DocumentDetail key={path} provider={provider} path={path} version={version} options={options} labels={labels} locale={language} older={older}\n onDirty={onDirty} onOpenRevision={revision => setPinned({ path, revision })} onBack={() => setPinned(undefined)} onClose={closeFile} />;\n return <DocumentsWords locale={locale}>\n <ListDetail list={list} detail={detail} onBack={closeFile} />\n </DocumentsWords>;\n}\n",
|
|
30
32
|
"type": "registry:component",
|
|
31
33
|
"target": "components/documents/documents.tsx"
|
|
32
34
|
},
|
|
@@ -44,7 +46,7 @@
|
|
|
44
46
|
},
|
|
45
47
|
{
|
|
46
48
|
"path": "registry/documents/words.tsx",
|
|
47
|
-
"content": "'use client';\n\n// The words the documents block shows: its own, and the viewers' (the pi-app labels the file viewer and the artifact panel read, and the\n// viewer window's labels the viewer bars read). One locale for all of them: `fr` takes the French sets, anything else English, and a\n// `labels` object overrides any word of the block's own.\nimport type { ReactNode } from 'react';\nimport { defaultAppIcons, defaultAppLabels, AppTextProvider } from '../pi-app/app-labels';\nimport { frenchAppLabels } from '../pi-app/app-labels-fr';\nimport { defaultViewerIcons, defaultViewerLabels, ViewerWindowProvider } from '../
|
|
49
|
+
"content": "'use client';\n\n// The words the documents block shows: its own, and the viewers' (the pi-app labels the file viewer and the artifact panel read, and the\n// viewer window's labels the viewer bars read). One locale for all of them: `fr` takes the French sets, anything else English, and a\n// `labels` object overrides any word of the block's own.\nimport type { ReactNode } from 'react';\nimport { defaultAppIcons, defaultAppLabels, AppTextProvider } from '../pi-app/app-labels';\nimport { frenchAppLabels } from '../pi-app/app-labels-fr';\nimport { defaultViewerIcons, defaultViewerLabels, ViewerWindowProvider } from '../viewer-frame/viewer-window';\nimport { frenchViewerLabels } from '../viewer-frame/viewer-labels-fr';\nimport { defaultDocumentsLabels, frenchDocumentsLabels } from './documents-labels';\nimport type { DocumentsLabels } from './documents-labels';\n\nexport type DocumentsLocale = 'en' | 'fr';\n\n/** The block's own words for `locale`, with the host's overrides. */\nexport function documentsLabels(locale: DocumentsLocale | undefined, given: Partial<DocumentsLabels> | undefined): DocumentsLabels {\n return { ...(locale === 'fr' ? frenchDocumentsLabels : defaultDocumentsLabels), ...given };\n}\n\n/** Provides the app's and the viewers' words for `locale` to everything below (the file viewer, the artifact panel, the viewer bars). */\nexport function DocumentsWords({ locale, children }: { readonly locale: DocumentsLocale | undefined; readonly children: ReactNode }) {\n const fr = locale === 'fr';\n return <AppTextProvider value={{ labels: fr ? frenchAppLabels : defaultAppLabels, icons: defaultAppIcons }}>\n <ViewerWindowProvider value={{ labels: fr ? frenchViewerLabels : defaultViewerLabels, icons: defaultViewerIcons }}>{children}</ViewerWindowProvider>\n </AppTextProvider>;\n}\n",
|
|
48
50
|
"type": "registry:component",
|
|
49
51
|
"target": "components/documents/words.tsx"
|
|
50
52
|
},
|
package/r/feedback-preview.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"title": "Feedback preview",
|
|
5
5
|
"description": "PreviewBanner: a live, reversible preview of a change in the page (Approve / Discard) over `@hachej/boring-feedback/preview`, whose subagent calls models through a host gateway. Separate from `feedback` so annotation-only apps do not install the model SDK; install `feedback` first (its styles cover the banner).",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-feedback@^0.4.
|
|
7
|
+
"@hachej/boring-feedback@^0.4.14",
|
|
8
8
|
"@earendil-works/pi-ai@~1.2.0",
|
|
9
9
|
"react@^19.0.0"
|
|
10
10
|
],
|
package/r/feedback.json
CHANGED
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
"title": "Feedback",
|
|
5
5
|
"description": "Feedback from the chat composer: one Feedback button starts feedback mode on the application page (hover, click to pin a numbered note, hold ⌥ to use the app, optional voice), and one chip carries the notes to the next message. FeedbackBar, NoteBubble, FeedbackChip with its review, useComposerFeedback for the pi-chat composer, and PickerOverlay styles. Optional, not mounted by default: PointButton, AnnotateSheet, FeedbackList and FeedbackReport. Picking, masking, anchoring and placement stay in @hachej/boring-feedback/page and /ui, so restyling cannot weaken them.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-feedback@^0.4.
|
|
8
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
9
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-feedback@^0.4.14",
|
|
8
|
+
"@hachej/boring-ui-kit@^0.4.14",
|
|
9
|
+
"@hachej/boring-files@^0.4.14",
|
|
10
10
|
"react@^19.0.0",
|
|
11
11
|
"react-dom@^19.0.0",
|
|
12
12
|
"lucide-react@^1.51.0"
|
|
13
13
|
],
|
|
14
14
|
"registryDependencies": [
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
17
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/pi-chat.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/pi-chat-voice.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/utils.json",
|
|
18
18
|
"button",
|
|
19
19
|
"toggle",
|
|
20
20
|
"badge",
|
package/r/file-tree.json
CHANGED
|
@@ -2,18 +2,19 @@
|
|
|
2
2
|
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
3
|
"name": "file-tree",
|
|
4
4
|
"title": "File tree",
|
|
5
|
-
"description": "Browse, search, upload and inspect retained versions through one revisionProvider binding. Uses the consumer's shadcn Button
|
|
5
|
+
"description": "Browse, search, upload, rename, move, delete and inspect retained versions through one revisionProvider binding. Uses the consumer's shadcn Button, dropdown-menu and dialog with shared version-history rows; Base UI style only.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.14",
|
|
8
|
+
"@hachej/boring-files@^0.4.14",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"lucide-react@^1.51.0"
|
|
11
11
|
],
|
|
12
12
|
"registryDependencies": [
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/theme.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/utils.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/version-history.json",
|
|
16
16
|
"button",
|
|
17
|
+
"dialog",
|
|
17
18
|
"dropdown-menu",
|
|
18
19
|
"collapsible",
|
|
19
20
|
"empty",
|
|
@@ -25,7 +26,7 @@
|
|
|
25
26
|
"files": [
|
|
26
27
|
{
|
|
27
28
|
"path": "registry/file-tree/file-tree.tsx",
|
|
28
|
-
"content": "'use client';\n\nimport '@hachej/boring-ui-kit/file-tree.css';\nimport { FileTree as PackageFileTree, FileTreeView as PackageFileTreeView } from '@hachej/boring-ui-kit/file-tree-view';\nimport type { FileTreeComponents, FileTreeActionsProps, FileTreeButtonProps, FileTreeVersionsProps, FileTreeProps as PackageFileTreeProps, FileTreeViewProps as PackageFileTreeViewProps } from '@hachej/boring-ui-kit/file-tree-view';\nimport { useRef } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';\nimport { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty';\nimport { Input } from '@/components/ui/input';\nimport { Progress } from '@/components/ui/progress';\nimport { Separator } from '@/components/ui/separator';\nimport { SidebarMenu, SidebarMenuItem, SidebarMenuSub } from '@/components/ui/sidebar';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { VersionHistoryRows, frenchVersionHistoryLabels } from '../version-history/version-history';\nimport { cn } from '../utils/utils';\n\n// Only touch-target layout is added; appearance remains the consumer's shadcn Button.\nfunction LibraryButton({ className, ...props }: FileTreeButtonProps) {\n return <Button {...props} className={cn('max-sm:min-h-11 max-sm:min-w-11 pointer-coarse:min-h-11 pointer-coarse:min-w-11', className)} />;\n}\n\nfunction FileActions({ label, icon, items }: FileTreeActionsProps) {\n const trigger = useRef<HTMLButtonElement>(null);\n const portal = useRef<HTMLElement | null>(null);\n return <DropdownMenu>\n <DropdownMenuTrigger ref={trigger} render={<LibraryButton type=\"button\" variant=\"ghost\" size=\"icon\" ref={node => { portal.current = node?.ownerDocument.body ?? null; }} />} data-file-more aria-label={label}>{icon}</DropdownMenuTrigger>\n <DropdownMenuPortal container={portal}>\n <DropdownMenuContent data-boring=\"file-actions\" align=\"end\" finalFocus={trigger} className=\"w-max max-w-(--available-width)\">\n {items.map(item => <DropdownMenuItem key={item.id} data-action={item.id} onClick={item.onSelect} className=\"max-sm:min-h-11 pointer-coarse:min-h-11\">{item.icon}{item.label}</DropdownMenuItem>)}\n </DropdownMenuContent>\n </DropdownMenuPortal>\n </DropdownMenu>;\n}\n\nfunction FileVersions({ versions, current, onSelect, locale, earlierVersion }: FileTreeVersionsProps) {\n return <VersionHistoryRows versions={versions.map(version => ({ id: version.revision, label: earlierVersion, savedAt: version.savedAt, ...(version.source ? { source: version.source } : {}) }))}\n current={current} onSelect={onSelect} testId=\"file-history-versions\" {...(locale === 'fr' ? { labels: frenchVersionHistoryLabels } : {})} />;\n}\n\nfunction FileTreeEmpty({ icon, title, description, className, children }: Parameters<FileTreeComponents['Empty']>[0]) {\n return <Empty className={className}>\n <EmptyHeader>\n {icon && <EmptyMedia variant=\"icon\">{icon}</EmptyMedia>}\n <EmptyTitle>{title}</EmptyTitle>\n {description && <EmptyDescription>{description}</EmptyDescription>}\n </EmptyHeader>\n {children && <EmptyContent>{children}</EmptyContent>}\n </Empty>;\n}\n\nexport const fileTreeComponents = {\n Button: LibraryButton, Actions: FileActions, Versions: FileVersions,\n Menu: SidebarMenu, MenuItem: SidebarMenuItem, MenuSub: SidebarMenuSub, Input, Separator: () => <Separator />,\n Collapsible, CollapsibleTrigger, CollapsibleContent, Empty: FileTreeEmpty,\n Progress: ({ value, 'aria-label': label }: { readonly value: number | null; readonly 'aria-label': string }) => <Progress value={value} aria-label={label} />,\n} satisfies FileTreeComponents;\nexport type FileTreeProps = Omit<PackageFileTreeProps, 'components'>;\nexport type FileTreeViewProps = Omit<PackageFileTreeViewProps, 'components'>;\n\nexport function FileTree(props: FileTreeProps) {\n return <PackageFileTree {...props} components={fileTreeComponents} />;\n}\nexport function FileTreeView(props: FileTreeViewProps) {\n return <PackageFileTreeView {...props} components={fileTreeComponents} />;\n}\nexport { createFileTreeController } from '@hachej/boring-ui-kit/file-tree';\nexport type { FileTreeController } from '@hachej/boring-ui-kit/file-tree';\n",
|
|
29
|
+
"content": "'use client';\n\nimport '@hachej/boring-ui-kit/file-tree.css';\nimport { FileTree as PackageFileTree, FileTreeView as PackageFileTreeView } from '@hachej/boring-ui-kit/file-tree-view';\nimport type { FileTreeComponents, FileTreeActionsProps, FileTreeButtonProps, FileTreeDialogProps, FileTreeVersionsProps, FileTreeProps as PackageFileTreeProps, FileTreeViewProps as PackageFileTreeViewProps } from '@hachej/boring-ui-kit/file-tree-view';\nimport { useRef } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';\nimport { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty';\nimport { Input } from '@/components/ui/input';\nimport { Progress } from '@/components/ui/progress';\nimport { Separator } from '@/components/ui/separator';\nimport { SidebarMenu, SidebarMenuItem, SidebarMenuSub } from '@/components/ui/sidebar';\nimport { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { VersionHistoryRows, frenchVersionHistoryLabels } from '../version-history/version-history';\nimport { cn } from '../utils/utils';\n\n// Only touch-target layout is added; appearance remains the consumer's shadcn Button.\nfunction LibraryButton({ className, ...props }: FileTreeButtonProps) {\n return <Button {...props} className={cn('max-sm:min-h-11 max-sm:min-w-11 pointer-coarse:min-h-11 pointer-coarse:min-w-11', className)} />;\n}\n\nfunction FileActions({ label, icon, items }: FileTreeActionsProps) {\n const trigger = useRef<HTMLButtonElement>(null);\n const portal = useRef<HTMLElement | null>(null);\n // Rename, Move to… and Delete move focus themselves (an inline input, a dialog): closing the menu leaves it there.\n const chosen = useRef<FileTreeActionsProps['items'][number] | undefined>(undefined);\n return <DropdownMenu>\n <DropdownMenuTrigger ref={trigger} onClick={() => { chosen.current = undefined; }} render={<LibraryButton type=\"button\" variant=\"ghost\" size=\"icon\" ref={node => { portal.current = node?.ownerDocument.body ?? null; }} />} data-file-more aria-label={label}>{icon}</DropdownMenuTrigger>\n <DropdownMenuPortal container={portal}>\n <DropdownMenuContent data-boring=\"file-actions\" align=\"end\" finalFocus={() => chosen.current?.ownsFocus ? false : trigger.current} className=\"w-max max-w-(--available-width)\">\n {items.map(item => <DropdownMenuItem key={item.id} data-action={item.id} variant={item.destructive ? 'destructive' : 'default'}\n onClick={() => { chosen.current = item; item.onSelect(); }} className=\"max-sm:min-h-11 pointer-coarse:min-h-11\">{item.icon}{item.label}</DropdownMenuItem>)}\n </DropdownMenuContent>\n </DropdownMenuPortal>\n </DropdownMenu>;\n}\n\nfunction FileVersions({ versions, current, onSelect, locale, earlierVersion }: FileTreeVersionsProps) {\n return <VersionHistoryRows versions={versions.map(version => ({ id: version.revision, label: earlierVersion, savedAt: version.savedAt, ...(version.source ? { source: version.source } : {}) }))}\n current={current} onSelect={onSelect} testId=\"file-history-versions\" {...(locale === 'fr' ? { labels: frenchVersionHistoryLabels } : {})} />;\n}\n\nfunction FileTreeModal({ open, onOpenChange, title, description, children, footer }: FileTreeDialogProps) {\n return <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent data-boring=\"file-tree-dialog\" showCloseButton={false}>\n <DialogHeader>\n <DialogTitle>{title}</DialogTitle>\n {description && <DialogDescription>{description}</DialogDescription>}\n </DialogHeader>\n {children}\n <DialogFooter>{footer}</DialogFooter>\n </DialogContent>\n </Dialog>;\n}\n\nfunction FileTreeEmpty({ icon, title, description, className, children }: Parameters<FileTreeComponents['Empty']>[0]) {\n return <Empty className={className}>\n <EmptyHeader>\n {icon && <EmptyMedia variant=\"icon\">{icon}</EmptyMedia>}\n <EmptyTitle>{title}</EmptyTitle>\n {description && <EmptyDescription>{description}</EmptyDescription>}\n </EmptyHeader>\n {children && <EmptyContent>{children}</EmptyContent>}\n </Empty>;\n}\n\nexport const fileTreeComponents = {\n Button: LibraryButton, Actions: FileActions, Versions: FileVersions,\n Menu: SidebarMenu, MenuItem: SidebarMenuItem, MenuSub: SidebarMenuSub, Input, Separator: () => <Separator />,\n Collapsible, CollapsibleTrigger, CollapsibleContent, Empty: FileTreeEmpty, Dialog: FileTreeModal,\n Progress: ({ value, 'aria-label': label }: { readonly value: number | null; readonly 'aria-label': string }) => <Progress value={value} aria-label={label} />,\n} satisfies FileTreeComponents;\nexport type FileTreeProps = Omit<PackageFileTreeProps, 'components'>;\nexport type FileTreeViewProps = Omit<PackageFileTreeViewProps, 'components'>;\n\nexport function FileTree(props: FileTreeProps) {\n return <PackageFileTree {...props} components={fileTreeComponents} />;\n}\nexport function FileTreeView(props: FileTreeViewProps) {\n return <PackageFileTreeView {...props} components={fileTreeComponents} />;\n}\nexport { createFileTreeController } from '@hachej/boring-ui-kit/file-tree';\nexport type { FileTreeController } from '@hachej/boring-ui-kit/file-tree';\n",
|
|
29
30
|
"type": "registry:component",
|
|
30
31
|
"target": "components/file-tree/file-tree.tsx"
|
|
31
32
|
}
|
package/r/highlights.json
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
"title": "Anchored highlights",
|
|
5
5
|
"description": "Exact Markdown highlights and comments in a conditional workspace sidecar.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.14",
|
|
8
|
+
"@hachej/boring-files@^0.4.14",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0"
|
|
11
11
|
],
|
|
12
12
|
"registryDependencies": [
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/markdown-editor.json",
|
|
14
14
|
"button",
|
|
15
15
|
"textarea",
|
|
16
16
|
"label",
|
package/r/html-viewer.json
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
"title": "HTML source and passive preview",
|
|
5
5
|
"description": "Exact HTML source editing with a reconstructed passive preview and borrowed controller. Browser security qualification remains separate.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.14",
|
|
8
|
+
"@hachej/boring-files@^0.4.14",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0"
|
|
11
11
|
],
|
|
12
12
|
"registryDependencies": [
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/button.json"
|
|
14
14
|
],
|
|
15
15
|
"files": [
|
|
16
16
|
{
|
package/r/looks-viewer.json
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"lucide-react@^1.51.0"
|
|
9
9
|
],
|
|
10
10
|
"registryDependencies": [
|
|
11
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.14/r/mockup-viewer.json",
|
|
12
12
|
"alert",
|
|
13
13
|
"button",
|
|
14
14
|
"card",
|
|
@@ -17,15 +17,21 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
{
|
|
19
19
|
"path": "registry/looks-viewer/looks-viewer.tsx",
|
|
20
|
-
"content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { CheckIcon, PaletteIcon } from 'lucide-react';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';\nimport { MockupViewer } from '../mockup-viewer/mockup-viewer';\nimport type { ShadcnTheme } from '../mockup-viewer/mockup-viewer';\nimport { besideLooks, parseLooks, swatches } from './looks';\nimport type { Look, Looks } from './looks';\n\nexport { parseLooks, parseShadcnTheme, swatches, besideLooks, shadcnValue, SHADCN_COLOR_VARIABLES, SHADCN_SHAPE_VARIABLES } from './looks';\nexport type { Look, Looks, LayoutVariant } from './looks';\n\nexport const isLooksPath = (path: string): boolean => /\\.looks\\.json$/i.test(path);\n\n/** The person pressed Pick: the host says it in the conversation (or records it); the viewer only marks it. */\nexport interface LookPick {\n readonly kind: 'look' | 'layout';\n readonly id: string;\n readonly label: string;\n /** The looks file the pick was made in. */\n readonly path: string;\n}\n\nexport interface LooksViewerProps {\n readonly text: string;\n readonly path?: string | undefined;\n /** Another file of the workspace as text (the artifact panel's `read`): the key screen and the layouts, next to the looks file. */\n readonly read?: ((path: string) => Promise<string | undefined>) | undefined;\n readonly onPick?: ((pick: LookPick) => void) | undefined;\n readonly locale?: 'en' | 'fr' | undefined;\n readonly onReady?: (() => void) | undefined;\n readonly onFailed?: ((reason: string) => void) | undefined;\n}\n\nconst LABELS = {\n en: { looks: 'Pick a look', layouts: 'Pick a layout', layoutsIn: (look: string) => `In the ${look} look`, pick: 'Pick', picked: 'Picked', light: 'Light', dark: 'Dark',\n loading: 'Loading the looks…', missing: 'The key screen cannot be read.', invalid: 'These looks cannot be shown', font: 'Font', radius: 'Roundness', look: (label: string) => `${label} look`, layout: (label: string) => `${label} layout` },\n fr: { looks: 'Choisir un style', layouts: 'Choisir une mise en page', layoutsIn: (look: string) => `Dans le style ${look}`, pick: 'Choisir', picked: 'Choisi', light: 'Clair', dark: 'Sombre',\n loading: 'Chargement des styles…', missing: \"L'écran clé est illisible.\", invalid: 'Ces styles ne peuvent pas être affichés', font: 'Police', radius: 'Arrondi', look: (label: string) => `Style ${label}`, layout: (label: string) => `Mise en page ${label}` },\n} as const;\n\n/** The key screen drawn at a desktop (or, in a narrow column, phone) width and scaled into the card, so a look reads as the whole app. */\nfunction ScaledMockup({ text, theme, mode, title, onReady }: { readonly text: string; readonly theme: ShadcnTheme; readonly mode?: 'light' | 'dark' | undefined; readonly title: string; readonly onReady: () => void }) {\n const outer = useRef<HTMLDivElement>(null);\n const [width, setWidth] = useState(0);\n useLayoutEffect(() => {\n const element = outer.current;\n if (!element) return undefined;\n const measure = () => setWidth(element.getBoundingClientRect().width);\n measure();\n if (typeof ResizeObserver === 'undefined') return undefined;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n // A phone draws the phone layout of the screen; a narrow column on a desktop still draws the desktop one, smaller.\n const phone = typeof matchMedia === 'function' && matchMedia('(max-width: 639px)').matches;\n const virtual = phone ? { w: 390, h: 640 } : { w: 1024, h: 600 };\n const scale = width > 0 ? width / virtual.w : 0.5;\n return <div ref={outer} data-testid=\"looks-frame\" className=\"relative w-full overflow-hidden bg-muted\" style={{ height: Math.round(virtual.h * scale) }}>\n <div className=\"absolute top-0 left-0 origin-top-left\" style={{ width: virtual.w, height: virtual.h, transform: `scale(${scale})` }}>\n <MockupViewer text={text} themeOverride={theme} mode={mode} title={title} onReady={onReady} />\n </div>\n </div>;\n}\n\n/** The swatch's corner, in a 15px square: a rem radius at 16px, capped. */\nconst corner = (radius: string) => Math.min(15, Math.max(0, parseFloat(radius) * (radius.endsWith('px') ? 1 : 16)));\n\nfunction Swatches({ look, dark, labels }: { readonly look: Look; readonly dark: boolean; readonly labels: (typeof LABELS)['en' | 'fr'] }) {\n const swatch = swatches(look, dark);\n return <div data-testid=\"looks-swatches\" className=\"flex flex-wrap items-center gap-3 text-xs text-muted-foreground\">\n <svg role=\"img\" aria-label={swatch.colors.map(color => color.name).join(', ')} width={swatch.colors.length * 20} height=\"18\" viewBox={`0 0 ${swatch.colors.length * 20} 18`}>\n {swatch.colors.map((color, index) => <circle key={color.name} cx={index * 20 + 9} cy=\"9\" r=\"8\" fill={color.value} stroke=\"currentColor\" strokeOpacity=\"0.3\"><title>{`${color.name}: ${color.value}`}</title></circle>)}\n </svg>\n <span title={labels.font} style={{ fontFamily: look.theme.theme?.['font-sans'] }}>{swatch.heading === swatch.font ? swatch.font : `${swatch.heading} / ${swatch.font}`}</span>\n <span className=\"flex items-center gap-1\" title={labels.radius}><svg aria-hidden=\"true\" width=\"18\" height=\"18\" viewBox=\"0 0 18 18\"><path d={`M2 17 V${2 + corner(swatch.radius)} Q2 2 ${2 + corner(swatch.radius)} 2 H17`} fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" /></svg>{swatch.radius}</span>\n </div>;\n}\n\nexport function LooksViewer({ text, path = '', read, onPick, locale = 'en', onReady, onFailed }: LooksViewerProps) {\n const labels = LABELS[locale];\n const parsed = useMemo(() => parseLooks(text), [text]);\n const spec: Looks | undefined = parsed.kind === 'ok' ? parsed.looks : undefined;\n const sources = useMemo(() => spec ? [...new Set([spec.mockup, ...spec.layouts.map(layout => layout.mockup)])] : [], [spec]);\n const [files, setFiles] = useState<{ readonly key: string; readonly texts: ReadonlyMap<string, string> } | 'missing'>();\n const key = JSON.stringify([path, sources]);\n useEffect(() => {\n if (!sources.length) return undefined;\n let live = true;\n setFiles(undefined);\n void Promise.all(sources.map(async relative => [relative, read ? await read(besideLooks(path, relative)).catch(() => undefined) : undefined] as const)).then(entries => {\n if (!live) return;\n if (entries.some(([, value]) => value === undefined)) { setFiles('missing'); onFailed?.('a mock-up named by the looks file cannot be read'); return; }\n setFiles({ key, texts: new Map(entries as [string, string][]) });\n });\n return () => { live = false; };\n }, [key, read]); // eslint-disable-line react-hooks/exhaustive-deps\n const [mode, setMode] = useState<'light' | 'dark' | undefined>();\n const [look, setLook] = useState<string | undefined>(spec?.look);\n const [layout, setLayout] = useState<string>();\n useEffect(() => { setLook(spec?.look); setLayout(undefined); }, [text]); // eslint-disable-line react-hooks/exhaustive-deps\n const frames = spec ? (spec.looks.length > 1 ? spec.looks.length : 0) + spec.layouts.length : 0;\n const ready = useRef(new Set<string>());\n useEffect(() => { ready.current = new Set(); }, [text]);\n const frameReady = useCallback((id: string) => () => {\n ready.current.add(id);\n if (ready.current.size === frames) onReady?.();\n }, [frames, onReady]);\n useEffect(() => { if (parsed.kind === 'invalid') onFailed?.(`invalid looks file: ${parsed.issues.slice(0, 3).join('; ')}`); }, [parsed]); // eslint-disable-line react-hooks/exhaustive-deps\n if (parsed.kind === 'invalid' || !spec) return <Alert role=\"alert\" data-testid=\"looks-invalid\"><AlertTitle>{labels.invalid}</AlertTitle>\n <AlertDescription>{parsed.kind === 'invalid' ? parsed.issues.slice(0, 6).join('; ') : ''}</AlertDescription></Alert>;\n if (files === 'missing') return <Alert role=\"alert\"><AlertTitle>{labels.missing}</AlertTitle></Alert>;\n if (!files || files.key !== key) return <p role=\"status\" className=\"p-3 text-muted-foreground\">{labels.loading}</p>;\n const keyScreen = files.texts.get(spec.mockup)!;\n const inLook = spec.looks.find(item => item.id === look) ?? spec.looks.find(item => item.id === spec.look) ?? spec.looks[0]!;\n const dark = mode === 'dark';\n const pick = (kind: LookPick['kind'], id: string, label: string) => { if (kind === 'look') setLook(id); else setLayout(id); onPick?.({ kind, id, label, path }); };\n return <div data-boring=\"looks-viewer\" className=\"flex flex-col gap-6 p-3 md:p-4\">\n <div className=\"flex items-center justify-between gap-3\">\n <h2 className=\"text-lg font-semibold\">{spec.looks.length > 1 ? labels.looks : labels.layouts}</h2>\n <ToggleGroup value={[mode ?? 'host']} onValueChange={(value: unknown[]) => { const next = value[0]; setMode(next === 'light' || next === 'dark' ? next : undefined); }} aria-label={`${labels.light} / ${labels.dark}`}>\n <ToggleGroupItem value=\"light\" data-testid=\"looks-light\">{labels.light}</ToggleGroupItem>\n <ToggleGroupItem value=\"dark\" data-testid=\"looks-dark\">{labels.dark}</ToggleGroupItem>\n </ToggleGroup>\n </div>\n {spec.looks.length > 1 && <section aria-label={labels.looks} data-testid=\"looks-grid\" className=\"grid gap-4 md:grid-cols-2\">\n {spec.looks.map(item => <Choice key={item.id} testId={`look-${item.id}`} title={item.label} line={item.personality} chosen={inLook.id === item.id && look !== undefined} labels={labels}\n onPick={() => pick('look', item.id, item.label)} extra={<Swatches look={item} dark={dark} labels={labels} />}>\n <ScaledMockup text={keyScreen} theme={item.theme} mode={mode} title={labels.look(item.label)} onReady={frameReady(`look:${item.id}`)} />\n </Choice>)}\n </section>}\n {spec.layouts.length > 0 && <section aria-label={labels.layouts} data-testid=\"layouts-grid\" className=\"flex flex-col gap-3\">\n {spec.looks.length > 1 && <h2 className=\"text-lg font-semibold\">{labels.layouts}</h2>}\n <p className=\"text-sm text-muted-foreground\">{labels.layoutsIn(inLook.label)}</p>\n <div className={spec.layouts.length > 2 ? 'grid gap-4 md:grid-cols-2 xl:grid-cols-3' : 'grid gap-4 md:grid-cols-2'}>\n {spec.layouts.map(item => <Choice key={item.id} testId={`layout-${item.id}`} title={item.label} line={item.why} chosen={layout === item.id} labels={labels} onPick={() => pick('layout', item.id, item.label)}>\n <ScaledMockup text={files.texts.get(item.mockup)!} theme={inLook.theme} mode={mode} title={labels.layout(item.label)} onReady={frameReady(`layout:${item.id}`)} />\n </Choice>)}\n </div>\n </section>}\n </div>;\n}\n\nfunction Choice({ testId, title, line, chosen, labels, onPick, extra, children }: { readonly testId: string; readonly title: string; readonly line?: string | undefined; readonly chosen: boolean; readonly labels: (typeof LABELS)['en' | 'fr']; readonly onPick: () => void; readonly extra?: ReactNode; readonly children: ReactNode }) {\n return <Card data-testid={testId} data-chosen={chosen ? '' : undefined}>\n <CardContent>{children}</CardContent>\n <CardHeader>\n <CardTitle>{title}</CardTitle>\n {line && <CardDescription>{line}</CardDescription>}\n <CardAction>\n <Button size=\"sm\" variant={chosen ? 'default' : 'outline'} aria-pressed={chosen} data-testid={`${testId}-pick`} onClick={onPick}>\n {chosen && <CheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}{chosen ? labels.picked : labels.pick}\n </Button>\n </CardAction>\n {extra}\n </CardHeader>\n </Card>;\n}\n\nexport interface LooksKind {\n readonly match: (target: { readonly resource: { readonly path: string } }) => boolean;\n readonly label: string;\n readonly icon: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }>;\n readonly renderer: (props: { readonly text: string; readonly onReady?: (() => void) | undefined; readonly onFailed?: ((reason: string) => void) | undefined; readonly read?: ((path: string) => Promise<string | undefined>) | undefined; readonly artifact?: { readonly target: { readonly resource: { readonly path: string } } } | undefined }) => ReactNode;\n readonly waitsForReady: true;\n}\n\n/** The `*.looks.json` kind: opt-in, like the board. `onPick` is the host's (the PM page says the pick in the conversation). */\nexport function createLooksKind({ onPick, locale }: { readonly onPick?: ((pick: LookPick) => void) | undefined; readonly locale?: 'en' | 'fr' | undefined } = {}): LooksKind {\n return { match: target => isLooksPath(target.resource.path), label: locale === 'fr' ? 'Styles' : 'Looks', icon: PaletteIcon, waitsForReady: true,\n renderer: ({ text, artifact, read, onReady, onFailed }) => <LooksViewer key={artifact?.target.resource.path} text={text} path={artifact?.target.resource.path} read={read} onPick={onPick} locale={locale} onReady={onReady} onFailed={onFailed} /> };\n}\n",
|
|
20
|
+
"content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { CheckIcon } from 'lucide-react';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';\nimport { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';\nimport { MockupViewer } from '../mockup-viewer/mockup-viewer';\nimport type { ShadcnTheme } from '../mockup-viewer/mockup-viewer';\nimport { besideLooks, parseLooks, swatches } from './looks';\nimport type { Look, Looks } from './looks';\n\nexport { isLooksPath, parseLooks, parseShadcnTheme, swatches, besideLooks, shadcnValue, SHADCN_COLOR_VARIABLES, SHADCN_SHAPE_VARIABLES } from './looks';\nexport type { Look, Looks, LayoutVariant } from './looks';\n\n\n/** The person pressed Pick: the host says it in the conversation (or records it); the viewer only marks it. */\nexport interface LookPick {\n readonly kind: 'look' | 'layout';\n readonly id: string;\n readonly label: string;\n /** The looks file the pick was made in. */\n readonly path: string;\n}\n\nexport interface LooksViewerProps {\n readonly text: string;\n readonly path?: string | undefined;\n /** Another file of the workspace as text (the artifact panel's `read`): the key screen and the layouts, next to the looks file. */\n readonly read?: ((path: string) => Promise<string | undefined>) | undefined;\n readonly onPick?: ((pick: LookPick) => void) | undefined;\n readonly locale?: 'en' | 'fr' | undefined;\n readonly onReady?: (() => void) | undefined;\n readonly onFailed?: ((reason: string) => void) | undefined;\n}\n\nconst LABELS = {\n en: { looks: 'Pick a look', layouts: 'Pick a layout', layoutsIn: (look: string) => `In the ${look} look`, pick: 'Pick', picked: 'Picked', light: 'Light', dark: 'Dark',\n loading: 'Loading the looks…', missing: 'The key screen cannot be read.', invalid: 'These looks cannot be shown', font: 'Font', radius: 'Roundness', look: (label: string) => `${label} look`, layout: (label: string) => `${label} layout` },\n fr: { looks: 'Choisir un style', layouts: 'Choisir une mise en page', layoutsIn: (look: string) => `Dans le style ${look}`, pick: 'Choisir', picked: 'Choisi', light: 'Clair', dark: 'Sombre',\n loading: 'Chargement des styles…', missing: \"L'écran clé est illisible.\", invalid: 'Ces styles ne peuvent pas être affichés', font: 'Police', radius: 'Arrondi', look: (label: string) => `Style ${label}`, layout: (label: string) => `Mise en page ${label}` },\n} as const;\n\n/** The key screen drawn at a desktop (or, in a narrow column, phone) width and scaled into the card, so a look reads as the whole app. */\nfunction ScaledMockup({ text, theme, mode, title, onReady }: { readonly text: string; readonly theme: ShadcnTheme; readonly mode?: 'light' | 'dark' | undefined; readonly title: string; readonly onReady: () => void }) {\n const outer = useRef<HTMLDivElement>(null);\n const [width, setWidth] = useState(0);\n useLayoutEffect(() => {\n const element = outer.current;\n if (!element) return undefined;\n const measure = () => setWidth(element.getBoundingClientRect().width);\n measure();\n if (typeof ResizeObserver === 'undefined') return undefined;\n const observer = new ResizeObserver(measure);\n observer.observe(element);\n return () => observer.disconnect();\n }, []);\n // A phone draws the phone layout of the screen; a narrow column on a desktop still draws the desktop one, smaller.\n const phone = typeof matchMedia === 'function' && matchMedia('(max-width: 639px)').matches;\n const virtual = phone ? { w: 390, h: 640 } : { w: 1024, h: 600 };\n const scale = width > 0 ? width / virtual.w : 0.5;\n return <div ref={outer} data-testid=\"looks-frame\" className=\"relative w-full overflow-hidden bg-muted\" style={{ height: Math.round(virtual.h * scale) }}>\n <div className=\"absolute top-0 left-0 origin-top-left\" style={{ width: virtual.w, height: virtual.h, transform: `scale(${scale})` }}>\n <MockupViewer text={text} themeOverride={theme} mode={mode} title={title} onReady={onReady} />\n </div>\n </div>;\n}\n\n/** The swatch's corner, in a 15px square: a rem radius at 16px, capped. */\nconst corner = (radius: string) => Math.min(15, Math.max(0, parseFloat(radius) * (radius.endsWith('px') ? 1 : 16)));\n\nfunction Swatches({ look, dark, labels }: { readonly look: Look; readonly dark: boolean; readonly labels: (typeof LABELS)['en' | 'fr'] }) {\n const swatch = swatches(look, dark);\n return <div data-testid=\"looks-swatches\" className=\"flex flex-wrap items-center gap-3 text-xs text-muted-foreground\">\n <svg role=\"img\" aria-label={swatch.colors.map(color => color.name).join(', ')} width={swatch.colors.length * 20} height=\"18\" viewBox={`0 0 ${swatch.colors.length * 20} 18`}>\n {swatch.colors.map((color, index) => <circle key={color.name} cx={index * 20 + 9} cy=\"9\" r=\"8\" fill={color.value} stroke=\"currentColor\" strokeOpacity=\"0.3\"><title>{`${color.name}: ${color.value}`}</title></circle>)}\n </svg>\n <span title={labels.font} style={{ fontFamily: look.theme.theme?.['font-sans'] }}>{swatch.heading === swatch.font ? swatch.font : `${swatch.heading} / ${swatch.font}`}</span>\n <span className=\"flex items-center gap-1\" title={labels.radius}><svg aria-hidden=\"true\" width=\"18\" height=\"18\" viewBox=\"0 0 18 18\"><path d={`M2 17 V${2 + corner(swatch.radius)} Q2 2 ${2 + corner(swatch.radius)} 2 H17`} fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" /></svg>{swatch.radius}</span>\n </div>;\n}\n\nexport function LooksViewer({ text, path = '', read, onPick, locale = 'en', onReady, onFailed }: LooksViewerProps) {\n const labels = LABELS[locale];\n const parsed = useMemo(() => parseLooks(text), [text]);\n const spec: Looks | undefined = parsed.kind === 'ok' ? parsed.looks : undefined;\n const sources = useMemo(() => spec ? [...new Set([spec.mockup, ...spec.layouts.map(layout => layout.mockup)])] : [], [spec]);\n const [files, setFiles] = useState<{ readonly key: string; readonly texts: ReadonlyMap<string, string> } | 'missing'>();\n const key = JSON.stringify([path, sources]);\n useEffect(() => {\n if (!sources.length) return undefined;\n let live = true;\n setFiles(undefined);\n void Promise.all(sources.map(async relative => [relative, read ? await read(besideLooks(path, relative)).catch(() => undefined) : undefined] as const)).then(entries => {\n if (!live) return;\n if (entries.some(([, value]) => value === undefined)) { setFiles('missing'); onFailed?.('a mock-up named by the looks file cannot be read'); return; }\n setFiles({ key, texts: new Map(entries as [string, string][]) });\n });\n return () => { live = false; };\n }, [key, read]); // eslint-disable-line react-hooks/exhaustive-deps\n const [mode, setMode] = useState<'light' | 'dark' | undefined>();\n const [look, setLook] = useState<string | undefined>(spec?.look);\n const [layout, setLayout] = useState<string>();\n useEffect(() => { setLook(spec?.look); setLayout(undefined); }, [text]); // eslint-disable-line react-hooks/exhaustive-deps\n const frames = spec ? (spec.looks.length > 1 ? spec.looks.length : 0) + spec.layouts.length : 0;\n const ready = useRef(new Set<string>());\n useEffect(() => { ready.current = new Set(); }, [text]);\n const frameReady = useCallback((id: string) => () => {\n ready.current.add(id);\n if (ready.current.size === frames) onReady?.();\n }, [frames, onReady]);\n useEffect(() => { if (parsed.kind === 'invalid') onFailed?.(`invalid looks file: ${parsed.issues.slice(0, 3).join('; ')}`); }, [parsed]); // eslint-disable-line react-hooks/exhaustive-deps\n if (parsed.kind === 'invalid' || !spec) return <Alert role=\"alert\" data-testid=\"looks-invalid\"><AlertTitle>{labels.invalid}</AlertTitle>\n <AlertDescription>{parsed.kind === 'invalid' ? parsed.issues.slice(0, 6).join('; ') : ''}</AlertDescription></Alert>;\n if (files === 'missing') return <Alert role=\"alert\"><AlertTitle>{labels.missing}</AlertTitle></Alert>;\n if (!files || files.key !== key) return <p role=\"status\" className=\"p-3 text-muted-foreground\">{labels.loading}</p>;\n const keyScreen = files.texts.get(spec.mockup)!;\n const inLook = spec.looks.find(item => item.id === look) ?? spec.looks.find(item => item.id === spec.look) ?? spec.looks[0]!;\n const dark = mode === 'dark';\n const pick = (kind: LookPick['kind'], id: string, label: string) => { if (kind === 'look') setLook(id); else setLayout(id); onPick?.({ kind, id, label, path }); };\n return <div data-boring=\"looks-viewer\" className=\"flex flex-col gap-6 p-3 md:p-4\">\n <div className=\"flex items-center justify-between gap-3\">\n <h2 className=\"text-lg font-semibold\">{spec.looks.length > 1 ? labels.looks : labels.layouts}</h2>\n <ToggleGroup value={[mode ?? 'host']} onValueChange={(value: unknown[]) => { const next = value[0]; setMode(next === 'light' || next === 'dark' ? next : undefined); }} aria-label={`${labels.light} / ${labels.dark}`}>\n <ToggleGroupItem value=\"light\" data-testid=\"looks-light\">{labels.light}</ToggleGroupItem>\n <ToggleGroupItem value=\"dark\" data-testid=\"looks-dark\">{labels.dark}</ToggleGroupItem>\n </ToggleGroup>\n </div>\n {spec.looks.length > 1 && <section aria-label={labels.looks} data-testid=\"looks-grid\" className=\"grid gap-4 md:grid-cols-2\">\n {spec.looks.map(item => <Choice key={item.id} testId={`look-${item.id}`} title={item.label} line={item.personality} chosen={inLook.id === item.id && look !== undefined} labels={labels}\n onPick={() => pick('look', item.id, item.label)} extra={<Swatches look={item} dark={dark} labels={labels} />}>\n <ScaledMockup text={keyScreen} theme={item.theme} mode={mode} title={labels.look(item.label)} onReady={frameReady(`look:${item.id}`)} />\n </Choice>)}\n </section>}\n {spec.layouts.length > 0 && <section aria-label={labels.layouts} data-testid=\"layouts-grid\" className=\"flex flex-col gap-3\">\n {spec.looks.length > 1 && <h2 className=\"text-lg font-semibold\">{labels.layouts}</h2>}\n <p className=\"text-sm text-muted-foreground\">{labels.layoutsIn(inLook.label)}</p>\n <div className={spec.layouts.length > 2 ? 'grid gap-4 md:grid-cols-2 xl:grid-cols-3' : 'grid gap-4 md:grid-cols-2'}>\n {spec.layouts.map(item => <Choice key={item.id} testId={`layout-${item.id}`} title={item.label} line={item.why} chosen={layout === item.id} labels={labels} onPick={() => pick('layout', item.id, item.label)}>\n <ScaledMockup text={files.texts.get(item.mockup)!} theme={inLook.theme} mode={mode} title={labels.layout(item.label)} onReady={frameReady(`layout:${item.id}`)} />\n </Choice>)}\n </div>\n </section>}\n </div>;\n}\n\nfunction Choice({ testId, title, line, chosen, labels, onPick, extra, children }: { readonly testId: string; readonly title: string; readonly line?: string | undefined; readonly chosen: boolean; readonly labels: (typeof LABELS)['en' | 'fr']; readonly onPick: () => void; readonly extra?: ReactNode; readonly children: ReactNode }) {\n return <Card data-testid={testId} data-chosen={chosen ? '' : undefined}>\n <CardContent>{children}</CardContent>\n <CardHeader>\n <CardTitle>{title}</CardTitle>\n {line && <CardDescription>{line}</CardDescription>}\n <CardAction>\n <Button size=\"sm\" variant={chosen ? 'default' : 'outline'} aria-pressed={chosen} data-testid={`${testId}-pick`} onClick={onPick}>\n {chosen && <CheckIcon data-icon=\"inline-start\" aria-hidden=\"true\" />}{chosen ? labels.picked : labels.pick}\n </Button>\n </CardAction>\n {extra}\n </CardHeader>\n </Card>;\n}\n",
|
|
21
21
|
"type": "registry:component",
|
|
22
22
|
"target": "components/looks-viewer/looks-viewer.tsx"
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
"path": "registry/looks-viewer/looks.ts",
|
|
26
|
-
"content": "// looks@1: the looks an assistant offers for an app (`*.looks.json`). A look is a shadcn theme, exactly as shadcn writes it: a preset\n// code (`npx shadcn apply <code> --only theme,font`) and/or the theme block that preset produces (:root, .dark and the font and radius\n// variables of `@theme inline`). Nothing here invents a theme model: the block's variables are shadcn's own.\nimport type { ShadcnTheme } from '../mockup-viewer/mockup-viewer';\n\n/** The colour variables of a shadcn theme (`:root` and `.dark`). */\nexport const SHADCN_COLOR_VARIABLES: readonly string[] = Object.freeze([\n 'background', 'foreground', 'card', 'card-foreground', 'popover', 'popover-foreground', 'primary', 'primary-foreground',\n 'secondary', 'secondary-foreground', 'muted', 'muted-foreground', 'accent', 'accent-foreground', 'destructive', 'destructive-foreground',\n // the hairline variable's name is joined: the design scan reads that bare word as a class\n ['bord', 'er'].join(''), 'input', 'ring', 'chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5', 'sidebar', 'sidebar-foreground', 'sidebar-primary',\n 'sidebar-primary-foreground', 'sidebar-accent', 'sidebar-accent-foreground', 'sidebar-border', 'sidebar-ring',\n]);\n/** The non-colour variables a shadcn theme sets: the radius (`:root`) and the font families (`@theme inline`). */\nexport const SHADCN_SHAPE_VARIABLES: readonly string[] = Object.freeze(['radius', 'font-sans', 'font-serif', 'font-mono', 'font-heading']);\n\nconst COLOR = /^(?:#[0-9a-f]{3,8}|(?:oklch|oklab|lab|lch|hsla?|rgba?)\\([0-9a-z .,%/+-]{1,80}\\))$/i;\nconst LENGTH = /^(?:0|\\d{1,2}(?:\\.\\d{1,4})?(?:rem|px|em))$/;\nconst FONT = /^(?:[A-Za-z0-9 ,'\"-]{1,160}|var\\(--font-(?:sans|serif|mono)\\))$/;\nconst ID = /^[a-z0-9][a-z0-9-]{0,39}$/;\n/** A shadcn preset: a code (`bIkeymG`), a named style (`nova`) or a ui.shadcn.com init URL. */\nconst PRESET = /^(?:[A-Za-z0-9]{1,24}|https:\\/\\/ui\\.shadcn\\.com\\/init\\?[A-Za-z0-9=&_.-]{1,600})$/;\n\nexport type ThemeIssue = string;\n\n/** Is this a valid value for that shadcn variable? */\nexport function shadcnValue(name: string, value: string): boolean {\n if (SHADCN_COLOR_VARIABLES.includes(name)) return COLOR.test(value);\n if (name === 'radius') return LENGTH.test(value);\n if (name.startsWith('font-')) return SHADCN_SHAPE_VARIABLES.includes(name) && FONT.test(value);\n return false;\n}\n\n/**\n * A shadcn theme block (`:root { … } .dark { … } @theme inline { … }`, as `shadcn apply` writes it) into shadcn's `cssVars` shape.\n * Only shadcn theme variables with valid values are accepted; anything else is an issue, never silently kept.\n */\nexport function parseShadcnTheme(css: string): { readonly kind: 'ok'; readonly theme: ShadcnTheme } | { readonly kind: 'invalid'; readonly issues: readonly ThemeIssue[] } {\n const issues: ThemeIssue[] = [];\n const out = { theme: {} as Record<string, string>, light: {} as Record<string, string>, dark: {} as Record<string, string> };\n const text = css.replace(/\\/\\*[\\s\\S]*?\\*\\//g, '');\n const block = /([^{}]+)\\{([^{}]*)\\}/g;\n let consumed = '';\n for (const match of text.matchAll(block)) {\n consumed += match[0];\n const selector = match[1]!.trim();\n const target = selector === ':root' ? out.light : selector === '.dark' ? out.dark : /^@theme(?:\\s+inline)?$/.test(selector) ? out.theme : undefined;\n if (!target) { issues.push(`unexpected block ${selector.slice(0, 40)}: only :root, .dark and @theme inline`); continue; }\n for (const raw of match[2]!.split(';')) {\n const declaration = raw.trim();\n if (!declaration) continue;\n const colon = declaration.indexOf(':');\n const name = declaration.slice(0, colon).trim().replace(/^--/, ''), value = declaration.slice(colon + 1).trim();\n if (colon < 0 || !declaration.startsWith('--')) { issues.push(`not a variable: ${declaration.slice(0, 40)}`); continue; }\n // @theme inline also carries shadcn's colour and radius bindings (--color-primary: var(--primary)); they are the wiring, not the look.\n if (target === out.theme && (/^color-/.test(name) || /^radius-/.test(name))) continue;\n const allowed = target === out.theme ? name.startsWith('font-') : true;\n if (!allowed || !shadcnValue(name, value)) { issues.push(`${selector} --${name}: not a shadcn theme variable or not a valid value`); continue; }\n target[name] = value;\n }\n }\n if (text.replace(block, '').trim()) issues.push('text outside the :root, .dark and @theme blocks');\n if (!consumed) issues.push('no :root block');\n for (const name of ['background', 'foreground', 'primary', 'primary-foreground']) {\n if (!out.light[name]) issues.push(`:root misses --${name}`);\n if (!out.dark[name]) issues.push(`.dark misses --${name}`);\n }\n return issues.length ? { kind: 'invalid', issues } : { kind: 'ok', theme: out };\n}\n\nexport interface Look {\n readonly id: string;\n readonly label: string;\n /** One line: how the app feels in this look. */\n readonly personality: string;\n /** The shadcn preset this look applies (`npx shadcn apply <preset> --only theme,font`). */\n readonly preset?: string | undefined;\n /** The shadcn theme block, parsed. */\n readonly theme: ShadcnTheme;\n}\nexport interface LayoutVariant { readonly id: string; readonly label: string; readonly why?: string | undefined; readonly mockup: string }\nexport interface Looks {\n /** The key screen, a `*.mockup.html` next to the looks file. */\n readonly mockup: string;\n readonly looks: readonly Look[];\n readonly layouts: readonly LayoutVariant[];\n /** The look the layouts are drawn in (a look's id); absent: the first look. */\n readonly look?: string | undefined;\n}\n\nconst MOCKUP = /^[A-Za-z0-9][A-Za-z0-9 ._/-]{0,160}\\.mockup\\.html$/i;\nconst text = (value: unknown, max: number) => typeof value === 'string' && value.trim() && value.length <= max ? value.trim() : undefined;\n\n/** Read a `*.looks.json`. Paths are relative to the looks file; a layout is a path or `{ id, label, why?, mockup }`. */\nexport function parseLooks(source: string): { readonly kind: 'ok'; readonly looks: Looks } | { readonly kind: 'invalid'; readonly issues: readonly string[] } {\n let data: unknown;\n try { data = JSON.parse(source); } catch { return { kind: 'invalid', issues: ['not JSON'] }; }\n if (!data || typeof data !== 'object' || Array.isArray(data)) return { kind: 'invalid', issues: ['a looks file is an object { mockup, looks, layouts? }'] };\n const spec = data as Record<string, unknown>;\n const issues: string[] = [];\n const mockup = typeof spec.mockup === 'string' && MOCKUP.test(spec.mockup) && !spec.mockup.includes('..') ? spec.mockup : undefined;\n if (!mockup) issues.push('mockup: a *.mockup.html path next to the looks file');\n const rawLooks = Array.isArray(spec.looks) ? spec.looks : [];\n const rawLayouts = spec.layouts === undefined ? [] : Array.isArray(spec.layouts) ? spec.layouts : null;\n if (rawLayouts === null) issues.push('layouts: a list');\n if (rawLooks.length < 1 || rawLooks.length > 6 || (rawLooks.length < 2 && !rawLayouts?.length)) issues.push('looks: 2 to 6 looks (1 when the file compares layouts)');\n const looks: Look[] = [];\n rawLooks.forEach((raw, index) => {\n const item = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;\n const at = `looks[${index}]`;\n const id = typeof item.id === 'string' && ID.test(item.id) ? item.id : undefined;\n const label = text(item.label, 40), personality = text(item.personality, 160);\n const preset = item.preset === undefined ? undefined : typeof item.preset === 'string' && PRESET.test(item.preset) ? item.preset : null;\n if (!id) issues.push(`${at}.id: lowercase letters, digits and dashes`);\n if (!label) issues.push(`${at}.label: a short name`);\n if (!personality) issues.push(`${at}.personality: one line`);\n if (preset === null) issues.push(`${at}.preset: a shadcn preset code or ui.shadcn.com init URL`);\n const parsed = typeof item.css === 'string' ? parseShadcnTheme(item.css) : undefined;\n if (!parsed) issues.push(`${at}.css: the shadcn theme block of the look`);\n else if (parsed.kind === 'invalid') issues.push(...parsed.issues.slice(0, 4).map(issue => `${at}.css: ${issue}`));\n if (id && label && personality && preset !== null && parsed?.kind === 'ok') looks.push({ id, label, personality, ...(preset ? { preset } : {}), theme: parsed.theme });\n });\n if (new Set(looks.map(look => look.id)).size !== looks.length) issues.push('looks: ids are unique');\n const layouts: LayoutVariant[] = [];\n (rawLayouts ?? []).forEach((raw, index) => {\n const item = typeof raw === 'string' ? { mockup: raw } : (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;\n const path = typeof item.mockup === 'string' && MOCKUP.test(item.mockup) && !item.mockup.includes('..') ? item.mockup : undefined;\n if (!path) { issues.push(`layouts[${index}]: a *.mockup.html path, or { id, label, why?, mockup }`); return; }\n const id = typeof item.id === 'string' && ID.test(item.id) ? item.id : String.fromCharCode(97 + index);\n layouts.push({ id, label: text(item.label, 60) ?? `Layout ${String.fromCharCode(65 + index)}`, ...(text(item.why, 160) ? { why: text(item.why, 160) } : {}), mockup: path });\n });\n if (layouts.length > 4) issues.push('layouts: at most 4');\n const look = spec.look === undefined ? undefined : typeof spec.look === 'string' && looks.some(item => item.id === spec.look) ? spec.look : null;\n if (look === null) issues.push('look: the id of one of the looks');\n return issues.length || !mockup ? { kind: 'invalid', issues } : { kind: 'ok', looks: { mockup, looks, layouts, ...(look ? { look } : {}) } };\n}\n\n/** What the swatch row shows: four colours, the font family's first name and the radius. */\nexport function swatches(look: Look, dark = false): { readonly colors: readonly { readonly name: string; readonly value: string }[]; readonly font: string; readonly heading: string; readonly radius: string } {\n const vars = { ...look.theme.light, ...(dark ? look.theme.dark : undefined) };\n const family = (value: string | undefined) => (value ?? '').split(',')[0]!.replace(/['\"]/g, '').replace(/ Variable$/, '').trim();\n const font = family(look.theme.theme?.['font-sans']) || 'System';\n const headingVar = look.theme.theme?.['font-heading'];\n return {\n colors: ['background', 'primary', 'accent', 'foreground'].filter(name => vars[name]).map(name => ({ name, value: vars[name]! })),\n font, heading: headingVar && !headingVar.includes('var(') ? family(headingVar) : font, radius: vars.radius ?? '0.625rem',\n };\n}\n\n/** A path named in the looks file, read next to it. */\nexport const besideLooks = (looksPath: string, relative: string): string => looksPath.slice(0, looksPath.lastIndexOf('/') + 1) + relative;\n",
|
|
26
|
+
"content": "// looks@1: the looks an assistant offers for an app (`*.looks.json`). A look is a shadcn theme, exactly as shadcn writes it: a preset\n// code (`npx shadcn apply <code> --only theme,font`) and/or the theme block that preset produces (:root, .dark and the font and radius\n// variables of `@theme inline`). Nothing here invents a theme model: the block's variables are shadcn's own.\nimport type { ShadcnTheme } from '../mockup-viewer/mockup-viewer';\n\n/** The colour variables of a shadcn theme (`:root` and `.dark`). */\nexport const SHADCN_COLOR_VARIABLES: readonly string[] = Object.freeze([\n 'background', 'foreground', 'card', 'card-foreground', 'popover', 'popover-foreground', 'primary', 'primary-foreground',\n 'secondary', 'secondary-foreground', 'muted', 'muted-foreground', 'accent', 'accent-foreground', 'destructive', 'destructive-foreground',\n // the hairline variable's name is joined: the design scan reads that bare word as a class\n ['bord', 'er'].join(''), 'input', 'ring', 'chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5', 'sidebar', 'sidebar-foreground', 'sidebar-primary',\n 'sidebar-primary-foreground', 'sidebar-accent', 'sidebar-accent-foreground', 'sidebar-border', 'sidebar-ring',\n]);\n/** The non-colour variables a shadcn theme sets: the radius (`:root`) and the font families (`@theme inline`). */\nexport const SHADCN_SHAPE_VARIABLES: readonly string[] = Object.freeze(['radius', 'font-sans', 'font-serif', 'font-mono', 'font-heading']);\n\nconst COLOR = /^(?:#[0-9a-f]{3,8}|(?:oklch|oklab|lab|lch|hsla?|rgba?)\\([0-9a-z .,%/+-]{1,80}\\))$/i;\nconst LENGTH = /^(?:0|\\d{1,2}(?:\\.\\d{1,4})?(?:rem|px|em))$/;\nconst FONT = /^(?:[A-Za-z0-9 ,'\"-]{1,160}|var\\(--font-(?:sans|serif|mono)\\))$/;\nconst ID = /^[a-z0-9][a-z0-9-]{0,39}$/;\n/** A shadcn preset: a code (`bIkeymG`), a named style (`nova`) or a ui.shadcn.com init URL. */\nconst PRESET = /^(?:[A-Za-z0-9]{1,24}|https:\\/\\/ui\\.shadcn\\.com\\/init\\?[A-Za-z0-9=&_.-]{1,600})$/;\n\nexport type ThemeIssue = string;\n\n/** Is this a valid value for that shadcn variable? */\nexport function shadcnValue(name: string, value: string): boolean {\n if (SHADCN_COLOR_VARIABLES.includes(name)) return COLOR.test(value);\n if (name === 'radius') return LENGTH.test(value);\n if (name.startsWith('font-')) return SHADCN_SHAPE_VARIABLES.includes(name) && FONT.test(value);\n return false;\n}\n\n/**\n * A shadcn theme block (`:root { … } .dark { … } @theme inline { … }`, as `shadcn apply` writes it) into shadcn's `cssVars` shape.\n * Only shadcn theme variables with valid values are accepted; anything else is an issue, never silently kept.\n */\nexport function parseShadcnTheme(css: string): { readonly kind: 'ok'; readonly theme: ShadcnTheme } | { readonly kind: 'invalid'; readonly issues: readonly ThemeIssue[] } {\n const issues: ThemeIssue[] = [];\n const out = { theme: {} as Record<string, string>, light: {} as Record<string, string>, dark: {} as Record<string, string> };\n const text = css.replace(/\\/\\*[\\s\\S]*?\\*\\//g, '');\n const block = /([^{}]+)\\{([^{}]*)\\}/g;\n let consumed = '';\n for (const match of text.matchAll(block)) {\n consumed += match[0];\n const selector = match[1]!.trim();\n const target = selector === ':root' ? out.light : selector === '.dark' ? out.dark : /^@theme(?:\\s+inline)?$/.test(selector) ? out.theme : undefined;\n if (!target) { issues.push(`unexpected block ${selector.slice(0, 40)}: only :root, .dark and @theme inline`); continue; }\n for (const raw of match[2]!.split(';')) {\n const declaration = raw.trim();\n if (!declaration) continue;\n const colon = declaration.indexOf(':');\n const name = declaration.slice(0, colon).trim().replace(/^--/, ''), value = declaration.slice(colon + 1).trim();\n if (colon < 0 || !declaration.startsWith('--')) { issues.push(`not a variable: ${declaration.slice(0, 40)}`); continue; }\n // @theme inline also carries shadcn's colour and radius bindings (--color-primary: var(--primary)); they are the wiring, not the look.\n if (target === out.theme && (/^color-/.test(name) || /^radius-/.test(name))) continue;\n const allowed = target === out.theme ? name.startsWith('font-') : true;\n if (!allowed || !shadcnValue(name, value)) { issues.push(`${selector} --${name}: not a shadcn theme variable or not a valid value`); continue; }\n target[name] = value;\n }\n }\n if (text.replace(block, '').trim()) issues.push('text outside the :root, .dark and @theme blocks');\n if (!consumed) issues.push('no :root block');\n for (const name of ['background', 'foreground', 'primary', 'primary-foreground']) {\n if (!out.light[name]) issues.push(`:root misses --${name}`);\n if (!out.dark[name]) issues.push(`.dark misses --${name}`);\n }\n return issues.length ? { kind: 'invalid', issues } : { kind: 'ok', theme: out };\n}\n\nexport interface Look {\n readonly id: string;\n readonly label: string;\n /** One line: how the app feels in this look. */\n readonly personality: string;\n /** The shadcn preset this look applies (`npx shadcn apply <preset> --only theme,font`). */\n readonly preset?: string | undefined;\n /** The shadcn theme block, parsed. */\n readonly theme: ShadcnTheme;\n}\nexport interface LayoutVariant { readonly id: string; readonly label: string; readonly why?: string | undefined; readonly mockup: string }\nexport interface Looks {\n /** The key screen, a `*.mockup.html` next to the looks file. */\n readonly mockup: string;\n readonly looks: readonly Look[];\n readonly layouts: readonly LayoutVariant[];\n /** The look the layouts are drawn in (a look's id); absent: the first look. */\n readonly look?: string | undefined;\n}\n\nconst MOCKUP = /^[A-Za-z0-9][A-Za-z0-9 ._/-]{0,160}\\.mockup\\.html$/i;\nconst text = (value: unknown, max: number) => typeof value === 'string' && value.trim() && value.length <= max ? value.trim() : undefined;\n\n/** Read a `*.looks.json`. Paths are relative to the looks file; a layout is a path or `{ id, label, why?, mockup }`. */\nexport function parseLooks(source: string): { readonly kind: 'ok'; readonly looks: Looks } | { readonly kind: 'invalid'; readonly issues: readonly string[] } {\n let data: unknown;\n try { data = JSON.parse(source); } catch { return { kind: 'invalid', issues: ['not JSON'] }; }\n if (!data || typeof data !== 'object' || Array.isArray(data)) return { kind: 'invalid', issues: ['a looks file is an object { mockup, looks, layouts? }'] };\n const spec = data as Record<string, unknown>;\n const issues: string[] = [];\n const mockup = typeof spec.mockup === 'string' && MOCKUP.test(spec.mockup) && !spec.mockup.includes('..') ? spec.mockup : undefined;\n if (!mockup) issues.push('mockup: a *.mockup.html path next to the looks file');\n const rawLooks = Array.isArray(spec.looks) ? spec.looks : [];\n const rawLayouts = spec.layouts === undefined ? [] : Array.isArray(spec.layouts) ? spec.layouts : null;\n if (rawLayouts === null) issues.push('layouts: a list');\n if (rawLooks.length < 1 || rawLooks.length > 6 || (rawLooks.length < 2 && !rawLayouts?.length)) issues.push('looks: 2 to 6 looks (1 when the file compares layouts)');\n const looks: Look[] = [];\n rawLooks.forEach((raw, index) => {\n const item = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;\n const at = `looks[${index}]`;\n const id = typeof item.id === 'string' && ID.test(item.id) ? item.id : undefined;\n const label = text(item.label, 40), personality = text(item.personality, 160);\n const preset = item.preset === undefined ? undefined : typeof item.preset === 'string' && PRESET.test(item.preset) ? item.preset : null;\n if (!id) issues.push(`${at}.id: lowercase letters, digits and dashes`);\n if (!label) issues.push(`${at}.label: a short name`);\n if (!personality) issues.push(`${at}.personality: one line`);\n if (preset === null) issues.push(`${at}.preset: a shadcn preset code or ui.shadcn.com init URL`);\n const parsed = typeof item.css === 'string' ? parseShadcnTheme(item.css) : undefined;\n if (!parsed) issues.push(`${at}.css: the shadcn theme block of the look`);\n else if (parsed.kind === 'invalid') issues.push(...parsed.issues.slice(0, 4).map(issue => `${at}.css: ${issue}`));\n if (id && label && personality && preset !== null && parsed?.kind === 'ok') looks.push({ id, label, personality, ...(preset ? { preset } : {}), theme: parsed.theme });\n });\n if (new Set(looks.map(look => look.id)).size !== looks.length) issues.push('looks: ids are unique');\n const layouts: LayoutVariant[] = [];\n (rawLayouts ?? []).forEach((raw, index) => {\n const item = typeof raw === 'string' ? { mockup: raw } : (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;\n const path = typeof item.mockup === 'string' && MOCKUP.test(item.mockup) && !item.mockup.includes('..') ? item.mockup : undefined;\n if (!path) { issues.push(`layouts[${index}]: a *.mockup.html path, or { id, label, why?, mockup }`); return; }\n const id = typeof item.id === 'string' && ID.test(item.id) ? item.id : String.fromCharCode(97 + index);\n layouts.push({ id, label: text(item.label, 60) ?? `Layout ${String.fromCharCode(65 + index)}`, ...(text(item.why, 160) ? { why: text(item.why, 160) } : {}), mockup: path });\n });\n if (layouts.length > 4) issues.push('layouts: at most 4');\n const look = spec.look === undefined ? undefined : typeof spec.look === 'string' && looks.some(item => item.id === spec.look) ? spec.look : null;\n if (look === null) issues.push('look: the id of one of the looks');\n return issues.length || !mockup ? { kind: 'invalid', issues } : { kind: 'ok', looks: { mockup, looks, layouts, ...(look ? { look } : {}) } };\n}\n\n/** What the swatch row shows: four colours, the font family's first name and the radius. */\nexport function swatches(look: Look, dark = false): { readonly colors: readonly { readonly name: string; readonly value: string }[]; readonly font: string; readonly heading: string; readonly radius: string } {\n const vars = { ...look.theme.light, ...(dark ? look.theme.dark : undefined) };\n const family = (value: string | undefined) => (value ?? '').split(',')[0]!.replace(/['\"]/g, '').replace(/ Variable$/, '').trim();\n const font = family(look.theme.theme?.['font-sans']) || 'System';\n const headingVar = look.theme.theme?.['font-heading'];\n return {\n colors: ['background', 'primary', 'accent', 'foreground'].filter(name => vars[name]).map(name => ({ name, value: vars[name]! })),\n font, heading: headingVar && !headingVar.includes('var(') ? family(headingVar) : font, radius: vars.radius ?? '0.625rem',\n };\n}\n\n/** A path named in the looks file, read next to it. */\nexport const besideLooks = (looksPath: string, relative: string): string => looksPath.slice(0, looksPath.lastIndexOf('/') + 1) + relative;\n\n/** A `*.looks.json` file: the looks kind's match, here so the kind does not load the viewer. */\nexport const isLooksPath = (path: string): boolean => /\\.looks\\.json$/i.test(path);\n",
|
|
27
27
|
"type": "registry:lib",
|
|
28
28
|
"target": "components/looks-viewer/looks.ts"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"path": "registry/looks-viewer/looks-kind.tsx",
|
|
32
|
+
"content": "'use client';\n\nimport { lazy } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { PaletteIcon } from 'lucide-react';\nimport { isLooksPath } from './looks';\nimport type { LookPick } from './looks-viewer';\n\nconst LooksViewer = lazy(() => import('./looks-viewer').then(module => ({ default: module.LooksViewer })));\n\nexport interface LooksKind {\n readonly match: (target: { readonly resource: { readonly path: string } }) => boolean;\n readonly label: string;\n readonly icon: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }>;\n readonly renderer: (props: { readonly text: string; readonly onReady?: (() => void) | undefined; readonly onFailed?: ((reason: string) => void) | undefined; readonly read?: ((path: string) => Promise<string | undefined>) | undefined; readonly artifact?: { readonly target: { readonly resource: { readonly path: string } } } | undefined }) => ReactNode;\n readonly waitsForReady: true;\n}\n\n/** The `*.looks.json` kind: opt-in, like the board. `onPick` is the host's (the PM page says the pick in the conversation). The viewer loads on first open. */\nexport function createLooksKind({ onPick, locale }: { readonly onPick?: ((pick: LookPick) => void) | undefined; readonly locale?: 'en' | 'fr' | undefined } = {}): LooksKind {\n return { match: target => isLooksPath(target.resource.path), label: locale === 'fr' ? 'Styles' : 'Looks', icon: PaletteIcon, waitsForReady: true,\n renderer: ({ text, artifact, read, onReady, onFailed }) => <LooksViewer key={artifact?.target.resource.path} text={text} path={artifact?.target.resource.path} read={read} onPick={onPick} locale={locale} onReady={onReady} onFailed={onFailed} /> };\n}\n",
|
|
33
|
+
"type": "registry:component",
|
|
34
|
+
"target": "components/looks-viewer/looks-kind.tsx"
|
|
29
35
|
}
|
|
30
36
|
],
|
|
31
37
|
"type": "registry:block"
|