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 ec11d65..ff9a2e9 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) @@ -1855,6 +1951,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 3f34ddc..763c2d3 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..c946143 --- /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') + }) +})