@hachej/boring-ui-kit 0.4.12 → 0.4.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/package.json +2 -2
  2. package/r/agent-target.json +4 -4
  3. package/r/app-shell.json +9 -9
  4. package/r/board-viewer.json +12 -6
  5. package/r/button.json +1 -1
  6. package/r/diagram-viewer.json +8 -2
  7. package/r/dock.json +1 -1
  8. package/r/documents.json +14 -12
  9. package/r/feedback-preview.json +1 -1
  10. package/r/feedback.json +6 -6
  11. package/r/file-tree.json +5 -5
  12. package/r/highlights.json +3 -3
  13. package/r/html-viewer.json +3 -3
  14. package/r/looks-viewer.json +9 -3
  15. package/r/maquette-viewer.json +9 -3
  16. package/r/markdown-editor.json +3 -3
  17. package/r/mockup-viewer.json +14 -2
  18. package/r/pi-ambient.json +10 -10
  19. package/r/pi-app.json +31 -20
  20. package/r/pi-artifacts.json +3 -3
  21. package/r/pi-chat-approval.json +1 -1
  22. package/r/pi-chat-attachments.json +1 -1
  23. package/r/pi-chat-markdown.json +2 -2
  24. package/r/pi-chat-mentions.json +1 -1
  25. package/r/pi-chat-pickers.json +1 -1
  26. package/r/pi-chat-plugins.json +11 -11
  27. package/r/pi-chat-question.json +1 -1
  28. package/r/pi-chat-slash.json +1 -1
  29. package/r/pi-chat-subagent.json +1 -1
  30. package/r/pi-chat-voice.json +2 -2
  31. package/r/pi-chat.json +5 -5
  32. package/r/pi-workspace.json +4 -4
  33. package/r/placed-agent.json +4 -4
  34. package/r/provider-setup.json +2 -2
  35. package/r/record-viewer.json +8 -2
  36. package/r/registry.json +396 -203
  37. package/r/requests-board.json +1 -1
  38. package/r/shared-chat.json +6 -6
  39. package/r/task-list-viewer.json +2 -2
  40. package/r/version-history.json +3 -3
  41. package/r/viewer-code.json +36 -0
  42. package/r/viewer-frame.json +447 -0
  43. package/r/viewer-html.json +32 -0
  44. package/r/viewer-image.json +36 -0
  45. package/r/viewer-markdown.json +47 -0
  46. package/r/viewer-pdf.json +45 -0
  47. package/r/viewers.json +8 -491
  48. package/r/windows.json +1 -1
@@ -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.12/r/mockup-viewer.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/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"
@@ -8,8 +8,8 @@
8
8
  "lucide-react@^1.51.0"
9
9
  ],
10
10
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/theme.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/diagram-viewer.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/theme.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/diagram-viewer.json",
13
13
  "alert",
14
14
  "badge",
15
15
  "bubble",
@@ -49,9 +49,15 @@
49
49
  },
