@hachej/boring-ui-kit 0.4.57 → 0.4.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/bin/boring-ui-blocks.mjs +49 -4
  2. package/dist/chat-react-conversations.d.ts +7 -1
  3. package/dist/chat-react-conversations.js +58 -18
  4. package/package.json +6 -6
  5. package/r/_shared.json +19 -0
  6. package/r/agent-target.json +4 -4
  7. package/r/app-shell.json +17 -17
  8. package/r/board-viewer.json +10 -10
  9. package/r/button.json +4 -4
  10. package/r/data-table.json +21 -0
  11. package/r/diagram-viewer.json +6 -4
  12. package/r/dock.json +1 -1
  13. package/r/documents.json +13 -14
  14. package/r/drawing-viewer.json +6 -4
  15. package/r/empty-state.json +22 -0
  16. package/r/feedback.json +14 -14
  17. package/r/file-tree.json +4 -4
  18. package/r/highlights.json +3 -3
  19. package/r/html-viewer.json +3 -3
  20. package/r/kpi-stat.json +22 -0
  21. package/r/list-row.json +21 -0
  22. package/r/looks-viewer.json +6 -6
  23. package/r/maquette-viewer.json +7 -6
  24. package/r/markdown-editor.json +10 -9
  25. package/r/mockup-viewer.json +5 -5
  26. package/r/page-header.json +22 -0
  27. package/r/pi-ambient.json +12 -12
  28. package/r/pi-app-kinds.json +10 -10
  29. package/r/pi-app.json +17 -17
  30. package/r/pi-artifacts.json +4 -4
  31. package/r/pi-chat-approval.json +4 -4
  32. package/r/pi-chat-attachments.json +4 -4
  33. package/r/pi-chat-markdown.json +3 -3
  34. package/r/pi-chat-mentions.json +4 -4
  35. package/r/pi-chat-pickers.json +4 -4
  36. package/r/pi-chat-plugins.json +11 -11
  37. package/r/pi-chat-question.json +3 -3
  38. package/r/pi-chat-slash.json +5 -5
  39. package/r/pi-chat-subagent.json +4 -4
  40. package/r/pi-chat-voice.json +7 -7
  41. package/r/pi-chat.json +20 -20
  42. package/r/pi-workspace.json +3 -3
  43. package/r/placed-agent.json +7 -7
  44. package/r/provider-setup.json +4 -4
  45. package/r/record-viewer.json +5 -3
  46. package/r/registry.json +494 -280
  47. package/r/shared-chat.json +11 -11
  48. package/r/spaces.json +7 -5
  49. package/r/task-list-viewer.json +28 -3
  50. package/r/theme.json +115 -50
  51. package/r/version-history.json +2 -2
  52. package/r/viewer-code.json +5 -5
  53. package/r/viewer-frame.json +9 -9
  54. package/r/viewer-html.json +7 -7
  55. package/r/viewer-image.json +5 -5
  56. package/r/viewer-markdown.json +7 -7
  57. package/r/viewer-pdf.json +6 -6
  58. package/r/viewers.json +6 -6
  59. package/r/windows.json +4 -4
@@ -0,0 +1,21 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "list-row",
4
+ "title": "List row",
5
+ "description": "A row with a leading slot, a title, a muted subtitle and a trailing slot. Hover wash, no separator lines, 44px touch target on phones, truncation instead of wrapping.",
6
+ "dependencies": [
7
+ "react@^19.0.0"
8
+ ],
9
+ "registryDependencies": [
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/utils.json"
11
+ ],
12
+ "files": [
13
+ {
14
+ "path": "registry/list-row/list-row.tsx",
15
+ "content": "import type { ReactNode } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { cn } from '../utils/utils';\n\nexport type ListRowProps = {\n title: string;\n subtitle?: string;\n /** An avatar, icon or any leading control. */\n leading?: ReactNode;\n /** Metadata or an action, right-aligned. */\n trailing?: ReactNode;\n /** Makes the row a button. Without it the row is static text. */\n onSelect?: () => void;\n className?: string;\n};\n\n/** No separator lines: the hover wash marks the row. 44px tall on phones (touch target), 36px from md up. */\nexport function ListRow({ title, subtitle, leading, trailing, onSelect, className }: ListRowProps) {\n const content = (\n <>\n {leading ? <span className=\"flex shrink-0 items-center\">{leading}</span> : null}\n <span className=\"flex min-w-0 flex-1 flex-col text-left\">\n <span className=\"truncate text-sm font-medium\">{title}</span>\n {subtitle ? <span className=\"truncate text-xs text-muted-foreground\">{subtitle}</span> : null}\n </span>\n {trailing ? <span className=\"flex shrink-0 items-center gap-2 text-xs text-muted-foreground\">{trailing}</span> : null}\n </>\n );\n const box = 'flex min-h-11 w-full min-w-0 items-center gap-3 px-2 py-2 md:min-h-9';\n // The Button keeps the app's ghost wash on hover; the static row adds the same wash itself.\n if (onSelect) {\n return (\n <Button variant=\"ghost\" className={cn(box, 'h-auto justify-start', className)} onClick={onSelect}>\n {content}\n </Button>\n );\n }\n return <div className={cn(box, 'rounded-(--radius) hover:bg-accent', className)}>{content}</div>;\n}\n",
16
+ "type": "registry:component",
17
+ "target": "components/list-row/list-row.tsx"
18
+ }
19
+ ],
20
+ "type": "registry:component"
21
+ }
@@ -4,17 +4,17 @@
4
4
  "title": "Looks viewer",
