From e9f56757deaeca682273500fa043add407ac9f02 Mon Sep 17 00:00:00 2001 From: Tomasz Zajac Date: Wed, 23 Sep 2026 21:02:33 +0200 Subject: [PATCH] feat(forge): add a Mockups stage, explicit early finish, single action bar Mockups stage - New Forge stage between Scenarios and C4 (requirements -> fitness -> scenarios -> mockups -> c4). Screens are part of the spec the architecture follows from, so they come before C4. - The stage creates `mockup` nodes for the user-facing screens (skipping API-only behaviour, or creating none for a system with no UI), linked with `illustrates` (-> requirement / scenario) and `navigates-to` (-> mockup, labelled with the triggering user action). - The C4 stage now takes the mockups into account and adds `presented-by` (mockup -> webapp / container), the same way it adds `constrains` for fitness functions once real elements exist. - After the stage, "Generate N wireframes" draws a low-fi wireframe for every mockup without one, one call at a time (ai/mockupWireframe.ts), with progress, cancel, and tokens added to the session total. Explicit early finish - "Finish here" on every stage ends the run cleanly: whatever has been generated stays in the model and the wizard moves to the final step. - The final step ("Finish", formerly "Export") summarises each stage (+N nodes / +M relations, or skipped) and keeps the .feature export. - "<- Back" from an early finish resumes at the stage it was stopped at. - The stepper ticks only stages that actually generated, marks skipped ones, and does not let the user jump to stages never reached. Single action bar - Flow actions (Generate, Regenerate, Cancel, Confirm answers / Skip questions, Continue / Finish) now live only in the modal footer, which shows whatever moves the current stage forward. Previously they were split between the stage body and the footer, e.g. an in-body Generate button next to a disabled footer Continue, and two different Cancels. The stage body keeps only per-item actions (Hub import, edit answers, generate wireframes, export). - Fixes the stage progress panel showing during wireframe generation and "Edit answers" being clickable while a stage was generating. Co-Authored-By: Claude Opus 5.5 --- src/renderer/src/ai/forgePrompts.ts | 63 +++- .../src/components/RadicalForgeModal.tsx | 293 ++++++++++++++---- src/renderer/src/components/Toolbar.tsx | 2 +- src/renderer/src/components/WelcomeScreen.tsx | 2 +- src/renderer/src/index.css | 47 ++- tests/forgePrompts.test.ts | 26 +- 6 files changed, 353 insertions(+), 80 deletions(-) diff --git a/src/renderer/src/ai/forgePrompts.ts b/src/renderer/src/ai/forgePrompts.ts index 0554f4e..0cb62b3 100644 --- a/src/renderer/src/ai/forgePrompts.ts +++ b/src/renderer/src/ai/forgePrompts.ts @@ -1,24 +1,32 @@ // ─── Radical Forge stage prompts ──────────────────────────────────────────── -// Radical Forge walks a free-text system description through four sequential +// Radical Forge walks a free-text system description through five sequential // AI generation stages — requirements → fitness functions → Gherkin -// scenarios → C4 model — each a normal `runAIPrompt` call sharing one +// scenarios → UI mockups → C4 model — each a normal `runAIPrompt` call sharing one // running `history` array — so a later stage sees everything an earlier // stage created (via buildContextMessage in systemPrompt.ts), same as any // multi-turn chat. No new AI infrastructure: these are just task-scoped // prompt strings. // // C4 deliberately runs LAST: the behavior/quality spec (requirements, -// fitness functions, scenarios) is nailed down first, and the architecture +// fitness functions, scenarios, screens) is nailed down first, and the architecture // follows from it rather than the other way around. This changes how // fitness functions link back — with no C4 elements to `constrains` yet, // they're linked from the requirement side via `traces-to` instead; once C4 // runs last, it links the elements it creates to matching fitness-fns via // `constrains` (see the governance metamodel preset for why the relation // only goes fitness-fn/adr/requirement → C4-element, never the reverse). +// +// Mockups are part of that spec, so they come before C4: the screens a user +// needs shape the front-end decomposition, not the other way around. Same +// trick as fitness functions — with no webapp to point at yet, `presented-by` +// (mockup → webapp/container) is added by the C4 stage once the elements +// exist. The mockups stage only creates the mockup nodes and their links; +// wireframes are drawn by a separate per-mockup call (ai/mockupWireframe.ts), +// triggered from the wizard. import type { HubConceptSummary } from '../store/hubStore' -export type ForgeStageId = 'requirements' | 'fitness' | 'scenarios' | 'c4' +export type ForgeStageId = 'requirements' | 'fitness' | 'scenarios' | 'c4' | 'mockups' export interface ForgeStage { id: ForgeStageId @@ -35,6 +43,7 @@ export const PRIMARY_TYPE_IDS_FOR_STAGE: Record = { requirements: ['requirement'], fitness: ['fitness-fn'], scenarios: ['scenario'], + mockups: ['mockup'], c4: ['person', 'system', 'container', 'component', 'database', 'webapp', 'queue', 'domain', 'group'], } @@ -54,6 +63,11 @@ export const FORGE_STAGES: ForgeStage[] = [ title: 'Gherkin scenarios', blurb: 'Write Given/When/Then scenarios that verify each requirement.', }, + { + id: 'mockups', + title: 'Mockups', + blurb: 'Sketch the user-facing screens that illustrate the requirements and scenarios, then draw low-fi wireframes for them.', + }, { id: 'c4', title: 'C4 model', @@ -161,17 +175,20 @@ export function buildForgeStagePrompt( return [ descBlock, '', - 'Task: using the requirements, fitness functions and Gherkin scenarios already', - 'in the model, plus the description above, derive the C4 structure — the', - 'people/systems/containers/components involved — and the relations between', - 'them. This is the last stage: the behavior and quality spec is already fully', - 'formed, so let the architecture follow from it rather than guessing ahead of', - 'it — e.g. a fitness function with a tight latency threshold or a scenario', - 'implying an async flow should visibly shape how you decompose the system.', + 'Task: using the requirements, fitness functions, Gherkin scenarios and UI', + 'mockups already in the model, plus the description above, derive the C4', + 'structure — the people/systems/containers/components involved — and the', + 'relations between them. This is the last stage: the behavior and quality', + 'spec is already fully formed, so let the architecture follow from it rather', + 'than guessing ahead of it — e.g. a fitness function with a tight latency', + 'threshold, a scenario implying an async flow, or a set of screens implying a', + 'separate admin front-end should visibly shape how you decompose the system.', 'Link each element to the requirement(s) it satisfies with a `satisfies`', 'relation, AND link each existing `fitness-fn` node to whichever new element(s)', - 'it actually constrains with a `constrains` relation (this only becomes', - 'possible now that real elements exist for it to point at). Do not invent', + 'it actually constrains with a `constrains` relation, AND link each existing', + '`mockup` node to the webapp or container that renders it with a', + '`presented-by` relation FROM the mockup TO that element (both only become', + 'possible now that real elements exist to point at). Do not invent', 'requirements at this stage; if the description implies something not yet', 'covered by a requirement, model the C4 element anyway but leave it unlinked', 'rather than fabricating a requirement here.', @@ -201,5 +218,25 @@ export function buildForgeStagePrompt( 'placeholders) and use the `gherkin` field only for extra `And`/`But` steps.', 'Link each scenario to the requirement it exercises with a `verifies` relation.', ].join('\n') + + case 'mockups': + return [ + descBlock, + '', + 'Task: identify the key user-facing screens implied by the requirements and', + 'scenarios already in the model — only where a person interacts through a UI;', + 'skip machine-to-machine / API-only behaviour. Create one `mockup` node per', + 'screen (typically 3-8; fold small variations such as an error state into the', + 'screen they belong to). Set `screen` to its route or screen name and', + '`description` to what the user sees and does there. Leave `link` empty.', + 'Link each mockup to the requirement(s) and scenario(s) it covers with', + '`illustrates` (mockup → requirement / scenario), and model the main navigation', + 'between screens with `navigates-to` (mockup → mockup), using the relation', + 'label for the user action that triggers it (e.g. "Pay"). No systems or', + 'containers exist yet (C4 runs next and will link each screen to the front-end', + 'that renders it), so do not create any here. Do not draw wireframes here —', + 'they are generated separately from these nodes. If the system has no user', + 'interface at all, create no mockups and say so in your summary.', + ].join('\n') } } diff --git a/src/renderer/src/components/RadicalForgeModal.tsx b/src/renderer/src/components/RadicalForgeModal.tsx index 70acc7c..df58fe9 100644 --- a/src/renderer/src/components/RadicalForgeModal.tsx +++ b/src/renderer/src/components/RadicalForgeModal.tsx @@ -19,8 +19,21 @@ import { } from '../ai/forgeClarify' import { addTokenUsage, type AISettings, type TokenUsage } from '../ai/types' import type { ApplyReport } from '../ai/diagramFacade' +import { generateWireframe } from '../ai/mockupWireframe' type ClarifyStatus = 'asking' | 'form' | 'done' + +/** Batch wireframe generation after the Mockups stage — one tool-less call + * per mockup (ai/mockupWireframe.ts), run sequentially so a large batch + * doesn't trip provider rate limits. */ +interface WireframeRun { + total: number + done: number + failed: number + /** Label of the mockup currently being drawn (while running). */ + current?: string + running: boolean +} type ClarifyAnswers = Record interface ProgressEntry { @@ -75,7 +88,8 @@ const STEP_LABELS: Record = { c4: 'C4 model', fitness: 'Fitness fns', scenarios: 'Scenarios', - export: 'Export', + mockups: 'Mockups', + export: 'Finish', } /** Browser-only text-file picker (mirrors documentStore.ts's defaultWebFilePicker, @@ -129,6 +143,15 @@ export function RadicalForgeModal({ open, onClose }: Props): React.ReactElement /** Running total across every stage generated so far this wizard session — * undefined until the first stage with usage data completes. */ const [sessionUsage, setSessionUsage] = useState(undefined) + const [wireframeRun, setWireframeRun] = useState(null) + /** Furthest step reached by normal forward navigation — stepper tabs past + * it stay disabled. An early finish jumps to the last step without + * raising this, so stages the user never reached can't be opened from + * the stepper (they resume via ← Back instead). */ + const [reachedIndex, setReachedIndex] = useState(0) + /** Stage the user explicitly finished the run at ("Finish here"), or null + * when the run went through every stage / hasn't finished. */ + const [finishedAt, setFinishedAt] = useState(null) const progressIdRef = useRef(0) const progressListRef = useRef(null) const abortRef = useRef(null) @@ -161,6 +184,9 @@ export function RadicalForgeModal({ open, onClose }: Props): React.ReactElement setClarifyAnswersByStage({}) setProgressByStage({}) setSessionUsage(undefined) + setWireframeRun(null) + setReachedIndex(0) + setFinishedAt(null) clarifyStartedRef.current = new Set() }, [open]) @@ -367,6 +393,46 @@ export function RadicalForgeModal({ open, onClose }: Props): React.ReactElement const cancelStage = useCallback(() => { abortRef.current?.abort() }, []) + const generateMissingWireframes = useCallback(async () => { + if (busy || unavailableReason) return + const { c4Nodes } = useDiagramStore.getState() + const targets = Object.values(c4Nodes).filter( + (n) => n.type === 'mockup' && !(n as unknown as Record).wireframe, + ) + if (!targets.length) return + setBusy(true) + setError(null) + const ctl = new AbortController() + abortRef.current = ctl + let done = 0 + let failed = 0 + setWireframeRun({ total: targets.length, done, failed, running: true }) + try { + for (const target of targets) { + if (ctl.signal.aborted) break + setWireframeRun({ total: targets.length, done, failed, current: target.label, running: true }) + try { + // Re-read the model each time: earlier wireframes / user edits + // made while the batch runs are then part of the next prompt. + const { c4Nodes: nodes, c4Relations, updateNode } = useDiagramStore.getState() + if (!nodes[target.id]) continue + const { svg, usage } = await generateWireframe(target.id, nodes, c4Relations, aiSettings, ctl.signal) + updateNode(target.id, { wireframe: svg } as Parameters[1]) + if (usage) setSessionUsage((u) => addTokenUsage(u, usage)) + } catch (err) { + if (ctl.signal.aborted) break + failed++ + setError(`${target.label}: ${(err as Error).message || String(err)}`) + } + done++ + } + } finally { + setWireframeRun({ total: targets.length, done, failed, running: false }) + abortRef.current = null + setBusy(false) + } + }, [busy, unavailableReason, aiSettings]) + // Keep the live progress feed scrolled to its newest entry. useEffect(() => { const el = progressListRef.current @@ -391,16 +457,79 @@ export function RadicalForgeModal({ open, onClose }: Props): React.ReactElement const goTo = useCallback((s: WizardStep) => { if (!busy) setStep(s) }, [busy]) const goNext = useCallback(() => { const next = STEP_ORDER[stepIndex + 1] - if (next) setStep(next) + if (!next) return + setStep(next) + setReachedIndex((r) => Math.max(r, stepIndex + 1)) + setFinishedAt(null) }, [stepIndex]) const goBack = useCallback(() => { + // Back from an early finish resumes the run where it was stopped. + if (step === 'export' && finishedAt) { + setStep(finishedAt) + setFinishedAt(null) + return + } const prev = STEP_ORDER[stepIndex - 1] if (prev) setStep(prev) - }, [stepIndex]) + }, [step, stepIndex, finishedAt]) + /** Explicitly end the run at the current stage — everything generated so + * far stays in the model; later stages (and this one, if it was never + * generated) are reported as skipped on the finish step. */ + const finishHere = useCallback(() => { + if (busy || !currentStageId) return + setFinishedAt(currentStageId) + setStep('export') + }, [busy, currentStageId]) const nodes = useDiagramStore((s) => s.c4Nodes) const relations = useDiagramStore((s) => s.c4Relations) const gherkinFiles = useMemo(() => buildGherkinFiles(nodes, relations), [nodes, relations]) + const mockupStats = useMemo(() => { + const mockups = Object.values(nodes).filter((n) => n.type === 'mockup') + const missing = mockups.filter((n) => !(n as unknown as Record).wireframe).length + return { total: mockups.length, missing } + }, [nodes]) + + const isLastStage = currentStageId === FORGE_STAGES[FORGE_STAGES.length - 1].id + // The footer is the wizard's single action bar: its right-hand side shows + // whatever moves the current stage forward right now (answer the clarifying + // questions → generate → continue), so the stage body only ever holds + // per-item actions (Hub import, editing answers, wireframes, export). + const stageAction: React.ReactNode = (() => { + if (!currentStageId) return null + const stageTitle = FORGE_STAGES.find((st) => st.id === currentStageId)!.title.toLowerCase() + const clarifyStatus = clarifyStatusByStage[currentStageId] + if (busy) { + return + } + if (clarifyStatus === 'form') { + return ( + <> + + + + ) + } + if (stageReports[currentStageId]) { + return ( + <> + + + + ) + } + return ( + + ) + })() if (!open) return null @@ -442,26 +571,33 @@ export function RadicalForgeModal({ open, onClose }: Props): React.ReactElement )}