50
50
  {
51
51
  "path": "registry/maquette-viewer/maquette-viewer.tsx",
52
- "content": "'use client';\n\n// The maquette viewer: a PM agent's `*.maquette.json` mock-up, one screen at a time. The screen is the content; the chrome is one thin bar\n// of icons: Back, ‹ 2 / 5 › (also arrow keys and swipe) and a menu holding the guided tours and \"How it works\" (the workflow, in a dialog).\n// During a tour the bar shows the step dots and ×, with the step's caption under it. `maquetteKind` is the kind the pi-app artifact panel\n// uses by default for `.maquette.json` files; an app's own kind in `artifactKinds` wins. It draws data only: nothing in the file runs.\n// The workflow is drawn by the diagram-viewer item's `DiagramView`, inline or from a linked `*.diagram.json`. With `comments` (the feedback\n// item's `usePointComments`, structurally) one more icon enters point mode on the screen: a tap on a block pins a note that names the\n// maquette path, the screen id and the block id; the pins of the current screen show as small numbers on their blocks.\nimport { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';\nimport type { ComponentType, KeyboardEvent, ReactNode, TouchEvent } from 'react';\nimport { ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon, CompassIcon, EllipsisIcon, InfoIcon, LayoutTemplateIcon, MessageSquarePlusIcon, TriangleAlertIcon, WorkflowIcon, XIcon } from 'lucide-react';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent } from '@/components/ui/card';\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { parseDiagram } from '../diagram-viewer/diagram';\nimport type { Diagram } from '../diagram-viewer/diagram';\nimport { DiagramView } from '../diagram-viewer/diagram-view';\nimport { MAQUETTE_FORMAT_VERSION, diagramPathOf, isMaquettePath, parseMaquette, screenFromFragment } from './maquette';\nimport type { Maquette, MaquetteBlock, MaquetteCase, MaquetteScreen } from './maquette';\nimport { maquetteLabelsFor } from './maquette-labels';\nimport type { MaquetteLabels, MaquetteLocale } from './maquette-labels';\nimport { MaquetteScreenView } from './maquette-blocks';\nimport type { MaquetteLinks } from './maquette-blocks';\n\nexport { MAQUETTE_FORMAT_VERSION, diagramPathOf, isMaquettePath, linksOf, parseMaquette, screenFromFragment } from './maquette';\nexport type { Maquette, MaquetteBlock, MaquetteBlockV1, MaquetteCase, MaquetteFileV1, MaquetteParse, MaquetteScreen, MaquetteState, MaquetteTransition, MaquetteWorkflow } from './maquette';\nexport { defaultMaquetteLabels, frenchMaquetteLabels, maquetteLabelsFor } from './maquette-labels';\nexport type { MaquetteLabels, MaquetteLocale } from './maquette-labels';\n\n/**\n * Comment pins, structurally the feedback item's `usePointComments(session)`: pass it and the bar shows a comment icon; omit it (no feedback\n * session in the host) and nothing shows.\n */\nexport interface MaquetteComments {\n /** Point mode is on. */\n readonly active: boolean;\n /** Enters point mode (the person taps blocks to pin notes), or ends it when it is on. */\n readonly point: () => void;\n /** The pins taken: each one's number and the `data-feedback-id` its element sits in. */\n readonly pins: readonly { readonly number: number; readonly target: string }[];\n}\n\n/** Reads another file of the workspace as text (the artifact panel passes one): a linked `*.diagram.json`. Undefined when it cannot. */\nexport type MaquetteRead = (path: string) => Promise<string | undefined>;\n\n/** A block's `data-feedback-id`: `maquette:<path>#screen=<screen id>&block=<block id>`. */\nexport const maquetteTarget = (path: string | undefined, screen: string, block: string): string => `maquette:${path ?? ''}#screen=${screen}&block=${block}`;\n\nexport interface MaquetteViewerProps {\n /** The file's text (JSON). */\n readonly text: string;\n /** The file's path: named by comment pins, and the folder a linked diagram is read from. */\n readonly path?: string | undefined;\n /** Comment pins on blocks (the feedback item's `usePointComments`). */\n readonly comments?: MaquetteComments | undefined;\n /** Reads a linked `*.diagram.json`; without it a linked workflow is not offered. */\n readonly read?: MaquetteRead | undefined;\n /** The page's URL fragment: `screen=<id>` opens that screen (and follows later changes). */\n readonly fragment?: string | undefined;\n /** The base words: English by default. */\n readonly locale?: MaquetteLocale | undefined;\n /** Any word over the locale's. */\n readonly labels?: Partial<MaquetteLabels> | undefined;\n}\n\n/** Where the person is: the screens visited (the last is shown) and the guided tour under way. */\ninterface Walk { readonly history: readonly string[]; readonly tour: { readonly name: string; readonly index: number } | null }\n\n/**\n * A host bar that owns the one \"…\" menu (the pi-app artifact panel) provides this: the viewer then publishes its tours and \"How it works\"\n * there instead of drawing a second \"…\" menu beside the screen counter. Without a provider the viewer keeps its own menu.\n */\nexport interface MaquetteMenuEntry { readonly id: string; readonly label: string; readonly icon: ReactNode; readonly testId: string; readonly separatorBefore: boolean; readonly onSelect: () => void }\nexport const MaquetteMenuContext = createContext<((items: readonly MaquetteMenuEntry[]) => void) | undefined>(undefined);\n\nconst TYPING = 'input, select, textarea, [contenteditable=\"true\"]';\nconst ICON = 'pointer-coarse:min-h-11 pointer-coarse:min-w-11';\n\n/** One dot per tour step, the current one in the primary colour. */\nfunction Dots({ count, index, label }: { readonly count: number; readonly index: number; readonly label: string }) {\n return <svg role=\"img\" aria-label={label} data-testid=\"maquette-dots\" width={count * 12} height=\"8\" viewBox={`0 0 ${count * 12} 8`} className=\"shrink-0\">\n {Array.from({ length: count }, (_, i) => <circle key={i} cx={i * 12 + 6} cy=\"4\" r=\"3\" className={i === index ? 'fill-primary' : 'fill-muted-foreground/30'} />)}\n </svg>;\n}\n\n/** The diagram of a linked `*.diagram.json`, read when the dialog opens: undefined while reading, null when it cannot be read. */\nfunction useLinked(read: MaquetteRead | undefined, path: string | undefined, open: boolean): Diagram | null | undefined {\n const [loaded, setLoaded] = useState<{ readonly path: string; readonly diagram: Diagram | null }>();\n useEffect(() => {\n if (!open || !path || !read || loaded?.path === path) return undefined;\n let off = false;\n read(path).then(text => text === undefined ? null : parseDiagram(text), () => null).then(parsed => {\n if (!off) setLoaded({ path, diagram: parsed?.kind === 'ok' ? parsed.diagram : null });\n });\n return () => { off = true; };\n }, [open, path, read, loaded?.path]);\n return loaded && loaded.path === path ? loaded.diagram : undefined;\n}\n\n/** The workflow diagram, its legend and the cases' `path` highlights, in a dialog opened from the menu. */\nfunction HowItWorks({ maquette, source, labels, open, onOpenChange }: {\n readonly maquette: Maquette; readonly source: DiagramSource; readonly labels: MaquetteLabels; readonly open: boolean; readonly onOpenChange: (open: boolean) => void;\n}) {\n const [shown, setShown] = useState<string | null>(null);\n const loaded = useLinked(source.read, source.path, open);\n const diagram = maquette.diagram ?? loaded;\n const highlighted = maquette.cases.filter(item => item.path.length > 0);\n if (!maquette.diagram && !source.path) return null;\n return <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent data-testid=\"maquette-how\" className=\"max-h-[85dvh] overflow-y-auto\">\n <DialogHeader><DialogTitle>{labels.howItWorks}</DialogTitle></DialogHeader>\n {diagram ? <DiagramView diagram={diagram} highlight={highlighted.find(item => item.name === shown)?.path ?? diagram.path} labels={labels} />\n : diagram === null ? <Alert variant=\"destructive\" data-testid=\"maquette-how-error\"><TriangleAlertIcon aria-hidden=\"true\" /><AlertTitle>{labels.diagramUnavailable}</AlertTitle></Alert>\n : <p role=\"status\" className=\"m-0 text-sm text-muted-foreground\">{labels.loading}</p>}\n {highlighted.length > 0 && <div role=\"group\" className=\"flex flex-wrap gap-2\">\n {highlighted.map(item => <Button key={item.name} type=\"button\" size=\"sm\" variant={shown === item.name ? 'default' : 'outline'} aria-pressed={shown === item.name}\n data-testid=\"maquette-case\" className=\"pointer-coarse:min-h-11\" onClick={() => setShown(current => current === item.name ? null : item.name)}>{item.name}</Button>)}\n </div>}\n </DialogContent>\n </Dialog>;\n}\n\n/** Where a linked diagram is read from: its resolved path and the reader (both absent for an inline workflow, or with no reader). */\ninterface DiagramSource { readonly path: string | undefined; readonly read: MaquetteRead | undefined }\n\nfunction Walkthrough({ maquette, labels, fragment, path, comments, read }: {\n readonly maquette: Maquette; readonly labels: MaquetteLabels; readonly fragment: string | undefined;\n readonly path: string | undefined; readonly comments: MaquetteComments | undefined; readonly read: MaquetteRead | undefined;\n}) {\n const exists = (id: string | undefined): id is string => !!id && maquette.screens.some(screen => screen.id === id);\n const linked = screenFromFragment(fragment);\n const [walk, setWalk] = useState<Walk>(() => ({ history: [exists(linked) ? linked : maquette.start], tour: null }));\n const [how, setHow] = useState(false);\n const seen = useRef(new Set<string>());\n const heading = useRef<HTMLHeadingElement>(null);\n const moved = useRef(false);\n const touch = useRef<{ x: number; y: number } | null>(null);\n const screens = maquette.screens;\n const current: MaquetteScreen = screens.find(screen => screen.id === walk.history.at(-1)) ?? screens[0]!;\n const position = screens.indexOf(current);\n const tour: MaquetteCase | undefined = walk.tour ? maquette.cases.find(item => item.name === walk.tour!.name) : undefined;\n const tours = useMemo(() => maquette.cases.filter(item => item.steps.length > 0), [maquette]);\n const titleOf = (id: string) => screens.find(screen => screen.id === id)?.title ?? id;\n // Goto blocks pulse once, the first time their screen is shown.\n // Decided once per shown screen, so a re-render of the same screen (the host menu updating) does not end the pulse.\n const shown = useRef<{ readonly id: string; readonly first: boolean } | undefined>(undefined);\n const view = shown.current?.id === current.id ? shown.current : { id: current.id, first: !seen.current.has(current.id) };\n shown.current = view;\n const firstView = view.first;\n useEffect(() => { seen.current.add(current.id); }, [current.id]);\n\n const move = (next: Walk) => { moved.current = true; setWalk(next); };\n const go = (id: string) => { if (exists(id) && id !== current.id) move({ history: [...walk.history, id], tour: null }); };\n const step = (delta: number) => {\n if (tour && walk.tour) {\n const index = walk.tour.index + delta, target = tour.steps[index];\n if (target) move({ history: [...walk.history, target.screen], tour: { name: tour.name, index } });\n return;\n }\n const target = screens[position + delta];\n if (target) move({ history: [...walk.history, target.id], tour: null });\n };\n const back = () => { if (walk.history.length > 1) move({ history: walk.history.slice(0, -1), tour: null }); };\n const startTour = (item: MaquetteCase) => move({ history: [...walk.history, item.steps[0]!.screen], tour: { name: item.name, index: 0 } });\n // A later `#screen=<id>` (the PM's \"see screen 2\" link) opens that screen.\n useEffect(() => { if (exists(linked) && linked !== walk.history.at(-1)) setWalk(previous => ({ history: [...previous.history, linked], tour: null })); }, [linked]); // eslint-disable-line react-hooks/exhaustive-deps\n // After a move, keyboard focus starts on the new screen, so Tab continues inside the screen just opened.\n useEffect(() => { if (moved.current) { moved.current = false; heading.current?.focus(); } }, [walk]);\n\n const index = tour && walk.tour ? walk.tour.index : position, count = tour ? tour.steps.length : screens.length;\n const target = (block: MaquetteBlock) => maquetteTarget(path, current.id, block.id);\n // While pointing, the session draws its own numbered pins over the page; the small numbers on the blocks are for afterwards.\n const pins = (block: MaquetteBlock) => comments && !comments.active ? comments.pins.filter(pin => pin.target === target(block)).map(pin => pin.number) : [];\n const links: MaquetteLinks = { go, hint: id => labels.opens(titleOf(id)), target, pins };\n const source: DiagramSource = { path: maquette.diagramLink && read ? diagramPathOf(path ?? '', maquette.diagramLink) : undefined, read };\n const workflow = !!maquette.diagram || !!source.path;\n // Inside a host bar with its own \"…\" menu, the tours and \"How it works\" go there (one menu, not two).\n const publish = useContext(MaquetteMenuContext);\n const latestTour = useRef(startTour); latestTour.current = startTour;\n useEffect(() => {\n if (!publish) return undefined;\n const items: MaquetteMenuEntry[] = [\n ...(tour ? [] : tours).map((item, i) => ({ id: `tour-${i}`, label: item.name, icon: <CompassIcon aria-hidden=\"true\" />, testId: 'maquette-tour', separatorBefore: i === 0, onSelect: () => latestTour.current(item) })),\n ...(workflow && !tour ? [{ id: 'how-it-works', label: labels.howItWorks, icon: <WorkflowIcon aria-hidden=\"true\" />, testId: 'maquette-how-open', separatorBefore: true, onSelect: () => setHow(true) }] : []),\n ];\n publish(items);\n return () => publish([]);\n }, [publish, tours, workflow, tour, labels.howItWorks]);\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.shiftKey || (event.target as Element).closest?.(TYPING)) return;\n if (event.altKey) { if (event.key === 'ArrowLeft') { event.preventDefault(); back(); } return; }\n if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); step(event.key === 'ArrowLeft' ? -1 : 1); }\n };\n const onTouchStart = (event: TouchEvent) => { const point = event.touches[0]; touch.current = point ? { x: point.clientX, y: point.clientY } : null; };\n const onTouchEnd = (event: TouchEvent) => {\n const start = touch.current, point = event.changedTouches[0];\n touch.current = null;\n if (!start || !point) return;\n const dx = point.clientX - start.x, dy = point.clientY - start.y;\n if (Math.abs(dx) > 56 && Math.abs(dx) > Math.abs(dy) * 1.5) step(dx < 0 ? 1 : -1);\n };\n const caption = tour && walk.tour ? tour.steps[walk.tour.index]?.caption : undefined;\n\n return <div data-testid=\"maquette-walk\" data-screen={current.id} data-tour={tour?.name} className=\"flex min-w-0 flex-col gap-2\" onKeyDown={onKeyDown}>\n {/* The viewer's own chrome is never pinned, and keeps working while pointing: walk to another screen and pin there too. */}\n <nav aria-label={labels.screens} data-testid=\"maquette-stepper\" data-feedback-ignore=\"\" className=\"flex items-center gap-1\">\n {walk.history.length > 1 && <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.back} data-testid=\"maquette-back\" onClick={back}><ArrowLeftIcon aria-hidden=\"true\" /></Button>}\n <div className=\"flex flex-1 items-center justify-center gap-1\">\n <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.previous} data-testid=\"maquette-previous\" disabled={index <= 0} onClick={() => step(-1)}><ChevronLeftIcon aria-hidden=\"true\" /></Button>\n {tour ? <Dots count={count} index={index} label={labels.tourStep(index + 1, count)} />\n : <span data-testid=\"maquette-position\" className=\"min-w-10 text-center text-sm text-muted-foreground tabular-nums\">{index + 1} / {count}</span>}\n <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.next} data-testid=\"maquette-next\" disabled={index >= count - 1} onClick={() => step(1)}><ChevronRightIcon aria-hidden=\"true\" /></Button>\n </div>\n {comments && <Tooltip><TooltipTrigger render={<Toggle className={ICON} aria-label={labels.comment} pressed={comments.active}\n data-testid=\"maquette-comment\" onPressedChange={() => comments.point()} />}><MessageSquarePlusIcon aria-hidden=\"true\" /></TooltipTrigger><TooltipContent>{labels.comment}</TooltipContent></Tooltip>}\n {tour ? <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.endTour} data-testid=\"maquette-end-tour\" onClick={() => setWalk(previous => ({ ...previous, tour: null }))}><XIcon aria-hidden=\"true\" /></Button>\n : !publish && (tours.length > 0 || workflow) && <DropdownMenu>\n <DropdownMenuTrigger render={<Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.more} data-testid=\"maquette-menu\" />}><EllipsisIcon aria-hidden=\"true\" /></DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" data-testid=\"maquette-menu-list\">\n {tours.map(item => <DropdownMenuItem key={item.name} className=\"pointer-coarse:min-h-11\" data-testid=\"maquette-tour\" onClick={() => startTour(item)}><CompassIcon aria-hidden=\"true\" />{item.name}</DropdownMenuItem>)}\n {tours.length > 0 && workflow && <DropdownMenuSeparator />}\n {workflow && <DropdownMenuItem className=\"pointer-coarse:min-h-11\" data-testid=\"maquette-how-open\" onClick={() => setHow(true)}><WorkflowIcon aria-hidden=\"true\" />{labels.howItWorks}</DropdownMenuItem>}\n </DropdownMenuContent>\n </DropdownMenu>}\n </nav>\n {caption && <p data-testid=\"maquette-caption\" className=\"m-0 line-clamp-2 px-1 text-sm text-pretty text-muted-foreground\">{caption}</p>}\n\n <div className={maquette.device === 'phone' ? 'mx-auto flex w-full max-w-sm flex-col gap-2' : 'flex min-w-0 flex-col gap-2'}>\n <div className=\"min-w-0\" data-hint={firstView ? 'pulse' : undefined} onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>\n <Card data-testid=\"maquette-frame\" data-device={maquette.device ?? 'panel'}><CardContent>\n <MaquetteScreenView key={current.id} maquette={maquette} screen={current} links={links} labels={labels} headingRef={heading} />\n </CardContent></Card>\n </div>\n {current.note && <div className=\"flex min-w-0 items-center gap-1 px-1\">\n <p data-testid=\"maquette-note\" title={current.note} className=\"m-0 min-w-0 flex-1 truncate text-xs text-muted-foreground\">{current.note}</p>\n <Popover>\n <PopoverTrigger render={<Button type=\"button\" variant=\"ghost\" size=\"icon-xs\" className={ICON} aria-label={labels.aboutScreen} data-testid=\"maquette-note-more\" />}><InfoIcon aria-hidden=\"true\" /></PopoverTrigger>\n <PopoverContent align=\"end\" data-testid=\"maquette-note-full\">{current.note}</PopoverContent>\n </Popover>\n </div>}\n </div>\n <p aria-live=\"polite\" className=\"m-0 sr-only\">{labels.showing(current.title)}</p>\n <HowItWorks maquette={maquette} source={source} labels={labels} open={how} onOpenChange={setHow} />\n </div>;\n}\n\n/** A maquette file drawn one screen at a time; a newer format version shows a notice, an unreadable file an inline error. */\nexport function MaquetteViewer({ text, fragment, locale, labels: given, path, comments, read }: MaquetteViewerProps) {\n const labels = useMemo(() => maquetteLabelsFor(locale, given), [locale, given]);\n const parsed = useMemo(() => parseMaquette(text), [text]);\n const newer = parsed.version !== undefined && parsed.version > MAQUETTE_FORMAT_VERSION;\n const notice = newer && <Alert data-testid=\"maquette-newer\"><InfoIcon aria-hidden=\"true\" /><AlertTitle>{labels.newerTitle}</AlertTitle><AlertDescription>{labels.newer(parsed.version!)}</AlertDescription></Alert>;\n return <section data-boring=\"maquette-viewer\" data-state={parsed.kind} aria-label={parsed.kind === 'ok' ? parsed.maquette.title || labels.kind : labels.kind}\n className=\"mx-auto flex w-full max-w-5xl min-w-0 flex-col gap-2 px-3 pt-1 pb-4 sm:px-6\">\n {notice}\n {parsed.kind === 'ok' ? <Walkthrough maquette={parsed.maquette} labels={labels} fragment={fragment} path={path} comments={comments} read={read} />\n : <Alert variant=\"destructive\" data-testid=\"maquette-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\n/**\n * The artifact kind of a maquette file (structurally an `ArtifactKind` of pi-artifacts): it matches `*.maquette.json`, names it, gives its icon\n * and draws it with `MaquetteViewer`. Pass it, or your own, in `artifactKinds`; kinds an app passes are consulted before the built-in one.\n */\nexport interface MaquetteKind {\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: {\n readonly text: string; readonly fragment?: string | undefined;\n readonly artifact?: { readonly target: { readonly resource: { readonly path: string } } } | undefined; readonly read?: MaquetteRead | undefined;\n }) => ReactNode;\n}\n\n/**\n * A maquette kind in `locale` (\"Mock-up\" in English, \"Maquette\" in French) with any word overridden. `comments` (the feedback item's\n * `usePointComments(session)`) adds the comment icon; pass a fresh kind when it changes.\n */\nexport function createMaquetteKind({ locale, labels, comments }: {\n readonly locale?: MaquetteLocale | undefined; readonly labels?: Partial<MaquetteLabels> | undefined; readonly comments?: MaquetteComments | undefined;\n} = {}): MaquetteKind {\n return {\n match: target => isMaquettePath(target.resource.path),\n label: maquetteLabelsFor(locale, labels).kind,\n icon: LayoutTemplateIcon,\n renderer: ({ text, fragment, artifact, read }) => <MaquetteViewer text={text} fragment={fragment} locale={locale} labels={labels}\n path={artifact?.target.resource.path} comments={comments} read={read} />,\n };\n}\n\n/** The built-in maquette kind, in English. */\nexport const maquetteKind: MaquetteKind = createMaquetteKind();\n",
52
+ "content": "'use client';\n\n// The maquette viewer: a PM agent's `*.maquette.json` mock-up, one screen at a time. The screen is the content; the chrome is one thin bar\n// of icons: Back, ‹ 2 / 5 › (also arrow keys and swipe) and a menu holding the guided tours and \"How it works\" (the workflow, in a dialog).\n// During a tour the bar shows the step dots and ×, with the step's caption under it. `maquetteKind` (maquette-kind.ts) is the kind the pi-app artifact panel\n// uses by default for `.maquette.json` files; an app's own kind in `artifactKinds` wins. It draws data only: nothing in the file runs.\n// The workflow is drawn by the diagram-viewer item's `DiagramView`, inline or from a linked `*.diagram.json`. With `comments` (the feedback\n// item's `usePointComments`, structurally) one more icon enters point mode on the screen: a tap on a block pins a note that names the\n// maquette path, the screen id and the block id; the pins of the current screen show as small numbers on their blocks.\nimport { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';\nimport type { KeyboardEvent, ReactNode, TouchEvent } from 'react';\nimport { ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon, CompassIcon, EllipsisIcon, InfoIcon, MessageSquarePlusIcon, TriangleAlertIcon, WorkflowIcon, XIcon } from 'lucide-react';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent } from '@/components/ui/card';\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { parseDiagram } from '../diagram-viewer/diagram';\nimport type { Diagram } from '../diagram-viewer/diagram';\nimport { DiagramView } from '../diagram-viewer/diagram-view';\nimport { MAQUETTE_FORMAT_VERSION, diagramPathOf, parseMaquette, screenFromFragment } from './maquette';\nimport type { Maquette, MaquetteBlock, MaquetteCase, MaquetteScreen } from './maquette';\nimport { maquetteLabelsFor } from './maquette-labels';\nimport type { MaquetteLabels, MaquetteLocale } from './maquette-labels';\nimport { MaquetteScreenView } from './maquette-blocks';\nimport type { MaquetteLinks } from './maquette-blocks';\n\nexport { MAQUETTE_FORMAT_VERSION, diagramPathOf, isMaquettePath, linksOf, parseMaquette, screenFromFragment } from './maquette';\nexport type { Maquette, MaquetteBlock, MaquetteBlockV1, MaquetteCase, MaquetteFileV1, MaquetteParse, MaquetteScreen, MaquetteState, MaquetteTransition, MaquetteWorkflow } from './maquette';\nexport { defaultMaquetteLabels, frenchMaquetteLabels, maquetteLabelsFor } from './maquette-labels';\nexport type { MaquetteLabels, MaquetteLocale } from './maquette-labels';\n\n/**\n * Comment pins, structurally the feedback item's `usePointComments(session)`: pass it and the bar shows a comment icon; omit it (no feedback\n * session in the host) and nothing shows.\n */\nexport interface MaquetteComments {\n /** Point mode is on. */\n readonly active: boolean;\n /** Enters point mode (the person taps blocks to pin notes), or ends it when it is on. */\n readonly point: () => void;\n /** The pins taken: each one's number and the `data-feedback-id` its element sits in. */\n readonly pins: readonly { readonly number: number; readonly target: string }[];\n}\n\n/** Reads another file of the workspace as text (the artifact panel passes one): a linked `*.diagram.json`. Undefined when it cannot. */\nexport type MaquetteRead = (path: string) => Promise<string | undefined>;\n\n/** A block's `data-feedback-id`: `maquette:<path>#screen=<screen id>&block=<block id>`. */\nexport const maquetteTarget = (path: string | undefined, screen: string, block: string): string => `maquette:${path ?? ''}#screen=${screen}&block=${block}`;\n\nexport interface MaquetteViewerProps {\n /** The file's text (JSON). */\n readonly text: string;\n /** The file's path: named by comment pins, and the folder a linked diagram is read from. */\n readonly path?: string | undefined;\n /** Comment pins on blocks (the feedback item's `usePointComments`). */\n readonly comments?: MaquetteComments | undefined;\n /** Reads a linked `*.diagram.json`; without it a linked workflow is not offered. */\n readonly read?: MaquetteRead | undefined;\n /** The page's URL fragment: `screen=<id>` opens that screen (and follows later changes). */\n readonly fragment?: string | undefined;\n /** The base words: English by default. */\n readonly locale?: MaquetteLocale | undefined;\n /** Any word over the locale's. */\n readonly labels?: Partial<MaquetteLabels> | undefined;\n /** A host bar's \"…\" menu (the pi-app artifact panel's `setMenu`): the tours and \"How it works\" go there, as with `MaquetteMenuContext`. */\n readonly publishMenu?: ((items: readonly MaquetteMenuEntry[]) => void) | undefined;\n}\n\n/** Where the person is: the screens visited (the last is shown) and the guided tour under way. */\ninterface Walk { readonly history: readonly string[]; readonly tour: { readonly name: string; readonly index: number } | null }\n\n/**\n * A host bar that owns the one \"…\" menu (the pi-app artifact panel) provides this: the viewer then publishes its tours and \"How it works\"\n * there instead of drawing a second \"…\" menu beside the screen counter. Without a provider the viewer keeps its own menu.\n */\nexport interface MaquetteMenuEntry { readonly id: string; readonly label: string; readonly icon: ReactNode; readonly testId: string; readonly separatorBefore: boolean; readonly onSelect: () => void }\nexport const MaquetteMenuContext = createContext<((items: readonly MaquetteMenuEntry[]) => void) | undefined>(undefined);\n\nconst TYPING = 'input, select, textarea, [contenteditable=\"true\"]';\nconst ICON = 'pointer-coarse:min-h-11 pointer-coarse:min-w-11';\n\n/** One dot per tour step, the current one in the primary colour. */\nfunction Dots({ count, index, label }: { readonly count: number; readonly index: number; readonly label: string }) {\n return <svg role=\"img\" aria-label={label} data-testid=\"maquette-dots\" width={count * 12} height=\"8\" viewBox={`0 0 ${count * 12} 8`} className=\"shrink-0\">\n {Array.from({ length: count }, (_, i) => <circle key={i} cx={i * 12 + 6} cy=\"4\" r=\"3\" className={i === index ? 'fill-primary' : 'fill-muted-foreground/30'} />)}\n </svg>;\n}\n\n/** The diagram of a linked `*.diagram.json`, read when the dialog opens: undefined while reading, null when it cannot be read. */\nfunction useLinked(read: MaquetteRead | undefined, path: string | undefined, open: boolean): Diagram | null | undefined {\n const [loaded, setLoaded] = useState<{ readonly path: string; readonly diagram: Diagram | null }>();\n useEffect(() => {\n if (!open || !path || !read || loaded?.path === path) return undefined;\n let off = false;\n read(path).then(text => text === undefined ? null : parseDiagram(text), () => null).then(parsed => {\n if (!off) setLoaded({ path, diagram: parsed?.kind === 'ok' ? parsed.diagram : null });\n });\n return () => { off = true; };\n }, [open, path, read, loaded?.path]);\n return loaded && loaded.path === path ? loaded.diagram : undefined;\n}\n\n/** The workflow diagram, its legend and the cases' `path` highlights, in a dialog opened from the menu. */\nfunction HowItWorks({ maquette, source, labels, open, onOpenChange }: {\n readonly maquette: Maquette; readonly source: DiagramSource; readonly labels: MaquetteLabels; readonly open: boolean; readonly onOpenChange: (open: boolean) => void;\n}) {\n const [shown, setShown] = useState<string | null>(null);\n const loaded = useLinked(source.read, source.path, open);\n const diagram = maquette.diagram ?? loaded;\n const highlighted = maquette.cases.filter(item => item.path.length > 0);\n if (!maquette.diagram && !source.path) return null;\n return <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent data-testid=\"maquette-how\" className=\"max-h-[85dvh] overflow-y-auto\">\n <DialogHeader><DialogTitle>{labels.howItWorks}</DialogTitle></DialogHeader>\n {diagram ? <DiagramView diagram={diagram} highlight={highlighted.find(item => item.name === shown)?.path ?? diagram.path} labels={labels} />\n : diagram === null ? <Alert variant=\"destructive\" data-testid=\"maquette-how-error\"><TriangleAlertIcon aria-hidden=\"true\" /><AlertTitle>{labels.diagramUnavailable}</AlertTitle></Alert>\n : <p role=\"status\" className=\"m-0 text-sm text-muted-foreground\">{labels.loading}</p>}\n {highlighted.length > 0 && <div role=\"group\" className=\"flex flex-wrap gap-2\">\n {highlighted.map(item => <Button key={item.name} type=\"button\" size=\"sm\" variant={shown === item.name ? 'default' : 'outline'} aria-pressed={shown === item.name}\n data-testid=\"maquette-case\" className=\"pointer-coarse:min-h-11\" onClick={() => setShown(current => current === item.name ? null : item.name)}>{item.name}</Button>)}\n </div>}\n </DialogContent>\n </Dialog>;\n}\n\n/** Where a linked diagram is read from: its resolved path and the reader (both absent for an inline workflow, or with no reader). */\ninterface DiagramSource { readonly path: string | undefined; readonly read: MaquetteRead | undefined }\n\nfunction Walkthrough({ maquette, labels, fragment, path, comments, read, publishMenu }: {\n readonly maquette: Maquette; readonly labels: MaquetteLabels; readonly fragment: string | undefined;\n readonly path: string | undefined; readonly comments: MaquetteComments | undefined; readonly read: MaquetteRead | undefined;\n readonly publishMenu: ((items: readonly MaquetteMenuEntry[]) => void) | undefined;\n}) {\n const exists = (id: string | undefined): id is string => !!id && maquette.screens.some(screen => screen.id === id);\n const linked = screenFromFragment(fragment);\n const [walk, setWalk] = useState<Walk>(() => ({ history: [exists(linked) ? linked : maquette.start], tour: null }));\n const [how, setHow] = useState(false);\n const seen = useRef(new Set<string>());\n const heading = useRef<HTMLHeadingElement>(null);\n const moved = useRef(false);\n const touch = useRef<{ x: number; y: number } | null>(null);\n const screens = maquette.screens;\n const current: MaquetteScreen = screens.find(screen => screen.id === walk.history.at(-1)) ?? screens[0]!;\n const position = screens.indexOf(current);\n const tour: MaquetteCase | undefined = walk.tour ? maquette.cases.find(item => item.name === walk.tour!.name) : undefined;\n const tours = useMemo(() => maquette.cases.filter(item => item.steps.length > 0), [maquette]);\n const titleOf = (id: string) => screens.find(screen => screen.id === id)?.title ?? id;\n // Goto blocks pulse once, the first time their screen is shown.\n // Decided once per shown screen, so a re-render of the same screen (the host menu updating) does not end the pulse.\n const shown = useRef<{ readonly id: string; readonly first: boolean } | undefined>(undefined);\n const view = shown.current?.id === current.id ? shown.current : { id: current.id, first: !seen.current.has(current.id) };\n shown.current = view;\n const firstView = view.first;\n useEffect(() => { seen.current.add(current.id); }, [current.id]);\n\n const move = (next: Walk) => { moved.current = true; setWalk(next); };\n const go = (id: string) => { if (exists(id) && id !== current.id) move({ history: [...walk.history, id], tour: null }); };\n const step = (delta: number) => {\n if (tour && walk.tour) {\n const index = walk.tour.index + delta, target = tour.steps[index];\n if (target) move({ history: [...walk.history, target.screen], tour: { name: tour.name, index } });\n return;\n }\n const target = screens[position + delta];\n if (target) move({ history: [...walk.history, target.id], tour: null });\n };\n const back = () => { if (walk.history.length > 1) move({ history: walk.history.slice(0, -1), tour: null }); };\n const startTour = (item: MaquetteCase) => move({ history: [...walk.history, item.steps[0]!.screen], tour: { name: item.name, index: 0 } });\n // A later `#screen=<id>` (the PM's \"see screen 2\" link) opens that screen.\n useEffect(() => { if (exists(linked) && linked !== walk.history.at(-1)) setWalk(previous => ({ history: [...previous.history, linked], tour: null })); }, [linked]); // eslint-disable-line react-hooks/exhaustive-deps\n // After a move, keyboard focus starts on the new screen, so Tab continues inside the screen just opened.\n useEffect(() => { if (moved.current) { moved.current = false; heading.current?.focus(); } }, [walk]);\n\n const index = tour && walk.tour ? walk.tour.index : position, count = tour ? tour.steps.length : screens.length;\n const target = (block: MaquetteBlock) => maquetteTarget(path, current.id, block.id);\n // While pointing, the session draws its own numbered pins over the page; the small numbers on the blocks are for afterwards.\n const pins = (block: MaquetteBlock) => comments && !comments.active ? comments.pins.filter(pin => pin.target === target(block)).map(pin => pin.number) : [];\n const links: MaquetteLinks = { go, hint: id => labels.opens(titleOf(id)), target, pins };\n const source: DiagramSource = { path: maquette.diagramLink && read ? diagramPathOf(path ?? '', maquette.diagramLink) : undefined, read };\n const workflow = !!maquette.diagram || !!source.path;\n // Inside a host bar with its own \"…\" menu, the tours and \"How it works\" go there (one menu, not two).\n const fromContext = useContext(MaquetteMenuContext);\n const publish = publishMenu ?? fromContext;\n const latestTour = useRef(startTour); latestTour.current = startTour;\n useEffect(() => {\n if (!publish) return undefined;\n const items: MaquetteMenuEntry[] = [\n ...(tour ? [] : tours).map((item, i) => ({ id: `tour-${i}`, label: item.name, icon: <CompassIcon aria-hidden=\"true\" />, testId: 'maquette-tour', separatorBefore: i === 0, onSelect: () => latestTour.current(item) })),\n ...(workflow && !tour ? [{ id: 'how-it-works', label: labels.howItWorks, icon: <WorkflowIcon aria-hidden=\"true\" />, testId: 'maquette-how-open', separatorBefore: true, onSelect: () => setHow(true) }] : []),\n ];\n publish(items);\n return () => publish([]);\n }, [publish, tours, workflow, tour, labels.howItWorks]);\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.shiftKey || (event.target as Element).closest?.(TYPING)) return;\n if (event.altKey) { if (event.key === 'ArrowLeft') { event.preventDefault(); back(); } return; }\n if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); step(event.key === 'ArrowLeft' ? -1 : 1); }\n };\n const onTouchStart = (event: TouchEvent) => { const point = event.touches[0]; touch.current = point ? { x: point.clientX, y: point.clientY } : null; };\n const onTouchEnd = (event: TouchEvent) => {\n const start = touch.current, point = event.changedTouches[0];\n touch.current = null;\n if (!start || !point) return;\n const dx = point.clientX - start.x, dy = point.clientY - start.y;\n if (Math.abs(dx) > 56 && Math.abs(dx) > Math.abs(dy) * 1.5) step(dx < 0 ? 1 : -1);\n };\n const caption = tour && walk.tour ? tour.steps[walk.tour.index]?.caption : undefined;\n\n return <div data-testid=\"maquette-walk\" data-screen={current.id} data-tour={tour?.name} className=\"flex min-w-0 flex-col gap-2\" onKeyDown={onKeyDown}>\n {/* The viewer's own chrome is never pinned, and keeps working while pointing: walk to another screen and pin there too. */}\n <nav aria-label={labels.screens} data-testid=\"maquette-stepper\" data-feedback-ignore=\"\" className=\"flex items-center gap-1\">\n {walk.history.length > 1 && <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.back} data-testid=\"maquette-back\" onClick={back}><ArrowLeftIcon aria-hidden=\"true\" /></Button>}\n <div className=\"flex flex-1 items-center justify-center gap-1\">\n <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.previous} data-testid=\"maquette-previous\" disabled={index <= 0} onClick={() => step(-1)}><ChevronLeftIcon aria-hidden=\"true\" /></Button>\n {tour ? <Dots count={count} index={index} label={labels.tourStep(index + 1, count)} />\n : <span data-testid=\"maquette-position\" className=\"min-w-10 text-center text-sm text-muted-foreground tabular-nums\">{index + 1} / {count}</span>}\n <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.next} data-testid=\"maquette-next\" disabled={index >= count - 1} onClick={() => step(1)}><ChevronRightIcon aria-hidden=\"true\" /></Button>\n </div>\n {comments && <Tooltip><TooltipTrigger render={<Toggle className={ICON} aria-label={labels.comment} pressed={comments.active}\n data-testid=\"maquette-comment\" onPressedChange={() => comments.point()} />}><MessageSquarePlusIcon aria-hidden=\"true\" /></TooltipTrigger><TooltipContent>{labels.comment}</TooltipContent></Tooltip>}\n {tour ? <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.endTour} data-testid=\"maquette-end-tour\" onClick={() => setWalk(previous => ({ ...previous, tour: null }))}><XIcon aria-hidden=\"true\" /></Button>\n : !publish && (tours.length > 0 || workflow) && <DropdownMenu>\n <DropdownMenuTrigger render={<Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.more} data-testid=\"maquette-menu\" />}><EllipsisIcon aria-hidden=\"true\" /></DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" data-testid=\"maquette-menu-list\">\n {tours.map(item => <DropdownMenuItem key={item.name} className=\"pointer-coarse:min-h-11\" data-testid=\"maquette-tour\" onClick={() => startTour(item)}><CompassIcon aria-hidden=\"true\" />{item.name}</DropdownMenuItem>)}\n {tours.length > 0 && workflow && <DropdownMenuSeparator />}\n {workflow && <DropdownMenuItem className=\"pointer-coarse:min-h-11\" data-testid=\"maquette-how-open\" onClick={() => setHow(true)}><WorkflowIcon aria-hidden=\"true\" />{labels.howItWorks}</DropdownMenuItem>}\n </DropdownMenuContent>\n </DropdownMenu>}\n </nav>\n {caption && <p data-testid=\"maquette-caption\" className=\"m-0 line-clamp-2 px-1 text-sm text-pretty text-muted-foreground\">{caption}</p>}\n\n <div className={maquette.device === 'phone' ? 'mx-auto flex w-full max-w-sm flex-col gap-2' : 'flex min-w-0 flex-col gap-2'}>\n <div className=\"min-w-0\" data-hint={firstView ? 'pulse' : undefined} onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>\n <Card data-testid=\"maquette-frame\" data-device={maquette.device ?? 'panel'}><CardContent>\n <MaquetteScreenView key={current.id} maquette={maquette} screen={current} links={links} labels={labels} headingRef={heading} />\n </CardContent></Card>\n </div>\n {current.note && <div className=\"flex min-w-0 items-center gap-1 px-1\">\n <p data-testid=\"maquette-note\" title={current.note} className=\"m-0 min-w-0 flex-1 truncate text-xs text-muted-foreground\">{current.note}</p>\n <Popover>\n <PopoverTrigger render={<Button type=\"button\" variant=\"ghost\" size=\"icon-xs\" className={ICON} aria-label={labels.aboutScreen} data-testid=\"maquette-note-more\" />}><InfoIcon aria-hidden=\"true\" /></PopoverTrigger>\n <PopoverContent align=\"end\" data-testid=\"maquette-note-full\">{current.note}</PopoverContent>\n </Popover>\n </div>}\n </div>\n <p aria-live=\"polite\" className=\"m-0 sr-only\">{labels.showing(current.title)}</p>\n <HowItWorks maquette={maquette} source={source} labels={labels} open={how} onOpenChange={setHow} />\n </div>;\n}\n\n/** A maquette file drawn one screen at a time; a newer format version shows a notice, an unreadable file an inline error. */\nexport function MaquetteViewer({ text, fragment, locale, labels: given, path, comments, read, publishMenu }: MaquetteViewerProps) {\n const labels = useMemo(() => maquetteLabelsFor(locale, given), [locale, given]);\n const parsed = useMemo(() => parseMaquette(text), [text]);\n const newer = parsed.version !== undefined && parsed.version > MAQUETTE_FORMAT_VERSION;\n const notice = newer && <Alert data-testid=\"maquette-newer\"><InfoIcon aria-hidden=\"true\" /><AlertTitle>{labels.newerTitle}</AlertTitle><AlertDescription>{labels.newer(parsed.version!)}</AlertDescription></Alert>;\n return <section data-boring=\"maquette-viewer\" data-state={parsed.kind} aria-label={parsed.kind === 'ok' ? parsed.maquette.title || labels.kind : labels.kind}\n className=\"mx-auto flex w-full max-w-5xl min-w-0 flex-col gap-2 px-3 pt-1 pb-4 sm:px-6\">\n {notice}\n {parsed.kind === 'ok' ? <Walkthrough maquette={parsed.maquette} labels={labels} fragment={fragment} path={path} comments={comments} read={read} publishMenu={publishMenu} />\n : <Alert variant=\"destructive\" data-testid=\"maquette-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",
53
53
  "type": "registry:component",
54
54
  "target": "components/maquette-viewer/maquette-viewer.tsx"
55
+ },
56
+ {
57
+ "path": "registry/maquette-viewer/maquette-kind.tsx",
58
+ "content": "'use client';\n\nimport { lazy } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { LayoutTemplateIcon } from 'lucide-react';\nimport { isMaquettePath } from './maquette';\nimport { maquetteLabelsFor } from './maquette-labels';\nimport type { MaquetteLabels, MaquetteLocale } from './maquette-labels';\nimport type { MaquetteComments, MaquetteMenuEntry, MaquetteRead } from './maquette-viewer';\n\n// The viewer loads only when a maquette opens: this module (match, label, icon) is all a host's first paint carries.\nconst MaquetteViewer = lazy(() => import('./maquette-viewer').then(module => ({ default: module.MaquetteViewer })));\n\n/**\n * The artifact kind of a maquette file (structurally an `ArtifactKind` of pi-artifacts): it matches `*.maquette.json`, names it, gives its icon\n * and draws it with `MaquetteViewer`, loaded on first open. Pass it, or your own, in `artifactKinds`; kinds an app passes are consulted first.\n */\nexport interface MaquetteKind {\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: {\n readonly text: string; readonly fragment?: string | undefined;\n readonly artifact?: { readonly target: { readonly resource: { readonly path: string } } } | undefined; readonly read?: MaquetteRead | undefined;\n readonly setMenu?: ((items: readonly MaquetteMenuEntry[]) => void) | undefined;\n }) => ReactNode;\n}\n\n/**\n * A maquette kind in `locale` (\"Mock-up\" in English, \"Maquette\" in French) with any word overridden. `comments` (the feedback item's\n * `usePointComments(session)`) adds the comment icon; pass a fresh kind when it changes.\n */\nexport function createMaquetteKind({ locale, labels, comments }: {\n readonly locale?: MaquetteLocale | undefined; readonly labels?: Partial<MaquetteLabels> | undefined; readonly comments?: MaquetteComments | undefined;\n} = {}): MaquetteKind {\n return {\n match: target => isMaquettePath(target.resource.path),\n label: maquetteLabelsFor(locale, labels).kind,\n icon: LayoutTemplateIcon,\n renderer: ({ text, fragment, artifact, read, setMenu }) => <MaquetteViewer text={text} fragment={fragment} locale={locale} labels={labels}\n path={artifact?.target.resource.path} comments={comments} read={read} publishMenu={setMenu} />,\n };\n}\n\n/** The built-in maquette kind, in English. */\nexport const maquetteKind: MaquetteKind = createMaquetteKind();\n",
59
+ "type": "registry:component",
60
+ "target": "components/maquette-viewer/maquette-kind.tsx"
55
61
  }
