From c290ec2638106b918903546b2183e34279532ec6 Mon Sep 17 00:00:00 2001 From: Tomasz Zajac Date: Wed, 23 Sep 2026 18:43:53 +0200 Subject: [PATCH] fix: show mockup wireframes in the Wiki view The Wiki element page only renders metamodel properties, and `wireframe` is deliberately not a PropertyDef, so generated wireframes never appeared there. Extract the properties-panel Wireframe section into a shared MockupWireframe component and render it at the top of a mockup's Wiki page (preview, Generate / Regenerate, Remove, Open design). Overview cards for mockups also get a thumbnail. Co-Authored-By: Claude Opus 5.5 --- .../src/components/MockupWireframe.tsx | 110 ++++++++++++++++++ src/renderer/src/components/RightPanel.tsx | 98 +--------------- src/renderer/src/components/WikiView.tsx | 18 +++ src/renderer/src/index.css | 33 ++++-- 4 files changed, 155 insertions(+), 104 deletions(-) create mode 100644 src/renderer/src/components/MockupWireframe.tsx diff --git a/src/renderer/src/components/MockupWireframe.tsx b/src/renderer/src/components/MockupWireframe.tsx new file mode 100644 index 0000000..684ea92 --- /dev/null +++ b/src/renderer/src/components/MockupWireframe.tsx @@ -0,0 +1,110 @@ +// ─── Mockup wireframe section ─────────────────────────────────────────────── +// Preview of a Mockup node's AI-generated low-fi wireframe plus its actions +// (generate / regenerate, remove, open the external design link). Shared by +// the properties panel and the Wiki element page. + +import React, { useEffect, useRef, useState } from 'react' +import { useDiagramStore } from '../store/diagramStore' +import { loadAISettings } from '../ai/settings' +import { generateWireframe, wireframeDataUri } from '../ai/mockupWireframe' + +export function isHttpUrl(value: string): boolean { + try { + const u = new URL(value) + return u.protocol === 'http:' || u.protocol === 'https:' + } catch { + return false + } +} + +export function MockupWireframe({ + nodeId, + readOnly, + heading =
Wireframe
, + className = 'mockup-wf', +}: { + nodeId: string + readOnly: boolean + heading?: React.ReactNode + className?: string +}): React.ReactElement | null { + 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 component switches to 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 ( +
+ {heading} + {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}
+ )} +
+ ) +} diff --git a/src/renderer/src/components/RightPanel.tsx b/src/renderer/src/components/RightPanel.tsx index ff9a2e9..33792d1 100644 --- a/src/renderer/src/components/RightPanel.tsx +++ b/src/renderer/src/components/RightPanel.tsx @@ -4,8 +4,7 @@ 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' +import { MockupWireframe } from './MockupWireframe' // ── AutoResizeTextarea ──────────────────────────────────────────────────────── @@ -1628,99 +1627,6 @@ 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) @@ -1951,7 +1857,7 @@ function PropertiesContent({ readOnly = false }: { readOnly?: boolean }) { } {parentSelector} - {node.type === 'mockup' && } + {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 33e11cf..1398eb6 100644 --- a/src/renderer/src/components/WikiView.tsx +++ b/src/renderer/src/components/WikiView.tsx @@ -3,6 +3,8 @@ import { useDiagramStore } from '../store/diagramStore' import { isParentAllowed, isRelationAllowed, isPropertyVisible, resolveEarsSubject, PropertyDef } from '../types/metamodel' import { useOutsideClick } from '../hooks/useOutsideClick' import { EarsQuickEntry } from './EarsQuickEntry' +import { MockupWireframe } from './MockupWireframe' +import { wireframeDataUri } from '../ai/mockupWireframe' import { loadStudioSettings, STUDIO_SETTINGS_CHANGED_EVENT } from '../studioSettings' import { C4Node, @@ -560,6 +562,13 @@ function WikiNodeCard({ {sub} {node.description && {node.description}} + {node.type === 'mockup' && typeof (node as unknown as Record).wireframe === 'string' && ( + ).wireframe)} + alt="" + /> + )} {onDelete && ( @@ -1081,6 +1090,15 @@ function WikiElementPage({
+ {node.type === 'mockup' && ( + Wireframe} + /> + )} + {/* Long-form sections */} {(hasMeta ? sectionProps : []).map((p) => (
diff --git a/src/renderer/src/index.css b/src/renderer/src/index.css index b6266cf..0909cb4 100644 --- a/src/renderer/src/index.css +++ b/src/renderer/src/index.css @@ -2167,8 +2167,8 @@ body { } .props-delete:hover { background: rgba(255,107,107,0.12); } -.props-mockup { margin-bottom: 12px; } -.props-mockup-preview { +.mockup-wf { margin-bottom: 12px; } +.mockup-wf-preview { display: block; width: 100%; aspect-ratio: 4 / 3; @@ -2177,7 +2177,7 @@ body { border: 1px solid var(--border-color); border-radius: 4px; } -.props-mockup-empty { +.mockup-wf-empty { font-size: 11px; color: var(--text-muted); font-style: italic; @@ -2185,8 +2185,8 @@ body { 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 { +.mockup-wf-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } +.mockup-wf-btn { padding: 5px 9px; border: 1px solid var(--border-color); border-radius: 4px; @@ -2195,9 +2195,26 @@ body { 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); } +.mockup-wf-btn:hover { border-color: var(--accent); } +.mockup-wf-status { font-size: 11px; margin-top: 6px; color: var(--text-muted); } +.mockup-wf-status--error { color: var(--danger); } + +/* Wiki page: wider preview, capped so a 4:3 frame doesn't dominate the page */ +.wiki-prose-section.mockup-wf .mockup-wf-preview { max-width: 640px; } +.wiki-prose-section.mockup-wf .mockup-wf-empty { font-size: 13px; max-width: 640px; } + +/* Wiki overview card thumbnail */ +.wiki-card-thumb { + display: block; + width: 100%; + max-width: 240px; + aspect-ratio: 4 / 3; + object-fit: contain; + background: #fff; + border: 1px solid var(--border-color); + border-radius: 3px; + margin-top: 6px; +} /* ─── Wiki view ───────────────────────────────────────────────────── */ .wiki-view {