From 23d9d0a3d6aec2bdd2ecbca3bd18b2c5308602af Mon Sep 17 00:00:00 2001 From: Tomasz Zajac Date: Thu, 1 Oct 2026 18:34:08 +0200 Subject: [PATCH] style(studio): redesign the node wizard Two columns: a sidebar with the element type and a vertical stepper (checked steps already visited), and the step itself under a progress bar with a large heading and its guidance. Short enums are chips, relation candidates are selectable cards with the type icon, the EARS step shows a preview card, and a step's only field drops its label, which repeats the heading. Blurred backdrop, entry and step transitions (off under reduced motion), a single column below 720px. The primary button gets a deeper blue in the dark theme so white text reaches 4.5:1. Co-Authored-By: Claude Opus 5.5 --- apps/e2e/tests/studio/wizard.spec.ts | 2 +- packages/ui/src/components/NodeWizard.tsx | 347 ++++++++++----- packages/ui/src/index.css | 501 ++++++++++++++++++---- 3 files changed, 640 insertions(+), 210 deletions(-) diff --git a/apps/e2e/tests/studio/wizard.spec.ts b/apps/e2e/tests/studio/wizard.spec.ts index 5316f53e..7c1dc842 100644 --- a/apps/e2e/tests/studio/wizard.spec.ts +++ b/apps/e2e/tests/studio/wizard.spec.ts @@ -19,7 +19,7 @@ test('dropping an ADR on the canvas opens its wizard; finishing creates it with await expect(studio.nodes).toHaveCount(3) await wizard.getByLabel('Name').fill('Use PostgreSQL for orders') - await expect(wizard.getByLabel('Status')).toHaveValue('proposed') + await expect(wizard.getByRole('radio', { name: 'proposed' })).toHaveAttribute('aria-checked', 'true') await wizard.getByRole('button', { name: 'Next' }).click() await wizard.getByLabel('Context').fill('Orders need transactions.') await wizard.getByRole('button', { name: /Affected elements/ }).click() diff --git a/packages/ui/src/components/NodeWizard.tsx b/packages/ui/src/components/NodeWizard.tsx index 5c3792eb..65bb53e3 100644 --- a/packages/ui/src/components/NodeWizard.tsx +++ b/packages/ui/src/components/NodeWizard.tsx @@ -61,6 +61,9 @@ function initialValues(session: NodeWizardSession, wizard: NodeWizardDef, typeDe return values } + +type TypeMeta = { label: string; color: string; fg: string; iconPath?: string } + function WizardDialog({ session, wizard, typeDef, type }: { session: NodeWizardSession wizard: NodeWizardDef @@ -79,6 +82,7 @@ function WizardDialog({ session, wizard, typeDef, type }: { [wizard, metamodel, type], ) const [stepIndex, setStepIndex] = useState(0) + const [furthest, setFurthest] = useState(0) const [values, setValues] = useState(() => initialValues(session, wizard, typeDef)) const [links, setLinks] = useState(() => session.mode === 'create' @@ -90,20 +94,32 @@ function WizardDialog({ session, wizard, typeDef, type }: { const step = steps[Math.min(stepIndex, steps.length - 1)] const isLast = stepIndex >= steps.length - 1 const missing = wizardMissingRequired(wizard, typeDef, values) + const verb = session.mode === 'create' ? 'Create' : 'Save' - const typeLabel = typeDef?.label ?? TYPE_LABELS[type as C4Node['type']] ?? type - const badgeBg = typeDef?.color ?? NODE_COLORS[type as C4Node['type']] ?? '#334155' - const badgeFg = typeDef?.fg ?? NODE_FG[type as C4Node['type']] ?? '#fff' - const badgeIcon = typeDef?.iconPath ?? TYPE_ICON_PATHS[type as C4Node['type']] + const typeMetaOf = (t: string): TypeMeta => { + const def = metamodel?.nodeTypes[t] + const c4 = t as C4Node['type'] + return { + label: def?.label ?? TYPE_LABELS[c4] ?? t, + color: def?.color ?? NODE_COLORS[c4] ?? '#334155', + fg: def?.fg ?? NODE_FG[c4] ?? '#fff', + iconPath: def?.iconPath ?? TYPE_ICON_PATHS[c4], + } + } + const meta = typeMetaOf(type) const title = session.mode === 'create' - ? `New ${typeLabel}` - : String(c4Nodes[session.nodeId]?.label ?? typeLabel) + ? `New ${meta.label}` + : String(c4Nodes[session.nodeId]?.label ?? meta.label) + const goTo = (i: number): void => { + setStepIndex(i) + setFurthest((f) => Math.max(f, i)) + } const done = (): void => { if (missing.length) return finish(values, links) } - const next = (): void => (isLast ? done() : setStepIndex((i) => i + 1)) + const next = (): void => (isLast ? done() : goTo(stepIndex + 1)) // Esc cancels, Ctrl/Cmd+Enter finishes from any step. const doneRef = useRef(done) @@ -128,88 +144,112 @@ function WizardDialog({ session, wizard, typeDef, type }: { const setValue = (key: string, value: unknown): void => setValues((v) => ({ ...v, [key]: value })) return ( -
+
e.stopPropagation()} > -
- - {badgeIcon && ( - - +
+ +
+
{meta.label}
+
{title}
+
+
+ +
    + {steps.map((s, i) => { + const state = i === stepIndex ? 'active' : i <= furthest ? 'done' : 'todo' + return ( +
  1. + +
  2. + ) + })} +
+ + + + +
+
+ {step.kind === 'relations' && ( + + )} + {step.kind === 'custom' && ( + + )} +
-
    - {steps.map((s, i) => ( -
  1. - + )} + {!isLast && ( + + )} + {isLast ? ( + + ) : ( + -
  2. - ))} -
- -
- {step.help &&

{step.help}

} - {step.kind === 'fields' && ( - - )} - {step.kind === 'relations' && ( - metamodel?.nodeTypes[t]?.label ?? TYPE_LABELS[t as C4Node['type']] ?? t} - /> - )} - {step.kind === 'custom' && ( - - )} -
- -
- - - {missing.length ? `Required: ${missing.join(', ')}` : `${isMac() ? '⌘' : 'Ctrl'}+Enter to ${session.mode === 'create' ? 'create' : 'save'}`} - - {stepIndex > 0 && ( - - )} - {!isLast && ( - - )} - -
+ )} + +
) @@ -219,6 +259,29 @@ function isMac(): boolean { return typeof navigator !== 'undefined' && !!navigator.platform?.includes('Mac') } +function CheckIcon(): React.ReactElement { + return ( + + ) +} + +function TypeIcon({ meta, size }: { meta: TypeMeta; size: number }): React.ReactElement { + return ( + + ) +} + +/** Enums with up to this many options render as chips instead of a select. */ +const MAX_CHOICE_CHIPS = 6 + function FieldsStep({ fields, values, setValue, roomy, onEnter }: { fields: PropertyDef[] values: Record @@ -227,34 +290,62 @@ function FieldsStep({ fields, values, setValue, roomy, onEnter }: { onEnter: () => void }): React.ReactElement { return ( - <> +
{fields.map((p) => { const value = values[p.key] - const id = `node-wizard-field-${p.key}` + const id = `nw-field-${p.key}` + const label = ( + <> + {p.label} + {p.required && } + + ) if (p.type === 'boolean') { return ( -