- Turn a free-text system description into requirements, a C4 model, fitness - functions and Gherkin scenarios — one reviewable stage at a time. + Turn a free-text system description into requirements, fitness functions, + Gherkin scenarios, UI mockups and a C4 model — one reviewable stage at a time.

- {STEP_ORDER.map((s, i) => ( - - ))} + {STEP_ORDER.map((s, i) => { + const isStage = s !== 'input' && s !== 'export' + const done = s === 'input' ? stepIndex > 0 : isStage && !!stageReports[s as ForgeStageId] + // Only meaningful once the run has ended: a stage that never + // produced anything was skipped by finishing early. + const skipped = step === 'export' && isStage && !done + return ( + + ) + })}
{unavailableReason && ( @@ -562,23 +698,10 @@ export function RadicalForgeModal({ open, onClose }: Props): React.ReactElement )} ))} -
- {/* Deliberately NOT labeled "Continue" — that's the - footer button's job (advancing to the next wizard - step once this stage has generated). This one only - confirms the answers above and reveals the Generate - button for the current stage. */} - - -
)} - {clarifyStatusByStage[currentStage.id] === 'done' && !!clarifyQuestionsByStage[currentStage.id]?.length && !stageReports[currentStage.id] && ( + {clarifyStatusByStage[currentStage.id] === 'done' && !!clarifyQuestionsByStage[currentStage.id]?.length && !stageReports[currentStage.id] && !busy && (
Clarified ✓
)} - {clarifyStatusByStage[currentStage.id] === 'done' && !stageReports[currentStage.id] && !busy && ( - - )} - {busy && (() => { + {busy && !wireframeRun?.running && (() => { const prog = progressByStage[currentStage.id] ?? { round: 0, entries: [] } const createdCount = prog.entries.filter((e) => e.kind === 'action' && e.ok && e.text.startsWith('+')).length const failedCount = prog.entries.filter((e) => e.kind === 'action' && e.ok === false).length @@ -614,8 +727,6 @@ export function RadicalForgeModal({ open, onClose }: Props): React.ReactElement {formatTokenCount(prog.usage.inputTokens + prog.usage.outputTokens)} tok )} - -
{visible.length === 0 && ( @@ -654,14 +765,39 @@ export function RadicalForgeModal({ open, onClose }: Props): React.ReactElement
) })()} - + + )} + {currentStage.id === 'mockups' && stageReports.mockups && mockupStats.total > 0 && ( +
+
Wireframes
+ {wireframeRun?.running ? ( +
+ + + Drawing {Math.min(wireframeRun.done + 1, wireframeRun.total)}/{wireframeRun.total} + {wireframeRun.current ? ` — ${wireframeRun.current}` : ''} + +
+ ) : ( + <> +
+ {mockupStats.missing === 0 + ? `All ${mockupStats.total} mockup${mockupStats.total === 1 ? ' has' : 's have'} a wireframe.` + : `${mockupStats.missing} of ${mockupStats.total} mockup${mockupStats.total === 1 ? '' : 's'} without a wireframe.`} + {wireframeRun && wireframeRun.failed > 0 && ` ${wireframeRun.failed} failed.`} +
+ {mockupStats.missing > 0 && !busy && ( + + )} + + )}
)} @@ -669,6 +805,26 @@ export function RadicalForgeModal({ open, onClose }: Props): React.ReactElement {step === 'export' && (
+

+ {finishedAt + ? `Run finished early at ${FORGE_STAGES.find((st) => st.id === finishedAt)!.title.toLowerCase()}. Everything generated so far stays in the model — use ← Back to resume.` + : 'All stages done.'} +

+
    + {FORGE_STAGES.map((st) => { + const report = stageReports[st.id] + return ( +
  • + {st.title} + + {report + ? `+${report.added.nodes} node${report.added.nodes === 1 ? '' : 's'} · +${report.added.relations} relation${report.added.relations === 1 ? '' : 's'}` + : 'skipped'} + +
  • + ) + })} +

{gherkinFiles.length > 0 ? `Ready to export ${gherkinFiles.length} .feature file${gherkinFiles.length === 1 ? '' : 's'} from the scenarios in this model.` @@ -689,19 +845,30 @@ export function RadicalForgeModal({ open, onClose }: Props): React.ReactElement {step === 'export' ? ( - ) : ( + ) : !currentStage ? ( + ) : ( +

+ {!(isLastStage && stageReports[currentStage.id]) && ( + + )} + {stageAction} +
)}
, diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx index 1f47651..7d3ec8d 100644 --- a/src/renderer/src/components/Toolbar.tsx +++ b/src/renderer/src/components/Toolbar.tsx @@ -439,7 +439,7 @@ function AppMenu({ className="app-menu-item" role="menuitem" onClick={run(onOpenForge)} - title="Generate requirements, a C4 model, fitness functions and Gherkin scenarios from a description" + title="Generate requirements, fitness functions, Gherkin scenarios, UI mockups and a C4 model from a description" > Radical Forge… diff --git a/src/renderer/src/components/WelcomeScreen.tsx b/src/renderer/src/components/WelcomeScreen.tsx index 71c8a67..58c7daa 100644 --- a/src/renderer/src/components/WelcomeScreen.tsx +++ b/src/renderer/src/components/WelcomeScreen.tsx @@ -107,7 +107,7 @@ export function WelcomeScreen({ onDismiss }: Props): React.ReactElement {