@hachej/boring-ui-kit 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -1
- package/bin/boring-ui-blocks.mjs +29 -0
- package/dist/canvas-editor-native.d.ts +8 -1
- package/dist/canvas-editor-native.js +13 -7
- package/dist/canvas-editor.d.ts +4 -2
- package/dist/canvas-editor.js +5 -0
- package/dist/chat-outbox.d.ts +18 -0
- package/dist/chat-outbox.js +12 -0
- package/dist/file-tree-view.d.ts +29 -1
- package/dist/file-tree-view.js +74 -7
- package/dist/mark-store.d.ts +38 -0
- package/dist/mark-store.js +84 -0
- package/dist/markdown-editor.d.ts +6 -0
- package/dist/markdown-editor.js +17 -2
- package/dist/markdown-highlight-layer.d.ts +26 -0
- package/dist/markdown-highlight-layer.js +107 -0
- package/dist/markdown-quote.d.ts +23 -0
- package/dist/markdown-quote.js +39 -0
- package/dist/native-chat.d.ts +21 -0
- package/dist/native-chat.js +111 -7
- package/dist/remote-chat.js +16 -14
- package/dist/shared-chat/error.d.ts +10 -0
- package/dist/shared-chat/error.js +13 -0
- package/dist/shared-chat/fake.d.ts +39 -0
- package/dist/shared-chat/fake.js +90 -0
- package/dist/shared-chat/index.d.ts +51 -0
- package/dist/shared-chat/index.js +255 -0
- package/dist/shared-chat/socket.d.ts +20 -0
- package/dist/shared-chat/socket.js +114 -0
- package/dist/shared-chat/source.d.ts +117 -0
- package/dist/shared-chat/source.js +6 -0
- package/package.json +15 -3
- package/r/agent-target.json +36 -0
- package/r/app-shell.json +26 -11
- package/r/board-viewer.json +49 -0
- package/r/button.json +1 -1
- package/r/diagram-viewer.json +42 -0
- package/r/dock.json +1 -1
- package/r/documents.json +11 -11
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +6 -6
- package/r/file-tree.json +7 -6
- package/r/highlights.json +29 -0
- package/r/html-viewer.json +3 -3
- package/r/maquette-viewer.json +10 -13
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +45 -0
- package/r/pi-ambient.json +7 -7
- package/r/pi-app.json +21 -18
- package/r/pi-chat.json +28 -20
- package/r/pi-workspace.json +4 -4
- package/r/provider-setup.json +2 -2
- package/r/record-viewer.json +40 -0
- package/r/registry.json +449 -83
- package/r/requests-board.json +41 -0
- package/r/shared-chat.json +79 -0
- package/r/task-list-viewer.json +2 -2
- package/r/theme.json +15 -0
- package/r/version-history.json +3 -3
- package/r/viewers.json +16 -10
package/r/file-tree.json
CHANGED
|
@@ -4,27 +4,28 @@
|
|
|
4
4
|
"title": "File tree",
|
|
5
5
|
"description": "Browse, search, upload and inspect retained versions through one revisionProvider binding. Uses the consumer's shadcn Button and dropdown-menu with shared version-history rows; Base UI style only.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.4",
|
|
8
|
+
"@hachej/boring-files@^0.4.4",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"lucide-react@^1.51.0"
|
|
11
11
|
],
|
|
12
12
|
"registryDependencies": [
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
14
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.4/r/theme.json",
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.4/r/utils.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.4/r/version-history.json",
|
|
16
16
|
"button",
|
|
17
17
|
"dropdown-menu",
|
|
18
18
|
"collapsible",
|
|
19
19
|
"empty",
|
|
20
20
|
"input",
|
|
21
|
+
"progress",
|
|
21
22
|
"separator",
|
|
22
23
|
"sidebar"
|
|
23
24
|
],
|
|
24
25
|
"files": [
|
|
25
26
|
{
|
|
26
27
|
"path": "registry/file-tree/file-tree.tsx",
|
|
27
|
-
"content": "'use client';\n\nimport '@hachej/boring-ui-kit/file-tree.css';\nimport { FileTree as PackageFileTree, FileTreeView as PackageFileTreeView } from '@hachej/boring-ui-kit/file-tree-view';\nimport type { FileTreeComponents, FileTreeActionsProps, FileTreeButtonProps, FileTreeVersionsProps, FileTreeProps as PackageFileTreeProps, FileTreeViewProps as PackageFileTreeViewProps } from '@hachej/boring-ui-kit/file-tree-view';\nimport { useRef } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';\nimport { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty';\nimport { Input } from '@/components/ui/input';\nimport { Separator } from '@/components/ui/separator';\nimport { SidebarMenu, SidebarMenuItem, SidebarMenuSub } from '@/components/ui/sidebar';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { VersionHistoryRows, frenchVersionHistoryLabels } from '../version-history/version-history';\nimport { cn } from '../utils/utils';\n\n// Only touch-target layout is added; appearance remains the consumer's shadcn Button.\nfunction LibraryButton({ className, ...props }: FileTreeButtonProps) {\n return <Button {...props} className={cn('max-sm:min-h-11 max-sm:min-w-11 pointer-coarse:min-h-11 pointer-coarse:min-w-11', className)} />;\n}\n\nfunction FileActions({ label, icon, items }: FileTreeActionsProps) {\n const trigger = useRef<HTMLButtonElement>(null);\n const portal = useRef<HTMLElement | null>(null);\n return <DropdownMenu>\n <DropdownMenuTrigger ref={trigger} render={<LibraryButton type=\"button\" variant=\"ghost\" size=\"icon\" ref={node => { portal.current = node?.ownerDocument.body ?? null; }} />} data-file-more aria-label={label}>{icon}</DropdownMenuTrigger>\n <DropdownMenuPortal container={portal}>\n <DropdownMenuContent data-boring=\"file-actions\" align=\"end\" finalFocus={trigger} className=\"w-max max-w-(--available-width)\">\n {items.map(item => <DropdownMenuItem key={item.id} data-action={item.id} onClick={item.onSelect} className=\"max-sm:min-h-11 pointer-coarse:min-h-11\">{item.icon}{item.label}</DropdownMenuItem>)}\n </DropdownMenuContent>\n </DropdownMenuPortal>\n </DropdownMenu>;\n}\n\nfunction FileVersions({ versions, current, onSelect, locale, earlierVersion }: FileTreeVersionsProps) {\n return <VersionHistoryRows versions={versions.map(version => ({ id: version.revision, label: earlierVersion, savedAt: version.savedAt, ...(version.source ? { source: version.source } : {}) }))}\n current={current} onSelect={onSelect} testId=\"file-history-versions\" {...(locale === 'fr' ? { labels: frenchVersionHistoryLabels } : {})} />;\n}\n\nfunction FileTreeEmpty({ icon, title, description, className, children }: Parameters<FileTreeComponents['Empty']>[0]) {\n return <Empty className={className}>\n <EmptyHeader>\n {icon && <EmptyMedia variant=\"icon\">{icon}</EmptyMedia>}\n <EmptyTitle>{title}</EmptyTitle>\n {description && <EmptyDescription>{description}</EmptyDescription>}\n </EmptyHeader>\n {children && <EmptyContent>{children}</EmptyContent>}\n </Empty>;\n}\n\nexport const fileTreeComponents = {\n Button: LibraryButton, Actions: FileActions, Versions: FileVersions,\n Menu: SidebarMenu, MenuItem: SidebarMenuItem, MenuSub: SidebarMenuSub, Input, Separator: () => <Separator />,\n Collapsible, CollapsibleTrigger, CollapsibleContent, Empty: FileTreeEmpty,\n} satisfies FileTreeComponents;\nexport type FileTreeProps = Omit<PackageFileTreeProps, 'components'>;\nexport type FileTreeViewProps = Omit<PackageFileTreeViewProps, 'components'>;\n\nexport function FileTree(props: FileTreeProps) {\n return <PackageFileTree {...props} components={fileTreeComponents} />;\n}\nexport function FileTreeView(props: FileTreeViewProps) {\n return <PackageFileTreeView {...props} components={fileTreeComponents} />;\n}\nexport { createFileTreeController } from '@hachej/boring-ui-kit/file-tree';\nexport type { FileTreeController } from '@hachej/boring-ui-kit/file-tree';\n",
|
|
28
|
+
"content": "'use client';\n\nimport '@hachej/boring-ui-kit/file-tree.css';\nimport { FileTree as PackageFileTree, FileTreeView as PackageFileTreeView } from '@hachej/boring-ui-kit/file-tree-view';\nimport type { FileTreeComponents, FileTreeActionsProps, FileTreeButtonProps, FileTreeVersionsProps, FileTreeProps as PackageFileTreeProps, FileTreeViewProps as PackageFileTreeViewProps } from '@hachej/boring-ui-kit/file-tree-view';\nimport { useRef } from 'react';\nimport { Button } from '@/components/ui/button';\nimport { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';\nimport { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty';\nimport { Input } from '@/components/ui/input';\nimport { Progress } from '@/components/ui/progress';\nimport { Separator } from '@/components/ui/separator';\nimport { SidebarMenu, SidebarMenuItem, SidebarMenuSub } from '@/components/ui/sidebar';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { VersionHistoryRows, frenchVersionHistoryLabels } from '../version-history/version-history';\nimport { cn } from '../utils/utils';\n\n// Only touch-target layout is added; appearance remains the consumer's shadcn Button.\nfunction LibraryButton({ className, ...props }: FileTreeButtonProps) {\n return <Button {...props} className={cn('max-sm:min-h-11 max-sm:min-w-11 pointer-coarse:min-h-11 pointer-coarse:min-w-11', className)} />;\n}\n\nfunction FileActions({ label, icon, items }: FileTreeActionsProps) {\n const trigger = useRef<HTMLButtonElement>(null);\n const portal = useRef<HTMLElement | null>(null);\n return <DropdownMenu>\n <DropdownMenuTrigger ref={trigger} render={<LibraryButton type=\"button\" variant=\"ghost\" size=\"icon\" ref={node => { portal.current = node?.ownerDocument.body ?? null; }} />} data-file-more aria-label={label}>{icon}</DropdownMenuTrigger>\n <DropdownMenuPortal container={portal}>\n <DropdownMenuContent data-boring=\"file-actions\" align=\"end\" finalFocus={trigger} className=\"w-max max-w-(--available-width)\">\n {items.map(item => <DropdownMenuItem key={item.id} data-action={item.id} onClick={item.onSelect} className=\"max-sm:min-h-11 pointer-coarse:min-h-11\">{item.icon}{item.label}</DropdownMenuItem>)}\n </DropdownMenuContent>\n </DropdownMenuPortal>\n </DropdownMenu>;\n}\n\nfunction FileVersions({ versions, current, onSelect, locale, earlierVersion }: FileTreeVersionsProps) {\n return <VersionHistoryRows versions={versions.map(version => ({ id: version.revision, label: earlierVersion, savedAt: version.savedAt, ...(version.source ? { source: version.source } : {}) }))}\n current={current} onSelect={onSelect} testId=\"file-history-versions\" {...(locale === 'fr' ? { labels: frenchVersionHistoryLabels } : {})} />;\n}\n\nfunction FileTreeEmpty({ icon, title, description, className, children }: Parameters<FileTreeComponents['Empty']>[0]) {\n return <Empty className={className}>\n <EmptyHeader>\n {icon && <EmptyMedia variant=\"icon\">{icon}</EmptyMedia>}\n <EmptyTitle>{title}</EmptyTitle>\n {description && <EmptyDescription>{description}</EmptyDescription>}\n </EmptyHeader>\n {children && <EmptyContent>{children}</EmptyContent>}\n </Empty>;\n}\n\nexport const fileTreeComponents = {\n Button: LibraryButton, Actions: FileActions, Versions: FileVersions,\n Menu: SidebarMenu, MenuItem: SidebarMenuItem, MenuSub: SidebarMenuSub, Input, Separator: () => <Separator />,\n Collapsible, CollapsibleTrigger, CollapsibleContent, Empty: FileTreeEmpty,\n Progress: ({ value, 'aria-label': label }: { readonly value: number | null; readonly 'aria-label': string }) => <Progress value={value} aria-label={label} />,\n} satisfies FileTreeComponents;\nexport type FileTreeProps = Omit<PackageFileTreeProps, 'components'>;\nexport type FileTreeViewProps = Omit<PackageFileTreeViewProps, 'components'>;\n\nexport function FileTree(props: FileTreeProps) {\n return <PackageFileTree {...props} components={fileTreeComponents} />;\n}\nexport function FileTreeView(props: FileTreeViewProps) {\n return <PackageFileTreeView {...props} components={fileTreeComponents} />;\n}\nexport { createFileTreeController } from '@hachej/boring-ui-kit/file-tree';\nexport type { FileTreeController } from '@hachej/boring-ui-kit/file-tree';\n",
|
|
28
29
|
"type": "registry:component",
|
|
29
30
|
"target": "components/file-tree/file-tree.tsx"
|
|
30
31
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "highlights",
|
|
4
|
+
"title": "Anchored highlights",
|
|
5
|
+
"description": "Exact Markdown highlights and comments in a conditional workspace sidecar.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.4",
|
|
8
|
+
"@hachej/boring-files@^0.4.4",
|
|
9
|
+
"react@^19.0.0",
|
|
10
|
+
"react-dom@^19.0.0"
|
|
11
|
+
],
|
|
12
|
+
"registryDependencies": [
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.4/r/markdown-editor.json",
|
|
14
|
+
"button",
|
|
15
|
+
"textarea",
|
|
16
|
+
"label",
|
|
17
|
+
"item",
|
|
18
|
+
"dropdown-menu"
|
|
19
|
+
],
|
|
20
|
+
"files": [
|
|
21
|
+
{
|
|
22
|
+
"path": "registry/highlights/highlights.tsx",
|
|
23
|
+
"content": "'use client';\n\nimport { Fragment, useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport type { ResourceLocator } from '@hachej/boring-files';\nimport { randomUUID } from '@hachej/boring-files/platform';\nimport type { MarkdownController } from '@hachej/boring-ui-kit/markdown';\nimport { captureMarkdownQuote, markdownQuoteResolution, type MarkdownQuoteAnchor, type MarkdownQuoteRange } from '@hachej/boring-ui-kit/markdown-quote';\nimport { createMarkStore, type MarkStore, type MarkResource, type MarkColour } from '@hachej/boring-ui-kit/mark-store';\nimport { markdownMarkerBackground, type MarkdownQuoteCapture } from '@hachej/boring-ui-kit/markdown-editor';\nimport { Button } from '@/components/ui/button';\nimport { Textarea } from '@/components/ui/textarea';\nimport { Label } from '@/components/ui/label';\nimport { Item, ItemContent, ItemDescription, ItemGroup } from '@/components/ui/item';\nimport { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem } from '@/components/ui/dropdown-menu';\nimport { MarkdownEditor } from '../markdown-editor/markdown-editor';\n\nexport interface HighlightAction { readonly id: string; readonly label: string; readonly run: (id: string) => void }\nexport interface Highlight { readonly id: string; readonly anchor: MarkdownQuoteAnchor; readonly colour: MarkColour; readonly note?: string; readonly actions?: readonly HighlightAction[] }\nexport interface HighlightsProps {\n readonly controller: MarkdownController;\n readonly resource?: MarkResource;\n readonly locator?: ResourceLocator;\n readonly store?: MarkStore;\n readonly marks?: readonly Highlight[];\n readonly locale?: 'en' | 'fr';\n}\nconst labels = {\n en: { title: 'Highlights', add: 'Highlight selection', note: 'Comment', save: 'Save comment', remove: 'Remove highlight', actions: 'Highlight actions', tray: (count: number) => `${count} ${count === 1 ? 'highlight could' : 'highlights could'} not be placed`, missing: 'This passage is no longer in the document', ambiguous: 'This passage appears several times — pick the right one', candidate: 'Choose passage', attach: 'Attach to selection', hint: 'Select a passage in the document first.', refused: \"This selection can't be highlighted here. Try selecting it in Markdown source.\", conflict: 'Highlights changed elsewhere. Reload before editing.', unknown: \"We couldn't confirm the save. Check before making another change.\", check: 'Check save outcome', reload: 'Reload highlights', failed: \"Your highlights couldn't be saved.\", unavailable: \"Highlights aren't available.\", source: 'View marked text', confirmed: 'Your choice is kept for this version.', commented: 'Has a comment' },\n fr: { title: 'Surlignages', add: 'Surligner la sélection', note: 'Commentaire', save: 'Enregistrer le commentaire', remove: 'Supprimer le surlignage', actions: 'Actions du surlignage', tray: (count: number) => count === 1 ? \"1 surlignage n'a pas pu être placé\" : `${count} surlignages n'ont pas pu être placés`, missing: \"Ce passage n'est plus dans le document.\", ambiguous: 'Ce passage apparaît plusieurs fois — choisissez le bon.', candidate: 'Choisir ce passage', attach: 'Rattacher à la sélection', hint: \"Sélectionnez d'abord un passage dans le document.\", refused: 'Ce passage ne peut pas être surligné ici. Essayez de le sélectionner dans la source Markdown.', conflict: 'Les surlignages ont changé ailleurs. Rechargez avant de modifier.', unknown: \"L'enregistrement n'a pas été confirmé. Vérifiez avant de continuer.\", check: 'Vérifier le résultat', reload: 'Recharger les surlignages', failed: \"Les surlignages n'ont pas pu être enregistrés.\", unavailable: 'Les surlignages ne sont pas disponibles.', source: 'Voir le texte surligné', confirmed: 'Votre choix est conservé pour cette version.', commented: 'Avec un commentaire' },\n};\nconst empty = { marks: [], save: { kind: 'idle' }, dirty: false, lifecycle: 'active' } as const;\nconst idleSubscribe = () => () => {};\ntype Choice = { readonly source: string; readonly range: MarkdownQuoteRange };\n\n/** Borrows supplied controllers/stores. Only the default sidecar store is owned here. */\nexport function Highlights({ controller, resource, locator, store: supplied, marks: suppliedMarks, locale = 'en' }: HighlightsProps) {\n const l = labels[locale];\n const [owned, setOwned] = useState<{ store: MarkStore; client: MarkResource['client']; key: string } | null>(null);\n const [error, setError] = useState<string | null>(null);\n const [active, setActive] = useState<string | null>(null);\n const [note, setNote] = useState('');\n const [capture, setCapture] = useState<{ controller: MarkdownController; text: string; value: MarkdownQuoteCapture } | null>(null);\n const [choices, setChoices] = useState<Readonly<Record<string, Choice>>>({});\n const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);\n const path = locator?.resource.path, providerId = locator?.resource.providerId, view = JSON.stringify(locator?.view);\n const client = resource?.client, scope = resource?.identity.scopeId, principal = resource?.identity.principalId, initiator = resource?.identity.initiatorId;\n const ownerKey = JSON.stringify([scope, principal, initiator, path, providerId, view]);\n useEffect(() => {\n if (supplied || !resource || !locator) { setOwned(null); return; }\n let live = true, acquired: MarkStore | undefined;\n setOwned(null); setError(null);\n void createMarkStore(resource, locator).then(store => { acquired = store; if (live) setOwned({ store, client: resource.client, key: ownerKey }); else store.dispose(); }, () => { if (live) setError(l.unavailable); });\n return () => { live = false; acquired?.dispose(); };\n }, [supplied, client, scope, principal, initiator, path, providerId, view]);\n const store = supplied ?? (owned?.client === client && owned?.key === ownerKey ? owned.store : null);\n const binding = useRef({ controller, store }); binding.current = { controller, store };\n const live = useRef(false);\n useEffect(() => { live.current = true; return () => { live.current = false; }; }, []);\n const snapshot = useSyncExternalStore(store?.subscribe ?? idleSubscribe, store?.getSnapshot ?? (() => empty), store?.getSnapshot ?? (() => empty));\n const marks: readonly Highlight[] = suppliedMarks ?? snapshot.marks;\n const result = snapshot.save.kind === 'settled' ? snapshot.save.result : null;\n const busy = snapshot.lifecycle !== 'active' || snapshot.save.kind === 'pending' || !!result && result.kind !== 'saved';\n const currentBinding = () => live.current && binding.current.controller === controller && binding.current.store === store && controller.getSnapshot().lifecycle === 'active';\n const currentText = () => currentBinding() && controller.getSnapshot().text === state.text;\n const selection = capture?.controller === controller && capture.text === state.text && capture.value.kind === 'captured' ? capture.value.anchor : null;\n const selectQuote = useCallback((value: MarkdownQuoteCapture) => { setCapture({ controller, text: controller.getSnapshot().text, value }); }, [controller]);\n const placed = useMemo(() => marks.map(mark => ({ mark, placement: markdownQuoteResolution.resolve(mark.anchor, state.text, String(state.bufferVersion)) })), [marks, state.text, state.bufferVersion]);\n const decorations = useMemo(() => marks.map(mark => ({ ...mark, active: mark.id === active, commented: !!mark.note?.trim(), commentLabel: l.commented, ...(choices[mark.id]?.source === state.text ? { confirmed: choices[mark.id] } : {}) })), [marks, active, l.commented, choices, state.text]);\n const current = marks.find(m => m.id === active);\n const activate = useCallback((id: string) => { setActive(id); setNote(store?.getSnapshot().marks.find(m => m.id === id)?.note ?? ''); }, [store]);\n useEffect(() => { setActive(null); setCapture(null); setChoices({}); setError(null); }, [controller, store]);\n const run = async (operation: () => Promise<unknown>) => {\n if (!currentBinding()) return;\n setError(null);\n try {\n const value = await operation();\n if (currentBinding() && value && typeof value === 'object' && 'kind' in value && !['saved', 'available', 'missing'].includes(String(value.kind))) setError(value.kind === 'conflict' ? l.conflict : value.kind === 'unknown' ? l.unknown : l.failed);\n } catch { if (currentBinding()) setError(l.failed); }\n };\n const add = () => {\n if (!currentText() || !selection || !store || busy) return;\n void run(() => store.add({ id: randomUUID(), anchor: selection, colour: 'accent', note: '', author: resource?.identity.principalId ?? state.identity.principalId, time: new Date().toISOString() }));\n };\n const choose = (mark: Highlight, range: MarkdownQuoteRange) => {\n if (!currentText() || busy) return;\n const fresh = markdownQuoteResolution.resolve(mark.anchor, controller.getSnapshot().text, String(state.bufferVersion));\n if (fresh.kind !== 'ambiguous' || !fresh.candidates.some(r => r.start === range.start && r.end === range.end)) return;\n setChoices(previous => ({ ...previous, [mark.id]: { source: state.text, range } })); activate(mark.id);\n const captured = captureMarkdownQuote(state.text, range);\n if (store && captured.kind === 'captured') void run(() => store.edit(mark.id, { anchor: captured.anchor }));\n };\n const lost = placed.filter(p => (p.placement.kind === 'missing' || p.placement.kind === 'ambiguous') && choices[p.mark.id]?.source !== state.text);\n const noteId = useId();\n const hintId = `${noteId}-selection-hint`;\n const hint = capture?.controller === controller && capture.text === state.text && capture.value.kind === 'refused' && capture.value.reason.includes('no exact source projection') ? l.refused : l.hint;\n return <section data-boring=\"highlights\" className=\"flex min-w-0 flex-col gap-4\" lang={locale}>\n <header className=\"flex flex-col gap-2\">\n <div className=\"flex flex-wrap items-center justify-between gap-2\"><h2>{l.title}</h2>\n {store && <Button variant=\"secondary\" size=\"sm\" disabled={busy || !selection} aria-describedby={!selection ? hintId : undefined} onMouseDown={event => event.preventDefault()} onClick={add}>{l.add}</Button>}\n </div>\n {store && !selection && <ItemDescription id={hintId}>{hint}</ItemDescription>}\n </header>\n {error && <p role=\"alert\">{error}</p>}\n {result && result.kind !== 'saved' && <div role=\"alert\"><p>{result.kind === 'unknown' ? l.unknown : result.kind === 'conflict' ? l.conflict : l.failed}</p>\n {result.kind === 'unknown' ? <Button variant=\"outline\" size=\"sm\" onClick={() => void run(() => store!.reconcile())}>{l.check}</Button> : <Button variant=\"outline\" size=\"sm\" onClick={() => void run(async () => { await store!.reload(); if (currentBinding()) { setActive(null); setChoices({}); } })}>{l.reload}</Button>}\n </div>}\n <MarkdownEditor controller={controller} highlights={decorations} onQuoteSelection={selectQuote} onHighlightActivate={activate} autosave={false} />\n <details><summary>{l.source}</summary><AnnotatedSource source={state.text} placements={placed} choices={choices} active={active} commentLabel={l.commented} activate={activate} /></details>\n {current && <div className=\"flex flex-col items-start gap-2\" data-testid=\"highlight-context\">\n {(store || current.actions?.length) && <DropdownMenu><DropdownMenuTrigger render={<Button variant=\"outline\" size=\"sm\" />}>{l.actions}</DropdownMenuTrigger>\n <DropdownMenuContent><DropdownMenuGroup>\n {current.actions?.map(action => <DropdownMenuItem key={action.id} onClick={() => { if (currentText()) action.run(current.id); }}>{action.label}</DropdownMenuItem>)}\n {store && <DropdownMenuItem disabled={busy} onClick={() => { if (currentText()) void run(() => store.remove(current.id)); }}>{l.remove}</DropdownMenuItem>}\n </DropdownMenuGroup></DropdownMenuContent>\n </DropdownMenu>}\n {choices[current.id]?.source === state.text && <p role=\"status\">{l.confirmed}</p>}\n {store && <><Label htmlFor={noteId}>{l.note}</Label><Textarea id={noteId} value={note} disabled={busy} onChange={event => setNote(event.target.value)} />\n <Button variant=\"secondary\" size=\"sm\" disabled={busy} onClick={() => { if (currentText()) void run(() => store.edit(current.id, { note })); }}>{l.save}</Button></>}\n </div>}\n {lost.length > 0 && <aside aria-label={l.tray(lost.length)} className=\"flex flex-col gap-3\"><h3>{l.tray(lost.length)}</h3>\n <ItemGroup>{lost.map(({ mark, placement }, lostIndex) => <Item key={mark.id} role=\"listitem\" variant=\"outline\" size=\"sm\" data-testid=\"lost-mark\">\n <ItemContent className=\"min-w-0 gap-3\">\n <div className=\"flex flex-col gap-1\"><p><q>{mark.anchor.exact}</q></p><ItemDescription>{placement.kind === 'missing' ? l.missing : l.ambiguous}</ItemDescription></div>\n {placement.kind === 'ambiguous' && <div className=\"grid gap-3 sm:grid-cols-2\">{placement.candidates.map((range, index) => <div key={range.start} className=\"flex min-w-0 flex-col items-start gap-2\" data-testid=\"highlight-candidate\">\n <CandidateContext source={state.text} range={range} />\n <Button variant=\"outline\" size=\"sm\" disabled={busy} onClick={() => choose(mark, range)}>{l.candidate} {index + 1}</Button>\n </div>)}</div>}\n {store && <div className=\"flex flex-col items-start gap-2\">\n <Button variant=\"outline\" size=\"sm\" disabled={!selection} aria-describedby={!selection ? `${hintId}-${lostIndex}` : undefined} onClick={() => { if (currentText() && selection) void run(() => store.edit(mark.id, { anchor: selection })); }}>{l.attach}</Button>\n {!selection && <ItemDescription id={`${hintId}-${lostIndex}`}>{hint}</ItemDescription>}\n </div>}\n </ItemContent>\n </Item>)}</ItemGroup>\n </aside>}\n </section>;\n}\n\nfunction CandidateContext({ source, range }: { readonly source: string; readonly range: MarkdownQuoteRange }) {\n const before = source.slice(0, range.start), lineStart = before.lastIndexOf('\\n') + 1;\n const previousWords = [...before.slice(lineStart).matchAll(/\\S+/gu)];\n const from = lineStart + (previousWords.slice(-5)[0]?.index ?? before.length - lineStart);\n const lineEnd = source.indexOf('\\n', range.end);\n const after = source.slice(range.end, lineEnd === -1 ? source.length : lineEnd);\n const nextWords = [...after.matchAll(/\\S+/gu)].slice(0, 5), last = nextWords.at(-1);\n const to = range.end + (last ? last.index + last[0].length : 0);\n return <p className=\"break-words\" data-testid=\"candidate-context\">\n {source.slice(0, from).trim() ? '… ' : ''}{source.slice(from, range.start).replace(/\\s+/gu, ' ')}\n <mark style={{ backgroundColor: markdownMarkerBackground('primary'), color: 'var(--foreground)' }}>{source.slice(range.start, range.end)}</mark>\n {source.slice(range.end, to).replace(/\\s+/gu, ' ')}{source.slice(to).trim() ? ' …' : ''}\n </p>;\n}\n\nfunction CommentIndicator({ id, label, activate }: { readonly id: string; readonly label: string; readonly activate: (id: string) => void }) {\n return <span data-mark-comment={id} role=\"img\" aria-label={label} className=\"inline-flex align-super\" style={{ color: 'var(--muted-foreground)', marginInlineStart: '0.15em' }} onClick={() => activate(id)}>\n <svg viewBox=\"0 0 16 16\" className=\"size-3\" aria-hidden=\"true\"><path d=\"M3 2.5h10v8H7l-4 3v-11Z\" fill=\"none\" stroke=\"currentColor\" /></svg>\n </span>;\n}\n\nfunction AnnotatedSource({ source, placements, choices, active, commentLabel, activate }: { readonly source: string; readonly placements: readonly { mark: Highlight; placement: ReturnType<typeof markdownQuoteResolution.resolve> }[]; readonly choices: Readonly<Record<string, Choice>>; readonly active: string | null; readonly commentLabel: string; readonly activate: (id: string) => void }) {\n const ranges = placements.flatMap(({ mark, placement }) => {\n if (!['accent', 'primary', 'muted'].includes(mark.colour)) return [];\n const choice = choices[mark.id];\n const range = choice?.source === source && placement.kind === 'ambiguous' && placement.candidates.some(r => r.start === choice.range.start && r.end === choice.range.end) ? choice.range : placement.kind === 'exact' || placement.kind === 'moved' ? placement.range : null;\n return range ? [{ mark, range }] : [];\n });\n const cuts = [...new Set([0, source.length, ...ranges.flatMap(r => [r.range.start, r.range.end])])].sort((a, b) => a - b);\n return <pre className=\"whitespace-pre-wrap break-words\">{cuts.slice(0, -1).map((start, index) => {\n const end = cuts[index + 1]!, covering = ranges.filter(r => r.range.start <= start && r.range.end >= end);\n return covering.length ? <Fragment key={start}><mark role=\"button\" tabIndex={0} aria-label={covering.map(r => r.mark.anchor.fallback).join('; ')} style={{ backgroundColor: markdownMarkerBackground(covering[0]!.mark.colour), color: 'var(--foreground)', ...(covering.some(r => r.mark.id === active) ? { outline: 'var(--border-width, 1px) solid var(--ring)' } : {}) }}\n onClick={() => activate(covering[0]!.mark.id)} onKeyDown={event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); activate(covering[0]!.mark.id); } }}>{source.slice(start, end)}</mark>\n {covering.filter(r => r.range.end === end && r.mark.note?.trim()).map(r => <CommentIndicator key={r.mark.id} id={r.mark.id} label={commentLabel} activate={activate} />)}\n </Fragment> : source.slice(start, end);\n })}</pre>;\n}\n",
|
|
24
|
+
"type": "registry:component",
|
|
25
|
+
"target": "components/highlights/highlights.tsx"
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"type": "registry:block"
|
|
29
|
+
}
|
package/r/html-viewer.json
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
"title": "HTML source and passive preview",
|
|
5
5
|
"description": "Exact HTML source editing with a reconstructed passive preview and borrowed controller. Browser security qualification remains separate.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.4",
|
|
8
|
+
"@hachej/boring-files@^0.4.4",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0"
|
|
11
11
|
],
|
|
12
12
|
"registryDependencies": [
|
|
13
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
13
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.4/r/button.json"
|
|
14
14
|
],
|
|
15
15
|
"files": [
|
|
16
16
|
{
|
package/r/maquette-viewer.json
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
3
|
"name": "maquette-viewer",
|
|
4
4
|
"title": "Maquette viewer",
|
|
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), and a menu with guided tours and How it works (the workflow diagram in a dialog). Optional phone or desktop frame and #screen=<id> deep links. The pi-app artifact panel shows .maquette.json files with it by default; app artifactKinds win.",
|
|
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. The pi-app artifact panel shows .maquette.json files with it by default; app artifactKinds win.",
|
|
6
6
|
"dependencies": [
|
|
7
7
|
"react@^19.0.0",
|
|
8
8
|
"lucide-react@^1.51.0"
|
|
9
9
|
],
|
|
10
10
|
"registryDependencies": [
|
|
11
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
11
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.4/r/theme.json",
|
|
12
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.4/r/diagram-viewer.json",
|
|
12
13
|
"alert",
|
|
13
14
|
"badge",
|
|
14
15
|
"bubble",
|
|
@@ -23,36 +24,32 @@
|
|
|
23
24
|
"table",
|
|
24
25
|
"dialog",
|
|
25
26
|
"dropdown-menu",
|
|
26
|
-
"popover"
|
|
27
|
+
"popover",
|
|
28
|
+
"toggle",
|
|
29
|
+
"tooltip"
|
|
27
30
|
],
|
|
28
31
|
"files": [
|
|
29
32
|
{
|
|
30
33
|
"path": "registry/maquette-viewer/maquette.ts",
|
|
31
|
-
"content": "/*\n * The maquette format: a lo-fi mock-up a PM agent writes as `<name>.maquette.json` (screens built from a small catalogue of blocks, goto\n * links between them, an optional workflow and its cases). Plain data drawn by `MaquetteViewer`: nothing in it is built or runs.\n * The format is documented in registry/README.md (\"Maquette viewer\"). Model output is untrusted: `parseMaquette` reads it defensively,\n * keeps what it understands, bounds every list and string, and never throws.\n */\n\n/** The format version this viewer writes and reads. A file without `version` is read as version 1. */\nexport const MAQUETTE_FORMAT_VERSION = 1;\n\n/** A block as written in format v1. `type` picks the drawing; the other fields are read by the types that use them. */\nexport interface MaquetteBlockV1 {\n readonly type: string;\n /** The block's words (`text` and `title` are accepted as aliases). */\n readonly label?: string;\n readonly text?: string;\n readonly title?: string;\n /** The screen id a click on this block opens (buttons, links, list items, placeholders). */\n readonly goto?: string;\n /** A primary button. */\n readonly primary?: boolean;\n /** Nested blocks of `card`, `row`, `stack` (`blocks` is accepted as an alias). */\n readonly children?: readonly MaquetteBlockV1[];\n readonly blocks?: readonly MaquetteBlockV1[];\n /** `list` rows, or `select` options. */\n readonly items?: readonly string[];\n readonly options?: readonly string[];\n /** `table` header and rows. */\n readonly columns?: readonly string[];\n readonly rows?: readonly (readonly string[])[];\n /** `chat` messages; `from: \"user\"` (or `person`, `me`, `moi`) is the person's side. */\n readonly messages?: readonly { readonly from?: string; readonly text?: string }[];\n /** `document` lines. */\n readonly lines?: readonly string[];\n /** `input`, `select` and `chat` hint and value. */\n readonly placeholder?: string;\n readonly value?: string;\n /** A placeholder's note under its name. */\n readonly note?: string;\n}\n\n/** A maquette file as written in format v1. */\nexport interface MaquetteFileV1 {\n /** Absent means 1. */\n readonly version?: 1;\n readonly title?: string;\n /** The screen id play mode opens first; the first screen otherwise. */\n readonly start?: string;\n /** The frame screens are drawn in: a light phone or desktop frame. Absent: the panel's width. */\n readonly device?: 'phone' | 'desktop';\n /** Screens by id, in the order the stepper walks them. `note` says why the screen exists and what the person does there. */\n readonly screens: Readonly<Record<string, { readonly title?: string; readonly note?: string; readonly blocks: readonly MaquetteBlockV1[] }>>;\n /** The app's navigation bar, shown above every screen. */\n readonly nav?: readonly { readonly label: string; readonly goto: string }[];\n /** The workflow understood: states and the transitions between them (`on` is accepted as an alias of `label`). */\n readonly workflow?: {\n readonly states: readonly ({ readonly id: string; readonly label?: string } | string)[];\n readonly transitions: readonly { readonly from: string; readonly to: string; readonly label?: string; readonly on?: string; readonly by?: string }[];\n };\n /**\n * Cases. `steps` makes a guided tour: its screens in order, each with a caption (\"Camille receives the invoice and approves it\").\n * `path` (workflow state ids) highlights the case in the workflow diagram. A case may carry both.\n */\n readonly cases?: readonly { readonly name: string; readonly steps?: readonly { readonly screen: string; readonly caption?: string }[]; readonly path?: readonly string[] }[];\n}\n\n/** A block as the viewer draws it: every field read, bounded and defaulted. */\nexport interface MaquetteBlock {\n readonly type: string;\n readonly label: string;\n readonly goto: string | undefined;\n readonly primary: boolean;\n readonly children: readonly MaquetteBlock[];\n readonly items: readonly string[];\n readonly columns: readonly string[];\n readonly rows: readonly (readonly string[])[];\n readonly messages: readonly { readonly mine: boolean; readonly text: string }[];\n readonly lines: readonly string[];\n readonly placeholder: string;\n readonly value: string;\n readonly note: string;\n}\nexport interface MaquetteScreen { readonly id: string; readonly title: string; readonly note: string; readonly blocks: readonly MaquetteBlock[] }\nexport interface MaquetteState { readonly id: string; readonly label: string }\nexport interface MaquetteTransition { readonly from: string; readonly to: string; readonly label: string; readonly by: string }\nexport interface MaquetteWorkflow { readonly states: readonly MaquetteState[]; readonly transitions: readonly MaquetteTransition[] }\nexport interface MaquetteCase { readonly name: string; readonly steps: readonly { readonly screen: string; readonly caption: string }[]; readonly path: readonly string[] }\n/** A maquette ready to draw. */\nexport interface Maquette {\n readonly title: string;\n readonly start: string;\n readonly device: 'phone' | 'desktop' | undefined;\n readonly screens: readonly MaquetteScreen[];\n readonly nav: readonly { readonly label: string; readonly goto: string }[];\n readonly workflow: MaquetteWorkflow | null;\n readonly cases: readonly MaquetteCase[];\n}\n\n/**\n * The outcome of reading a file. `version` is the file's declared version (1 when absent); a version above `MAQUETTE_FORMAT_VERSION`\n * is read as v1, best effort. `invalid` is not JSON (`json`) or has no screen to show (`shape`).\n */\nexport type MaquetteParse =\n | { readonly kind: 'ok'; readonly maquette: Maquette; readonly version: number }\n | { readonly kind: 'invalid'; readonly reason: 'json' | 'shape'; readonly version: number | undefined };\n\nconst MINE = new Set(['user', 'person', 'me', 'moi']);\nconst str = (value: unknown, max = 400): string => typeof value === 'string' ? value.slice(0, max) : typeof value === 'number' && Number.isFinite(value) ? String(value) : '';\nconst arr = (value: unknown, max = 60): readonly unknown[] => Array.isArray(value) ? value.slice(0, max) : [];\nconst obj = (value: unknown): Record<string, unknown> => value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};\nconst strings = (value: unknown, max: number, length = 120): string[] => arr(value, max).map(item => str(item, length));\n\nfunction block(value: unknown, depth: number): MaquetteBlock | null {\n const raw = obj(value);\n const type = str(raw['type'], 40).trim().toLowerCase();\n if (!type || depth > 6) return null;\n const goto = str(raw['goto'], 60);\n return {\n type, label: str(raw['label'] ?? raw['text'] ?? raw['title']), goto: goto || undefined, primary: raw['primary'] === true,\n children: arr(raw['children'] ?? raw['blocks'], 40).map(child => block(child, depth + 1)).filter((child): child is MaquetteBlock => child !== null),\n items: strings(raw['items'] ?? raw['options'], 12),\n columns: strings(raw['columns'], 8, 40),\n rows: arr(raw['rows'], 12).map(row => strings(row, 8, 80)),\n messages: arr(raw['messages'], 10).map(item => { const message = obj(item); return { mine: MINE.has(str(message['from'], 20).toLowerCase()), text: str(message['text'], 600) }; }).filter(message => message.text),\n lines: strings(raw['lines'], 16, 300),\n placeholder: str(raw['placeholder'], 120), value: str(raw['value'], 120), note: str(raw['note'], 200),\n };\n}\n\n/** Reads a maquette (its JSON text or an already parsed value). Never throws. */\nexport function parseMaquette(input: unknown): MaquetteParse {\n let raw: unknown = input;\n if (typeof raw === 'string') {\n try { raw = JSON.parse(raw); } catch { return { kind: 'invalid', reason: 'json', version: undefined }; }\n }\n const spec = obj(raw);\n const declared = spec['version'];\n const version = declared === undefined ? 1 : typeof declared === 'number' && Number.isSafeInteger(declared) && declared >= 1 ? declared : undefined;\n if (version === undefined) return { kind: 'invalid', reason: 'shape', version: undefined };\n const screens = Object.entries(obj(spec['screens'])).slice(0, 30).map(([id, value]): MaquetteScreen => {\n const screen = obj(value);\n return { id: id.slice(0, 60), title: str(screen['title'], 120) || id.slice(0, 60), note: str(screen['note'], 300),\n blocks: arr(screen['blocks'] ?? screen['children']).map(item => block(item, 0)).filter((item): item is MaquetteBlock => item !== null) };\n });\n if (!screens.length) return { kind: 'invalid', reason: 'shape', version };\n const ids = new Set(screens.map(screen => screen.id));\n const flow = obj(spec['workflow']);\n const states = arr(flow['states'], 40).map((value): MaquetteState | null => {\n const state = obj(value), id = str(state['id'], 60) || str(value, 60);\n return id ? { id, label: str(state['label'], 80) || id } : null;\n }).filter((state): state is MaquetteState => state !== null);\n const known = new Set(states.map(state => state.id));\n const transitions = arr(flow['transitions'], 80).map((value): MaquetteTransition => {\n const transition = obj(value);\n return { from: str(transition['from'], 60), to: str(transition['to'], 60), label: str(transition['label'] ?? transition['on'], 80), by: str(transition['by'], 40) };\n }).filter(transition => known.has(transition.from) && known.has(transition.to));\n const start = str(spec['start'], 60), device = spec['device'];\n return { kind: 'ok', version, maquette: {\n title: str(spec['title'], 120), start: ids.has(start) ? start : screens[0]!.id, device: device === 'phone' || device === 'desktop' ? device : undefined, screens,\n nav: arr(spec['nav'], 8).map(value => { const item = obj(value); return { label: str(item['label'], 40), goto: str(item['goto'], 60) }; }).filter(item => item.label && ids.has(item.goto)),\n workflow: states.length ? { states, transitions } : null,\n cases: arr(spec['cases'], 10).map((value): MaquetteCase => {\n const item = obj(value);\n const steps = arr(item['steps'], 20).map(step => { const raw = obj(step); return { screen: str(raw['screen'], 60), caption: str(raw['caption'], 300) }; }).filter(step => ids.has(step.screen));\n return { name: str(item['name'], 80), steps, path: strings(item['path'], 20, 60).filter(id => known.has(id)) };\n }).filter(item => item.name && (item.steps.length > 0 || item.path.length > 0)),\n } };\n}\n\n/** Every screen id a block tree links to, in order, without repeats. */\nexport function linksOf(blocks: readonly MaquetteBlock[]): string[] {\n const found = new Set<string>();\n const walk = (list: readonly MaquetteBlock[]) => { for (const item of list) { if (item.goto) found.add(item.goto); walk(item.children); } };\n walk(blocks);\n return [...found];\n}\n\n/** True for a path the built-in maquette kind draws: a `.maquette.json` file. */\nexport const isMaquettePath = (path: string): boolean => /\\.maquette\\.json$/i.test(path);\n\n/** The screen id a URL fragment names (`#screen=<id>`, alone or among other `&`-separated keys), or undefined. */\nexport function screenFromFragment(fragment: string | undefined): string | undefined {\n if (!fragment) return undefined;\n try { return new URLSearchParams(fragment.replace(/^#/, '')).get('screen') ?? undefined; } catch { return undefined; }\n}\n",
|
|
34
|
+
"content": "/*\n * The maquette format: a lo-fi mock-up a PM agent writes as `<name>.maquette.json` (screens built from a small catalogue of blocks, goto\n * links between them, an optional workflow and its cases). Plain data drawn by `MaquetteViewer`: nothing in it is built or runs.\n * The format is documented in registry/README.md (\"Maquette viewer\"). Model output is untrusted: `parseMaquette` reads it defensively,\n * keeps what it understands, bounds every list and string, and never throws. The workflow is a diagram (`parseDiagram` of the\n * diagram-viewer item reads it), written inline or linked as a `*.diagram.json` path.\n */\nimport { parseDiagram } from '../diagram-viewer/diagram';\nimport type { Diagram, DiagramEdge, DiagramNode } from '../diagram-viewer/diagram';\n\n/** The format version this viewer writes and reads. A file without `version` is read as version 1. */\nexport const MAQUETTE_FORMAT_VERSION = 1;\n\n/** A block as written in format v1. `type` picks the drawing; the other fields are read by the types that use them. */\nexport interface MaquetteBlockV1 {\n readonly type: string;\n /** A stable name for the block, used when someone comments on it; its position (`2`, `2.0` for a child) otherwise. */\n readonly id?: string;\n /** The block's words (`text` and `title` are accepted as aliases). */\n readonly label?: string;\n readonly text?: string;\n readonly title?: string;\n /** The screen id a click on this block opens (buttons, links, list items, placeholders). */\n readonly goto?: string;\n /** A primary button. */\n readonly primary?: boolean;\n /** Nested blocks of `card`, `row`, `stack` (`blocks` is accepted as an alias). */\n readonly children?: readonly MaquetteBlockV1[];\n readonly blocks?: readonly MaquetteBlockV1[];\n /** `list` rows, or `select` options. */\n readonly items?: readonly string[];\n readonly options?: readonly string[];\n /** `table` header and rows. */\n readonly columns?: readonly string[];\n readonly rows?: readonly (readonly string[])[];\n /** `chat` messages; `from: \"user\"` (or `person`, `me`, `moi`) is the person's side. */\n readonly messages?: readonly { readonly from?: string; readonly text?: string }[];\n /** `document` lines. */\n readonly lines?: readonly string[];\n /** `input`, `select` and `chat` hint and value. */\n readonly placeholder?: string;\n readonly value?: string;\n /** A placeholder's note under its name. */\n readonly note?: string;\n}\n\n/** A maquette file as written in format v1. */\nexport interface MaquetteFileV1 {\n /** Absent means 1. */\n readonly version?: 1;\n readonly title?: string;\n /** The screen id play mode opens first; the first screen otherwise. */\n readonly start?: string;\n /** The frame screens are drawn in: a light phone or desktop frame. Absent: the panel's width. */\n readonly device?: 'phone' | 'desktop';\n /** Screens by id, in the order the stepper walks them. `note` says why the screen exists and what the person does there. */\n readonly screens: Readonly<Record<string, { readonly title?: string; readonly note?: string; readonly blocks: readonly MaquetteBlockV1[] }>>;\n /** The app's navigation bar, shown above every screen. */\n readonly nav?: readonly { readonly label: string; readonly goto: string }[];\n /**\n * The workflow understood: states and the transitions between them (`on` is accepted as an alias of `label`). It is a diagram (format v1\n * of `*.diagram.json`: `nodes`/`edges` are the same as `states`/`transitions`, plus optional `groups`, `path` and `note`), or the path of a\n * `*.diagram.json` file, relative to the maquette's folder.\n */\n readonly workflow?: string | {\n readonly states?: readonly ({ readonly id: string; readonly label?: string; readonly group?: string } | string)[];\n readonly transitions?: readonly { readonly from: string; readonly to: string; readonly label?: string; readonly on?: string; readonly by?: string }[];\n readonly nodes?: readonly ({ readonly id: string; readonly label?: string; readonly group?: string } | string)[];\n readonly edges?: readonly { readonly from: string; readonly to: string; readonly label?: string; readonly on?: string; readonly by?: string }[];\n readonly groups?: readonly ({ readonly id: string; readonly label?: string } | string)[];\n readonly path?: readonly string[];\n readonly note?: string;\n };\n /**\n * Cases. `steps` makes a guided tour: its screens in order, each with a caption (\"Camille receives the invoice and approves it\").\n * `path` (workflow state ids) highlights the case in the workflow diagram. A case may carry both.\n */\n readonly cases?: readonly { readonly name: string; readonly steps?: readonly { readonly screen: string; readonly caption?: string }[]; readonly path?: readonly string[] }[];\n}\n\n/** A block as the viewer draws it: every field read, bounded and defaulted. */\nexport interface MaquetteBlock {\n readonly type: string;\n /** The block's `id`, or its position among the screen's blocks (`2`, `2.0` for the first child of the third block). */\n readonly id: string;\n readonly label: string;\n readonly goto: string | undefined;\n readonly primary: boolean;\n readonly children: readonly MaquetteBlock[];\n readonly items: readonly string[];\n readonly columns: readonly string[];\n readonly rows: readonly (readonly string[])[];\n readonly messages: readonly { readonly mine: boolean; readonly text: string }[];\n readonly lines: readonly string[];\n readonly placeholder: string;\n readonly value: string;\n readonly note: string;\n}\nexport interface MaquetteScreen { readonly id: string; readonly title: string; readonly note: string; readonly blocks: readonly MaquetteBlock[] }\nexport type MaquetteState = DiagramNode;\nexport type MaquetteTransition = DiagramEdge;\nexport interface MaquetteWorkflow { readonly states: readonly MaquetteState[]; readonly transitions: readonly MaquetteTransition[] }\nexport interface MaquetteCase { readonly name: string; readonly steps: readonly { readonly screen: string; readonly caption: string }[]; readonly path: readonly string[] }\n/** A maquette ready to draw. */\nexport interface Maquette {\n readonly title: string;\n readonly start: string;\n readonly device: 'phone' | 'desktop' | undefined;\n readonly screens: readonly MaquetteScreen[];\n readonly nav: readonly { readonly label: string; readonly goto: string }[];\n readonly workflow: MaquetteWorkflow | null;\n /** The inline workflow as a diagram (with its groups, path and note), for `DiagramView`. */\n readonly diagram: Diagram | null;\n /** The `*.diagram.json` path the workflow links to, as written (relative to the maquette's folder). */\n readonly diagramLink: string | undefined;\n readonly cases: readonly MaquetteCase[];\n}\n\n/**\n * The outcome of reading a file. `version` is the file's declared version (1 when absent); a version above `MAQUETTE_FORMAT_VERSION`\n * is read as v1, best effort. `invalid` is not JSON (`json`) or has no screen to show (`shape`).\n */\nexport type MaquetteParse =\n | { readonly kind: 'ok'; readonly maquette: Maquette; readonly version: number }\n | { readonly kind: 'invalid'; readonly reason: 'json' | 'shape'; readonly version: number | undefined };\n\nconst MINE = new Set(['user', 'person', 'me', 'moi']);\nconst str = (value: unknown, max = 400): string => typeof value === 'string' ? value.slice(0, max) : typeof value === 'number' && Number.isFinite(value) ? String(value) : '';\nconst arr = (value: unknown, max = 60): readonly unknown[] => Array.isArray(value) ? value.slice(0, max) : [];\nconst obj = (value: unknown): Record<string, unknown> => value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};\nconst strings = (value: unknown, max: number, length = 120): string[] => arr(value, max).map(item => str(item, length));\n\nfunction block(value: unknown, depth: number, at: string): MaquetteBlock | null {\n const raw = obj(value);\n const type = str(raw['type'], 40).trim().toLowerCase();\n if (!type || depth > 6) return null;\n const goto = str(raw['goto'], 60);\n return {\n type, id: str(raw['id'], 60).trim() || at, label: str(raw['label'] ?? raw['text'] ?? raw['title']), goto: goto || undefined, primary: raw['primary'] === true,\n children: arr(raw['children'] ?? raw['blocks'], 40).map((child, index) => block(child, depth + 1, `${at}.${index}`)).filter((child): child is MaquetteBlock => child !== null),\n items: strings(raw['items'] ?? raw['options'], 12),\n columns: strings(raw['columns'], 8, 40),\n rows: arr(raw['rows'], 12).map(row => strings(row, 8, 80)),\n messages: arr(raw['messages'], 10).map(item => { const message = obj(item); return { mine: MINE.has(str(message['from'], 20).toLowerCase()), text: str(message['text'], 600) }; }).filter(message => message.text),\n lines: strings(raw['lines'], 16, 300),\n placeholder: str(raw['placeholder'], 120), value: str(raw['value'], 120), note: str(raw['note'], 200),\n };\n}\n\n/** Reads a maquette (its JSON text or an already parsed value). Never throws. */\nexport function parseMaquette(input: unknown): MaquetteParse {\n let raw: unknown = input;\n if (typeof raw === 'string') {\n try { raw = JSON.parse(raw); } catch { return { kind: 'invalid', reason: 'json', version: undefined }; }\n }\n const spec = obj(raw);\n const declared = spec['version'];\n const version = declared === undefined ? 1 : typeof declared === 'number' && Number.isSafeInteger(declared) && declared >= 1 ? declared : undefined;\n if (version === undefined) return { kind: 'invalid', reason: 'shape', version: undefined };\n const screens = Object.entries(obj(spec['screens'])).slice(0, 30).map(([id, value]): MaquetteScreen => {\n const screen = obj(value);\n return { id: id.slice(0, 60), title: str(screen['title'], 120) || id.slice(0, 60), note: str(screen['note'], 300),\n blocks: arr(screen['blocks'] ?? screen['children']).map((item, index) => block(item, 0, String(index))).filter((item): item is MaquetteBlock => item !== null) };\n });\n if (!screens.length) return { kind: 'invalid', reason: 'shape', version };\n const ids = new Set(screens.map(screen => screen.id));\n const link = typeof spec['workflow'] === 'string' ? str(spec['workflow'], 200).trim() : '';\n const flow = link ? undefined : parseDiagram(obj(spec['workflow']));\n const diagram = flow?.kind === 'ok' ? flow.diagram : null;\n // A linked diagram is read later: its node ids are not known yet, so a case's path is kept as written.\n const known = (id: string) => link ? true : !!diagram?.nodes.some(node => node.id === id);\n const start = str(spec['start'], 60), device = spec['device'];\n return { kind: 'ok', version, maquette: {\n title: str(spec['title'], 120), start: ids.has(start) ? start : screens[0]!.id, device: device === 'phone' || device === 'desktop' ? device : undefined, screens,\n nav: arr(spec['nav'], 8).map(value => { const item = obj(value); return { label: str(item['label'], 40), goto: str(item['goto'], 60) }; }).filter(item => item.label && ids.has(item.goto)),\n workflow: diagram ? { states: diagram.nodes, transitions: diagram.edges } : null, diagram, diagramLink: isDiagramLink(link) ? link : undefined,\n cases: arr(spec['cases'], 10).map((value): MaquetteCase => {\n const item = obj(value);\n const steps = arr(item['steps'], 20).map(step => { const raw = obj(step); return { screen: str(raw['screen'], 60), caption: str(raw['caption'], 300) }; }).filter(step => ids.has(step.screen));\n return { name: str(item['name'], 80), steps, path: strings(item['path'], 20, 60).filter(known) };\n }).filter(item => item.name && (item.steps.length > 0 || item.path.length > 0)),\n } };\n}\n\n/** Every screen id a block tree links to, in order, without repeats. */\nexport function linksOf(blocks: readonly MaquetteBlock[]): string[] {\n const found = new Set<string>();\n const walk = (list: readonly MaquetteBlock[]) => { for (const item of list) { if (item.goto) found.add(item.goto); walk(item.children); } };\n walk(blocks);\n return [...found];\n}\n\n/** A workflow link the viewer follows: a relative `*.diagram.json` path (no scheme, no leading slash, no `..`). */\nconst isDiagramLink = (link: string): boolean => /\\.diagram\\.json$/i.test(link) && !/^[a-z][a-z0-9+.-]*:|^\\/|(^|\\/)\\.\\.(\\/|$)/i.test(link);\n\n/** The path of a linked diagram: `link` resolved against the folder of the maquette at `path`. */\nexport const diagramPathOf = (path: string, link: string): string => `${path.includes('/') ? path.slice(0, path.lastIndexOf('/') + 1) : ''}${link.replace(/^\\.\\//, '')}`;\n\n/** True for a path the built-in maquette kind draws: a `.maquette.json` file. */\nexport const isMaquettePath = (path: string): boolean => /\\.maquette\\.json$/i.test(path);\n\n/** The screen id a URL fragment names (`#screen=<id>`, alone or among other `&`-separated keys), or undefined. */\nexport function screenFromFragment(fragment: string | undefined): string | undefined {\n if (!fragment) return undefined;\n try { return new URLSearchParams(fragment.replace(/^#/, '')).get('screen') ?? undefined; } catch { return undefined; }\n}\n",
|
|
32
35
|
"type": "registry:lib",
|
|
33
36
|
"target": "components/maquette-viewer/maquette.ts"
|
|
34
37
|
},
|
|
35
38
|
{
|
|
36
39
|
"path": "registry/maquette-viewer/maquette-labels.ts",
|
|
37
|
-
"content": "/* The maquette viewer's words in English and French. `locale` picks the base set; a host's `labels` still override any of them. */\n\nexport type MaquetteLocale = 'en' | 'fr';\n\nexport const defaultMaquetteLabels = {\n /** The kind's name on the artifact card and in the panel bar. */\n kind: 'Mock-up',\n screens: 'Screens',\n previous: 'Previous screen',\n next: 'Next screen',\n back: 'Back',\n more: 'More',\n appNavigation: 'App navigation',\n howItWorks: 'How it works',\n endTour: 'End tour',\n aboutScreen: 'About this screen',\n opens: (title: string) => `Opens “${title}”`,\n tourStep: (index: number, count: number) => `Step ${index} of ${count}`,\n transitionBy: (by: string) => `by ${by}`,\n unknownBlock: 'Not drawn in this mock-up',\n showing: (title: string) => `Showing ${title}`,\n newerTitle: 'Made with a newer version',\n newer: (version: number) => `This mock-up uses format version ${version}. This viewer reads version 1: parts it does not know may be missing.`,\n errorTitle: 'This mock-up cannot be shown',\n invalidJson: 'The file is not valid JSON. Ask the PM to present it again.',\n invalidShape: 'The file has no screen to show (a mock-up needs \"screens\": { \"<id>\": { \"title\", \"blocks\" } }).',\n};\nexport type MaquetteLabels = typeof defaultMaquetteLabels;\n\n/** Typed as `MaquetteLabels`, so a key added in English must be added here too. */\nexport const frenchMaquetteLabels: MaquetteLabels = {\n kind: 'Maquette',\n screens: 'Écrans',\n previous: 'Écran précédent',\n next: 'Écran suivant',\n back: 'Retour',\n more: 'Plus',\n appNavigation: 'Navigation de l’application',\n howItWorks: 'Comment ça marche',\n endTour: 'Terminer la visite',\n aboutScreen: 'À propos de cet écran',\n opens: title => `Ouvre « ${title} »`,\n tourStep: (index, count) => `Étape ${index} sur ${count}`,\n transitionBy: by => `par ${by}`,\n unknownBlock: 'Pas dessiné dans cette maquette',\n showing: title => `Écran affiché : ${title}`,\n newerTitle: 'Faite avec une version plus récente',\n newer: version => `Cette maquette utilise la version ${version} du format. Cet afficheur lit la version 1 : ce qu’il ne connaît pas peut manquer.`,\n errorTitle: 'Cette maquette ne peut pas être affichée',\n invalidJson: 'Le fichier n’est pas du JSON valide. Demandez au PM de la présenter à nouveau.',\n invalidShape: 'Le fichier n’a aucun écran à montrer (une maquette a besoin de « screens » : { \"<id>\": { \"title\", \"blocks\" } }).',\n};\n\n/** The viewer's words for `locale` (English by default) with the host's overrides. */\nexport const maquetteLabelsFor = (locale: MaquetteLocale | undefined, labels?: Partial<MaquetteLabels>): MaquetteLabels =>\n ({ ...(locale === 'fr' ? frenchMaquetteLabels : defaultMaquetteLabels), ...labels });\n",
|
|
40
|
+
"content": "/* The maquette viewer's words in English and French. `locale` picks the base set; a host's `labels` still override any of them. */\n\nexport type MaquetteLocale = 'en' | 'fr';\n\nexport const defaultMaquetteLabels = {\n /** The kind's name on the artifact card and in the panel bar. */\n kind: 'Mock-up',\n screens: 'Screens',\n previous: 'Previous screen',\n next: 'Next screen',\n back: 'Back',\n more: 'More',\n appNavigation: 'App navigation',\n howItWorks: 'How it works',\n comment: 'Comment on this screen',\n loading: 'Loading…',\n diagramUnavailable: 'The linked diagram cannot be read',\n endTour: 'End tour',\n aboutScreen: 'About this screen',\n opens: (title: string) => `Opens “${title}”`,\n tourStep: (index: number, count: number) => `Step ${index} of ${count}`,\n transitionBy: (by: string) => `by ${by}`,\n unknownBlock: 'Not drawn in this mock-up',\n showing: (title: string) => `Showing ${title}`,\n newerTitle: 'Made with a newer version',\n newer: (version: number) => `This mock-up uses format version ${version}. This viewer reads version 1: parts it does not know may be missing.`,\n errorTitle: 'This mock-up cannot be shown',\n invalidJson: 'The file is not valid JSON. Ask the PM to present it again.',\n invalidShape: 'The file has no screen to show (a mock-up needs \"screens\": { \"<id>\": { \"title\", \"blocks\" } }).',\n};\nexport type MaquetteLabels = typeof defaultMaquetteLabels;\n\n/** Typed as `MaquetteLabels`, so a key added in English must be added here too. */\nexport const frenchMaquetteLabels: MaquetteLabels = {\n kind: 'Maquette',\n screens: 'Écrans',\n previous: 'Écran précédent',\n next: 'Écran suivant',\n back: 'Retour',\n more: 'Plus',\n appNavigation: 'Navigation de l’application',\n howItWorks: 'Comment ça marche',\n comment: 'Commenter cet écran',\n loading: 'Chargement…',\n diagramUnavailable: 'Le diagramme lié ne peut pas être lu',\n endTour: 'Terminer la visite',\n aboutScreen: 'À propos de cet écran',\n opens: title => `Ouvre « ${title} »`,\n tourStep: (index, count) => `Étape ${index} sur ${count}`,\n transitionBy: by => `par ${by}`,\n unknownBlock: 'Pas dessiné dans cette maquette',\n showing: title => `Écran affiché : ${title}`,\n newerTitle: 'Faite avec une version plus récente',\n newer: version => `Cette maquette utilise la version ${version} du format. Cet afficheur lit la version 1 : ce qu’il ne connaît pas peut manquer.`,\n errorTitle: 'Cette maquette ne peut pas être affichée',\n invalidJson: 'Le fichier n’est pas du JSON valide. Demandez au PM de la présenter à nouveau.',\n invalidShape: 'Le fichier n’a aucun écran à montrer (une maquette a besoin de « screens » : { \"<id>\": { \"title\", \"blocks\" } }).',\n};\n\n/** The viewer's words for `locale` (English by default) with the host's overrides. */\nexport const maquetteLabelsFor = (locale: MaquetteLocale | undefined, labels?: Partial<MaquetteLabels>): MaquetteLabels =>\n ({ ...(locale === 'fr' ? frenchMaquetteLabels : defaultMaquetteLabels), ...labels });\n",
|
|
38
41
|
"type": "registry:lib",
|
|
39
42
|
"target": "components/maquette-viewer/maquette-labels.ts"
|
|
40
43
|
},
|
|
41
44
|
{
|
|
42
45
|
"path": "registry/maquette-viewer/maquette-blocks.tsx",
|
|
43
|
-
"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.\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
|
|
46
|
+
"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",
|
|
44
47
|
"type": "registry:component",
|
|
45
48
|
"target": "components/maquette-viewer/maquette-blocks.tsx"
|
|
46
49
|
},
|
|
47
|
-
{
|
|
48
|
-
"path": "registry/maquette-viewer/maquette-diagram.tsx",
|
|
49
|
-
"content": "'use client';\n\n// The workflow diagram: states in columns by distance from the first state, one arrow per transition with a numbered badge, and a legend\n// under it that says what happens and who acts (labels of any length). Columns run left to right while they fit the box; in a narrow box\n// (a phone) the steps run top to bottom instead. A diagram still wider than its box scrolls inside it, never the page.\nimport { useEffect, useId, useRef, useState } from 'react';\nimport { Badge } from '@/components/ui/badge';\nimport type { MaquetteWorkflow } from './maquette';\nimport type { MaquetteLabels } from './maquette-labels';\n\nconst H = 44, W_WIDE = 148, GX = 72, GY = 36, VX = 16, VY = 56, LOOP = 36;\n\ntype Point = { readonly x: number; readonly y: number };\n\n/** Each state's step: the shortest number of transitions from the first state; states it never reaches come after, one step each. */\nfunction steps(workflow: MaquetteWorkflow): Map<number, string[]> {\n const { states, transitions } = workflow;\n const depth = new Map<string, number>([[states[0]!.id, 0]]);\n for (let changed = true, guard = 0; changed && guard < 50; guard++) {\n changed = false;\n for (const t of transitions) if (depth.has(t.from) && !depth.has(t.to)) { depth.set(t.to, depth.get(t.from)! + 1); changed = true; }\n }\n let extra = Math.max(0, ...depth.values()) + 1;\n for (const s of states) if (!depth.has(s.id)) depth.set(s.id, extra++);\n const columns = new Map<number, string[]>();\n for (const s of states) { const d = depth.get(s.id)!; columns.set(d, [...(columns.get(d) ?? []), s.id]); }\n return columns;\n}\n\n/** The SVG path of a transition and where its number sits. */\nfunction arrow(a: Point, b: Point, W: number, vertical: boolean): { d: string; x: number; y: number } {\n if (!vertical) {\n // Next step: right edge to left edge. Same column, below: straight down. Otherwise (back, or skipping a step): a loop under both boxes.\n const kind = b.x > a.x && b.x - a.x <= W + GX ? 'forward' : b.x === a.x && b.y > a.y ? 'down' : 'back';\n const x1 = kind === 'forward' ? a.x + W : a.x + W / 2, y1 = kind === 'forward' ? a.y + H / 2 : a.y + H;\n const x2 = kind === 'forward' ? b.x : b.x + W / 2, y2 = kind === 'forward' ? b.y + H / 2 : kind === 'down' ? b.y : b.y + H;\n const d = kind === 'forward' ? `M${x1} ${y1} C${x1 + GX / 2} ${y1} ${x2 - GX / 2} ${y2} ${x2} ${y2}` : kind === 'down' ? `M${x1} ${y1} L${x2} ${y2}` : `M${x1} ${y1} C${x1} ${y1 + 30} ${x2} ${y2 + 30} ${x2} ${y2}`;\n return { d, x: kind === 'down' ? x1 + 12 : (x1 + x2) / 2, y: kind === 'back' ? Math.max(y1, y2) + 22 : (y1 + y2) / 2 };\n }\n // Next step: bottom edge to top edge. Same row: side to side. Otherwise (back, or skipping a step): a loop on the right.\n const kind = b.y > a.y && b.y - a.y <= H + VY ? 'forward' : b.y === a.y ? 'across' : 'back';\n const right = b.x > a.x;\n const x1 = kind === 'forward' ? a.x + W / 2 : kind === 'across' ? (right ? a.x + W : a.x) : a.x + W, y1 = kind === 'forward' ? a.y + H : a.y + H / 2;\n const x2 = kind === 'forward' ? b.x + W / 2 : kind === 'across' ? (right ? b.x : b.x + W) : b.x + W, y2 = kind === 'forward' ? b.y : b.y + H / 2;\n const out = Math.max(x1, x2) + LOOP - 8;\n const d = kind === 'forward' ? `M${x1} ${y1} C${x1} ${y1 + VY / 2} ${x2} ${y2 - VY / 2} ${x2} ${y2}` : kind === 'across' ? `M${x1} ${y1} L${x2} ${y2}` : `M${x1} ${y1} C${out} ${y1} ${out} ${y2} ${x2} ${y2}`;\n return { d, x: kind === 'back' ? out - 6 : (x1 + x2) / 2, y: (y1 + y2) / 2 };\n}\n\n/** The diagram and its legend. `highlight`: the states of the case on display, drawn stronger with the transitions between them. */\nexport function MaquetteDiagram({ workflow, highlight, labels }: { readonly workflow: MaquetteWorkflow; readonly highlight: readonly string[]; readonly labels: MaquetteLabels }) {\n const { states, transitions } = workflow;\n const box = useRef<HTMLDivElement>(null);\n const [room, setRoom] = useState(0);\n const marker = `maquette-arrow-${useId().replace(/:/g, '')}`;\n useEffect(() => {\n const element = box.current;\n if (!element) return undefined;\n const measure = () => setRoom(element.clientWidth);\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 const columns = steps(workflow);\n const count = Math.max(...columns.keys()) + 1, widest = Math.max(...[...columns.values()].map(ids => ids.length));\n const vertical = room > 0 && 16 + count * W_WIDE + (count - 1) * GX > room;\n const W = vertical ? Math.max(104, Math.min(W_WIDE, Math.floor((room - 16 - LOOP - (widest - 1) * VX) / widest))) : W_WIDE;\n const pos = new Map<string, Point>();\n for (const [d, ids] of columns) ids.forEach((id, i) => pos.set(id, vertical ? { x: 8 + i * (W + VX), y: 8 + d * (H + VY) } : { x: 8 + d * (W + GX), y: 8 + i * (H + GY) }));\n const points = [...pos.values()];\n const width = (vertical ? LOOP : 16) + Math.max(...points.map(p => p.x)) + W + 8, height = (vertical ? 16 : 40) + Math.max(...points.map(p => p.y)) + H;\n const on = new Set(highlight);\n const label = (id: string) => states.find(s => s.id === id)?.label ?? id;\n const fit = Math.floor((W - 16) / 7);\n return <div className=\"flex min-w-0 flex-col gap-4\">\n <div ref={box} data-testid=\"maquette-diagram\" data-layout={vertical ? 'vertical' : 'horizontal'} className=\"min-w-0 overflow-x-auto\">\n <svg role=\"img\" aria-label={states.map(s => s.label).join(' → ')} width={width} height={height} viewBox={`0 0 ${width} ${height}`} className=\"block\">\n <defs><marker id={marker} viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"6\" markerHeight=\"6\" orient=\"auto-start-reverse\"><path d=\"M0 0L10 5L0 10z\" className=\"fill-muted-foreground\" /></marker></defs>\n {transitions.map((t, i) => {\n const path = arrow(pos.get(t.from)!, pos.get(t.to)!, W, vertical);\n const lit = on.has(t.from) && on.has(t.to);\n return <g key={i} data-lit={lit || undefined}>\n <path d={path.d} fill=\"none\" className={lit ? 'stroke-primary' : 'stroke-muted-foreground/60'} strokeWidth={lit ? 2 : 1.25} markerEnd={`url(#${marker})`} />\n <circle cx={path.x} cy={path.y} r=\"9\" className={lit ? 'fill-primary stroke-primary' : 'fill-background stroke-border'} />\n <text x={path.x} y={path.y + 3.5} textAnchor=\"middle\" fontSize=\"10\" className={lit ? 'fill-primary-foreground' : 'fill-foreground'}>{i + 1}</text>\n </g>;\n })}\n {states.map(s => {\n const p = pos.get(s.id)!, lit = on.has(s.id);\n return <g key={s.id} data-state={s.id} data-lit={lit || undefined}>\n <rect x={p.x} y={p.y} width={W} height={H} rx=\"10\" className={lit ? 'fill-primary/10 stroke-primary' : 'fill-card stroke-border'} strokeWidth={lit ? 2 : 1} />\n <text x={p.x + W / 2} y={p.y + H / 2 + 4} textAnchor=\"middle\" fontSize=\"12\" className=\"fill-foreground\">{s.label.length > fit ? `${s.label.slice(0, fit - 1)}…` : s.label}<title>{s.label}</title></text>\n </g>;\n })}\n </svg>\n </div>\n {transitions.length > 0 && <ol data-testid=\"maquette-legend\" className=\"m-0 list-none p-0 grid gap-x-6 gap-y-2 text-sm sm:grid-cols-2\">\n {transitions.map((t, i) => <li key={i} className=\"flex min-w-0 items-start gap-2\">\n <Badge variant={on.has(t.from) && on.has(t.to) ? 'default' : 'outline'} className=\"tabular-nums\">{i + 1}</Badge>\n <span className=\"min-w-0 text-pretty\"><span className=\"font-medium\">{label(t.from)} → {label(t.to)}</span>{t.label && <>: {t.label}</>}\n {t.by && <span className=\"text-muted-foreground\"> ({labels.transitionBy(t.by)})</span>}</span>\n </li>)}\n </ol>}\n </div>;\n}\n",
|
|
50
|
-
"type": "registry:component",
|
|
51
|
-
"target": "components/maquette-viewer/maquette-diagram.tsx"
|
|
52
|
-
},
|
|
53
50
|
{
|
|
54
51
|
"path": "registry/maquette-viewer/maquette-viewer.tsx",
|
|
55
|
-
"content": "'use client';\n\n// The maquette viewer: a PM agent's `*.maquette.json` mock-up, one screen at a time. The screen is the content; the chrome is one thin bar\n// of icons: Back, ‹ 2 / 5 › (also arrow keys and swipe) and a menu holding the guided tours and \"How it works\" (the workflow, in a dialog).\n// During a tour the bar shows the step dots and ×, with the step's caption under it. `maquetteKind` is the kind the pi-app artifact panel\n// uses by default for `.maquette.json` files; an app's own kind in `artifactKinds` wins. It draws data only: nothing in the file runs.\nimport { useEffect, useMemo, useRef, useState } from 'react';\nimport type { ComponentType, KeyboardEvent, ReactNode, TouchEvent } from 'react';\nimport { ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon, CompassIcon, EllipsisIcon, InfoIcon, LayoutTemplateIcon, 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 { MAQUETTE_FORMAT_VERSION, isMaquettePath, parseMaquette, screenFromFragment } from './maquette';\nimport type { Maquette, 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';\nimport { MaquetteDiagram } from './maquette-diagram';\n\nexport { MAQUETTE_FORMAT_VERSION, 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\nexport interface MaquetteViewerProps {\n /** The file's text (JSON). */\n readonly text: string;\n /** The page's URL fragment: `screen=<id>` opens that screen (and follows later changes). */\n readonly fragment?: string | undefined;\n /** The base words: English by default. */\n readonly locale?: MaquetteLocale | undefined;\n /** Any word over the locale's. */\n readonly labels?: Partial<MaquetteLabels> | undefined;\n}\n\n/** Where the person is: the screens visited (the last is shown) and the guided tour under way. */\ninterface Walk { readonly history: readonly string[]; readonly tour: { readonly name: string; readonly index: number } | null }\n\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 workflow diagram, its legend and the cases' `path` highlights, in a dialog opened from the menu. */\nfunction HowItWorks({ maquette, labels, open, onOpenChange }: { readonly maquette: Maquette; readonly labels: MaquetteLabels; readonly open: boolean; readonly onOpenChange: (open: boolean) => void }) {\n const [shown, setShown] = useState<string | null>(null);\n const highlighted = maquette.cases.filter(item => item.path.length > 0);\n if (!maquette.workflow) 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 <MaquetteDiagram workflow={maquette.workflow} highlight={highlighted.find(item => item.name === shown)?.path ?? []} labels={labels} />\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 ? 'secondary' : '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\nfunction Walkthrough({ maquette, labels, fragment }: { readonly maquette: Maquette; readonly labels: MaquetteLabels; readonly fragment: string | undefined }) {\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 = maquette.cases.filter(item => item.steps.length > 0);\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 const firstView = !seen.current.has(current.id);\n useEffect(() => { seen.current.add(current.id); }, [current.id]);\n\n const move = (next: Walk) => { moved.current = true; setWalk(next); };\n const go = (id: string) => { if (exists(id) && id !== current.id) move({ history: [...walk.history, id], tour: null }); };\n const step = (delta: number) => {\n if (tour && walk.tour) {\n const index = walk.tour.index + delta, target = tour.steps[index];\n if (target) move({ history: [...walk.history, target.screen], tour: { name: tour.name, index } });\n return;\n }\n const target = screens[position + delta];\n if (target) move({ history: [...walk.history, target.id], tour: null });\n };\n const back = () => { if (walk.history.length > 1) move({ history: walk.history.slice(0, -1), tour: null }); };\n const startTour = (item: MaquetteCase) => move({ history: [...walk.history, item.steps[0]!.screen], tour: { name: item.name, index: 0 } });\n // A later `#screen=<id>` (the PM's \"see screen 2\" link) opens that screen.\n useEffect(() => { if (exists(linked) && linked !== walk.history.at(-1)) setWalk(previous => ({ history: [...previous.history, linked], tour: null })); }, [linked]); // eslint-disable-line react-hooks/exhaustive-deps\n // After a move, keyboard focus starts on the new screen, so Tab continues inside the screen just opened.\n useEffect(() => { if (moved.current) { moved.current = false; heading.current?.focus(); } }, [walk]);\n\n const index = tour && walk.tour ? walk.tour.index : position, count = tour ? tour.steps.length : screens.length;\n const links: MaquetteLinks = { go, hint: id => labels.opens(titleOf(id)) };\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 <nav aria-label={labels.screens} data-testid=\"maquette-stepper\" 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 {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 : (tours.length > 0 || maquette.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 && maquette.workflow && <DropdownMenuSeparator />}\n {maquette.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} 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 }: 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} />\n : <Alert variant=\"destructive\" data-testid=\"maquette-error\" data-reason={parsed.reason}><TriangleAlertIcon aria-hidden=\"true\" /><AlertTitle>{labels.errorTitle}</AlertTitle>\n <AlertDescription>{parsed.reason === 'json' ? labels.invalidJson : labels.invalidShape}</AlertDescription></Alert>}\n </section>;\n}\n\n/**\n * The artifact kind of a maquette file (structurally an `ArtifactKind` of pi-chat): it matches `*.maquette.json`, names it, gives its icon\n * and draws it with `MaquetteViewer`. Pass it, or your own, in `artifactKinds`; kinds an app passes are consulted before the built-in one.\n */\nexport interface MaquetteKind {\n readonly match: (target: { readonly resource: { readonly path: string } }) => boolean;\n readonly label: string;\n readonly icon: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }>;\n readonly renderer: (props: { readonly text: string; readonly fragment?: string | undefined }) => ReactNode;\n}\n\n/** A maquette kind in `locale` (\"Mock-up\" in English, \"Maquette\" in French) with any word overridden. */\nexport function createMaquetteKind({ locale, labels }: { readonly locale?: MaquetteLocale | undefined; readonly labels?: Partial<MaquetteLabels> | undefined } = {}): MaquetteKind {\n return {\n match: target => isMaquettePath(target.resource.path),\n label: maquetteLabelsFor(locale, labels).kind,\n icon: LayoutTemplateIcon,\n renderer: ({ text, fragment }) => <MaquetteViewer text={text} fragment={fragment} locale={locale} labels={labels} />,\n };\n}\n\n/** The built-in maquette kind, in English. */\nexport const maquetteKind: MaquetteKind = createMaquetteKind();\n",
|
|
52
|
+
"content": "'use client';\n\n// The maquette viewer: a PM agent's `*.maquette.json` mock-up, one screen at a time. The screen is the content; the chrome is one thin bar\n// of icons: Back, ‹ 2 / 5 › (also arrow keys and swipe) and a menu holding the guided tours and \"How it works\" (the workflow, in a dialog).\n// During a tour the bar shows the step dots and ×, with the step's caption under it. `maquetteKind` is the kind the pi-app artifact panel\n// uses by default for `.maquette.json` files; an app's own kind in `artifactKinds` wins. It draws data only: nothing in the file runs.\n// The workflow is drawn by the diagram-viewer item's `DiagramView`, inline or from a linked `*.diagram.json`. With `comments` (the feedback\n// item's `usePointComments`, structurally) one more icon enters point mode on the screen: a tap on a block pins a note that names the\n// maquette path, the screen id and the block id; the pins of the current screen show as small numbers on their blocks.\nimport { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';\nimport type { ComponentType, KeyboardEvent, ReactNode, TouchEvent } from 'react';\nimport { ArrowLeftIcon, ChevronLeftIcon, ChevronRightIcon, CompassIcon, EllipsisIcon, InfoIcon, LayoutTemplateIcon, MessageSquarePlusIcon, TriangleAlertIcon, WorkflowIcon, XIcon } from 'lucide-react';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent } from '@/components/ui/card';\nimport { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';\nimport { Toggle } from '@/components/ui/toggle';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { parseDiagram } from '../diagram-viewer/diagram';\nimport type { Diagram } from '../diagram-viewer/diagram';\nimport { DiagramView } from '../diagram-viewer/diagram-view';\nimport { MAQUETTE_FORMAT_VERSION, diagramPathOf, isMaquettePath, parseMaquette, screenFromFragment } from './maquette';\nimport type { Maquette, MaquetteBlock, MaquetteCase, MaquetteScreen } from './maquette';\nimport { maquetteLabelsFor } from './maquette-labels';\nimport type { MaquetteLabels, MaquetteLocale } from './maquette-labels';\nimport { MaquetteScreenView } from './maquette-blocks';\nimport type { MaquetteLinks } from './maquette-blocks';\n\nexport { MAQUETTE_FORMAT_VERSION, diagramPathOf, isMaquettePath, linksOf, parseMaquette, screenFromFragment } from './maquette';\nexport type { Maquette, MaquetteBlock, MaquetteBlockV1, MaquetteCase, MaquetteFileV1, MaquetteParse, MaquetteScreen, MaquetteState, MaquetteTransition, MaquetteWorkflow } from './maquette';\nexport { defaultMaquetteLabels, frenchMaquetteLabels, maquetteLabelsFor } from './maquette-labels';\nexport type { MaquetteLabels, MaquetteLocale } from './maquette-labels';\n\n/**\n * Comment pins, structurally the feedback item's `usePointComments(session)`: pass it and the bar shows a comment icon; omit it (no feedback\n * session in the host) and nothing shows.\n */\nexport interface MaquetteComments {\n /** Point mode is on. */\n readonly active: boolean;\n /** Enters point mode (the person taps blocks to pin notes), or ends it when it is on. */\n readonly point: () => void;\n /** The pins taken: each one's number and the `data-feedback-id` its element sits in. */\n readonly pins: readonly { readonly number: number; readonly target: string }[];\n}\n\n/** Reads another file of the workspace as text (the artifact panel passes one): a linked `*.diagram.json`. Undefined when it cannot. */\nexport type MaquetteRead = (path: string) => Promise<string | undefined>;\n\n/** A block's `data-feedback-id`: `maquette:<path>#screen=<screen id>&block=<block id>`. */\nexport const maquetteTarget = (path: string | undefined, screen: string, block: string): string => `maquette:${path ?? ''}#screen=${screen}&block=${block}`;\n\nexport interface MaquetteViewerProps {\n /** The file's text (JSON). */\n readonly text: string;\n /** The file's path: named by comment pins, and the folder a linked diagram is read from. */\n readonly path?: string | undefined;\n /** Comment pins on blocks (the feedback item's `usePointComments`). */\n readonly comments?: MaquetteComments | undefined;\n /** Reads a linked `*.diagram.json`; without it a linked workflow is not offered. */\n readonly read?: MaquetteRead | undefined;\n /** The page's URL fragment: `screen=<id>` opens that screen (and follows later changes). */\n readonly fragment?: string | undefined;\n /** The base words: English by default. */\n readonly locale?: MaquetteLocale | undefined;\n /** Any word over the locale's. */\n readonly labels?: Partial<MaquetteLabels> | undefined;\n}\n\n/** Where the person is: the screens visited (the last is shown) and the guided tour under way. */\ninterface Walk { readonly history: readonly string[]; readonly tour: { readonly name: string; readonly index: number } | null }\n\n/**\n * A host bar that owns the one \"…\" menu (the pi-app artifact panel) provides this: the viewer then publishes its tours and \"How it works\"\n * there instead of drawing a second \"…\" menu beside the screen counter. Without a provider the viewer keeps its own menu.\n */\nexport interface MaquetteMenuEntry { readonly id: string; readonly label: string; readonly icon: ReactNode; readonly testId: string; readonly separatorBefore: boolean; readonly onSelect: () => void }\nexport const MaquetteMenuContext = createContext<((items: readonly MaquetteMenuEntry[]) => void) | undefined>(undefined);\n\nconst TYPING = 'input, select, textarea, [contenteditable=\"true\"]';\nconst ICON = 'pointer-coarse:min-h-11 pointer-coarse:min-w-11';\n\n/** One dot per tour step, the current one in the primary colour. */\nfunction Dots({ count, index, label }: { readonly count: number; readonly index: number; readonly label: string }) {\n return <svg role=\"img\" aria-label={label} data-testid=\"maquette-dots\" width={count * 12} height=\"8\" viewBox={`0 0 ${count * 12} 8`} className=\"shrink-0\">\n {Array.from({ length: count }, (_, i) => <circle key={i} cx={i * 12 + 6} cy=\"4\" r=\"3\" className={i === index ? 'fill-primary' : 'fill-muted-foreground/30'} />)}\n </svg>;\n}\n\n/** The diagram of a linked `*.diagram.json`, read when the dialog opens: undefined while reading, null when it cannot be read. */\nfunction useLinked(read: MaquetteRead | undefined, path: string | undefined, open: boolean): Diagram | null | undefined {\n const [loaded, setLoaded] = useState<{ readonly path: string; readonly diagram: Diagram | null }>();\n useEffect(() => {\n if (!open || !path || !read || loaded?.path === path) return undefined;\n let off = false;\n read(path).then(text => text === undefined ? null : parseDiagram(text), () => null).then(parsed => {\n if (!off) setLoaded({ path, diagram: parsed?.kind === 'ok' ? parsed.diagram : null });\n });\n return () => { off = true; };\n }, [open, path, read, loaded?.path]);\n return loaded && loaded.path === path ? loaded.diagram : undefined;\n}\n\n/** The workflow diagram, its legend and the cases' `path` highlights, in a dialog opened from the menu. */\nfunction HowItWorks({ maquette, source, labels, open, onOpenChange }: {\n readonly maquette: Maquette; readonly source: DiagramSource; readonly labels: MaquetteLabels; readonly open: boolean; readonly onOpenChange: (open: boolean) => void;\n}) {\n const [shown, setShown] = useState<string | null>(null);\n const loaded = useLinked(source.read, source.path, open);\n const diagram = maquette.diagram ?? loaded;\n const highlighted = maquette.cases.filter(item => item.path.length > 0);\n if (!maquette.diagram && !source.path) return null;\n return <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent data-testid=\"maquette-how\" className=\"max-h-[85dvh] overflow-y-auto\">\n <DialogHeader><DialogTitle>{labels.howItWorks}</DialogTitle></DialogHeader>\n {diagram ? <DiagramView diagram={diagram} highlight={highlighted.find(item => item.name === shown)?.path ?? diagram.path} labels={labels} />\n : diagram === null ? <Alert variant=\"destructive\" data-testid=\"maquette-how-error\"><TriangleAlertIcon aria-hidden=\"true\" /><AlertTitle>{labels.diagramUnavailable}</AlertTitle></Alert>\n : <p role=\"status\" className=\"m-0 text-sm text-muted-foreground\">{labels.loading}</p>}\n {highlighted.length > 0 && <div role=\"group\" className=\"flex flex-wrap gap-2\">\n {highlighted.map(item => <Button key={item.name} type=\"button\" size=\"sm\" variant={shown === item.name ? 'default' : 'outline'} aria-pressed={shown === item.name}\n data-testid=\"maquette-case\" className=\"pointer-coarse:min-h-11\" onClick={() => setShown(current => current === item.name ? null : item.name)}>{item.name}</Button>)}\n </div>}\n </DialogContent>\n </Dialog>;\n}\n\n/** Where a linked diagram is read from: its resolved path and the reader (both absent for an inline workflow, or with no reader). */\ninterface DiagramSource { readonly path: string | undefined; readonly read: MaquetteRead | undefined }\n\nfunction Walkthrough({ maquette, labels, fragment, path, comments, read }: {\n readonly maquette: Maquette; readonly labels: MaquetteLabels; readonly fragment: string | undefined;\n readonly path: string | undefined; readonly comments: MaquetteComments | undefined; readonly read: MaquetteRead | undefined;\n}) {\n const exists = (id: string | undefined): id is string => !!id && maquette.screens.some(screen => screen.id === id);\n const linked = screenFromFragment(fragment);\n const [walk, setWalk] = useState<Walk>(() => ({ history: [exists(linked) ? linked : maquette.start], tour: null }));\n const [how, setHow] = useState(false);\n const seen = useRef(new Set<string>());\n const heading = useRef<HTMLHeadingElement>(null);\n const moved = useRef(false);\n const touch = useRef<{ x: number; y: number } | null>(null);\n const screens = maquette.screens;\n const current: MaquetteScreen = screens.find(screen => screen.id === walk.history.at(-1)) ?? screens[0]!;\n const position = screens.indexOf(current);\n const tour: MaquetteCase | undefined = walk.tour ? maquette.cases.find(item => item.name === walk.tour!.name) : undefined;\n const tours = useMemo(() => maquette.cases.filter(item => item.steps.length > 0), [maquette]);\n const titleOf = (id: string) => screens.find(screen => screen.id === id)?.title ?? id;\n // Goto blocks pulse once, the first time their screen is shown.\n // Decided once per shown screen, so a re-render of the same screen (the host menu updating) does not end the pulse.\n const shown = useRef<{ readonly id: string; readonly first: boolean } | undefined>(undefined);\n const view = shown.current?.id === current.id ? shown.current : { id: current.id, first: !seen.current.has(current.id) };\n shown.current = view;\n const firstView = view.first;\n useEffect(() => { seen.current.add(current.id); }, [current.id]);\n\n const move = (next: Walk) => { moved.current = true; setWalk(next); };\n const go = (id: string) => { if (exists(id) && id !== current.id) move({ history: [...walk.history, id], tour: null }); };\n const step = (delta: number) => {\n if (tour && walk.tour) {\n const index = walk.tour.index + delta, target = tour.steps[index];\n if (target) move({ history: [...walk.history, target.screen], tour: { name: tour.name, index } });\n return;\n }\n const target = screens[position + delta];\n if (target) move({ history: [...walk.history, target.id], tour: null });\n };\n const back = () => { if (walk.history.length > 1) move({ history: walk.history.slice(0, -1), tour: null }); };\n const startTour = (item: MaquetteCase) => move({ history: [...walk.history, item.steps[0]!.screen], tour: { name: item.name, index: 0 } });\n // A later `#screen=<id>` (the PM's \"see screen 2\" link) opens that screen.\n useEffect(() => { if (exists(linked) && linked !== walk.history.at(-1)) setWalk(previous => ({ history: [...previous.history, linked], tour: null })); }, [linked]); // eslint-disable-line react-hooks/exhaustive-deps\n // After a move, keyboard focus starts on the new screen, so Tab continues inside the screen just opened.\n useEffect(() => { if (moved.current) { moved.current = false; heading.current?.focus(); } }, [walk]);\n\n const index = tour && walk.tour ? walk.tour.index : position, count = tour ? tour.steps.length : screens.length;\n const target = (block: MaquetteBlock) => maquetteTarget(path, current.id, block.id);\n // While pointing, the session draws its own numbered pins over the page; the small numbers on the blocks are for afterwards.\n const pins = (block: MaquetteBlock) => comments && !comments.active ? comments.pins.filter(pin => pin.target === target(block)).map(pin => pin.number) : [];\n const links: MaquetteLinks = { go, hint: id => labels.opens(titleOf(id)), target, pins };\n const source: DiagramSource = { path: maquette.diagramLink && read ? diagramPathOf(path ?? '', maquette.diagramLink) : undefined, read };\n const workflow = !!maquette.diagram || !!source.path;\n // Inside a host bar with its own \"…\" menu, the tours and \"How it works\" go there (one menu, not two).\n const publish = useContext(MaquetteMenuContext);\n const latestTour = useRef(startTour); latestTour.current = startTour;\n useEffect(() => {\n if (!publish) return undefined;\n const items: MaquetteMenuEntry[] = [\n ...(tour ? [] : tours).map((item, i) => ({ id: `tour-${i}`, label: item.name, icon: <CompassIcon aria-hidden=\"true\" />, testId: 'maquette-tour', separatorBefore: i === 0, onSelect: () => latestTour.current(item) })),\n ...(workflow && !tour ? [{ id: 'how-it-works', label: labels.howItWorks, icon: <WorkflowIcon aria-hidden=\"true\" />, testId: 'maquette-how-open', separatorBefore: true, onSelect: () => setHow(true) }] : []),\n ];\n publish(items);\n return () => publish([]);\n }, [publish, tours, workflow, tour, labels.howItWorks]);\n const onKeyDown = (event: KeyboardEvent) => {\n if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.shiftKey || (event.target as Element).closest?.(TYPING)) return;\n if (event.altKey) { if (event.key === 'ArrowLeft') { event.preventDefault(); back(); } return; }\n if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); step(event.key === 'ArrowLeft' ? -1 : 1); }\n };\n const onTouchStart = (event: TouchEvent) => { const point = event.touches[0]; touch.current = point ? { x: point.clientX, y: point.clientY } : null; };\n const onTouchEnd = (event: TouchEvent) => {\n const start = touch.current, point = event.changedTouches[0];\n touch.current = null;\n if (!start || !point) return;\n const dx = point.clientX - start.x, dy = point.clientY - start.y;\n if (Math.abs(dx) > 56 && Math.abs(dx) > Math.abs(dy) * 1.5) step(dx < 0 ? 1 : -1);\n };\n const caption = tour && walk.tour ? tour.steps[walk.tour.index]?.caption : undefined;\n\n return <div data-testid=\"maquette-walk\" data-screen={current.id} data-tour={tour?.name} className=\"flex min-w-0 flex-col gap-2\" onKeyDown={onKeyDown}>\n {/* The viewer's own chrome is never pinned, and keeps working while pointing: walk to another screen and pin there too. */}\n <nav aria-label={labels.screens} data-testid=\"maquette-stepper\" data-feedback-ignore=\"\" className=\"flex items-center gap-1\">\n {walk.history.length > 1 && <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.back} data-testid=\"maquette-back\" onClick={back}><ArrowLeftIcon aria-hidden=\"true\" /></Button>}\n <div className=\"flex flex-1 items-center justify-center gap-1\">\n <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.previous} data-testid=\"maquette-previous\" disabled={index <= 0} onClick={() => step(-1)}><ChevronLeftIcon aria-hidden=\"true\" /></Button>\n {tour ? <Dots count={count} index={index} label={labels.tourStep(index + 1, count)} />\n : <span data-testid=\"maquette-position\" className=\"min-w-10 text-center text-sm text-muted-foreground tabular-nums\">{index + 1} / {count}</span>}\n <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.next} data-testid=\"maquette-next\" disabled={index >= count - 1} onClick={() => step(1)}><ChevronRightIcon aria-hidden=\"true\" /></Button>\n </div>\n {comments && <Tooltip><TooltipTrigger render={<Toggle className={ICON} aria-label={labels.comment} pressed={comments.active}\n data-testid=\"maquette-comment\" onPressedChange={() => comments.point()} />}><MessageSquarePlusIcon aria-hidden=\"true\" /></TooltipTrigger><TooltipContent>{labels.comment}</TooltipContent></Tooltip>}\n {tour ? <Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.endTour} data-testid=\"maquette-end-tour\" onClick={() => setWalk(previous => ({ ...previous, tour: null }))}><XIcon aria-hidden=\"true\" /></Button>\n : !publish && (tours.length > 0 || workflow) && <DropdownMenu>\n <DropdownMenuTrigger render={<Button type=\"button\" variant=\"ghost\" size=\"icon-sm\" className={ICON} aria-label={labels.more} data-testid=\"maquette-menu\" />}><EllipsisIcon aria-hidden=\"true\" /></DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\" data-testid=\"maquette-menu-list\">\n {tours.map(item => <DropdownMenuItem key={item.name} className=\"pointer-coarse:min-h-11\" data-testid=\"maquette-tour\" onClick={() => startTour(item)}><CompassIcon aria-hidden=\"true\" />{item.name}</DropdownMenuItem>)}\n {tours.length > 0 && workflow && <DropdownMenuSeparator />}\n {workflow && <DropdownMenuItem className=\"pointer-coarse:min-h-11\" data-testid=\"maquette-how-open\" onClick={() => setHow(true)}><WorkflowIcon aria-hidden=\"true\" />{labels.howItWorks}</DropdownMenuItem>}\n </DropdownMenuContent>\n </DropdownMenu>}\n </nav>\n {caption && <p data-testid=\"maquette-caption\" className=\"m-0 line-clamp-2 px-1 text-sm text-pretty text-muted-foreground\">{caption}</p>}\n\n <div className={maquette.device === 'phone' ? 'mx-auto flex w-full max-w-sm flex-col gap-2' : 'flex min-w-0 flex-col gap-2'}>\n <div className=\"min-w-0\" data-hint={firstView ? 'pulse' : undefined} onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>\n <Card data-testid=\"maquette-frame\" data-device={maquette.device ?? 'panel'}><CardContent>\n <MaquetteScreenView key={current.id} maquette={maquette} screen={current} links={links} labels={labels} headingRef={heading} />\n </CardContent></Card>\n </div>\n {current.note && <div className=\"flex min-w-0 items-center gap-1 px-1\">\n <p data-testid=\"maquette-note\" title={current.note} className=\"m-0 min-w-0 flex-1 truncate text-xs text-muted-foreground\">{current.note}</p>\n <Popover>\n <PopoverTrigger render={<Button type=\"button\" variant=\"ghost\" size=\"icon-xs\" className={ICON} aria-label={labels.aboutScreen} data-testid=\"maquette-note-more\" />}><InfoIcon aria-hidden=\"true\" /></PopoverTrigger>\n <PopoverContent align=\"end\" data-testid=\"maquette-note-full\">{current.note}</PopoverContent>\n </Popover>\n </div>}\n </div>\n <p aria-live=\"polite\" className=\"m-0 sr-only\">{labels.showing(current.title)}</p>\n <HowItWorks maquette={maquette} source={source} labels={labels} open={how} onOpenChange={setHow} />\n </div>;\n}\n\n/** A maquette file drawn one screen at a time; a newer format version shows a notice, an unreadable file an inline error. */\nexport function MaquetteViewer({ text, fragment, locale, labels: given, path, comments, read }: MaquetteViewerProps) {\n const labels = useMemo(() => maquetteLabelsFor(locale, given), [locale, given]);\n const parsed = useMemo(() => parseMaquette(text), [text]);\n const newer = parsed.version !== undefined && parsed.version > MAQUETTE_FORMAT_VERSION;\n const notice = newer && <Alert data-testid=\"maquette-newer\"><InfoIcon aria-hidden=\"true\" /><AlertTitle>{labels.newerTitle}</AlertTitle><AlertDescription>{labels.newer(parsed.version!)}</AlertDescription></Alert>;\n return <section data-boring=\"maquette-viewer\" data-state={parsed.kind} aria-label={parsed.kind === 'ok' ? parsed.maquette.title || labels.kind : labels.kind}\n className=\"mx-auto flex w-full max-w-5xl min-w-0 flex-col gap-2 px-3 pt-1 pb-4 sm:px-6\">\n {notice}\n {parsed.kind === 'ok' ? <Walkthrough maquette={parsed.maquette} labels={labels} fragment={fragment} path={path} comments={comments} read={read} />\n : <Alert variant=\"destructive\" data-testid=\"maquette-error\" data-reason={parsed.reason}><TriangleAlertIcon aria-hidden=\"true\" /><AlertTitle>{labels.errorTitle}</AlertTitle>\n <AlertDescription>{parsed.reason === 'json' ? labels.invalidJson : labels.invalidShape}</AlertDescription></Alert>}\n </section>;\n}\n\n/**\n * The artifact kind of a maquette file (structurally an `ArtifactKind` of pi-chat): it matches `*.maquette.json`, names it, gives its icon\n * and draws it with `MaquetteViewer`. Pass it, or your own, in `artifactKinds`; kinds an app passes are consulted before the built-in one.\n */\nexport interface MaquetteKind {\n readonly match: (target: { readonly resource: { readonly path: string } }) => boolean;\n readonly label: string;\n readonly icon: ComponentType<{ readonly className?: string; readonly 'aria-hidden'?: boolean | 'true' }>;\n readonly renderer: (props: {\n readonly text: string; readonly fragment?: string | undefined;\n readonly artifact?: { readonly target: { readonly resource: { readonly path: string } } } | undefined; readonly read?: MaquetteRead | undefined;\n }) => ReactNode;\n}\n\n/**\n * A maquette kind in `locale` (\"Mock-up\" in English, \"Maquette\" in French) with any word overridden. `comments` (the feedback item's\n * `usePointComments(session)`) adds the comment icon; pass a fresh kind when it changes.\n */\nexport function createMaquetteKind({ locale, labels, comments }: {\n readonly locale?: MaquetteLocale | undefined; readonly labels?: Partial<MaquetteLabels> | undefined; readonly comments?: MaquetteComments | undefined;\n} = {}): MaquetteKind {\n return {\n match: target => isMaquettePath(target.resource.path),\n label: maquetteLabelsFor(locale, labels).kind,\n icon: LayoutTemplateIcon,\n renderer: ({ text, fragment, artifact, read }) => <MaquetteViewer text={text} fragment={fragment} locale={locale} labels={labels}\n path={artifact?.target.resource.path} comments={comments} read={read} />,\n };\n}\n\n/** The built-in maquette kind, in English. */\nexport const maquetteKind: MaquetteKind = createMaquetteKind();\n",
|
|
56
53
|
"type": "registry:component",
|
|
57
54
|
"target": "components/maquette-viewer/maquette-viewer.tsx"
|
|
58
55
|
}
|
package/r/markdown-editor.json
CHANGED
|
@@ -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.
|
|
8
|
-
"@hachej/boring-files@^0.4.
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.4",
|
|
8
|
+
"@hachej/boring-files@^0.4.4",
|
|
9
9
|
"react@^19.0.0",
|
|
10
10
|
"react-dom@^19.0.0",
|
|
11
11
|
"marked@^17.0.6",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"@tiptap/extension-floating-menu@^3.31.4"
|
|
24
24
|
],
|
|
25
25
|
"registryDependencies": [
|
|
26
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
26
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.4/r/button.json"
|
|
27
27
|
],
|
|
28
28
|
"files": [
|
|
29
29
|
{
|