5
5
  "description": "A looks@1 artifact kind for *.looks.json: the same *.mockup.html key screen in 2 to 6 shadcn themes side by side (label, one-line personality, colour, font and radius swatches, Pick), then layout variants in the chosen look. A look is a shadcn preset and its theme block, applied to each sandboxed frame through MockupViewer's themeOverride. The host decides what a pick does.",
6
6
  "dependencies": [
7
- "react@^19.0.0",
8
- "lucide-react@^1.51.0"
7
+ "react@^19.0.0"
9
8
  ],
10
9
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/mockup-viewer.json",
12
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/utils.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/mockup-viewer.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/utils.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
13
13
  ],
14
14
  "files": [
15
15
  {
16
16
  "path": "registry/looks-viewer/looks-viewer.tsx",
17
- "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 { usePhone } from '../utils/use-media';\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 = usePhone();\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 // eslint-disable-next-line react-hooks/set-state-in-effect -- a new read (sources, path or reader) shows loading until it settles; once per change\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 -- key covers path and sources by value; onFailed is a notification, not a reason to read again\n const [mode, setMode] = useState<'light' | 'dark' | undefined>();\n const [look, setLook] = useState<string | undefined>(spec?.look);\n const [layout, setLayout] = useState<string>();\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a new looks file resets the chosen look and layout, once per text\n useEffect(() => { setLook(spec?.look); setLayout(undefined); }, [text]); // eslint-disable-line react-hooks/exhaustive-deps -- spec derives from text\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 -- reports once per parse; a new onFailed callback alone does not report again\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",
17
+ "content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { CheckIcon } from '../_shared/icons';\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 { usePhone } from '../utils/use-media';\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 = usePhone();\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 // eslint-disable-next-line react-hooks/set-state-in-effect -- a new read (sources, path or reader) shows loading until it settles; once per change\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 -- key covers path and sources by value; onFailed is a notification, not a reason to read again\n const [mode, setMode] = useState<'light' | 'dark' | undefined>();\n const [look, setLook] = useState<string | undefined>(spec?.look);\n const [layout, setLayout] = useState<string>();\n // eslint-disable-next-line react-hooks/set-state-in-effect -- a new looks file resets the chosen look and layout, once per text\n useEffect(() => { setLook(spec?.look); setLayout(undefined); }, [text]); // eslint-disable-line react-hooks/exhaustive-deps -- spec derives from text\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 -- reports once per parse; a new onFailed callback alone does not report again\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-sm 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-sm 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",
18
18
  "type": "registry:component",
19
19
  "target": "components/looks-viewer/looks-viewer.tsx"
20
20
  },
@@ -26,7 +26,7 @@
26
26
  },
27
27
  {
28
28
  "path": "registry/looks-viewer/looks-kind.tsx",
29
- "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",
29
+ "content": "'use client';\n\nimport { lazy } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { PaletteIcon } from '../_shared/icons';\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",
30
30
  "type": "registry:component",
31
31
  "target": "components/looks-viewer/looks-kind.tsx"
32
32
  }
@@ -4,11 +4,12 @@
4
4
  "title": "Maquette viewer",
5
5
  "description": "MaquetteViewer and maquetteKind: a PM agent's *.maquette.json mock-up (format v1, versioned) one screen at a time, drawn from the host's shadcn components, under one thin icon bar: Back, previous and next (also arrow keys and swipe), an optional comment icon (point mode of the feedback item: a note pinned on a block names the maquette path, screen id and block id) and a menu with guided tours and How it works (the workflow, inline or a linked *.diagram.json, drawn by the diagram-viewer item in a dialog). Optional phone or desktop frame and #screen=<id> deep links. Its kind is in the pi-app-kinds default list, which an app passes to the pi-app artifact panel; an app kind placed before it wins.",
6
6
  "dependencies": [
7
- "react@^19.0.0",
8
- "lucide-react@^1.51.0"
7
+ "react@^19.0.0"
9
8
  ],
10
9
  "registryDependencies": [
11
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/diagram-viewer.json"
10
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/diagram-viewer.json",
11
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/data-table.json",
12
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
12
13
  ],
