Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions src/renderer/src/ai/mockupWireframe.ts
Original file line number Diff line number Diff line change
@@ -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 <img> 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<string, C4Node>
type RelationMap = Record<string, C4Relation>

function prop(node: C4Node, key: string): string {
const v = (node as unknown as Record<string, unknown>)[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 <svg> 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 <svg>…</svg> 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(/<svg[\s\S]*?<\/svg>/i)
if (!match) return null
let svg = match[0]
svg = svg
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<script[^>]*\/>/gi, '')
.replace(/<foreignObject[\s\S]*?<\/foreignObject>/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(/^<svg/i, '<svg xmlns="http://www.w3.org/2000/svg"')
}
if (svg.length > MAX_WIREFRAME_CHARS) return null
return svg
}

/** Data URI for rendering a stored wireframe through <img>. */
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<WireframeResult> {
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 }
}
3 changes: 2 additions & 1 deletion src/renderer/src/components/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 = {
Expand Down
97 changes: 97 additions & 0 deletions src/renderer/src/components/RightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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'] },
]

Expand Down Expand Up @@ -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<string, unknown> | 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<AbortController | null>(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<void> => {
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<typeof updateNode>[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 (
<div className="props-mockup">
<div className="props-section-title">Wireframe</div>
{wireframe ? (
<img className="props-mockup-preview" src={wireframeDataUri(wireframe)} alt="Wireframe" />
) : (
<div className="props-mockup-empty">
{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.'}
</div>
)}
<div className="props-mockup-actions">
{link && isHttpUrl(link) && (
<button className="props-mockup-btn" onClick={() => window.open(link, '_blank', 'noopener')}>
🔗 Open design
</button>
)}
{!readOnly && aiEnabled && (
busy ? (
<button className="props-mockup-btn" onClick={() => abortRef.current?.abort()}>
Cancel…
</button>
) : (
<button className="props-mockup-btn" onClick={() => void generate()}>
✨ {wireframe ? 'Regenerate' : 'Generate'} wireframe
</button>
)
)}
{!readOnly && wireframe && !busy && (
<button className="props-mockup-btn" onClick={() => updateNode(nodeId, { wireframe: undefined } as Parameters<typeof updateNode>[1])}>
Remove
</button>
)}
</div>
{status && (
<div className={`props-mockup-status props-mockup-status--${status.kind}`}>{status.text}</div>
)}
</div>
)
}

function PropertiesContent({ readOnly = false }: { readOnly?: boolean }) {
const selectedNodeId = useDiagramStore((s) => s.selectedNodeId)
const selectedEdgeId = useDiagramStore((s) => s.selectedEdgeId)
Expand Down Expand Up @@ -1855,6 +1951,7 @@ function PropertiesContent({ readOnly = false }: { readOnly?: boolean }) {
}
{parentSelector}
</div>
{node.type === 'mockup' && <MockupSection nodeId={node.id} readOnly={readOnly} />}
{(() => {
const entry = Object.entries(hubTemplates as Record<string, HubImportRecord>)
.find(([, rec]) => rec.nodeIds.includes(node.id))
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/components/WikiView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ const ARCHITECTURE_ROOT_TYPES: ReadonlySet<string> = 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<string> = new Set(GOVERNANCE_TYPE_ORDER)

type RootSectionId = 'architecture' | 'governance' | 'other'
Expand Down
68 changes: 68 additions & 0 deletions src/renderer/src/components/nodes/C4Nodes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -789,6 +790,73 @@ export const ScenarioNode = memo(({ data, selected }: NodeProps<C4NodeRFData>) =

ScenarioNode.displayName = 'ScenarioNode'

// ─── Mockup Node ──────────────────────────────────────────────────────────────
//
// A UI screen: header strip + label, then the AI-generated wireframe as a
// thumbnail (rendered via <img> 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<C4NodeRFData>) => {
const node = useDiagramStore(s => s.c4Nodes[data.c4id]) as unknown as Record<string, unknown> | 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 (
<div
className="c4-node"
style={{
position: 'relative',
width: data.width,
height: data.height,
background: MOCKUP_COLOR,
border: `2px solid ${selected ? 'var(--accent)' : 'rgba(0,0,0,0.25)'}`,
borderRadius: 6,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<AllHandles />
<DiffOverlay c4id={data.c4id} />

{/* Row 1: type + screen */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '3px 7px', background: 'rgba(0,0,0,0.25)' }}>
<span style={{ fontSize: 8, fontWeight: 700, letterSpacing: '0.07em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.7)' }}>
MOCKUP
</span>
{screen && (
<span style={{ fontSize: 8, color: 'rgba(255,255,255,0.6)', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{screen}
</span>
)}
</div>

{/* Row 2: label */}
<div style={{ padding: '2px 7px 3px', overflow: 'hidden' }}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#fff', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>
{data.label}
</span>
</div>

{/* Row 3: wireframe thumbnail / placeholder */}
<div style={{ flex: 1, margin: '0 5px 5px', background: '#fff', borderRadius: 3, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{wireframe ? (
<img src={wireframeDataUri(wireframe)} alt="" draggable={false} style={{ width: '100%', height: '100%', objectFit: 'contain', pointerEvents: 'none' }} />
) : (
<span style={{ fontSize: 9, color: '#9ca3af', fontStyle: 'italic', padding: 6, textAlign: 'center', wordBreak: 'break-all' }}>
{link ? `🔗 ${link.replace(/^https?:\/\//, '')}` : 'No wireframe yet'}
</span>
)}
</div>
</div>
)
})

MockupNode.displayName = 'MockupNode'

// ─── Requirement Node (EARS) ──────────────────────────────────────────────────
//
// Compact pill: teal/cyan header strip with the EARS sentence below.
Expand Down
Loading
Loading