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
110 changes: 110 additions & 0 deletions src/renderer/src/components/MockupWireframe.tsx
Original file line number Diff line number Diff line change
@@ -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 = <div className="props-section-title">Wireframe</div>,
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<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 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<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={className}>
{heading}
{wireframe ? (
<img className="mockup-wf-preview" src={wireframeDataUri(wireframe)} alt="Wireframe" />
) : (
<div className="mockup-wf-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="mockup-wf-actions">
{link && isHttpUrl(link) && (
<button className="mockup-wf-btn" onClick={() => window.open(link, '_blank', 'noopener')}>
🔗 Open design
</button>
)}
{!readOnly && aiEnabled && (
busy ? (
<button className="mockup-wf-btn" onClick={() => abortRef.current?.abort()}>
Cancel…
</button>
) : (
<button className="mockup-wf-btn" onClick={() => void generate()}>
✨ {wireframe ? 'Regenerate' : 'Generate'} wireframe
</button>
)
)}
{!readOnly && wireframe && !busy && (
<button className="mockup-wf-btn" onClick={() => updateNode(nodeId, { wireframe: undefined } as Parameters<typeof updateNode>[1])}>
Remove
</button>
)}
</div>
{status && (
<div className={`mockup-wf-status mockup-wf-status--${status.kind}`}>{status.text}</div>
)}
</div>
)
}
98 changes: 2 additions & 96 deletions src/renderer/src/components/RightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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<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 @@ -1951,7 +1857,7 @@ function PropertiesContent({ readOnly = false }: { readOnly?: boolean }) {
}
{parentSelector}
</div>
{node.type === 'mockup' && <MockupSection nodeId={node.id} readOnly={readOnly} />}
{node.type === 'mockup' && <MockupWireframe nodeId={node.id} readOnly={readOnly} />}
{(() => {
const entry = Object.entries(hubTemplates as Record<string, HubImportRecord>)
.find(([, rec]) => rec.nodeIds.includes(node.id))
Expand Down
18 changes: 18 additions & 0 deletions src/renderer/src/components/WikiView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -560,6 +562,13 @@ function WikiNodeCard({
{sub}
</span>
{node.description && <span className="wiki-card-desc">{node.description}</span>}
{node.type === 'mockup' && typeof (node as unknown as Record<string, unknown>).wireframe === 'string' && (
<img
className="wiki-card-thumb"
src={wireframeDataUri((node as unknown as Record<string, string>).wireframe)}
alt=""
/>
)}
</span>
</button>
{onDelete && (
Expand Down Expand Up @@ -1081,6 +1090,15 @@ function WikiElementPage({
</header>

<div className="wiki-main">
{node.type === 'mockup' && (
<MockupWireframe
nodeId={node.id}
readOnly={readOnly}
className="wiki-prose-section mockup-wf"
heading={<h2 className="wiki-h2">Wireframe</h2>}
/>
)}

{/* Long-form sections */}
{(hasMeta ? sectionProps : []).map((p) => (
<section className="wiki-prose-section" key={p.key}>
Expand Down
33 changes: 25 additions & 8 deletions src/renderer/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -2177,16 +2177,16 @@ 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;
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 {
.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;
Expand All @@ -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 {
Expand Down
Loading