13
14
  "files": [
14
15
  {
@@ -25,19 +26,19 @@
25
26
  },
26
27
  {
27
28
  "path": "registry/maquette-viewer/maquette-blocks.tsx",
28
- "content": "'use client';\n\n// One maquette screen drawn from the host's own shadcn components: each catalogue type is a real Button, Input, NativeSelect, Badge, Table,\n// Item, Card or Bubble, so a mock-up looks like the app it announces. A type the catalogue does not know becomes a named placeholder\n// (a grey dashed Empty). Blocks with a goto open their screen; the item's CSS gives them a soft ring on hover and focus. Each block sits\n// in a box marked `data-feedback-id` (the maquette path, screen id and block id), so a note pinned on it with the feedback item's point mode\n// says exactly which block it is about; the screen is a `data-feedback-visible` region, so the block's words go with the note.\nimport type { ReactNode, Ref } from 'react';\nimport { BoxIcon } from 'lucide-react';\nimport { Badge } from '@/components/ui/badge';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty';\nimport { Input } from '@/components/ui/input';\nimport { Item, ItemContent, ItemGroup, ItemTitle } from '@/components/ui/item';\nimport { Label } from '@/components/ui/label';\nimport { NativeSelect, NativeSelectOption } from '@/components/ui/native-select';\nimport { Separator } from '@/components/ui/separator';\nimport { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';\nimport type { Maquette, MaquetteBlock, MaquetteScreen } from './maquette';\nimport type { MaquetteLabels } from './maquette-labels';\n\n/** Opens a screen by id. */\nexport type MaquetteGo = (screen: string) => void;\n/**\n * How a block reaches other screens: `go` opens one, `hint` names where a goto leads (the button's tooltip). `target` is a block's\n * `data-feedback-id` and `pins` the numbers of the comment pins on it (none while point mode draws its own).\n */\nexport interface MaquetteLinks {\n readonly go: MaquetteGo;\n readonly hint: (screen: string) => string;\n readonly target: (block: MaquetteBlock) => string;\n readonly pins: (block: MaquetteBlock) => readonly number[];\n}\n\nconst SKELETON = [92, 100, 64];\n\nfunction Placeholder({ block, links, labels }: { readonly block: MaquetteBlock; readonly links: MaquetteLinks; readonly labels: MaquetteLabels }) {\n return <div data-testid=\"maquette-placeholder\" data-type={block.type}>\n <Empty data-maquette=\"placeholder\">\n <EmptyHeader>\n <EmptyMedia variant=\"icon\"><BoxIcon aria-hidden=\"true\" /></EmptyMedia>\n <EmptyTitle>{block.label || block.type}</EmptyTitle>\n <EmptyDescription>{block.note || labels.unknownBlock}</EmptyDescription>\n </EmptyHeader>\n {block.goto && <EmptyContent><GotoButton block={block} links={links} label={block.label || block.type} /></EmptyContent>}\n </Empty>\n </div>;\n}\n\n// A block with a goto opens its screen (its tooltip names it); one without is drawn but does nothing.\nfunction GotoButton({ block, links, label }: { readonly block: MaquetteBlock; readonly links: MaquetteLinks; readonly label: string }) {\n const target = block.goto;\n return <Button type=\"button\" variant={block.type === 'link' ? 'link' : block.primary ? 'default' : 'outline'} className=\"w-fit max-w-full pointer-coarse:min-h-11\"\n data-testid={target ? 'maquette-goto' : 'maquette-button'} data-goto={target} title={target ? links.hint(target) : undefined} onClick={() => { if (target) links.go(target); }}>\n <span className=\"truncate\">{label || '…'}</span>\n </Button>;\n}\n\n/** One block and its children, in its marked box with its comment pins. */\nexport function MaquetteBlockView({ block, links, labels }: { readonly block: MaquetteBlock; readonly links: MaquetteLinks; readonly labels: MaquetteLabels }): ReactNode {\n const pins = links.pins(block);\n return <div data-testid=\"maquette-block\" data-block={block.id} data-feedback-id={links.target(block)} className=\"relative min-w-0\">\n <BlockBody block={block} links={links} labels={labels} />\n {pins.length > 0 && <Badge data-testid=\"maquette-pin\" data-feedback-ignore=\"\" aria-hidden=\"true\" className=\"absolute -top-2 -left-2 tabular-nums\">{pins.join(' ')}</Badge>}\n </div>;\n}\n\nfunction BlockBody({ block, links, labels }: { readonly block: MaquetteBlock; readonly links: MaquetteLinks; readonly labels: MaquetteLabels }): ReactNode {\n const children = block.children.map((child, index) => <MaquetteBlockView key={index} block={child} links={links} labels={labels} />);\n switch (block.type) {\n case 'heading': return <h4 className=\"m-0 text-base font-semibold text-balance\">{block.label}</h4>;\n case 'text': return <p className=\"m-0 text-sm text-pretty text-muted-foreground\">{block.label}</p>;\n case 'button': case 'link': return <GotoButton block={block} links={links} label={block.label} />;\n case 'input': return <Label className=\"flex-col items-stretch gap-1.5\">{block.label}\n <Input className=\"pointer-coarse:min-h-11\" placeholder={block.placeholder} defaultValue={block.value} /></Label>;\n case 'select': return <Label className=\"flex-col items-stretch gap-1.5\">{block.label}\n <NativeSelect className=\"w-full pointer-coarse:[&_select]:min-h-11\" defaultValue={block.value}>\n {(block.items.length ? block.items : [block.value || block.placeholder || '…']).map((option, index) => <NativeSelectOption key={index} value={option}>{option}</NativeSelectOption>)}\n </NativeSelect></Label>;\n case 'badge': return <Badge variant=\"secondary\">{block.label}</Badge>;\n case 'list': return <ItemGroup className=\"gap-2\">\n {block.label && <p className=\"m-0 text-sm font-medium\">{block.label}</p>}\n {block.items.map((item, index) => <Item key={index} variant=\"outline\" size=\"sm\"><ItemContent><ItemTitle>{item}</ItemTitle></ItemContent></Item>)}\n </ItemGroup>;\n case 'table': return <div className=\"min-w-0 overflow-x-auto\"><Table>\n <TableHeader><TableRow>{block.columns.map((column, index) => <TableHead key={index}>{column}</TableHead>)}</TableRow></TableHeader>\n <TableBody>{block.rows.map((row, index) => <TableRow key={index}>{row.map((cell, column) => <TableCell key={column}>{cell}</TableCell>)}</TableRow>)}</TableBody>\n </Table></div>;\n case 'card': return <Card size=\"sm\">\n {block.label && <CardHeader><CardTitle>{block.label}</CardTitle></CardHeader>}\n {children.length > 0 && <CardContent className=\"flex flex-col gap-3\">{children}</CardContent>}\n </Card>;\n case 'row': return <div className=\"flex flex-wrap items-start gap-3 *:min-w-40 *:flex-1\">{children}</div>;\n case 'stack': return <div className=\"flex flex-col gap-3\">{children}</div>;\n case 'chat': return <Card size=\"sm\">\n {block.label && <CardHeader><CardTitle>{block.label}</CardTitle></CardHeader>}\n <CardContent className=\"flex flex-col gap-2\">\n {block.messages.map((message, index) => <Bubble key={index} align={message.mine ? 'end' : 'start'} variant={message.mine ? 'default' : 'muted'}><BubbleContent>{message.text}</BubbleContent></Bubble>)}\n <Input className=\"pointer-coarse:min-h-11\" placeholder={block.placeholder || '…'} aria-label={block.placeholder || block.label || 'chat'} />\n {children}\n </CardContent>\n </Card>;\n case 'document': return <Card size=\"sm\">\n {block.label && <CardHeader><CardTitle>{block.label}</CardTitle></CardHeader>}\n <CardContent className=\"flex flex-col gap-2\">\n {block.lines.length ? block.lines.map((line, index) => <p key={index} className=\"m-0 text-sm text-muted-foreground\">{line}</p>)\n : SKELETON.map(width => <span key={width} aria-hidden=\"true\" className=\"block h-2 bg-muted\" style={{ width: `${width}%` }} />)}\n {children}\n </CardContent>\n </Card>;\n case 'placeholder': default: return <Placeholder block={block} links={links} labels={labels} />;\n }\n}\n\n/** A screen as the app would show it: the app's navigation bar (when the maquette has one), then its blocks. Its name is for screen readers and focus. */\nexport function MaquetteScreenView({ maquette, screen, links, labels, headingRef }: {\n readonly maquette: Maquette; readonly screen: MaquetteScreen; readonly links: MaquetteLinks; readonly labels: MaquetteLabels;\n readonly headingRef?: Ref<HTMLHeadingElement> | undefined;\n}) {\n return <div data-testid=\"maquette-screen\" data-screen={screen.id} data-feedback-visible=\"\" className=\"flex min-w-0 flex-col gap-4\">\n {maquette.nav.length > 0 && <>\n <nav aria-label={labels.appNavigation} className=\"flex flex-wrap gap-1\">\n {maquette.nav.map(item => <Button key={item.goto} type=\"button\" size=\"sm\" variant={item.goto === screen.id ? 'secondary' : 'ghost'} className=\"pointer-coarse:min-h-11\"\n aria-current={item.goto === screen.id ? 'page' : undefined} data-testid=\"maquette-nav\" title={links.hint(item.goto)} onClick={() => links.go(item.goto)}>{item.label}</Button>)}\n </nav>\n <Separator />\n </>}\n <h3 ref={headingRef} tabIndex={-1} data-testid=\"maquette-screen-title\" className=\"sr-only\">{screen.title}</h3>\n {screen.blocks.map((block, index) => <MaquetteBlockView key={index} block={block} links={links} labels={labels} />)}\n </div>;\n}\n",
29
+ "content": "'use client';\n\n// One maquette screen drawn from the host's own shadcn components: each catalogue type is a real Button, Input, NativeSelect, Badge, Table,\n// Item, Card or Bubble, so a mock-up looks like the app it announces. A type the catalogue does not know becomes a named placeholder\n// (a grey dashed Empty). Blocks with a goto open their screen; the item's CSS gives them a soft ring on hover and focus. Each block sits\n// in a box marked `data-feedback-id` (the maquette path, screen id and block id), so a note pinned on it with the feedback item's point mode\n// says exactly which block it is about; the screen is a `data-feedback-visible` region, so the block's words go with the note.\nimport type { ReactNode, Ref } from 'react';\nimport { BoxIcon } from '../_shared/icons';\nimport { Badge } from '@/components/ui/badge';\nimport { Bubble, BubbleContent } from '@/components/ui/bubble';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';\nimport { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty';\nimport { Input } from '@/components/ui/input';\nimport { Item, ItemContent, ItemGroup, ItemTitle } from '@/components/ui/item';\nimport { Label } from '@/components/ui/label';\nimport { NativeSelect, NativeSelectOption } from '@/components/ui/native-select';\nimport { Separator } from '@/components/ui/separator';\nimport { DataTable } from '../data-table/data-table';\nimport type { Maquette, MaquetteBlock, MaquetteScreen } from './maquette';\nimport type { MaquetteLabels } from './maquette-labels';\n\n/** Opens a screen by id. */\nexport type MaquetteGo = (screen: string) => void;\n/**\n * How a block reaches other screens: `go` opens one, `hint` names where a goto leads (the button's tooltip). `target` is a block's\n * `data-feedback-id` and `pins` the numbers of the comment pins on it (none while point mode draws its own).\n */\nexport interface MaquetteLinks {\n readonly go: MaquetteGo;\n readonly hint: (screen: string) => string;\n readonly target: (block: MaquetteBlock) => string;\n readonly pins: (block: MaquetteBlock) => readonly number[];\n}\n\nconst SKELETON = [92, 100, 64];\n\nfunction Placeholder({ block, links, labels }: { readonly block: MaquetteBlock; readonly links: MaquetteLinks; readonly labels: MaquetteLabels }) {\n return <div data-testid=\"maquette-placeholder\" data-type={block.type}>\n <Empty data-maquette=\"placeholder\">\n <EmptyHeader>\n <EmptyMedia variant=\"icon\"><BoxIcon aria-hidden=\"true\" /></EmptyMedia>\n <EmptyTitle>{block.label || block.type}</EmptyTitle>\n <EmptyDescription>{block.note || labels.unknownBlock}</EmptyDescription>\n </EmptyHeader>\n {block.goto && <EmptyContent><GotoButton block={block} links={links} label={block.label || block.type} /></EmptyContent>}\n </Empty>\n </div>;\n}\n\n// A block with a goto opens its screen (its tooltip names it); one without is drawn but does nothing.\nfunction GotoButton({ block, links, label }: { readonly block: MaquetteBlock; readonly links: MaquetteLinks; readonly label: string }) {\n const target = block.goto;\n return <Button type=\"button\" variant={block.type === 'link' ? 'link' : block.primary ? 'default' : 'outline'} className=\"w-fit max-w-full pointer-coarse:min-h-11\"\n data-testid={target ? 'maquette-goto' : 'maquette-button'} data-goto={target} title={target ? links.hint(target) : undefined} onClick={() => { if (target) links.go(target); }}>\n <span className=\"truncate\">{label || '…'}</span>\n </Button>;\n}\n\n/** One block and its children, in its marked box with its comment pins. */\nexport function MaquetteBlockView({ block, links, labels }: { readonly block: MaquetteBlock; readonly links: MaquetteLinks; readonly labels: MaquetteLabels }): ReactNode {\n const pins = links.pins(block);\n return <div data-testid=\"maquette-block\" data-block={block.id} data-feedback-id={links.target(block)} className=\"relative min-w-0\">\n <BlockBody block={block} links={links} labels={labels} />\n {pins.length > 0 && <Badge data-testid=\"maquette-pin\" data-feedback-ignore=\"\" aria-hidden=\"true\" className=\"absolute -top-2 -left-2 tabular-nums\">{pins.join(' ')}</Badge>}\n </div>;\n}\n\nfunction BlockBody({ block, links, labels }: { readonly block: MaquetteBlock; readonly links: MaquetteLinks; readonly labels: MaquetteLabels }): ReactNode {\n const children = block.children.map((child, index) => <MaquetteBlockView key={index} block={child} links={links} labels={labels} />);\n switch (block.type) {\n case 'heading': return <h4 className=\"m-0 text-sm font-semibold text-balance\">{block.label}</h4>;\n case 'text': return <p className=\"m-0 text-sm text-pretty text-muted-foreground\">{block.label}</p>;\n case 'button': case 'link': return <GotoButton block={block} links={links} label={block.label} />;\n case 'input': return <Label className=\"flex-col items-stretch gap-1.5\">{block.label}\n <Input className=\"pointer-coarse:min-h-11\" placeholder={block.placeholder} defaultValue={block.value} /></Label>;\n case 'select': return <Label className=\"flex-col items-stretch gap-1.5\">{block.label}\n <NativeSelect className=\"w-full pointer-coarse:[&_select]:min-h-11\" defaultValue={block.value}>\n {(block.items.length ? block.items : [block.value || block.placeholder || '…']).map((option, index) => <NativeSelectOption key={index} value={option}>{option}</NativeSelectOption>)}\n </NativeSelect></Label>;\n case 'badge': return <Badge variant=\"secondary\">{block.label}</Badge>;\n case 'list': return <ItemGroup className=\"gap-2\">\n {block.label && <p className=\"m-0 text-sm font-medium\">{block.label}</p>}\n {block.items.map((item, index) => <Item key={index} variant=\"outline\" size=\"sm\"><ItemContent><ItemTitle>{item}</ItemTitle></ItemContent></Item>)}\n </ItemGroup>;\n case 'table': return <DataTable\n caption={block.columns.join(', ')}\n columns={block.columns.map((header, column) => ({ id: String(column), header, priority: column === 0 ? 1 : 2, cell: (row: { index: number; cells: readonly string[] }) => row.cells[column] }))}\n rows={block.rows.map((cells, index) => ({ index, cells }))}\n rowKey={row => String(row.index)}\n />;\n case 'card': return <Card size=\"sm\">\n {block.label && <CardHeader><CardTitle>{block.label}</CardTitle></CardHeader>}\n {children.length > 0 && <CardContent className=\"flex flex-col gap-3\">{children}</CardContent>}\n </Card>;\n case 'row': return <div className=\"flex flex-wrap items-start gap-3 *:min-w-40 *:flex-1\">{children}</div>;\n case 'stack': return <div className=\"flex flex-col gap-3\">{children}</div>;\n case 'chat': return <Card size=\"sm\">\n {block.label && <CardHeader><CardTitle>{block.label}</CardTitle></CardHeader>}\n <CardContent className=\"flex flex-col gap-2\">\n {block.messages.map((message, index) => <Bubble key={index} align={message.mine ? 'end' : 'start'} variant={message.mine ? 'default' : 'muted'}><BubbleContent>{message.text}</BubbleContent></Bubble>)}\n <Input className=\"pointer-coarse:min-h-11\" placeholder={block.placeholder || '…'} aria-label={block.placeholder || block.label || 'chat'} />\n {children}\n </CardContent>\n </Card>;\n case 'document': return <Card size=\"sm\">\n {block.label && <CardHeader><CardTitle>{block.label}</CardTitle></CardHeader>}\n <CardContent className=\"flex flex-col gap-2\">\n {block.lines.length ? block.lines.map((line, index) => <p key={index} className=\"m-0 text-sm text-muted-foreground\">{line}</p>)\n : SKELETON.map(width => <span key={width} aria-hidden=\"true\" className=\"block h-2 bg-muted\" style={{ width: `${width}%` }} />)}\n {children}\n </CardContent>\n </Card>;\n case 'placeholder': default: return <Placeholder block={block} links={links} labels={labels} />;\n }\n}\n\n/** A screen as the app would show it: the app's navigation bar (when the maquette has one), then its blocks. Its name is for screen readers and focus. */\nexport function MaquetteScreenView({ maquette, screen, links, labels, headingRef }: {\n readonly maquette: Maquette; readonly screen: MaquetteScreen; readonly links: MaquetteLinks; readonly labels: MaquetteLabels;\n readonly headingRef?: Ref<HTMLHeadingElement> | undefined;\n}) {\n return <div data-testid=\"maquette-screen\" data-screen={screen.id} data-feedback-visible=\"\" className=\"flex min-w-0 flex-col gap-4\">\n {maquette.nav.length > 0 && <>\n <nav aria-label={labels.appNavigation} className=\"flex flex-wrap gap-1\">\n {maquette.nav.map(item => <Button key={item.goto} type=\"button\" size=\"sm\" variant={item.goto === screen.id ? 'secondary' : 'ghost'} className=\"pointer-coarse:min-h-11\"\n aria-current={item.goto === screen.id ? 'page' : undefined} data-testid=\"maquette-nav\" title={links.hint(item.goto)} onClick={() => links.go(item.goto)}>{item.label}</Button>)}\n </nav>\n <Separator />\n </>}\n <h3 ref={headingRef} tabIndex={-1} data-testid=\"maquette-screen-title\" className=\"sr-only\">{screen.title}</h3>\n {screen.blocks.map((block, index) => <MaquetteBlockView key={index} block={block} links={links} labels={labels} />)}\n </div>;\n}\n",
29
30
  "type": "registry:component",
30
31
  "target": "components/maquette-viewer/maquette-blocks.tsx"
31
32
  },
32
33
  {
33
34
  "path": "registry/maquette-viewer/maquette-viewer.tsx",
34
- "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 of the pi-app-kinds default\n// list for `.maquette.json` files; an app kind placed before it 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, useLayoutEffect, 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 // eslint-disable-next-line react-hooks/refs -- ref cache of the decision for the shown screen (no setState during render); a re-render of the same screen reuses it\n const view = shown.current?.id === current.id ? shown.current : { id: current.id, first: !seen.current.has(current.id) };\n // eslint-disable-next-line react-hooks/refs -- stores the decision above in the same ref cache\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 // eslint-disable-next-line react-hooks/set-state-in-effect -- follows a new fragment link once per change of `linked`; the guard skips the screen already shown\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 -- only a new link moves; the walk and the screen list are read as they are\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);\n useLayoutEffect(() => { 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",
35
+ "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 of the pi-app-kinds default\n// list for `.maquette.json` files; an app kind placed before it 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, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { KeyboardEvent, ReactNode, TouchEvent } from 'react';\nimport { ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon, CompassIcon, EllipsisIcon, InfoIcon, MessageSquarePlusIcon, TriangleAlertIcon, WorkflowIcon, XIcon } from '../_shared/icons';\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 // eslint-disable-next-line react-hooks/refs -- ref cache of the decision for the shown screen (no setState during render); a re-render of the same screen reuses it\n const view = shown.current?.id === current.id ? shown.current : { id: current.id, first: !seen.current.has(current.id) };\n // eslint-disable-next-line react-hooks/refs -- stores the decision above in the same ref cache\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 // eslint-disable-next-line react-hooks/set-state-in-effect -- follows a new fragment link once per change of `linked`; the guard skips the screen already shown\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 -- only a new link moves; the walk and the screen list are read as they are\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);\n useLayoutEffect(() => { 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",
35
36
  "type": "registry:component",
36
37
  "target": "components/maquette-viewer/maquette-viewer.tsx"
37
38
  },
38
39
  {
39
40
  "path": "registry/maquette-viewer/maquette-kind.tsx",
40
- "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. It is in the pi-app-kinds default list (`defaultKinds`); kinds in `artifactKinds` are consulted in order.\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",
41
+ "content": "'use client';\n\nimport { lazy } from 'react';\nimport type { ComponentType, ReactNode } from 'react';\nimport { LayoutTemplateIcon } from '../_shared/icons';\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. It is in the pi-app-kinds default list (`defaultKinds`); kinds in `artifactKinds` are consulted in order.\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",
41
42
  "type": "registry:component",
42
43
  "target": "components/maquette-viewer/maquette-kind.tsx"
43
44
  }
@@ -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.57",
8
- "@hachej/boring-files@^0.4.57",
7
+ "@hachej/boring-ui-kit@^0.4.59",
8
+ "@hachej/boring-files@^0.4.59",
9
9
  "react@^19.0.0",
10
10
  "react-dom@^19.0.0",
11
11
  "marked@^17.0.6",
@@ -20,16 +20,16 @@
20
20
  "@tiptap/extension-image@^3.31.4",
21
21
  "@tiptap/extension-highlight@^3.31.4",
22
22
  "@tiptap/extension-bubble-menu@^3.31.4",
23
- "@tiptap/extension-floating-menu@^3.31.4",
24
- "lucide-react@^1.51.0"
23
+ "@tiptap/extension-floating-menu@^3.31.4"
25
24
  ],