56
62
  ],
57
63
  "css": {
@@ -4,8 +4,8 @@
4
4
  "title": "Markdown editor",
5
5
  "description": "A host-owned styled wrapper over the concrete borrowed Markdown controller.",
6
6
  "dependencies": [
7
- "@hachej/boring-ui-kit@^0.4.12",
8
- "@hachej/boring-files@^0.4.12",
7
+ "@hachej/boring-ui-kit@^0.4.13",
8
+ "@hachej/boring-files@^0.4.13",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "marked@^17.0.6",
@@ -24,7 +24,7 @@
24
24
  "lucide-react@^1.51.0"
25
25
  ],
26
26
  "registryDependencies": [
27
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/button.json",
27
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/button.json",
28
28
  "button",
29
29
  "toggle",
30
30
  "dropdown-menu"
@@ -10,7 +10,7 @@
10
10
  "zod@^4.6.5"
11
11
  ],
12
12
  "registryDependencies": [
13
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.12/r/viewers.json",
13
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.13/r/viewer-frame.json",
14
14
  "badge",
15
15
  "toggle",
16
16
  "tooltip"
@@ -18,7 +18,7 @@
18
18
  "files": [
19
19
  {
20
20
  "path": "registry/mockup-viewer/mockup-viewer.tsx",
21
- "content": "'use client';\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ComponentType, ReactNode, RefObject } from 'react';\nimport { CodeXmlIcon, MessageSquarePlusIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { InteractiveFrame } from '../viewers/interactive-html';\nimport { mockupContent, readMockupPick } from './mockup-frame';\nimport type { MockupPick } from './mockup-frame';\n\nexport const isMockupPath = (path: string): boolean => /\\.mockup\\.html$/i.test(path);\nexport const mockupTarget = (path: string, selector: string): string => `mockup:${path}#element=${selector}`;\n\n/** Structurally the feedback item's usePointComments. Picks are host-owned proxy elements, never frame DOM. */\nexport interface MockupComments {\n readonly active: boolean;\n readonly picking?: boolean | undefined;\n readonly point: () => void;\n readonly pins: readonly { readonly number: number; readonly target: string }[];\n readonly pick?: ((element: Element) => void) | undefined;\n readonly reposition?: (() => void) | undefined;\n}\n\nexport interface MockupViewerProps {\n readonly text: string;\n readonly path?: string | undefined;\n readonly comments?: MockupComments | undefined;\n readonly locale?: 'en' | 'fr' | undefined;\n readonly onReady?: (() => void) | undefined;\n /**\n * Render the frame with this shadcn theme instead of the host's (a look being compared): shadcn's own `cssVars` shape, variable names\n * without `--`. Its variables are applied after the host's, so a look that names only some of them keeps the host's others.\n */\n readonly themeOverride?: ShadcnTheme | undefined;\n /** Force light or dark in the frame; absent: follow the host. */\n readonly mode?: 'light' | 'dark' | undefined;\n /** The frame's accessible name (several frames side by side need distinct names). */\n readonly title?: string | undefined;\n}\n\n/** A shadcn theme as the shadcn registry writes it (`cssVars`): `theme` (fonts, radius steps), `light` (:root) and `dark` (.dark). */\nexport interface ShadcnTheme {\n readonly theme?: Readonly<Record<string, string>> | undefined;\n readonly light?: Readonly<Record<string, string>> | undefined;\n readonly dark?: Readonly<Record<string, string>> | undefined;\n}\n\nconst NAME = /^[a-z][a-z0-9-]{0,47}$/;\nconst VALUE = /^[^;{}<>\\\\]{1,240}$/;\n\n/** The declarations a shadcn theme gives the frame in one mode. Names and values that could leave the declaration list are dropped. */\nexport function themeDeclarations(theme: ShadcnTheme, dark: boolean): string {\n const entries = { ...theme.theme, ...theme.light, ...(dark ? theme.dark : undefined) };\n // The frame's radius steps are bound to --radius (the registry theme's bindings), so a look's --radius reshapes every rounded-*.\n return Object.entries(entries).filter(([name, value]) => NAME.test(name) && typeof value === 'string' && VALUE.test(value.trim())).map(([name, value]) => `--${name}:${value.trim()}`).join(';');\n}\n\nfunction useTheme(root: RefObject<HTMLDivElement | null>) {\n const [theme, setTheme] = useState({ variables: '', dark: false });\n useEffect(() => {\n const element = root.current;\n if (!element) return undefined;\n const view = element.ownerDocument.defaultView;\n if (!view) return undefined;\n const media = view.matchMedia('(prefers-color-scheme: dark)');\n const read = () => {\n const computed = view.getComputedStyle(element);\n const variables = Array.from(computed).filter(name => name.startsWith('--')).map(name => `${name}:${computed.getPropertyValue(name)}`).join(';');\n const dark = !!element.closest('.dark') || (!element.closest('.light') && media.matches);\n setTheme(current => current.variables === variables && current.dark === dark ? current : { variables, dark });\n };\n read();\n const observer = typeof MutationObserver === 'undefined' ? undefined : new MutationObserver(read);\n for (let node: Element | null = element; node; node = node.parentElement) observer?.observe(node, { attributes: true, attributeFilter: ['class', 'style'] });\n media.addEventListener('change', read);\n return () => { observer?.disconnect(); media.removeEventListener('change', read); };\n }, [root]);\n return theme;\n}\n\nexport function MockupViewer({ text, path = '', comments, locale, onReady, themeOverride, mode: forced, title }: MockupViewerProps) {\n const root = useRef<HTMLDivElement>(null);\n const frame = useRef<HTMLIFrameElement>(null);\n const proxies = useRef(new Map<string, HTMLSpanElement>());\n const pending = useRef<string | undefined>(undefined);\n const host = useTheme(root);\n const overrideKey = themeOverride ? JSON.stringify(themeOverride) : '';\n const theme = useMemo(() => {\n const dark = forced ? forced === 'dark' : host.dark;\n if (!themeOverride) return dark === host.dark ? host : { ...host, dark };\n const own = themeDeclarations(themeOverride, dark);\n return { variables: [host.variables, own].filter(Boolean).join(';'), dark };\n }, [host, overrideKey, forced]); // eslint-disable-line react-hooks/exhaustive-deps\n const [compiled, setCompiled] = useState<{ text: string; css: string } | { text: string; error: true }>();\n const [picks, setPicks] = useState<readonly MockupPick[]>([]);\n const label = locale === 'fr' ? 'Commenter un élément' : 'Comment on an element';\n useEffect(() => {\n let off = false;\n setPicks([]);\n void import('./mockup-compile').then(({ compileMockup }) => compileMockup(text)).then(css => { if (!off) setCompiled({ text, css }); }, () => { if (!off) setCompiled({ text, error: true }); });\n return () => { off = true; };\n }, [text, path]);\n const ready = compiled?.text === text && 'css' in compiled;\n const content = ready ? mockupContent(text, compiled.css) : undefined;\n const picking = comments?.picking ?? comments?.active ?? false;\n const mode = () => frame.current?.contentWindow?.postMessage({ type: 'mockup:pick-mode', on: picking, theme }, '*');\n useEffect(mode, [picking, content, theme]);\n useLayoutEffect(() => { pending.current = undefined; setPicks([]); }, [content]);\n useEffect(() => {\n const view = root.current?.ownerDocument.defaultView;\n const onMessage = (event: MessageEvent<unknown>) => {\n if (!frame.current?.contentWindow || event.source !== frame.current.contentWindow || event.origin !== 'null' || !comments?.pick) return;\n const pick = readMockupPick(event.data);\n if (!pick || (pick.type === 'mockup:pick' && !picking)) return;\n if (pick.type === 'mockup:pick') pending.current = pick.selector;\n setPicks(current => {\n const exists = current.some(item => item.selector === pick.selector);\n if (!exists && (pick.type !== 'mockup:pick' || current.length >= 50)) { pending.current = undefined; return current; }\n return exists ? current.map(item => item.selector === pick.selector ? pick : item) : [...current, pick];\n });\n };\n view?.addEventListener('message', onMessage);\n return () => view?.removeEventListener('message', onMessage);\n }, [comments, picking]);\n useLayoutEffect(() => {\n const selector = pending.current;\n pending.current = undefined;\n const proxy = selector === undefined ? undefined : proxies.current.get(selector);\n if (proxy) comments?.pick?.(proxy);\n else comments?.reposition?.();\n }, [picks, comments?.pick, comments?.reposition]);\n return <div ref={root} data-boring=\"mockup-viewer\" className=\"flex h-full min-h-0 min-w-0 flex-col\">\n {comments?.pick && <nav data-feedback-ignore=\"\" className=\"flex shrink-0 justify-end px-2 py-1\">\n <Tooltip><TooltipTrigger render={<Toggle aria-label={label} pressed={comments.active} onPressedChange={comments.point} data-testid=\"mockup-comment\" className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11\" />}><MessageSquarePlusIcon aria-hidden=\"true\" /></TooltipTrigger><TooltipContent>{label}</TooltipContent></Tooltip>\n </nav>}\n <div className=\"relative flex min-h-0 min-w-0 flex-1 overflow-hidden\">\n {content === undefined ? <p role=\"status\" className=\"p-3 text-muted-foreground\">{compiled && 'error' in compiled ? 'HTML mock-up unavailable.' : 'Loading HTML mock-up…'}</p>\n : <InteractiveFrame key={content} text={content} sources={[]} title={title ?? 'HTML mock-up'} testId=\"mockup\" frameRef={frame} onLoad={() => { mode(); onReady?.(); }} />}\n {picks.map(pick => <span key={pick.selector} ref={element => { if (element) proxies.current.set(pick.selector, element); else proxies.current.delete(pick.selector); }}\n data-testid=\"mockup-element\" data-feedback-id={mockupTarget(path, pick.selector)} role=\"img\" aria-label=\"HTML mock-up element\"\n className=\"pointer-events-none absolute overflow-hidden\" style={{ left: pick.rect.x, top: pick.rect.y, width: pick.rect.w, height: pick.rect.h }}>\n {!comments?.active && comments?.pins.filter(pin => pin.target === mockupTarget(path, pick.selector)).map(pin => <Badge key={pin.number}>{pin.number}</Badge>)}\n </span>)}\n </div>\n </div>;\n}\n\nexport interface MockupKind {\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 artifact?: { readonly target: { readonly resource: { readonly path: string } } } | undefined }) => ReactNode;\n /** The sandboxed frame loads asynchronously: a `present` of a mock-up is confirmed only once it has loaded. */\n readonly waitsForReady: true;\n}\n\nexport function createMockupKind({ comments, locale }: { readonly comments?: MockupComments | undefined; readonly locale?: 'en' | 'fr' | undefined } = {}): MockupKind {\n return { match: target => isMockupPath(target.resource.path), label: locale === 'fr' ? 'Maquette HTML' : 'HTML mock-up', icon: CodeXmlIcon, waitsForReady: true,\n renderer: ({ text, artifact, onReady }) => <MockupViewer key={artifact?.target.resource.path} text={text} path={artifact?.target.resource.path} comments={comments} locale={locale} onReady={onReady} /> };\n}\nexport const mockupKind: MockupKind = createMockupKind();\n",
21
+ "content": "'use client';\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { RefObject } from 'react';\nimport { MessageSquarePlusIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { InteractiveFrame } from '../viewer-frame/interactive-html';\nimport { mockupContent, readMockupPick } from './mockup-frame';\nimport type { MockupPick } from './mockup-frame';\n\nimport { mockupTarget } from './mockup-path';\nexport { isMockupPath, mockupTarget } from './mockup-path';\n\n/** Structurally the feedback item's usePointComments. Picks are host-owned proxy elements, never frame DOM. */\nexport interface MockupComments {\n readonly active: boolean;\n readonly picking?: boolean | undefined;\n readonly point: () => void;\n readonly pins: readonly { readonly number: number; readonly target: string }[];\n readonly pick?: ((element: Element) => void) | undefined;\n readonly reposition?: (() => void) | undefined;\n}\n\nexport interface MockupViewerProps {\n readonly text: string;\n readonly path?: string | undefined;\n readonly comments?: MockupComments | undefined;\n readonly locale?: 'en' | 'fr' | undefined;\n readonly onReady?: (() => void) | undefined;\n /**\n * Render the frame with this shadcn theme instead of the host's (a look being compared): shadcn's own `cssVars` shape, variable names\n * without `--`. Its variables are applied after the host's, so a look that names only some of them keeps the host's others.\n */\n readonly themeOverride?: ShadcnTheme | undefined;\n /** Force light or dark in the frame; absent: follow the host. */\n readonly mode?: 'light' | 'dark' | undefined;\n /** The frame's accessible name (several frames side by side need distinct names). */\n readonly title?: string | undefined;\n}\n\n/** A shadcn theme as the shadcn registry writes it (`cssVars`): `theme` (fonts, radius steps), `light` (:root) and `dark` (.dark). */\nexport interface ShadcnTheme {\n readonly theme?: Readonly<Record<string, string>> | undefined;\n readonly light?: Readonly<Record<string, string>> | undefined;\n readonly dark?: Readonly<Record<string, string>> | undefined;\n}\n\nconst NAME = /^[a-z][a-z0-9-]{0,47}$/;\nconst VALUE = /^[^;{}<>\\\\]{1,240}$/;\n\n/** The declarations a shadcn theme gives the frame in one mode. Names and values that could leave the declaration list are dropped. */\nexport function themeDeclarations(theme: ShadcnTheme, dark: boolean): string {\n const entries = { ...theme.theme, ...theme.light, ...(dark ? theme.dark : undefined) };\n // The frame's radius steps are bound to --radius (the registry theme's bindings), so a look's --radius reshapes every rounded-*.\n return Object.entries(entries).filter(([name, value]) => NAME.test(name) && typeof value === 'string' && VALUE.test(value.trim())).map(([name, value]) => `--${name}:${value.trim()}`).join(';');\n}\n\nfunction useTheme(root: RefObject<HTMLDivElement | null>) {\n const [theme, setTheme] = useState({ variables: '', dark: false });\n useEffect(() => {\n const element = root.current;\n if (!element) return undefined;\n const view = element.ownerDocument.defaultView;\n if (!view) return undefined;\n const media = view.matchMedia('(prefers-color-scheme: dark)');\n const read = () => {\n const computed = view.getComputedStyle(element);\n const variables = Array.from(computed).filter(name => name.startsWith('--')).map(name => `${name}:${computed.getPropertyValue(name)}`).join(';');\n const dark = !!element.closest('.dark') || (!element.closest('.light') && media.matches);\n setTheme(current => current.variables === variables && current.dark === dark ? current : { variables, dark });\n };\n read();\n const observer = typeof MutationObserver === 'undefined' ? undefined : new MutationObserver(read);\n for (let node: Element | null = element; node; node = node.parentElement) observer?.observe(node, { attributes: true, attributeFilter: ['class', 'style'] });\n media.addEventListener('change', read);\n return () => { observer?.disconnect(); media.removeEventListener('change', read); };\n }, [root]);\n return theme;\n}\n\nexport function MockupViewer({ text, path = '', comments, locale, onReady, themeOverride, mode: forced, title }: MockupViewerProps) {\n const root = useRef<HTMLDivElement>(null);\n const frame = useRef<HTMLIFrameElement>(null);\n const proxies = useRef(new Map<string, HTMLSpanElement>());\n const pending = useRef<string | undefined>(undefined);\n const host = useTheme(root);\n const overrideKey = themeOverride ? JSON.stringify(themeOverride) : '';\n const theme = useMemo(() => {\n const dark = forced ? forced === 'dark' : host.dark;\n if (!themeOverride) return dark === host.dark ? host : { ...host, dark };\n const own = themeDeclarations(themeOverride, dark);\n return { variables: [host.variables, own].filter(Boolean).join(';'), dark };\n }, [host, overrideKey, forced]); // eslint-disable-line react-hooks/exhaustive-deps\n const [compiled, setCompiled] = useState<{ text: string; css: string } | { text: string; error: true }>();\n const [picks, setPicks] = useState<readonly MockupPick[]>([]);\n const label = locale === 'fr' ? 'Commenter un élément' : 'Comment on an element';\n useEffect(() => {\n let off = false;\n setPicks([]);\n void import('./mockup-compile').then(({ compileMockup }) => compileMockup(text)).then(css => { if (!off) setCompiled({ text, css }); }, () => { if (!off) setCompiled({ text, error: true }); });\n return () => { off = true; };\n }, [text, path]);\n const ready = compiled?.text === text && 'css' in compiled;\n const content = ready ? mockupContent(text, compiled.css) : undefined;\n const picking = comments?.picking ?? comments?.active ?? false;\n const mode = () => frame.current?.contentWindow?.postMessage({ type: 'mockup:pick-mode', on: picking, theme }, '*');\n useEffect(mode, [picking, content, theme]);\n useLayoutEffect(() => { pending.current = undefined; setPicks([]); }, [content]);\n useEffect(() => {\n const view = root.current?.ownerDocument.defaultView;\n const onMessage = (event: MessageEvent<unknown>) => {\n if (!frame.current?.contentWindow || event.source !== frame.current.contentWindow || event.origin !== 'null' || !comments?.pick) return;\n const pick = readMockupPick(event.data);\n if (!pick || (pick.type === 'mockup:pick' && !picking)) return;\n if (pick.type === 'mockup:pick') pending.current = pick.selector;\n setPicks(current => {\n const exists = current.some(item => item.selector === pick.selector);\n if (!exists && (pick.type !== 'mockup:pick' || current.length >= 50)) { pending.current = undefined; return current; }\n return exists ? current.map(item => item.selector === pick.selector ? pick : item) : [...current, pick];\n });\n };\n view?.addEventListener('message', onMessage);\n return () => view?.removeEventListener('message', onMessage);\n }, [comments, picking]);\n useLayoutEffect(() => {\n const selector = pending.current;\n pending.current = undefined;\n const proxy = selector === undefined ? undefined : proxies.current.get(selector);\n if (proxy) comments?.pick?.(proxy);\n else comments?.reposition?.();\n }, [picks, comments?.pick, comments?.reposition]);\n return <div ref={root} data-boring=\"mockup-viewer\" className=\"flex h-full min-h-0 min-w-0 flex-col\">\n {comments?.pick && <nav data-feedback-ignore=\"\" className=\"flex shrink-0 justify-end px-2 py-1\">\n <Tooltip><TooltipTrigger render={<Toggle aria-label={label} pressed={comments.active} onPressedChange={comments.point} data-testid=\"mockup-comment\" className=\"pointer-coarse:min-h-11 pointer-coarse:min-w-11\" />}><MessageSquarePlusIcon aria-hidden=\"true\" /></TooltipTrigger><TooltipContent>{label}</TooltipContent></Tooltip>\n </nav>}\n <div className=\"relative flex min-h-0 min-w-0 flex-1 overflow-hidden\">\n {content === undefined ? <p role=\"status\" className=\"p-3 text-muted-foreground\">{compiled && 'error' in compiled ? 'HTML mock-up unavailable.' : 'Loading HTML mock-up…'}</p>\n : <InteractiveFrame key={content} text={content} sources={[]} title={title ?? 'HTML mock-up'} testId=\"mockup\" frameRef={frame} onLoad={() => { mode(); onReady?.(); }} />}\n {picks.map(pick => <span key={pick.selector} ref={element => { if (element) proxies.current.set(pick.selector, element); else proxies.current.delete(pick.selector); }}\n data-testid=\"mockup-element\" data-feedback-id={mockupTarget(path, pick.selector)} role=\"img\" aria-label=\"HTML mock-up element\"\n className=\"pointer-events-none absolute overflow-hidden\" style={{ left: pick.rect.x, top: pick.rect.y, width: pick.rect.w, height: pick.rect.h }}>\n {!comments?.active && comments?.pins.filter(pin => pin.target === mockupTarget(path, pick.selector)).map(pin => <Badge key={pin.number}>{pin.number}</Badge>)}\n </span>)}\n </div>\n </div>;\n}\n",
22
22
  "type": "registry:component",
23
23
  "target": "components/mockup-viewer/mockup-viewer.tsx"
24
24
  },
@@ -39,6 +39,18 @@
39
39
  "content": "// Generated by build-tailwind.mjs from tailwindcss@4.3.3 and registry.json.\n/* MIT License\n\nCopyright (c) Tailwind Labs, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n */\nexport const sources = {\n \"theme\": \"@theme default {\\n --font-sans:\\n -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', Arial,\\n sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';\\n --font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;\\n --font-mono:\\n ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',\\n monospace;\\n\\n --color-red-50: oklch(97.1% 0.013 17.38);\\n --color-red-100: oklch(93.6% 0.032 17.717);\\n --color-red-200: oklch(88.5% 0.062 18.334);\\n --color-red-300: oklch(80.8% 0.114 19.571);\\n --color-red-400: oklch(70.4% 0.191 22.216);\\n --color-red-500: oklch(63.7% 0.237 25.331);\\n --color-red-600: oklch(57.7% 0.245 27.325);\\n --color-red-700: oklch(50.5% 0.213 27.518);\\n --color-red-800: oklch(44.4% 0.177 26.899);\\n --color-red-900: oklch(39.6% 0.141 25.723);\\n --color-red-950: oklch(25.8% 0.092 26.042);\\n\\n --color-orange-50: oklch(98% 0.016 73.684);\\n --color-orange-100: oklch(95.4% 0.038 75.164);\\n --color-orange-200: oklch(90.1% 0.076 70.697);\\n --color-orange-300: oklch(83.7% 0.128 66.29);\\n --color-orange-400: oklch(75% 0.183 55.934);\\n --color-orange-500: oklch(70.5% 0.213 47.604);\\n --color-orange-600: oklch(64.6% 0.222 41.116);\\n --color-orange-700: oklch(55.3% 0.195 38.402);\\n --color-orange-800: oklch(47% 0.157 37.304);\\n --color-orange-900: oklch(40.8% 0.123 38.172);\\n --color-orange-950: oklch(26.6% 0.079 36.259);\\n\\n --color-amber-50: oklch(98.7% 0.022 95.277);\\n --color-amber-100: oklch(96.2% 0.059 95.617);\\n --color-amber-200: oklch(92.4% 0.12 95.746);\\n --color-amber-300: oklch(87.9% 0.169 91.605);\\n --color-amber-400: oklch(82.8% 0.189 84.429);\\n --color-amber-500: oklch(76.9% 0.188 70.08);\\n --color-amber-600: oklch(66.6% 0.179 58.318);\\n --color-amber-700: oklch(55.5% 0.163 48.998);\\n --color-amber-800: oklch(47.3% 0.137 46.201);\\n --color-amber-900: oklch(41.4% 0.112 45.904);\\n --color-amber-950: oklch(27.9% 0.077 45.635);\\n\\n --color-yellow-50: oklch(98.7% 0.026 102.212);\\n --color-yellow-100: oklch(97.3% 0.071 103.193);\\n --color-yellow-200: oklch(94.5% 0.129 101.54);\\n --color-yellow-300: oklch(90.5% 0.182 98.111);\\n --color-yellow-400: oklch(85.2% 0.199 91.936);\\n --color-yellow-500: oklch(79.5% 0.184 86.047);\\n --color-yellow-600: oklch(68.1% 0.162 75.834);\\n --color-yellow-700: oklch(55.4% 0.135 66.442);\\n --color-yellow-800: oklch(47.6% 0.114 61.907);\\n --color-yellow-900: oklch(42.1% 0.095 57.708);\\n --color-yellow-950: oklch(28.6% 0.066 53.813);\\n\\n --color-lime-50: oklch(98.6% 0.031 120.757);\\n --color-lime-100: oklch(96.7% 0.067 122.328);\\n --color-lime-200: oklch(93.8% 0.127 124.321);\\n --color-lime-300: oklch(89.7% 0.196 126.665);\\n --color-lime-400: oklch(84.1% 0.238 128.85);\\n --color-lime-500: oklch(76.8% 0.233 130.85);\\n --color-lime-600: oklch(64.8% 0.2 131.684);\\n --color-lime-700: oklch(53.2% 0.157 131.589);\\n --color-lime-800: oklch(45.3% 0.124 130.933);\\n --color-lime-900: oklch(40.5% 0.101 131.063);\\n --color-lime-950: oklch(27.4% 0.072 132.109);\\n\\n --color-green-50: oklch(98.2% 0.018 155.826);\\n --color-green-100: oklch(96.2% 0.044 156.743);\\n --color-green-200: oklch(92.5% 0.084 155.995);\\n --color-green-300: oklch(87.1% 0.15 154.449);\\n --color-green-400: oklch(79.2% 0.209 151.711);\\n --color-green-500: oklch(72.3% 0.219 149.579);\\n --color-green-600: oklch(62.7% 0.194 149.214);\\n --color-green-700: oklch(52.7% 0.154 150.069);\\n --color-green-800: oklch(44.8% 0.119 151.328);\\n --color-green-900: oklch(39.3% 0.095 152.535);\\n --color-green-950: oklch(26.6% 0.065 152.934);\\n\\n --color-emerald-50: oklch(97.9% 0.021 166.113);\\n --color-emerald-100: oklch(95% 0.052 163.051);\\n --color-emerald-200: oklch(90.5% 0.093 164.15);\\n --color-emerald-300: oklch(84.5% 0.143 164.978);\\n --color-emerald-400: oklch(76.5% 0.177 163.223);\\n --color-emerald-500: oklch(69.6% 0.17 162.48);\\n --color-emerald-600: oklch(59.6% 0.145 163.225);\\n --color-emerald-700: oklch(50.8% 0.118 165.612);\\n --color-emerald-800: oklch(43.2% 0.095 166.913);\\n --color-emerald-900: oklch(37.8% 0.077 168.94);\\n --color-emerald-950: oklch(26.2% 0.051 172.552);\\n\\n --color-teal-50: oklch(98.4% 0.014 180.72);\\n --color-teal-100: oklch(95.3% 0.051 180.801);\\n --color-teal-200: oklch(91% 0.096 180.426);\\n --color-teal-300: oklch(85.5% 0.138 181.071);\\n --color-teal-400: oklch(77.7% 0.152 181.912);\\n --color-teal-500: oklch(70.4% 0.14 182.503);\\n --color-teal-600: oklch(60% 0.118 184.704);\\n --color-teal-700: oklch(51.1% 0.096 186.391);\\n --color-teal-800: oklch(43.7% 0.078 188.216);\\n --color-teal-900: oklch(38.6% 0.063 188.416);\\n --color-teal-950: oklch(27.7% 0.046 192.524);\\n\\n --color-cyan-50: oklch(98.4% 0.019 200.873);\\n --color-cyan-100: oklch(95.6% 0.045 203.388);\\n --color-cyan-200: oklch(91.7% 0.08 205.041);\\n --color-cyan-300: oklch(86.5% 0.127 207.078);\\n --color-cyan-400: oklch(78.9% 0.154 211.53);\\n --color-cyan-500: oklch(71.5% 0.143 215.221);\\n --color-cyan-600: oklch(60.9% 0.126 221.723);\\n --color-cyan-700: oklch(52% 0.105 223.128);\\n --color-cyan-800: oklch(45% 0.085 224.283);\\n --color-cyan-900: oklch(39.8% 0.07 227.392);\\n --color-cyan-950: oklch(30.2% 0.056 229.695);\\n\\n --color-sky-50: oklch(97.7% 0.013 236.62);\\n --color-sky-100: oklch(95.1% 0.026 236.824);\\n --color-sky-200: oklch(90.1% 0.058 230.902);\\n --color-sky-300: oklch(82.8% 0.111 230.318);\\n --color-sky-400: oklch(74.6% 0.16 232.661);\\n --color-sky-500: oklch(68.5% 0.169 237.323);\\n --color-sky-600: oklch(58.8% 0.158 241.966);\\n --color-sky-700: oklch(50% 0.134 242.749);\\n --color-sky-800: oklch(44.3% 0.11 240.79);\\n --color-sky-900: oklch(39.1% 0.09 240.876);\\n --color-sky-950: oklch(29.3% 0.066 243.157);\\n\\n --color-blue-50: oklch(97% 0.014 254.604);\\n --color-blue-100: oklch(93.2% 0.032 255.585);\\n --color-blue-200: oklch(88.2% 0.059 254.128);\\n --color-blue-300: oklch(80.9% 0.105 251.813);\\n --color-blue-400: oklch(70.7% 0.165 254.624);\\n --color-blue-500: oklch(62.3% 0.214 259.815);\\n --color-blue-600: oklch(54.6% 0.245 262.881);\\n --color-blue-700: oklch(48.8% 0.243 264.376);\\n --color-blue-800: oklch(42.4% 0.199 265.638);\\n --color-blue-900: oklch(37.9% 0.146 265.522);\\n --color-blue-950: oklch(28.2% 0.091 267.935);\\n\\n --color-indigo-50: oklch(96.2% 0.018 272.314);\\n --color-indigo-100: oklch(93% 0.034 272.788);\\n --color-indigo-200: oklch(87% 0.065 274.039);\\n --color-indigo-300: oklch(78.5% 0.115 274.713);\\n --color-indigo-400: oklch(67.3% 0.182 276.935);\\n --color-indigo-500: oklch(58.5% 0.233 277.117);\\n --color-indigo-600: oklch(51.1% 0.262 276.966);\\n --color-indigo-700: oklch(45.7% 0.24 277.023);\\n --color-indigo-800: oklch(39.8% 0.195 277.366);\\n --color-indigo-900: oklch(35.9% 0.144 278.697);\\n --color-indigo-950: oklch(25.7% 0.09 281.288);\\n\\n --color-violet-50: oklch(96.9% 0.016 293.756);\\n --color-violet-100: oklch(94.3% 0.029 294.588);\\n --color-violet-200: oklch(89.4% 0.057 293.283);\\n --color-violet-300: oklch(81.1% 0.111 293.571);\\n --color-violet-400: oklch(70.2% 0.183 293.541);\\n --color-violet-500: oklch(60.6% 0.25 292.717);\\n --color-violet-600: oklch(54.1% 0.281 293.009);\\n --color-violet-700: oklch(49.1% 0.27 292.581);\\n --color-violet-800: oklch(43.2% 0.232 292.759);\\n --color-violet-900: oklch(38% 0.189 293.745);\\n --color-violet-950: oklch(28.3% 0.141 291.089);\\n\\n --color-purple-50: oklch(97.7% 0.014 308.299);\\n --color-purple-100: oklch(94.6% 0.033 307.174);\\n --color-purple-200: oklch(90.2% 0.063 306.703);\\n --color-purple-300: oklch(82.7% 0.119 306.383);\\n --color-purple-400: oklch(71.4% 0.203 305.504);\\n --color-purple-500: oklch(62.7% 0.265 303.9);\\n --color-purple-600: oklch(55.8% 0.288 302.321);\\n --color-purple-700: oklch(49.6% 0.265 301.924);\\n --color-purple-800: oklch(43.8% 0.218 303.724);\\n --color-purple-900: oklch(38.1% 0.176 304.987);\\n --color-purple-950: oklch(29.1% 0.149 302.717);\\n\\n --color-fuchsia-50: oklch(97.7% 0.017 320.058);\\n --color-fuchsia-100: oklch(95.2% 0.037 318.852);\\n --color-fuchsia-200: oklch(90.3% 0.076 319.62);\\n --color-fuchsia-300: oklch(83.3% 0.145 321.434);\\n --color-fuchsia-400: oklch(74% 0.238 322.16);\\n --color-fuchsia-500: oklch(66.7% 0.295 322.15);\\n --color-fuchsia-600: oklch(59.1% 0.293 322.896);\\n --color-fuchsia-700: oklch(51.8% 0.253 323.949);\\n --color-fuchsia-800: oklch(45.2% 0.211 324.591);\\n --color-fuchsia-900: oklch(40.1% 0.17 325.612);\\n --color-fuchsia-950: oklch(29.3% 0.136 325.661);\\n\\n --color-pink-50: oklch(97.1% 0.014 343.198);\\n --color-pink-100: oklch(94.8% 0.028 342.258);\\n --color-pink-200: oklch(89.9% 0.061 343.231);\\n --color-pink-300: oklch(82.3% 0.12 346.018);\\n --color-pink-400: oklch(71.8% 0.202 349.761);\\n --color-pink-500: oklch(65.6% 0.241 354.308);\\n --color-pink-600: oklch(59.2% 0.249 0.584);\\n --color-pink-700: oklch(52.5% 0.223 3.958);\\n --color-pink-800: oklch(45.9% 0.187 3.815);\\n --color-pink-900: oklch(40.8% 0.153 2.432);\\n --color-pink-950: oklch(28.4% 0.109 3.907);\\n\\n --color-rose-50: oklch(96.9% 0.015 12.422);\\n --color-rose-100: oklch(94.1% 0.03 12.58);\\n --color-rose-200: oklch(89.2% 0.058 10.001);\\n --color-rose-300: oklch(81% 0.117 11.638);\\n --color-rose-400: oklch(71.2% 0.194 13.428);\\n --color-rose-500: oklch(64.5% 0.246 16.439);\\n --color-rose-600: oklch(58.6% 0.253 17.585);\\n --color-rose-700: oklch(51.4% 0.222 16.935);\\n --color-rose-800: oklch(45.5% 0.188 13.697);\\n --color-rose-900: oklch(41% 0.159 10.272);\\n --color-rose-950: oklch(27.1% 0.105 12.094);\\n\\n --color-slate-50: oklch(98.4% 0.003 247.858);\\n --color-slate-100: oklch(96.8% 0.007 247.896);\\n --color-slate-200: oklch(92.9% 0.013 255.508);\\n --color-slate-300: oklch(86.9% 0.022 252.894);\\n --color-slate-400: oklch(70.4% 0.04 256.788);\\n --color-slate-500: oklch(55.4% 0.046 257.417);\\n --color-slate-600: oklch(44.6% 0.043 257.281);\\n --color-slate-700: oklch(37.2% 0.044 257.287);\\n --color-slate-800: oklch(27.9% 0.041 260.031);\\n --color-slate-900: oklch(20.8% 0.042 265.755);\\n --color-slate-950: oklch(12.9% 0.042 264.695);\\n\\n --color-gray-50: oklch(98.5% 0.002 247.839);\\n --color-gray-100: oklch(96.7% 0.003 264.542);\\n --color-gray-200: oklch(92.8% 0.006 264.531);\\n --color-gray-300: oklch(87.2% 0.01 258.338);\\n --color-gray-400: oklch(70.7% 0.022 261.325);\\n --color-gray-500: oklch(55.1% 0.027 264.364);\\n --color-gray-600: oklch(44.6% 0.03 256.802);\\n --color-gray-700: oklch(37.3% 0.034 259.733);\\n --color-gray-800: oklch(27.8% 0.033 256.848);\\n --color-gray-900: oklch(21% 0.034 264.665);\\n --color-gray-950: oklch(13% 0.028 261.692);\\n\\n --color-zinc-50: oklch(98.5% 0 none);\\n --color-zinc-100: oklch(96.7% 0.001 286.375);\\n --color-zinc-200: oklch(92% 0.004 286.32);\\n --color-zinc-300: oklch(87.1% 0.006 286.286);\\n --color-zinc-400: oklch(70.5% 0.015 286.067);\\n --color-zinc-500: oklch(55.2% 0.016 285.938);\\n --color-zinc-600: oklch(44.2% 0.017 285.786);\\n --color-zinc-700: oklch(37% 0.013 285.805);\\n --color-zinc-800: oklch(27.4% 0.006 286.033);\\n --color-zinc-900: oklch(21% 0.006 285.885);\\n --color-zinc-950: oklch(14.1% 0.005 285.823);\\n\\n --color-neutral-50: oklch(98.5% 0 none);\\n --color-neutral-100: oklch(97% 0 none);\\n --color-neutral-200: oklch(92.2% 0 none);\\n --color-neutral-300: oklch(87% 0 none);\\n --color-neutral-400: oklch(70.8% 0 none);\\n --color-neutral-500: oklch(55.6% 0 none);\\n --color-neutral-600: oklch(43.9% 0 none);\\n --color-neutral-700: oklch(37.1% 0 none);\\n --color-neutral-800: oklch(26.9% 0 none);\\n --color-neutral-900: oklch(20.5% 0 none);\\n --color-neutral-950: oklch(14.5% 0 none);\\n\\n --color-stone-50: oklch(98.5% 0.001 106.423);\\n --color-stone-100: oklch(97% 0.001 106.424);\\n --color-stone-200: oklch(92.3% 0.003 48.717);\\n --color-stone-300: oklch(86.9% 0.005 56.366);\\n --color-stone-400: oklch(70.9% 0.01 56.259);\\n --color-stone-500: oklch(55.3% 0.013 58.071);\\n --color-stone-600: oklch(44.4% 0.011 73.639);\\n --color-stone-700: oklch(37.4% 0.01 67.558);\\n --color-stone-800: oklch(26.8% 0.007 34.298);\\n --color-stone-900: oklch(21.6% 0.006 56.043);\\n --color-stone-950: oklch(14.7% 0.004 49.25);\\n\\n --color-mauve-50: oklch(98.5% 0 none);\\n --color-mauve-100: oklch(96% 0.003 325.6);\\n --color-mauve-200: oklch(92.2% 0.005 325.62);\\n --color-mauve-300: oklch(86.5% 0.012 325.68);\\n --color-mauve-400: oklch(71.1% 0.019 323.02);\\n --color-mauve-500: oklch(54.2% 0.034 322.5);\\n --color-mauve-600: oklch(43.5% 0.029 321.78);\\n --color-mauve-700: oklch(36.4% 0.029 323.89);\\n --color-mauve-800: oklch(26.3% 0.024 320.12);\\n --color-mauve-900: oklch(21.2% 0.019 322.12);\\n --color-mauve-950: oklch(14.5% 0.008 326);\\n\\n --color-olive-50: oklch(98.8% 0.003 106.5);\\n --color-olive-100: oklch(96.6% 0.005 106.5);\\n --color-olive-200: oklch(93% 0.007 106.5);\\n --color-olive-300: oklch(88% 0.011 106.6);\\n --color-olive-400: oklch(73.7% 0.021 106.9);\\n --color-olive-500: oklch(58% 0.031 107.3);\\n --color-olive-600: oklch(46.6% 0.025 107.3);\\n --color-olive-700: oklch(39.4% 0.023 107.4);\\n --color-olive-800: oklch(28.6% 0.016 107.4);\\n --color-olive-900: oklch(22.8% 0.013 107.4);\\n --color-olive-950: oklch(15.3% 0.006 107.1);\\n\\n --color-mist-50: oklch(98.7% 0.002 197.1);\\n --color-mist-100: oklch(96.3% 0.002 197.1);\\n --color-mist-200: oklch(92.5% 0.005 214.3);\\n --color-mist-300: oklch(87.2% 0.007 219.6);\\n --color-mist-400: oklch(72.3% 0.014 214.4);\\n --color-mist-500: oklch(56% 0.021 213.5);\\n --color-mist-600: oklch(45% 0.017 213.2);\\n --color-mist-700: oklch(37.8% 0.015 216);\\n --color-mist-800: oklch(27.5% 0.011 216.9);\\n --color-mist-900: oklch(21.8% 0.008 223.9);\\n --color-mist-950: oklch(14.8% 0.004 228.8);\\n\\n --color-taupe-50: oklch(98.6% 0.002 67.8);\\n --color-taupe-100: oklch(96% 0.002 17.2);\\n --color-taupe-200: oklch(92.2% 0.005 34.3);\\n --color-taupe-300: oklch(86.8% 0.007 39.5);\\n --color-taupe-400: oklch(71.4% 0.014 41.2);\\n --color-taupe-500: oklch(54.7% 0.021 43.1);\\n --color-taupe-600: oklch(43.8% 0.017 39.3);\\n --color-taupe-700: oklch(36.7% 0.016 35.7);\\n --color-taupe-800: oklch(26.8% 0.011 36.5);\\n --color-taupe-900: oklch(21.4% 0.009 43.1);\\n --color-taupe-950: oklch(14.7% 0.004 49.3);\\n\\n --color-black: #000;\\n --color-white: #fff;\\n\\n --spacing: 0.25rem;\\n\\n --breakpoint-sm: 40rem;\\n --breakpoint-md: 48rem;\\n --breakpoint-lg: 64rem;\\n --breakpoint-xl: 80rem;\\n --breakpoint-2xl: 96rem;\\n\\n --container-3xs: 16rem;\\n --container-2xs: 18rem;\\n --container-xs: 20rem;\\n --container-sm: 24rem;\\n --container-md: 28rem;\\n --container-lg: 32rem;\\n --container-xl: 36rem;\\n --container-2xl: 42rem;\\n --container-3xl: 48rem;\\n --container-4xl: 56rem;\\n --container-5xl: 64rem;\\n --container-6xl: 72rem;\\n --container-7xl: 80rem;\\n\\n --text-xs: 0.75rem;\\n --text-xs--line-height: calc(1 / 0.75);\\n --text-sm: 0.875rem;\\n --text-sm--line-height: calc(1.25 / 0.875);\\n --text-base: 1rem;\\n --text-base--line-height: calc(1.5 / 1);\\n --text-lg: 1.125rem;\\n --text-lg--line-height: calc(1.75 / 1.125);\\n --text-xl: 1.25rem;\\n --text-xl--line-height: calc(1.75 / 1.25);\\n --text-2xl: 1.5rem;\\n --text-2xl--line-height: calc(2 / 1.5);\\n --text-3xl: 1.875rem;\\n --text-3xl--line-height: calc(2.25 / 1.875);\\n --text-4xl: 2.25rem;\\n --text-4xl--line-height: calc(2.5 / 2.25);\\n --text-5xl: 3rem;\\n --text-5xl--line-height: 1;\\n --text-6xl: 3.75rem;\\n --text-6xl--line-height: 1;\\n --text-7xl: 4.5rem;\\n --text-7xl--line-height: 1;\\n --text-8xl: 6rem;\\n --text-8xl--line-height: 1;\\n --text-9xl: 8rem;\\n --text-9xl--line-height: 1;\\n\\n --font-weight-thin: 100;\\n --font-weight-extralight: 200;\\n --font-weight-light: 300;\\n --font-weight-normal: 400;\\n --font-weight-medium: 500;\\n --font-weight-semibold: 600;\\n --font-weight-bold: 700;\\n --font-weight-extrabold: 800;\\n --font-weight-black: 900;\\n\\n --tracking-tighter: -0.05em;\\n --tracking-tight: -0.025em;\\n --tracking-normal: 0em;\\n --tracking-wide: 0.025em;\\n --tracking-wider: 0.05em;\\n --tracking-widest: 0.1em;\\n\\n --leading-tight: 1.25;\\n --leading-snug: 1.375;\\n --leading-normal: 1.5;\\n --leading-relaxed: 1.625;\\n --leading-loose: 2;\\n\\n --radius-xs: 0.125rem;\\n --radius-sm: 0.25rem;\\n --radius-md: 0.375rem;\\n --radius-lg: 0.5rem;\\n --radius-xl: 0.75rem;\\n --radius-2xl: 1rem;\\n --radius-3xl: 1.5rem;\\n --radius-4xl: 2rem;\\n\\n --shadow-2xs: 0 1px rgb(0 0 0 / 0.05);\\n --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);\\n --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);\\n --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);\\n --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);\\n --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);\\n --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);\\n\\n --inset-shadow-2xs: inset 0 1px rgb(0 0 0 / 0.05);\\n --inset-shadow-xs: inset 0 1px 1px rgb(0 0 0 / 0.05);\\n --inset-shadow-sm: inset 0 2px 4px rgb(0 0 0 / 0.05);\\n\\n --drop-shadow-xs: 0 1px 1px rgb(0 0 0 / 0.05);\\n --drop-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.15);\\n --drop-shadow-md: 0 3px 3px rgb(0 0 0 / 0.12);\\n --drop-shadow-lg: 0 4px 4px rgb(0 0 0 / 0.15);\\n --drop-shadow-xl: 0 9px 7px rgb(0 0 0 / 0.1);\\n --drop-shadow-2xl: 0 25px 25px rgb(0 0 0 / 0.15);\\n\\n --text-shadow-2xs: 0px 1px 0px rgb(0 0 0 / 0.15);\\n --text-shadow-xs: 0px 1px 1px rgb(0 0 0 / 0.2);\\n --text-shadow-sm:\\n 0px 1px 0px rgb(0 0 0 / 0.075), 0px 1px 1px rgb(0 0 0 / 0.075), 0px 2px 2px rgb(0 0 0 / 0.075);\\n --text-shadow-md:\\n 0px 1px 1px rgb(0 0 0 / 0.1), 0px 1px 2px rgb(0 0 0 / 0.1), 0px 2px 4px rgb(0 0 0 / 0.1);\\n --text-shadow-lg:\\n 0px 1px 2px rgb(0 0 0 / 0.1), 0px 3px 2px rgb(0 0 0 / 0.1), 0px 4px 8px rgb(0 0 0 / 0.1);\\n\\n --ease-in: cubic-bezier(0.4, 0, 1, 1);\\n --ease-out: cubic-bezier(0, 0, 0.2, 1);\\n --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);\\n\\n --animate-spin: spin 1s linear infinite;\\n --animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;\\n --animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;\\n --animate-bounce: bounce 1s infinite;\\n\\n @keyframes spin {\\n to {\\n transform: rotate(360deg);\\n }\\n }\\n\\n @keyframes ping {\\n 75%,\\n 100% {\\n transform: scale(2);\\n opacity: 0;\\n }\\n }\\n\\n @keyframes pulse {\\n 50% {\\n opacity: 0.5;\\n }\\n }\\n\\n @keyframes bounce {\\n 0%,\\n 100% {\\n transform: translateY(-25%);\\n animation-timing-function: cubic-bezier(0.8, 0, 1, 1);\\n }\\n\\n 50% {\\n transform: none;\\n animation-timing-function: cubic-bezier(0, 0, 0.2, 1);\\n }\\n }\\n\\n --blur-xs: 4px;\\n --blur-sm: 8px;\\n --blur-md: 12px;\\n --blur-lg: 16px;\\n --blur-xl: 24px;\\n --blur-2xl: 40px;\\n --blur-3xl: 64px;\\n\\n --perspective-dramatic: 100px;\\n --perspective-near: 300px;\\n --perspective-normal: 500px;\\n --perspective-midrange: 800px;\\n --perspective-distant: 1200px;\\n\\n --aspect-video: 16 / 9;\\n\\n --default-transition-duration: 150ms;\\n --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);\\n --default-font-family: --theme(--font-sans, initial);\\n --default-font-feature-settings: --theme(--font-sans--font-feature-settings, initial);\\n --default-font-variation-settings: --theme(--font-sans--font-variation-settings, initial);\\n --default-mono-font-family: --theme(--font-mono, initial);\\n --default-mono-font-feature-settings: --theme(--font-mono--font-feature-settings, initial);\\n --default-mono-font-variation-settings: --theme(--font-mono--font-variation-settings, initial);\\n}\\n\\n/* Deprecated */\\n@theme default inline reference {\\n --blur: 8px;\\n --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);\\n --shadow-inner: inset 0 2px 4px 0 rgb(0 0 0 / 0.05);\\n --drop-shadow: 0 1px 2px rgb(0 0 0 / 0.1), 0 1px 1px rgb(0 0 0 / 0.06);\\n --radius: 0.25rem;\\n --max-width-prose: 65ch;\\n}\\n\",\n \"preflight\": \"/*\\n 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)\\n 2. Remove default margins and padding\\n 3. Reset all borders.\\n*/\\n\\n*,\\n::after,\\n::before,\\n::backdrop,\\n::file-selector-button {\\n box-sizing: border-box; /* 1 */\\n margin: 0; /* 2 */\\n padding: 0; /* 2 */\\n border: 0 solid; /* 3 */\\n}\\n\\n/*\\n 1. Use a consistent sensible line-height in all browsers.\\n 2. Prevent adjustments of font size after orientation changes in iOS.\\n 3. Use a more readable tab size.\\n 4. Use the user's configured `sans` font-family by default.\\n 5. Use the user's configured `sans` font-feature-settings by default.\\n 6. Use the user's configured `sans` font-variation-settings by default.\\n 7. Disable tap highlights on iOS.\\n*/\\n\\nhtml,\\n:host {\\n line-height: 1.5; /* 1 */\\n -webkit-text-size-adjust: 100%; /* 2 */\\n tab-size: 4; /* 3 */\\n font-family: --theme(\\n --default-font-family,\\n -apple-system,\\n BlinkMacSystemFont,\\n 'Segoe UI',\\n Roboto,\\n 'Helvetica Neue',\\n 'Noto Sans',\\n Arial,\\n sans-serif,\\n 'Apple Color Emoji',\\n 'Segoe UI Emoji',\\n 'Segoe UI Symbol',\\n 'Noto Color Emoji'\\n ); /* 4 */\\n font-feature-settings: --theme(--default-font-feature-settings, normal); /* 5 */\\n font-variation-settings: --theme(--default-font-variation-settings, normal); /* 6 */\\n -webkit-tap-highlight-color: transparent; /* 7 */\\n}\\n\\n/*\\n 1. Add the correct height in Firefox.\\n 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)\\n 3. Reset the default border style to a 1px solid border.\\n*/\\n\\nhr {\\n height: 0; /* 1 */\\n color: inherit; /* 2 */\\n border-top-width: 1px; /* 3 */\\n}\\n\\n/*\\n Add the correct text decoration in Chrome, Edge, and Safari.\\n*/\\n\\nabbr:where([title]) {\\n -webkit-text-decoration: underline dotted;\\n text-decoration: underline dotted;\\n}\\n\\n/*\\n Remove the default font size and weight for headings.\\n*/\\n\\nh1,\\nh2,\\nh3,\\nh4,\\nh5,\\nh6 {\\n font-size: inherit;\\n font-weight: inherit;\\n}\\n\\n/*\\n Reset links to optimize for opt-in styling instead of opt-out.\\n*/\\n\\na {\\n color: inherit;\\n -webkit-text-decoration: inherit;\\n text-decoration: inherit;\\n}\\n\\n/*\\n Add the correct font weight in Edge and Safari.\\n*/\\n\\nb,\\nstrong {\\n font-weight: bolder;\\n}\\n\\n/*\\n 1. Use the user's configured `mono` font-family by default.\\n 2. Use the user's configured `mono` font-feature-settings by default.\\n 3. Use the user's configured `mono` font-variation-settings by default.\\n 4. Correct the odd `em` font sizing in all browsers.\\n*/\\n\\ncode,\\nkbd,\\nsamp,\\npre {\\n font-family: --theme(\\n --default-mono-font-family,\\n ui-monospace,\\n SFMono-Regular,\\n Menlo,\\n Monaco,\\n Consolas,\\n 'Liberation Mono',\\n 'Courier New',\\n monospace\\n ); /* 1 */\\n font-feature-settings: --theme(--default-mono-font-feature-settings, normal); /* 2 */\\n font-variation-settings: --theme(--default-mono-font-variation-settings, normal); /* 3 */\\n font-size: 1em; /* 4 */\\n}\\n\\n/*\\n Add the correct font size in all browsers.\\n*/\\n\\nsmall {\\n font-size: 80%;\\n}\\n\\n/*\\n Prevent `sub` and `sup` elements from affecting the line height in all browsers.\\n*/\\n\\nsub,\\nsup {\\n font-size: 75%;\\n line-height: 0;\\n position: relative;\\n vertical-align: baseline;\\n}\\n\\nsub {\\n bottom: -0.25em;\\n}\\n\\nsup {\\n top: -0.5em;\\n}\\n\\n/*\\n 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)\\n 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)\\n 3. Remove gaps between table borders by default.\\n*/\\n\\ntable {\\n text-indent: 0; /* 1 */\\n border-color: inherit; /* 2 */\\n border-collapse: collapse; /* 3 */\\n}\\n\\n/*\\n Use the modern Firefox focus style for all focusable elements.\\n*/\\n\\n:-moz-focusring:where(:not(iframe)) {\\n outline: auto;\\n}\\n\\n/*\\n Add the correct vertical alignment in Chrome and Firefox.\\n*/\\n\\nprogress {\\n vertical-align: baseline;\\n}\\n\\n/*\\n Add the correct display in Chrome and Safari.\\n*/\\n\\nsummary {\\n display: list-item;\\n}\\n\\n/*\\n Make lists unstyled by default.\\n*/\\n\\nol,\\nul,\\nmenu {\\n list-style: none;\\n}\\n\\n/*\\n 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)\\n 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)\\n This can trigger a poorly considered lint error in some tools but is included by design.\\n*/\\n\\nimg,\\nsvg,\\nvideo,\\ncanvas,\\naudio,\\niframe,\\nembed,\\nobject {\\n display: block; /* 1 */\\n vertical-align: middle; /* 2 */\\n}\\n\\n/*\\n Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)\\n*/\\n\\nimg,\\nvideo {\\n max-width: 100%;\\n height: auto;\\n}\\n\\n/*\\n 1. Inherit font styles in all browsers.\\n 2. Remove border radius in all browsers.\\n 3. Remove background color in all browsers.\\n 4. Ensure consistent opacity for disabled states in all browsers.\\n*/\\n\\nbutton,\\ninput,\\nselect,\\noptgroup,\\ntextarea,\\n::file-selector-button {\\n font: inherit; /* 1 */\\n font-feature-settings: inherit; /* 1 */\\n font-variation-settings: inherit; /* 1 */\\n letter-spacing: inherit; /* 1 */\\n color: inherit; /* 1 */\\n border-radius: 0; /* 2 */\\n background-color: transparent; /* 3 */\\n opacity: 1; /* 4 */\\n}\\n\\n/*\\n Restore default font weight.\\n*/\\n\\n:where(select:is([multiple], [size])) optgroup {\\n font-weight: bolder;\\n}\\n\\n/*\\n Restore indentation.\\n*/\\n\\n:where(select:is([multiple], [size])) optgroup option {\\n padding-inline-start: 20px;\\n}\\n\\n/*\\n Restore space after button.\\n*/\\n\\n::file-selector-button {\\n margin-inline-end: 4px;\\n}\\n\\n/*\\n Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)\\n*/\\n\\n::placeholder {\\n opacity: 1;\\n}\\n\\n/*\\n Set the default placeholder color to a semi-transparent version of the current text color in browsers that do not\\n crash when using `color-mix(…)` with `currentcolor`. (https://github.com/tailwindlabs/tailwindcss/issues/17194)\\n*/\\n\\n@supports (not (-webkit-appearance: -apple-pay-button)) /* Not Safari */ or\\n (contain-intrinsic-size: 1px) /* Safari 17+ */ {\\n ::placeholder {\\n color: color-mix(in oklab, currentcolor 50%, transparent);\\n }\\n}\\n\\n/*\\n Prevent resizing textareas horizontally by default.\\n*/\\n\\ntextarea {\\n resize: vertical;\\n}\\n\\n/*\\n Remove the inner padding in Chrome and Safari on macOS.\\n*/\\n\\n::-webkit-search-decoration {\\n -webkit-appearance: none;\\n}\\n\\n/*\\n 1. Ensure date/time inputs have the same height when empty in iOS Safari.\\n 2. Ensure text alignment can be changed on date/time inputs in iOS Safari.\\n*/\\n\\n::-webkit-date-and-time-value {\\n min-height: 1lh; /* 1 */\\n text-align: inherit; /* 2 */\\n}\\n\\n/*\\n Prevent height from changing on date/time inputs in macOS Safari when the input is set to `display: block`.\\n*/\\n\\n::-webkit-datetime-edit {\\n display: inline-flex;\\n}\\n\\n/*\\n Remove excess padding from pseudo-elements in date/time inputs to ensure consistent height across browsers.\\n*/\\n\\n::-webkit-datetime-edit-fields-wrapper {\\n padding: 0;\\n}\\n\\n::-webkit-datetime-edit,\\n::-webkit-datetime-edit-year-field,\\n::-webkit-datetime-edit-month-field,\\n::-webkit-datetime-edit-day-field,\\n::-webkit-datetime-edit-hour-field,\\n::-webkit-datetime-edit-minute-field,\\n::-webkit-datetime-edit-second-field,\\n::-webkit-datetime-edit-millisecond-field,\\n::-webkit-datetime-edit-meridiem-field {\\n padding-block: 0;\\n}\\n\\n/*\\n Center dropdown marker shown on inputs with paired `<datalist>`s in Chrome. (https://github.com/tailwindlabs/tailwindcss/issues/18499)\\n*/\\n\\n::-webkit-calendar-picker-indicator {\\n line-height: 1;\\n}\\n\\n/*\\n Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)\\n*/\\n\\n:-moz-ui-invalid {\\n box-shadow: none;\\n}\\n\\n/*\\n Correct the inability to style the border radius in iOS Safari.\\n*/\\n\\nbutton,\\ninput:where([type='button'], [type='reset'], [type='submit']),\\n::file-selector-button {\\n appearance: button;\\n}\\n\\n/*\\n Correct the cursor style of increment and decrement buttons in Safari.\\n*/\\n\\n::-webkit-inner-spin-button,\\n::-webkit-outer-spin-button {\\n height: auto;\\n}\\n\\n/*\\n Make elements with the HTML hidden attribute stay hidden by default.\\n*/\\n\\n[hidden]:where(:not([hidden='until-found'])) {\\n display: none !important;\\n}\\n\",\n \"utilities\": \"@tailwind utilities;\\n\",\n \"bindings\": \"@theme inline { --color-background: var(--background);\\n--color-foreground: var(--foreground);\\n--color-card: var(--card);\\n--color-card-foreground: var(--card-foreground);\\n--color-popover: var(--popover);\\n--color-popover-foreground: var(--popover-foreground);\\n--color-primary: var(--primary);\\n--color-primary-foreground: var(--primary-foreground);\\n--color-secondary: var(--secondary);\\n--color-secondary-foreground: var(--secondary-foreground);\\n--color-muted: var(--muted);\\n--color-muted-foreground: var(--muted-foreground);\\n--color-accent: var(--accent);\\n--color-accent-foreground: var(--accent-foreground);\\n--color-destructive: var(--destructive);\\n--color-border: var(--border);\\n--color-input: var(--input);\\n--color-ring: var(--ring);\\n--color-chart-1: var(--chart-1);\\n--color-chart-2: var(--chart-2);\\n--color-chart-3: var(--chart-3);\\n--color-chart-4: var(--chart-4);\\n--color-chart-5: var(--chart-5);\\n--radius-sm: calc(var(--radius) - 4px);\\n--radius-md: calc(var(--radius) - 2px);\\n--radius-lg: var(--radius);\\n--radius-xl: calc(var(--radius) + 4px);\\n--radius-2xl: calc(var(--radius) * 1.8);\\n--radius-3xl: calc(var(--radius) * 2.2);\\n--default-border-width: var(--border-width, 1px);\\n--shadow-xs: var(--shadow-xs);\\n--shadow-sm: var(--shadow-sm);\\n--shadow-md: var(--shadow-md);\\n--shadow-lg: var(--shadow-lg);\\n--shadow-xl: var(--shadow-xl);\\n--shadow-2xl: var(--shadow-2xl); }\"\n};\n",
40
40
  "type": "registry:lib",
41
41
  "target": "components/mockup-viewer/mockup-tailwind.ts"
42
+ },
43
+ {
44
+ "path": "registry/mockup-viewer/mockup-path.ts",
45
+ "content": "/** A `*.mockup.html` file: the mock-up kind's match, in its own module so the kind does not load the viewer. */\nexport const isMockupPath = (path: string): boolean => /\\.mockup\\.html$/i.test(path);\n/** An element's `data-feedback-id` in a mock-up: `mockup:<path>#element=<selector>`. */\nexport const mockupTarget = (path: string, selector: string): string => `mockup:${path}#element=${selector}`;\n",
46
+ "type": "registry:lib",
47
+ "target": "components/mockup-viewer/mockup-path.ts"
48
+ },
49
+ {
50
+ "path": "registry/mockup-viewer/mockup-kind.tsx",
51
+ "content": "'use client';\n\nimport { lazy } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { CodeXmlIcon } from 'lucide-react';\nimport { isMockupPath } from './mockup-path';\nimport type { MockupComments } from './mockup-viewer';\n\nconst MockupViewer = lazy(() => import('./mockup-viewer').then(module => ({ default: module.MockupViewer })));\n\nexport interface MockupKind {\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 artifact?: { readonly target: { readonly resource: { readonly path: string } } } | undefined }) => ReactNode;\n /** The sandboxed frame loads asynchronously: a `present` of a mock-up is confirmed only once it has loaded. */\n readonly waitsForReady: true;\n}\n\n/** The `*.mockup.html` kind; the viewer (and its Tailwind compiler) loads on first open. */\nexport function createMockupKind({ comments, locale }: { readonly comments?: MockupComments | undefined; readonly locale?: 'en' | 'fr' | undefined } = {}): MockupKind {\n return { match: target => isMockupPath(target.resource.path), label: locale === 'fr' ? 'Maquette HTML' : 'HTML mock-up', icon: CodeXmlIcon, waitsForReady: true,\n renderer: ({ text, artifact, onReady }) => <MockupViewer key={artifact?.target.resource.path} text={text} path={artifact?.target.resource.path} comments={comments} locale={locale} onReady={onReady} /> };\n}\nexport const mockupKind: MockupKind = createMockupKind();\n",
52
+ "type": "registry:component",
53
+ "target": "components/mockup-viewer/mockup-kind.tsx"
42
54
  }
43
55
  ],
44
56
  "type": "registry:block"