From 0562ddebc747d9b583296ac5117a36c3900128e5 Mon Sep 17 00:00:00 2001 From: Tomasz Zajac Date: Wed, 23 Sep 2026 18:33:44 +0200 Subject: [PATCH 1/2] feat: add Mockup node type with AI-generated low-fi wireframes Adds a `mockup` node type to the C4 + DDD + Governance preset so the model can show what the user-facing product looks like, not just how it is built and what it must do. A mockup is either a link to an external design (Figma, Penpot, ...) or a low-fidelity SVG wireframe generated by AI. Metamodel: - mockup node type (description, screen / route, design link), with its own Table View tab - illustrates: mockup -> requirement / scenario - presented-by: mockup -> webapp / container / component / system - navigates-to: mockup -> mockup (screen flow) Wireframe generation (ai/mockupWireframe.ts) is a single tool-less adapter call, like forgeClarify. The prompt is built from the model around the mockup: its description and route, the EARS requirements and Gherkin scenarios it illustrates, the container presenting it and the screens it navigates to. The returned SVG is extracted and sanitised (scripts, foreignObject, on* handlers and external hrefs stripped, 20 KB cap) and is only ever rendered through an data URI, so it can never run code or fetch anything. UI: MockupNode renders the wireframe thumbnail on the canvas (or the link / a placeholder), and the properties panel gets a Wireframe section with a preview plus Generate / Regenerate (cancellable), Remove and Open design (http/https only). New "UX" palette group; mockups listed with governance items in the Wiki view. The wireframe is stored on the node as a `wireframe` string, so in md-folder documents it lands in the frontmatter as a multi-line block scalar (round-trip covered by a test). That means it loads eagerly with the rest of the frontmatter; moving it to a lazily-loaded sidecar file is a possible follow-up once lazy node bodies land. Co-Authored-By: Claude Opus 5.5 --- src/renderer/src/ai/mockupWireframe.ts | 142 ++++++++++++++++++ src/renderer/src/components/Canvas.tsx | 3 +- src/renderer/src/components/RightPanel.tsx | 97 ++++++++++++ src/renderer/src/components/WikiView.tsx | 2 +- src/renderer/src/components/nodes/C4Nodes.tsx | 68 +++++++++ src/renderer/src/index.css | 32 ++++ src/renderer/src/store/diagramStore.ts | 2 +- src/renderer/src/types/c4.ts | 2 +- .../src/types/metamodel/presets/governance.ts | 74 +++++++++ tests/mockupWireframe.test.ts | 106 +++++++++++++ 10 files changed, 524 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/ai/mockupWireframe.ts create mode 100644 tests/mockupWireframe.test.ts diff --git a/src/renderer/src/ai/mockupWireframe.ts b/src/renderer/src/ai/mockupWireframe.ts new file mode 100644 index 0000000..d286c08 --- /dev/null +++ b/src/renderer/src/ai/mockupWireframe.ts @@ -0,0 +1,142 @@ +// ─── Mockup wireframe generation ──────────────────────────────────────────── +// Generates a low-fidelity SVG wireframe for a Mockup node from the model +// context around it: its own label / description / screen, the requirements +// and scenarios it `illustrates`, the container it is `presented-by`, and the +// screens it `navigates-to`. Like forgeClarify.ts this is a single tool-less +// call to the provider adapter, not a runAIPrompt loop — the output is one +// blob of markup, not a sequence of model edits. +// +// The SVG is only ever rendered through an data URI (see MockupNode), +// which never runs scripts or fetches external resources; sanitizeWireframeSvg +// still strips the obvious active content so the stored markup is inert even +// if something later inlines it. + +import { getAdapter } from './registry' +import { textOf, type AISettings, type TokenUsage } from './types' +import type { C4Node, C4Relation } from '../types/c4' + +export const WIREFRAME_WIDTH = 400 +export const WIREFRAME_HEIGHT = 300 + +/** Upper bound on stored markup — keeps a model full of mockups from turning + * into megabytes of eagerly-loaded frontmatter. */ +export const MAX_WIREFRAME_CHARS = 20_000 + +type NodeMap = Record +type RelationMap = Record + +function prop(node: C4Node, key: string): string { + const v = (node as unknown as Record)[key] + return typeof v === 'string' ? v.trim() : '' +} + +function describeRequirement(n: C4Node): string { + const action = prop(n, 'action') + return action ? `${n.label}: the system shall ${action}` : n.label +} + +function describeScenario(n: C4Node): string { + const parts = [ + prop(n, 'given') && `Given ${prop(n, 'given')}`, + prop(n, 'when') && `When ${prop(n, 'when')}`, + prop(n, 'then') && `Then ${prop(n, 'then')}`, + ].filter(Boolean) + return parts.length ? `${n.label}: ${parts.join('; ')}` : n.label +} + +export function buildWireframePrompt(mockupId: string, nodes: NodeMap, relations: RelationMap): string { + const mockup = nodes[mockupId] + if (!mockup) throw new Error(`Unknown mockup node: ${mockupId}`) + + const outgoing = Object.values(relations) + .filter((r) => r.sourceId === mockupId) + .map((r) => ({ rel: r, target: nodes[r.targetId] })) + .filter((x): x is { rel: C4Relation; target: C4Node } => !!x.target) + + const requirements = outgoing.filter((x) => x.target.type === 'requirement').map((x) => describeRequirement(x.target)) + const scenarios = outgoing.filter((x) => x.target.type === 'scenario').map((x) => describeScenario(x.target)) + const presentedBy = outgoing + .filter((x) => x.rel.relationType === 'presented-by') + .map((x) => [x.target.label, x.target.technology].filter(Boolean).join(' — ')) + const navigatesTo = outgoing + .filter((x) => x.target.type === 'mockup') + .map((x) => (x.rel.label ? `${x.target.label} (via ${x.rel.label})` : x.target.label)) + + const lines = [ + `Draw a low-fidelity UI wireframe for the screen "${mockup.label}".`, + ] + const screen = prop(mockup, 'screen') + if (screen) lines.push(`Screen / route: ${screen}`) + const description = prop(mockup, 'description') + if (description) lines.push('', 'Screen description:', '"""', description, '"""') + const section = (title: string, items: string[]): void => { + if (items.length) lines.push('', title, ...items.map((i) => `- ${i}`)) + } + section('Requirements this screen must illustrate:', requirements) + section('Scenarios this screen must support:', scenarios) + section('Rendered by:', presentedBy) + section('Navigation targets (show as buttons/links leading there):', navigatesTo) + + lines.push( + '', + 'Output rules:', + `- Respond with ONLY one element: xmlns="http://www.w3.org/2000/svg", viewBox="0 0 ${WIREFRAME_WIDTH} ${WIREFRAME_HEIGHT}".`, + '- Low-fi grayscale style: white background, #333 strokes, #eee/#ccc fills, one accent colour at most.', + '- Use only rect, line, circle, path, polygon, text and g. No images, no scripts, no foreignObject, no external references, no CSS.', + '- Short realistic labels (font-family="sans-serif", font-size 9-14); grey bars for body copy.', + '- Show the elements the requirements and scenarios need (inputs, buttons, lists, states).', + '- Keep it compact: well under 8000 characters.', + ) + return lines.join('\n') +} + +/** Pulls the first … out of a model response and strips active + * content. Returns null when the response has no usable SVG. */ +export function sanitizeWireframeSvg(text: string): string | null { + const match = text.match(//i) + if (!match) return null + let svg = match[0] + svg = svg + .replace(//gi, '') + .replace(/]*\/>/gi, '') + .replace(//gi, '') + .replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '') + .replace(/\s(?:xlink:)?href\s*=\s*("(?!#)[^"]*"|'(?!#)[^']*')/gi, '') + if (!/\sxmlns=/.test(svg.slice(0, svg.indexOf('>')))) { + svg = svg.replace(/^ MAX_WIREFRAME_CHARS) return null + return svg +} + +/** Data URI for rendering a stored wireframe through . */ +export function wireframeDataUri(svg: string): string { + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` +} + +export interface WireframeResult { + svg: string + usage?: TokenUsage +} + +export async function generateWireframe( + mockupId: string, + nodes: NodeMap, + relations: RelationMap, + settings: AISettings, + signal?: AbortSignal, +): Promise { + const adapter = getAdapter(settings.active) + const cfg = settings.providers[settings.active] + const res = await adapter.chat({ + // Layout quality matters here, so use the user's generation model. + model: cfg.model || adapter.defaultModel, + messages: [{ role: 'user', content: buildWireframePrompt(mockupId, nodes, relations) }], + maxTokens: 6000, + temperature: 0.4, + signal, + }, cfg) + const svg = sanitizeWireframeSvg(textOf(res.content)) + if (!svg) throw new Error('The model did not return a usable SVG wireframe.') + return { svg, usage: res.usage } +} diff --git a/src/renderer/src/components/Canvas.tsx b/src/renderer/src/components/Canvas.tsx index 331f174..6298ee0 100644 --- a/src/renderer/src/components/Canvas.tsx +++ b/src/renderer/src/components/Canvas.tsx @@ -12,7 +12,7 @@ import ReactFlow, { getViewportForBounds, } from 'reactflow' import { useDiagramStore } from '../store/diagramStore' -import { PersonNode, SystemNode, ContainerNode, ComponentNode, DatabaseNode, WebAppNode, QueueNode, DomainNode, GroupNode, AdrNode, FitnessFnNode, RequirementNode, ScenarioNode, BlueprintNode } from './nodes/C4Nodes' +import { PersonNode, SystemNode, ContainerNode, ComponentNode, DatabaseNode, WebAppNode, QueueNode, DomainNode, GroupNode, AdrNode, FitnessFnNode, RequirementNode, ScenarioNode, BlueprintNode, MockupNode } from './nodes/C4Nodes' import { RelationEdge } from './edges/RelationEdge' import { DeleteConfirmDialog } from './DeleteConfirmDialog' import { C4ElementType, NODE_SIZES, COLLAPSED_HEIGHT } from '../types/c4' @@ -34,6 +34,7 @@ const nodeTypes: NodeTypes = { requirement: RequirementNode as any, scenario: ScenarioNode as any, blueprint: BlueprintNode as any, + mockup: MockupNode as any, } const edgeTypes: EdgeTypes = { diff --git a/src/renderer/src/components/RightPanel.tsx b/src/renderer/src/components/RightPanel.tsx index 056112a..b112f35 100644 --- a/src/renderer/src/components/RightPanel.tsx +++ b/src/renderer/src/components/RightPanel.tsx @@ -4,6 +4,8 @@ import { C4ElementType, NODE_COLORS, TYPE_LABELS, TYPE_ICON_PATHS, NODE_FG, isCo import { resolveEarsSubject } from '../types/metamodel' import { EarsQuickEntry } from './EarsQuickEntry' import type { HubImportRecord } from '../store/hubStore' +import { loadAISettings } from '../ai/settings' +import { generateWireframe, wireframeDataUri } from '../ai/mockupWireframe' // ── AutoResizeTextarea ──────────────────────────────────────────────────────── @@ -63,6 +65,7 @@ const PALETTE_GROUPS: { label: string; types: string[] }[] = [ { label: 'Domain', types: ['domain'] }, { label: 'Governance', types: ['adr', 'fitness-fn', 'blueprint'] }, { label: 'Requirements',types: ['requirement', 'scenario'] }, + { label: 'UX', types: ['mockup'] }, { label: 'Other', types: ['group'] }, ] @@ -1625,6 +1628,99 @@ function HubTemplateSection({ ) } +// ── Mockup: design link + AI-generated low-fi wireframe ───────────────────── + +function isHttpUrl(value: string): boolean { + try { + const u = new URL(value) + return u.protocol === 'http:' || u.protocol === 'https:' + } catch { + return false + } +} + +function MockupSection({ nodeId, readOnly }: { nodeId: string; readOnly: boolean }) { + const node = useDiagramStore((s) => s.c4Nodes[nodeId]) as unknown as Record | undefined + const updateNode = useDiagramStore((s) => s.updateNode) + const [busy, setBusy] = useState(false) + const [status, setStatus] = useState<{ kind: 'error' | 'info'; text: string } | null>(null) + const abortRef = useRef(null) + + // Cancel an in-flight generation when the user selects another node. + useEffect(() => { + setStatus(null) + return () => abortRef.current?.abort() + }, [nodeId]) + + if (!node) return null + const wireframe = typeof node.wireframe === 'string' ? node.wireframe : '' + const link = typeof node.link === 'string' ? node.link.trim() : '' + const aiEnabled = loadAISettings().enabled + + const generate = async (): Promise => { + const settings = loadAISettings() + abortRef.current?.abort() + const ac = new AbortController() + abortRef.current = ac + setBusy(true) + setStatus(null) + try { + const { c4Nodes, c4Relations } = useDiagramStore.getState() + const { svg, usage } = await generateWireframe(nodeId, c4Nodes, c4Relations, settings, ac.signal) + updateNode(nodeId, { wireframe: svg } as Parameters[1]) + setStatus(usage ? { kind: 'info', text: `${usage.inputTokens + usage.outputTokens} tokens` } : null) + } catch (err) { + if (!ac.signal.aborted) setStatus({ kind: 'error', text: err instanceof Error ? err.message : String(err) }) + } finally { + if (abortRef.current === ac) { + abortRef.current = null + setBusy(false) + } + } + } + + return ( +
+
Wireframe
+ {wireframe ? ( + Wireframe + ) : ( +
+ {aiEnabled + ? 'Link requirements / scenarios with “Illustrates”, then generate a low-fi wireframe.' + : 'Enable AI in settings to generate a wireframe, or add a design link.'} +
+ )} +
+ {link && isHttpUrl(link) && ( + + )} + {!readOnly && aiEnabled && ( + busy ? ( + + ) : ( + + ) + )} + {!readOnly && wireframe && !busy && ( + + )} +
+ {status && ( +
{status.text}
+ )} +
+ ) +} + function PropertiesContent({ readOnly = false }: { readOnly?: boolean }) { const selectedNodeId = useDiagramStore((s) => s.selectedNodeId) const selectedEdgeId = useDiagramStore((s) => s.selectedEdgeId) @@ -1830,6 +1926,7 @@ function PropertiesContent({ readOnly = false }: { readOnly?: boolean }) { } {parentSelector} + {node.type === 'mockup' && } {(() => { const entry = Object.entries(hubTemplates as Record) .find(([, rec]) => rec.nodeIds.includes(node.id)) diff --git a/src/renderer/src/components/WikiView.tsx b/src/renderer/src/components/WikiView.tsx index e56ef96..33e11cf 100644 --- a/src/renderer/src/components/WikiView.tsx +++ b/src/renderer/src/components/WikiView.tsx @@ -375,7 +375,7 @@ const ARCHITECTURE_ROOT_TYPES: ReadonlySet = new Set([ // Governance items are further split by type — a flat "Governance" bucket // mixing ADRs, fitness functions and requirements is just a smaller version // of the same illegible wall, so each type gets its own labelled subgroup. -const GOVERNANCE_TYPE_ORDER: readonly string[] = ['requirement', 'scenario', 'adr', 'fitness-fn', 'blueprint'] +const GOVERNANCE_TYPE_ORDER: readonly string[] = ['requirement', 'scenario', 'mockup', 'adr', 'fitness-fn', 'blueprint'] const GOVERNANCE_ROOT_TYPES: ReadonlySet = new Set(GOVERNANCE_TYPE_ORDER) type RootSectionId = 'architecture' | 'governance' | 'other' diff --git a/src/renderer/src/components/nodes/C4Nodes.tsx b/src/renderer/src/components/nodes/C4Nodes.tsx index 3c3d139..f2fa9d0 100644 --- a/src/renderer/src/components/nodes/C4Nodes.tsx +++ b/src/renderer/src/components/nodes/C4Nodes.tsx @@ -3,6 +3,7 @@ import { NodeProps, Handle, Position } from 'reactflow' import { C4NodeRFData, NODE_COLORS, TYPE_ICON_PATHS } from '../../types/c4' import { useDiagramStore } from '../../store/diagramStore' import { composeEarsSentence, resolveEarsSubject } from '../../types/metamodel' +import { wireframeDataUri } from '../../ai/mockupWireframe' // ─── Diff highlight overlay ──────────────────────────────────────────────── function DiffOverlay({ c4id }: { c4id: string }) { @@ -789,6 +790,73 @@ export const ScenarioNode = memo(({ data, selected }: NodeProps) = ScenarioNode.displayName = 'ScenarioNode' +// ─── Mockup Node ────────────────────────────────────────────────────────────── +// +// A UI screen: header strip + label, then the AI-generated wireframe as a +// thumbnail (rendered via so the SVG can never run scripts), or a +// placeholder pointing at the external design link when there is none. + +const MOCKUP_COLOR = '#be185d' + +export const MockupNode = memo(({ data, selected }: NodeProps) => { + const node = useDiagramStore(s => s.c4Nodes[data.c4id]) as unknown as Record | undefined + const wireframe = typeof node?.wireframe === 'string' ? node.wireframe : '' + const link = typeof node?.link === 'string' ? node.link.trim() : '' + const screen = typeof node?.screen === 'string' ? node.screen.trim() : '' + + return ( +
+ + + + {/* Row 1: type + screen */} +
+ + MOCKUP + + {screen && ( + + {screen} + + )} +
+ + {/* Row 2: label */} +
+ + {data.label} + +
+ + {/* Row 3: wireframe thumbnail / placeholder */} +
+ {wireframe ? ( + + ) : ( + + {link ? `🔗 ${link.replace(/^https?:\/\//, '')}` : 'No wireframe yet'} + + )} +
+
+ ) +}) + +MockupNode.displayName = 'MockupNode' + // ─── Requirement Node (EARS) ────────────────────────────────────────────────── // // Compact pill: teal/cyan header strip with the EARS sentence below. diff --git a/src/renderer/src/index.css b/src/renderer/src/index.css index f732956..b6266cf 100644 --- a/src/renderer/src/index.css +++ b/src/renderer/src/index.css @@ -2167,6 +2167,38 @@ body { } .props-delete:hover { background: rgba(255,107,107,0.12); } +.props-mockup { margin-bottom: 12px; } +.props-mockup-preview { + display: block; + width: 100%; + aspect-ratio: 4 / 3; + object-fit: contain; + background: #fff; + border: 1px solid var(--border-color); + border-radius: 4px; +} +.props-mockup-empty { + font-size: 11px; + color: var(--text-muted); + font-style: italic; + padding: 10px; + border: 1px dashed var(--border-color); + border-radius: 4px; +} +.props-mockup-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } +.props-mockup-btn { + padding: 5px 9px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--input-bg); + color: var(--text-primary); + font-size: 11px; + cursor: pointer; +} +.props-mockup-btn:hover { border-color: var(--accent); } +.props-mockup-status { font-size: 11px; margin-top: 6px; color: var(--text-muted); } +.props-mockup-status--error { color: var(--danger); } + /* ─── Wiki view ───────────────────────────────────────────────────── */ .wiki-view { grid-area: canvas; diff --git a/src/renderer/src/store/diagramStore.ts b/src/renderer/src/store/diagramStore.ts index 5c2fbd7..56e8e9e 100644 --- a/src/renderer/src/store/diagramStore.ts +++ b/src/renderer/src/store/diagramStore.ts @@ -670,7 +670,7 @@ function deriveRFNodes( // Fixed-size node types always render at canonical NODE_SIZES regardless of // what is stored in the document (handles legacy nodes created with old sizes). - const isFixedSize = n.type === 'adr' || n.type === 'fitness-fn' || n.type === 'requirement' || n.type === 'scenario' + const isFixedSize = n.type === 'adr' || n.type === 'fitness-fn' || n.type === 'requirement' || n.type === 'scenario' || n.type === 'mockup' const effHeight = isFixedSize ? NODE_SIZES[n.type].height diff --git a/src/renderer/src/types/c4.ts b/src/renderer/src/types/c4.ts index 302fe46..4975e21 100644 --- a/src/renderer/src/types/c4.ts +++ b/src/renderer/src/types/c4.ts @@ -2,7 +2,7 @@ import { builtInGovernanceMetamodel, type Metamodel, type NodeTypeDef } from './metamodel' -export type C4ElementType = 'person' | 'system' | 'container' | 'component' | 'database' | 'webapp' | 'queue' | 'domain' | 'group' | 'adr' | 'fitness-fn' | 'requirement' | 'scenario' | 'blueprint' +export type C4ElementType = 'person' | 'system' | 'container' | 'component' | 'database' | 'webapp' | 'queue' | 'domain' | 'group' | 'adr' | 'fitness-fn' | 'requirement' | 'scenario' | 'blueprint' | 'mockup' /** Types that act as containers (can hold children, collapse, auto-resize). */ export const CONTAINER_TYPES: ReadonlySet = new Set([ diff --git a/src/renderer/src/types/metamodel/presets/governance.ts b/src/renderer/src/types/metamodel/presets/governance.ts index 4d3dddb..01e45e3 100644 --- a/src/renderer/src/types/metamodel/presets/governance.ts +++ b/src/renderer/src/types/metamodel/presets/governance.ts @@ -6,6 +6,8 @@ // • constrains — adr / fitness-fn → any C4 element // • supersedes — adr → adr (replaces an older decision) // • implements — fitness-fn → adr ("this FF verifies ADR-003") +// • mockup — a UI screen (design link or AI-generated wireframe), +// linked via illustrates / presented-by / navigates-to import { Metamodel, NodeTypeDef, PropertyDef, RelationPair, RelationTypeDef } from '../types' import { builtInDddC4Metamodel } from './ddd' @@ -256,6 +258,74 @@ export function builtInGovernanceMetamodel(): Metamodel { properties: blueprintProps, } + // ── Mockup ──────────────────────────────────────────────────────────────── + // + // A screen of the user-facing product: either a link to an external design + // (Figma, Penpot, …) or a low-fi SVG wireframe generated by AI from the + // requirements / scenarios it illustrates. The wireframe lives on the node + // as `wireframe` (SVG markup) but is deliberately not a PropertyDef — it is + // edited through the dedicated Mockup section, not a raw textarea / table + // column. + + const mockupProps: PropertyDef[] = [ + { key: 'description', label: 'Description', type: 'textarea' }, + { key: 'screen', label: 'Screen / route', type: 'text' }, + { key: 'link', label: 'Design link', type: 'text' }, + ] + + const mockup: NodeTypeDef = { + id: 'mockup', + label: 'Mockup', + color: '#be185d', + fg: '#fff', + // Browser window with a header bar and content blocks + iconPath: 'M2 2.5A1.5 1.5 0 0 1 3.5 1h9A1.5 1.5 0 0 1 14 2.5v11a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 13.5v-11ZM3 5v8.5c0 .28.22.5.5.5h9a.5.5 0 0 0 .5-.5V5H3Zm1 1.5h8v2H4v-2Zm0 3h3.5V13H4V9.5Zm4.5 0H12v1H8.5v-1Zm0 2H12v1H8.5v-1Z', + width: 220, + height: 190, + collapsedWidth: 220, + collapsedHeight: 190, + allowedParents: ['system', 'domain', 'group'], + allowedAtRoot: true, + builtin: true, + tableTab: true, + properties: mockupProps, + } + + // mockup → requirement / scenario: this screen illustrates that behaviour + const illustrates: RelationTypeDef = { + id: 'illustrates', + label: 'Illustrates', + allowedPairs: [ + { from: 'mockup', to: 'requirement' }, + { from: 'mockup', to: 'scenario' }, + ], + properties: [], + color: '#db2777', + builtin: true, + } + + // mockup → container: which part of the system renders this screen + const presentedBy: RelationTypeDef = { + id: 'presented-by', + label: 'Presented by', + allowedPairs: (['webapp', 'container', 'component', 'system'] as const).map(to => ({ from: 'mockup', to })), + properties: [], + color: '#be185d', + builtin: true, + } + + // mockup → mockup: screen flow + const navigatesTo: RelationTypeDef = { + id: 'navigates-to', + label: 'Navigates to', + allowedPairs: [{ from: 'mockup', to: 'mockup' }], + properties: [ + { key: 'description', label: 'Trigger', type: 'text' }, + ], + color: '#f472b6', + builtin: true, + } + return { id: 'c4-ddd-governance-builtin', name: 'C4 + DDD + Governance', @@ -266,6 +336,7 @@ export function builtInGovernanceMetamodel(): Metamodel { requirement, scenario, blueprint, + mockup, }, relationTypes: { ...base.relationTypes, @@ -276,6 +347,9 @@ export function builtInGovernanceMetamodel(): Metamodel { derives, 'traces-to': tracesTo, verifies, + illustrates, + 'presented-by': presentedBy, + 'navigates-to': navigatesTo, }, } } diff --git a/tests/mockupWireframe.test.ts b/tests/mockupWireframe.test.ts new file mode 100644 index 0000000..59e0c1f --- /dev/null +++ b/tests/mockupWireframe.test.ts @@ -0,0 +1,106 @@ +/** + * Mockup node type: metamodel wiring, the AI wireframe prompt built from the + * surrounding model, SVG extraction/sanitising of raw model output, and the + * wireframe surviving an md-folder round-trip as multi-line frontmatter. + */ +import { describe, it, expect } from 'vitest' +import { + buildWireframePrompt, + sanitizeWireframeSvg, + MAX_WIREFRAME_CHARS, +} from '../src/renderer/src/ai/mockupWireframe' +import { builtInGovernanceMetamodel, inferRelationType, isRelationAllowed } from '../src/renderer/src/types/metamodel' +import { serializeToMdFolder, deserializeFromMdFolder } from '../src/renderer/src/persist/mdFolder' +import type { C4Node, C4Relation } from '../src/renderer/src/types/c4' + +function node(partial: Partial & Pick & Record): C4Node { + return { collapsed: false, x: 0, y: 0, width: 220, height: 190, ...partial } as C4Node +} + +describe('mockup metamodel', () => { + const mm = builtInGovernanceMetamodel() + + it('defines the mockup node type without a status property', () => { + const def = mm.nodeTypes.mockup + expect(def).toBeDefined() + expect(def.properties?.map((p) => p.key)).toEqual(['description', 'screen', 'link']) + }) + + it('infers the specific relation type for each mockup pair', () => { + expect(inferRelationType(mm, 'mockup', 'requirement')).toBe('illustrates') + expect(inferRelationType(mm, 'mockup', 'scenario')).toBe('illustrates') + expect(inferRelationType(mm, 'mockup', 'webapp')).toBe('presented-by') + expect(inferRelationType(mm, 'mockup', 'mockup')).toBe('navigates-to') + expect(isRelationAllowed(mm, 'requirement', 'mockup')).toBe(false) + }) +}) + +describe('buildWireframePrompt', () => { + const nodes: Record = { + m1: node({ id: 'm1', type: 'mockup', label: 'Checkout', screen: '/checkout', description: 'Pay for the basket' }), + m2: node({ id: 'm2', type: 'mockup', label: 'Order confirmation' }), + r1: node({ id: 'r1', type: 'requirement', label: 'REQ-1', action: 'accept card payments' }), + s1: node({ id: 's1', type: 'scenario', label: 'Declined card', given: 'a basket', when: 'the card is declined', then: 'an error is shown' }), + w1: node({ id: 'w1', type: 'webapp', label: 'Storefront', technology: 'React' }), + other: node({ id: 'other', type: 'requirement', label: 'UNRELATED' }), + } + const relations: Record = { + a: { id: 'a', sourceId: 'm1', targetId: 'r1', relationType: 'illustrates' }, + b: { id: 'b', sourceId: 'm1', targetId: 's1', relationType: 'illustrates' }, + c: { id: 'c', sourceId: 'm1', targetId: 'w1', relationType: 'presented-by' }, + d: { id: 'd', sourceId: 'm1', targetId: 'm2', relationType: 'navigates-to', label: 'Pay' }, + } + + it('includes the linked model context', () => { + const prompt = buildWireframePrompt('m1', nodes, relations) + expect(prompt).toContain('"Checkout"') + expect(prompt).toContain('/checkout') + expect(prompt).toContain('Pay for the basket') + expect(prompt).toContain('REQ-1: the system shall accept card payments') + expect(prompt).toContain('When the card is declined') + expect(prompt).toContain('Storefront — React') + expect(prompt).toContain('Order confirmation (via Pay)') + expect(prompt).not.toContain('UNRELATED') + }) + + it('throws for an unknown node', () => { + expect(() => buildWireframePrompt('nope', nodes, relations)).toThrow() + }) +}) + +describe('sanitizeWireframeSvg', () => { + it('extracts the svg from fenced / chatty output', () => { + const svg = sanitizeWireframeSvg('Here you go:\n```svg\n\n```') + expect(svg).toBe('') + }) + + it('returns null when there is no svg', () => { + expect(sanitizeWireframeSvg('Sorry, I cannot do that.')).toBeNull() + }) + + it('strips scripts, foreignObject, event handlers and external hrefs', () => { + const svg = sanitizeWireframeSvg( + '
' + + '', + )! + expect(svg).not.toMatch(/script|foreignObject|onclick|onload|evil/) + expect(svg).toContain('xlink:href="#a"') + expect(svg).toMatch(/^ { + const big = `${''.repeat(MAX_WIREFRAME_CHARS)}` + expect(sanitizeWireframeSvg(big)).toBeNull() + }) +}) + +describe('mockup md-folder round-trip', () => { + it('keeps a multi-line wireframe and link lossless', () => { + const wireframe = '\n \n\n Checkout\n' + const m = node({ id: 'm1', type: 'mockup', label: 'Checkout', link: 'https://figma.com/file/abc', wireframe }) + const data = deserializeFromMdFolder(serializeToMdFolder({ nodes: [m], relations: [] })) + const back = data.nodes[0] as unknown as Record + expect(back.wireframe).toBe(wireframe) + expect(back.link).toBe('https://figma.com/file/abc') + }) +}) From ddad11d890ac9e91f4ec37ec838c91988b70980c Mon Sep 17 00:00:00 2001 From: Tomasz Zajac Date: Wed, 23 Sep 2026 18:37:26 +0200 Subject: [PATCH 2/2] test: adapt mockup md-folder round-trip to lazy deserialize result deserializeFromMdFolder now returns { data, bodyPaths? } since #82. Co-Authored-By: Claude Opus 5.5 --- tests/mockupWireframe.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/mockupWireframe.test.ts b/tests/mockupWireframe.test.ts index 59e0c1f..c946143 100644 --- a/tests/mockupWireframe.test.ts +++ b/tests/mockupWireframe.test.ts @@ -98,7 +98,7 @@ describe('mockup md-folder round-trip', () => { it('keeps a multi-line wireframe and link lossless', () => { const wireframe = '\n \n\n Checkout\n' const m = node({ id: 'm1', type: 'mockup', label: 'Checkout', link: 'https://figma.com/file/abc', wireframe }) - const data = deserializeFromMdFolder(serializeToMdFolder({ nodes: [m], relations: [] })) + const { data } = deserializeFromMdFolder(serializeToMdFolder({ nodes: [m], relations: [] })) const back = data.nodes[0] as unknown as Record expect(back.wireframe).toBe(wireframe) expect(back.link).toBe('https://figma.com/file/abc')