26
25
  "registryDependencies": [
27
- "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.57/r/button.json"
26
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/button.json",
27
+ "https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.59/r/_shared.json"
28
28
  ],
29
29
  "files": [
30
30
  {
31
31
  "path": "registry/markdown-editor/markdown-editor.tsx",
32
- "content": "'use client';\n\nimport { useId } from 'react';\nimport type { ComponentPropsWithRef } from 'react';\nimport { TypeIcon, CodeXmlIcon, EllipsisIcon, ListIcon, PencilLineIcon } from 'lucide-react';\nimport { MarkdownEditor as RuntimeMarkdownEditor } from '@hachej/boring-ui-kit/markdown-editor';\nimport type { MarkdownEditorMenuProps, MarkdownEditorProps } from '@hachej/boring-ui-kit/markdown-editor';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { DropdownMenu, DropdownMenuTrigger, DropdownMenuGroup, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuCheckboxItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';\nimport { ActionMenuContent, useMenuPortal } from '../button/actions';\nimport { editorControls } from '../button/editor-controls';\n\nexport type { MarkdownEditorProps } from '@hachej/boring-ui-kit/markdown-editor';\n\n/** Shared by the standalone editor and the viewer's existing overflow menu. */\nexport function MarkdownMenuItems({ labels, mode, onModeChange, richDisabledReason, outline, testId = 'markdown' }: MarkdownEditorMenuProps) {\n const reasonId = useId();\n const row = 'min-h-8 max-sm:min-h-12 pointer-coarse:min-h-12';\n return <>\n <DropdownMenuGroup><DropdownMenuRadioGroup aria-label={labels.editorMode} value={mode} onValueChange={value => { if (value === 'rich' || value === 'source') onModeChange(value); }}>\n <DropdownMenuRadioItem value=\"rich\" disabled={!!richDisabledReason} closeOnClick title={richDisabledReason ?? labels.formatted} aria-label={labels.formatted} aria-describedby={richDisabledReason ? reasonId : undefined} data-testid={`${testId}-mode-rich`} className={row}>\n <PencilLineIcon aria-hidden=\"true\" /><span className=\"flex min-w-0 flex-col\"><span>{labels.formatted}</span>{richDisabledReason && <span id={reasonId} className=\"whitespace-normal\">{richDisabledReason}</span>}</span>\n </DropdownMenuRadioItem>\n <DropdownMenuRadioItem value=\"source\" closeOnClick aria-label={labels.markdown} data-testid={`${testId}-mode-source`} className={row}><CodeXmlIcon aria-hidden=\"true\" />{labels.markdown}</DropdownMenuRadioItem>\n </DropdownMenuRadioGroup></DropdownMenuGroup>\n {outline && <><DropdownMenuSeparator /><DropdownMenuGroup>\n <DropdownMenuCheckboxItem checked={outline.shown} closeOnClick onCheckedChange={outline.onChange} data-testid={`${testId}-outline`} className={row}><ListIcon aria-hidden=\"true\" />{labels.showOutline}</DropdownMenuCheckboxItem>\n </DropdownMenuGroup></>}\n </>;\n}\n\nfunction MarkdownViewOptions(props: MarkdownEditorMenuProps) {\n const { ref: portalRef, container: portalContainer } = useMenuPortal();\n return <DropdownMenu>\n <DropdownMenuTrigger render={<Button ref={portalRef} variant=\"ghost\" size=\"icon-sm\" aria-label={props.labels.moreActions} title={props.labels.moreActions}><EllipsisIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>} />\n <ActionMenuContent label={props.labels.moreActions} testId=\"markdown-more\" items={[]} container={portalContainer}><MarkdownMenuItems {...props} /></ActionMenuContent>\n </DropdownMenu>;\n}\n\n/** The host Toggle supplies the visible pressed state as well as toggle-button semantics. */\nexport function MarkdownFormattingControl({ 'aria-pressed': pressed, value, ...props }: ComponentPropsWithRef<'button'>) {\n return <Toggle size=\"sm\" pressed={pressed === true || pressed === 'true'} value={value === undefined ? undefined : String(value)} {...props} className=\"max-sm:min-h-12! max-sm:min-w-12! pointer-coarse:min-h-12! pointer-coarse:min-w-12!\"><TypeIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Toggle>;\n}\n\nconst markdownControls = { ...editorControls, markdownMenu: MarkdownViewOptions, formattingControl: MarkdownFormattingControl };\n\nexport function MarkdownEditor({ className, ...props }: MarkdownEditorProps) {\n return <RuntimeMarkdownEditor components={markdownControls} {...props} className={['boring-markdown-recipe', className].filter(Boolean).join(' ')} />;\n}\n",
32
+ "content": "'use client';\n\nimport { useId } from 'react';\nimport type { ComponentPropsWithRef } from 'react';\nimport { TypeIcon, CodeXmlIcon, EllipsisIcon, ListIcon, PencilLineIcon } from '../_shared/icons';\nimport { MarkdownEditor as RuntimeMarkdownEditor } from '@hachej/boring-ui-kit/markdown-editor';\nimport type { MarkdownEditorMenuProps, MarkdownEditorProps } from '@hachej/boring-ui-kit/markdown-editor';\nimport { Button } from '@/components/ui/button';\nimport { Toggle } from '@/components/ui/toggle';\nimport { DropdownMenu, DropdownMenuTrigger, DropdownMenuGroup, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuCheckboxItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';\nimport { ActionMenuContent, useMenuPortal } from '../button/actions';\nimport { editorControls } from '../button/editor-controls';\n\nexport type { MarkdownEditorProps } from '@hachej/boring-ui-kit/markdown-editor';\n\n/** Shared by the standalone editor and the viewer's existing overflow menu. */\nexport function MarkdownMenuItems({ labels, mode, onModeChange, richDisabledReason, outline, testId = 'markdown' }: MarkdownEditorMenuProps) {\n const reasonId = useId();\n const row = 'min-h-8 max-sm:min-h-12 pointer-coarse:min-h-12';\n return <>\n <DropdownMenuGroup><DropdownMenuRadioGroup aria-label={labels.editorMode} value={mode} onValueChange={value => { if (value === 'rich' || value === 'source') onModeChange(value); }}>\n <DropdownMenuRadioItem value=\"rich\" disabled={!!richDisabledReason} closeOnClick title={richDisabledReason ?? labels.formatted} aria-label={labels.formatted} aria-describedby={richDisabledReason ? reasonId : undefined} data-testid={`${testId}-mode-rich`} className={row}>\n <PencilLineIcon aria-hidden=\"true\" /><span className=\"flex min-w-0 flex-col\"><span>{labels.formatted}</span>{richDisabledReason && <span id={reasonId} className=\"whitespace-normal\">{richDisabledReason}</span>}</span>\n </DropdownMenuRadioItem>\n <DropdownMenuRadioItem value=\"source\" closeOnClick aria-label={labels.markdown} data-testid={`${testId}-mode-source`} className={row}><CodeXmlIcon aria-hidden=\"true\" />{labels.markdown}</DropdownMenuRadioItem>\n </DropdownMenuRadioGroup></DropdownMenuGroup>\n {outline && <><DropdownMenuSeparator /><DropdownMenuGroup>\n <DropdownMenuCheckboxItem checked={outline.shown} closeOnClick onCheckedChange={outline.onChange} data-testid={`${testId}-outline`} className={row}><ListIcon aria-hidden=\"true\" />{labels.showOutline}</DropdownMenuCheckboxItem>\n </DropdownMenuGroup></>}\n </>;\n}\n\nfunction MarkdownViewOptions(props: MarkdownEditorMenuProps) {\n const { ref: portalRef, container: portalContainer } = useMenuPortal();\n return <DropdownMenu>\n <DropdownMenuTrigger render={<Button ref={portalRef} variant=\"ghost\" size=\"icon-sm\" aria-label={props.labels.moreActions} title={props.labels.moreActions}><EllipsisIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Button>} />\n <ActionMenuContent label={props.labels.moreActions} testId=\"markdown-more\" items={[]} container={portalContainer}><MarkdownMenuItems {...props} /></ActionMenuContent>\n </DropdownMenu>;\n}\n\n/** The host Toggle supplies the visible pressed state as well as toggle-button semantics. */\nexport function MarkdownFormattingControl({ 'aria-pressed': pressed, value, ...props }: ComponentPropsWithRef<'button'>) {\n return <Toggle size=\"sm\" pressed={pressed === true || pressed === 'true'} value={value === undefined ? undefined : String(value)} {...props} className=\"max-sm:min-h-12! max-sm:min-w-12! pointer-coarse:min-h-12! pointer-coarse:min-w-12!\"><TypeIcon data-icon=\"inline-start\" aria-hidden=\"true\" /></Toggle>;\n}\n\nconst markdownControls = { ...editorControls, markdownMenu: MarkdownViewOptions, formattingControl: MarkdownFormattingControl };\n\nexport function MarkdownEditor({ className, ...props }: MarkdownEditorProps) {\n return <RuntimeMarkdownEditor components={markdownControls} {...props} className={['boring-markdown-recipe', className].filter(Boolean).join(' ')} />;\n}\n",
33
33
  "type": "registry:component",
34
34
  "target": "components/markdown-editor/markdown-editor.tsx"
35
35
  }
@@ -80,6 +80,7 @@
80
80
  "max-width": "75ch",
81
81
  "width": "100%",
82
82
  "margin-inline": "auto",
83
+ "font-size": "var(--text-xs)",
83
84
  "line-height": "var(--leading-relaxed)",
84
85
  "overflow-wrap": "anywhere"
85
86
  },
@@ -87,19 +88,19 @@
87
88
  "margin": "0.75rem 0"
88
89
  },
89
90
  ".boring-markdown-recipe[data-boring=\"markdown-editor\"] .boring-markdown-content h1": {
90
- "font-size": "var(--text-3xl)",
91
+ "font-size": "var(--text-sm)",
91
92
  "line-height": "var(--leading-tight)",
92
93
  "margin": "1.25rem 0 0.75rem",
93
94
  "font-weight": "var(--font-weight-semibold)"
94
95
  },
95
96
  ".boring-markdown-recipe[data-boring=\"markdown-editor\"] .boring-markdown-content h2": {
96
- "font-size": "var(--text-2xl)",
97
+ "font-size": "var(--text-sm)",
97
98
  "line-height": "var(--leading-tight)",
98
99
  "margin": "1.25rem 0 0.75rem",
99
100
  "font-weight": "var(--font-weight-semibold)"
100
101
  },
101
102
  ".boring-markdown-recipe[data-boring=\"markdown-editor\"] .boring-markdown-content h3": {
102
- "font-size": "var(--text-xl)",
103
+ "font-size": "var(--text-sm)",
103
104
  "margin": "1rem 0 0.5rem",
104
105
  "font-weight": "var(--font-weight-semibold)"
105
106
  },