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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Welcome screen: on a first visit the sample model gets its own card (preview, what it contains, "Explore the sample") in place of the placeholder diagram, and opens on the System Context view instead of the all-elements canvas
- Desktop installers for macOS (dmg, Apple Silicon + Intel), Windows (NSIS) and Linux (AppImage), built by electron-builder and drafted as a GitHub Release when a `v*` tag is pushed
- App icon for the desktop builds
- Node wizards: creating an ADR, fitness function, requirement, scenario or mockup — on the canvas, in a table or from a wiki page — opens a step-by-step form with guidance per field and a final step for its relations (constrains, supersedes, satisfies…); the element is created when the wizard finishes, as one undo step. Reopen it for an existing element from the properties panel; turn the automatic wizard off with *Wizard on create* in the app menu. Wizards are metamodel data (`NodeTypeDef.wizard`), so custom node types can have them too
- Markdown-folder models follow edits made outside Studio (another editor, `git pull`): the active document's folder is polled (desktop every second, browser every two) and reloaded from disk, keeping the open view and selection; a save never overwrites a file changed on disk since Studio read it

### Changed
Expand Down
63 changes: 63 additions & 0 deletions apps/e2e/tests/studio/wizard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { test, expect, fixture } from '../../support/fixtures'

// The node wizard on the bookstore fixture, switched to the Governance
// metamodel (whose ADR type has a create-time wizard).
test.beforeEach(async ({ studio }) => {
const doc = JSON.parse(fixture('bookstore'))
doc.metamodel = { id: 'c4-ddd-governance-builtin', name: 'C4 + DDD + Governance', nodeTypes: {}, relationTypes: {} }
await studio.seedDocument(JSON.stringify(doc))
})

test('dropping an ADR on the canvas opens its wizard; finishing creates it with its relations', async ({ page, studio }) => {
await studio.open('v-context')
await expect(studio.nodes).toHaveCount(3)

await studio.addFromPalette('ADR', { x: 150, y: 750 })
const wizard = page.getByRole('dialog', { name: /New ADR/ })
await expect(wizard).toBeVisible()
// Nothing is created until the wizard finishes.
await expect(studio.nodes).toHaveCount(3)

await wizard.getByLabel('Name').fill('Use PostgreSQL for orders')
await expect(wizard.getByLabel('Status')).toHaveValue('proposed')
await wizard.getByRole('button', { name: 'Next' }).click()
await wizard.getByLabel('Context').fill('Orders need transactions.')
await wizard.getByRole('button', { name: /Affected elements/ }).click()
await wizard.getByRole('checkbox', { name: /Bookstore/ }).check()
await wizard.getByRole('button', { name: 'Create' }).click()

await expect(wizard).toHaveCount(0)
await expect(studio.nodeByLabel('Use PostgreSQL for orders')).toBeVisible()
await expect.poll(async () => {
const doc = await studio.storedDoc() as unknown as {
nodes: Array<{ id: string; label: string; context?: string; status?: string }>
relations: Array<{ sourceId: string; targetId: string; relationType?: string }>
}
const adr = doc.nodes.find((n) => n.label === 'Use PostgreSQL for orders')
return adr && {
context: adr.context,
status: adr.status,
constrains: doc.relations.filter((r) => r.sourceId === adr.id && r.relationType === 'constrains').map((r) => r.targetId),
}
}).toEqual({ context: 'Orders need transactions.', status: 'proposed', constrains: ['bookstore'] })
})

test('cancelling the wizard creates nothing', async ({ page, studio }) => {
await studio.open('v-context')
await studio.addFromPalette('ADR', { x: 150, y: 750 })
await expect(page.getByRole('dialog', { name: /New ADR/ })).toBeVisible()
await page.keyboard.press('Escape')
await expect(page.getByRole('dialog')).toHaveCount(0)
await expect(studio.nodes).toHaveCount(3)
})

test('dropping an ADR on the table view opens the same wizard', async ({ page, studio }) => {
await studio.open('v-table')
await page.locator('.palette-item', { hasText: 'ADR' }).first().dragTo(page.locator('.tv-wrap'))
const wizard = page.getByRole('dialog', { name: /New ADR/ })
await expect(wizard).toBeVisible()
await wizard.getByLabel('Name').fill('Event-sourced inventory')
await page.keyboard.press('ControlOrMeta+Enter')
await expect(wizard).toHaveCount(0)
await expect(page.getByText('Event-sourced inventory').first()).toBeVisible()
})
2 changes: 2 additions & 0 deletions apps/studio/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { PresentationBar, PresenterDock } from './components/PresentationBar'
import { RightPanel, LeftPanel } from '@radical/ui/components/RightPanel'
import { MetamodelEditor } from './components/MetamodelEditor'
import { NotificationHost } from '@radical/ui/components/NotificationHost'
import { NodeWizard } from '@radical/ui/components/NodeWizard'
import { SelectionActionBar } from './components/SelectionActionBar'
import { EdgeActionBar } from './components/EdgeActionBar'
import { QuickSearch } from './components/QuickSearch'
Expand Down Expand Up @@ -114,6 +115,7 @@ function AppInner(): React.ReactElement {
{!isPresenting && isCanvasView && <SelectionActionBar />}
{!isPresenting && isCanvasView && <EdgeActionBar />}
{isCanvasView && <QuickSearch />}
{isDesigner && <NodeWizard />}
<NotificationHost />
<MilestoneEditOverlay />
{isMetamodel && !isPresenting && <MetamodelEditor />}
Expand Down
19 changes: 19 additions & 0 deletions apps/studio/src/renderer/src/components/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,14 @@ function AppMenu({
return next
})
}, [])
const setNodeWizardOnCreate = useCallback((on: boolean) => {
setStudioSettings((prev) => {
const next = { ...prev, nodeWizardOnCreate: on }
saveStudioSettings(next)
window.dispatchEvent(new Event(STUDIO_SETTINGS_CHANGED_EVENT))
return next
})
}, [])

// Close on outside-click (covers pointerdown + mousedown, capture phase
// so descendants that call stopPropagation can't keep the menu open).
Expand Down Expand Up @@ -421,6 +429,17 @@ function AppMenu({
<option value="meta">{isMac ? '⌘ Cmd' : '⊞ Win'}</option>
</select>
</div>
<label
className="app-menu-row"
title="Open a guided form when you create a node whose type has a wizard (ADR, Requirement, …). The wizard stays available from the properties panel either way."
>
<span className="app-menu-row-label">Wizard on create</span>
<input
type="checkbox"
checked={studioSettings.nodeWizardOnCreate}
onChange={(e) => setNodeWizardOnCreate(e.target.checked)}
/>
</label>
</div>

<div className="app-menu-divider" />
Expand Down
2 changes: 2 additions & 0 deletions apps/studio/src/renderer/src/studio.css
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@
gap: 10px;
padding: 6px 10px;
}
label.app-menu-row { cursor: pointer; }
.app-menu-row input[type="checkbox"] { accent-color: var(--accent); cursor: pointer; }
.app-menu-row-label {
font-size: 12px;
color: var(--text-secondary);
Expand Down
4 changes: 4 additions & 0 deletions apps/web/manual.html
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,10 @@ <h3>ADRs and fitness functions</h3>
<p>
<strong>ADRs</strong> carry status (proposed → accepted → deprecated / superseded), date, context, decision, consequences and alternatives. <strong>Fitness functions</strong> carry category, trigger (on deploy / continuous / periodic), an <em>automated</em> flag, threshold and status. Both are ordinary elements: link them with <em>Constrains</em>, <em>Supersedes</em>, <em>Implements</em>, <em>Satisfies</em>, <em>Derives</em> and <em>Traces-to</em> relations to the parts of the architecture they govern, and they show up in the <a href="#view-table">Table</a>, <a href="#view-matrix">Matrix</a> and <a href="#view-wiki">Wiki</a> views like anything else.
</p>
<h3>Wizards</h3>
<p>
Creating an ADR, fitness function, requirement, scenario or mockup — dropped on the canvas or a table, or added from a wiki page — opens a <strong>wizard</strong> that walks through its fields one step at a time, with a prompt for each, and ends with the relations that give it meaning (which elements an ADR <em>constrains</em>, which earlier ADR it <em>supersedes</em>…). The element is only created when you press <strong>Create</strong> (<kbd>⌘/Ctrl</kbd>+<kbd>Enter</kbd> from any step); <kbd>Esc</kbd> discards it. <strong>Fill in with wizard…</strong> in the properties panel reopens it for an existing element, and <em>Wizard on create</em> in the app menu turns the automatic wizard off. Wizards are part of the metamodel (<code>wizard</code> on a node type), so custom metamodels can define their own.
</p>
</section>

<section class="doc-section" id="mockups">
Expand Down
24 changes: 24 additions & 0 deletions docs/IMPROVEMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,27 @@ Still open:
directory handle from IndexedDB (seen with OPFS handles in the e2e suite,
which therefore uses a persistent profile). Check whether Chrome Incognito
users of folder mode hit it with real picker handles.

### Node wizards (2026-10-01)

Added in `feat/node-wizard`: `NodeTypeDef.wizard` (metamodel data), wizards for
ADR, fitness function, requirement, scenario and mockup, and
`requestCreateNode` in the store so the canvas, table and wiki all open them
(`packages/ui/src/components/NodeWizard.tsx`). Still open:

1. **No editor for wizards.** The metamodel editor can't define or change a
type's wizard; a custom type gets one only through the metamodel JSON. Add
a "Wizard" section: trigger, steps, the fields of each step, help text.
2. **The wizard for an existing element is hard to find.** It opens only from
"Fill in with wizard…" in the properties panel. Add it to the selection
action bar and to Wiki pages.
3. **A palette drop on the canvas names the element after its type id**
("Adr", "Fitness-fn"); the table and wiki use the metamodel label. Using
the label on the canvas changes C4 names too ("System" → "Software
System", which `editing.spec.ts` checks).
4. **Superseding doesn't update the superseded ADR.** Linking `supersedes` in
the wizard could set the older ADR's status to `superseded`. ADRs also
have no numbering (ADR-007).
5. **Fields have no per-field hint.** Guidance is per step, so steps with
several fields share one help text. A `PropertyDef.placeholder` would let
the properties panel show the same prompts.
2 changes: 2 additions & 0 deletions packages/common/src/metamodel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
// • lookup.ts — allowed-parent / allowed-relation / cardinality helpers
// • validate.ts — validateModel + Issue
// • ears.ts — EARS sentence compose/parse for the Requirement node type
// • wizard.ts — step / field / relation helpers behind the node wizard
// • presets/ — the built-in C4, C4+DDD, and C4+DDD+Governance presets

export * from './types'
export * from './lookup'
export * from './validate'
export * from './ears'
export * from './wizard'
export * from './presets'
82 changes: 82 additions & 0 deletions packages/common/src/metamodel/presets/governance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,26 @@ export function builtInGovernanceMetamodel(): Metamodel {
builtin: true,
tableTab: true,
properties: adrProps,
wizard: {
trigger: 'create',
prefill: { status: 'proposed', date: '{{today}}' },
steps: [
{ kind: 'fields', title: 'Title & status', fields: ['label', 'status', 'date'],
help: 'Name the decision in a few words, e.g. "Use PostgreSQL for order storage".' },
{ kind: 'fields', title: 'Context', fields: ['context'],
help: 'What forces are at play — the problem, constraints and drivers that make a decision necessary now?' },
{ kind: 'fields', title: 'Decision', fields: ['decision'],
help: 'What did you decide? State it actively: "We will …".' },
{ kind: 'fields', title: 'Alternatives', fields: ['alternatives'],
help: 'Which other options did you consider, and why were they rejected?' },
{ kind: 'fields', title: 'Consequences', fields: ['consequences'],
help: 'What becomes easier or harder? Include the downsides and the risks you accept.' },
{ kind: 'relations', title: 'Affected elements', relationType: 'constrains', direction: 'out',
help: 'Which parts of the architecture must follow this decision?' },
{ kind: 'relations', title: 'Supersedes', relationType: 'supersedes', direction: 'out',
help: 'Does this decision replace an earlier one? Leave empty if not.' },
],
},
}

const fitnessFnProps: PropertyDef[] = [
Expand Down Expand Up @@ -76,6 +96,21 @@ export function builtInGovernanceMetamodel(): Metamodel {
builtin: true,
tableTab: true,
properties: fitnessFnProps,
wizard: {
trigger: 'create',
steps: [
{ kind: 'fields', title: 'Name & category', fields: ['label', 'category'],
help: 'Name the architectural characteristic this function protects, e.g. "Checkout p95 latency".' },
{ kind: 'fields', title: 'What it measures', fields: ['description'],
help: 'How is the characteristic measured — a test, a metric, a static check?' },
{ kind: 'fields', title: 'Success criteria', fields: ['threshold'],
help: 'When does it pass? Give a concrete threshold, e.g. "p95 < 300 ms".' },
{ kind: 'relations', title: 'Verified decisions', relationType: 'implements', direction: 'out',
help: 'Which ADRs does this fitness function verify?' },
{ kind: 'relations', title: 'Guarded elements', relationType: 'constrains', direction: 'out',
help: 'Which elements does it check?' },
],
},
}

// ── EARS Requirement ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -113,6 +148,25 @@ export function builtInGovernanceMetamodel(): Metamodel {
tableTab: true,
hierarchyRelation: 'derives',
properties: requirementProps,
wizard: {
trigger: 'create',
steps: [
{ kind: 'fields', title: 'Name', fields: ['label'],
help: 'A short name for the requirement, e.g. "Lock account after failed logins".' },
{ kind: 'custom', title: 'Sentence', component: 'ears-quick-entry',
help: 'Type the requirement as one sentence and press Enter — the EARS fields are filled for you. Or skip and fill them in the next step.' },
{ kind: 'fields', title: 'EARS fields', fields: ['ears_type', 'trigger', 'precondition', 'unwanted_condition', 'feature', 'action'],
help: 'Pick the EARS pattern; only the clauses it uses are shown.' },
{ kind: 'fields', title: 'Rationale', fields: ['rationale'],
help: 'Why is this requirement needed? Who asked for it?' },
{ kind: 'relations', title: 'Satisfied by', relationType: 'satisfies', direction: 'in',
help: 'Which elements must satisfy this requirement?' },
{ kind: 'relations', title: 'Derives from', relationType: 'derives', direction: 'out',
help: 'Is this a refinement of a broader requirement?' },
{ kind: 'relations', title: 'Traces to', relationType: 'traces-to', direction: 'out',
help: 'Which decisions or fitness functions follow from it?' },
],
},
}

const constraintSources = ['adr', 'fitness-fn', 'requirement'] as const
Expand Down Expand Up @@ -211,6 +265,21 @@ export function builtInGovernanceMetamodel(): Metamodel {
builtin: true,
tableTab: true,
properties: scenarioProps,
wizard: {
trigger: 'create',
steps: [
{ kind: 'fields', title: 'Name', fields: ['label'],
help: 'Name the behaviour, e.g. "Card payment declined".' },
{ kind: 'fields', title: 'Given', fields: ['given'],
help: 'The starting state: who is involved and what is already true.' },
{ kind: 'fields', title: 'When', fields: ['when'],
help: 'The single action or event under test.' },
{ kind: 'fields', title: 'Then', fields: ['then', 'gherkin'],
help: 'The observable outcome. Add And/But steps below if needed.' },
{ kind: 'relations', title: 'Verified requirements', relationType: 'verifies', direction: 'out',
help: 'Which requirements does this scenario verify?' },
],
},
}

// scenario → requirement: this scenario verifies that requirement
Expand Down Expand Up @@ -289,6 +358,19 @@ export function builtInGovernanceMetamodel(): Metamodel {
builtin: true,
tableTab: true,
properties: mockupProps,
wizard: {
trigger: 'create',
steps: [
{ kind: 'fields', title: 'Screen', fields: ['label', 'screen', 'description'],
help: 'Which screen is this, and what does the user do on it?' },
{ kind: 'fields', title: 'Design link', fields: ['link'],
help: 'A Figma / Penpot link, if the design exists. You can generate a wireframe from the properties panel later.' },
{ kind: 'relations', title: 'Illustrates', relationType: 'illustrates', direction: 'out',
help: 'Which requirements or scenarios does this screen show?' },
{ kind: 'relations', title: 'Presented by', relationType: 'presented-by', direction: 'out',
help: 'Which part of the system renders it?' },
],
},
}

// mockup → requirement / scenario: this screen illustrates that behaviour
Expand Down
49 changes: 49 additions & 0 deletions packages/common/src/metamodel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,55 @@ export interface NodeTypeDef {
* requiring `allowedParents` to include this type, which it usually
* can't — containment and this kind of relation are different things). */
hierarchyRelation?: string
/** Guided, step-by-step form for filling in a node of this type. */
wizard?: NodeWizardDef
}

// ─── Node wizard ─────────────────────────────────────────────────────────────
//
// A wizard walks the user through a node's properties (and the relations
// that give it meaning — e.g. which elements an ADR constrains) one step at
// a time, instead of leaving them to a wall of fields in the properties
// panel. It is plain data, so custom metamodels get wizards too.

export interface NodeWizardDef {
/** 'create' — opens whenever the user creates a node of this type (canvas,
* table, wiki). 'manual' — only from the properties panel on an existing
* node. */
trigger: 'create' | 'manual'
/** Values a new node starts with. The string `'{{today}}'` becomes the
* current date (YYYY-MM-DD). */
prefill?: Record<string, string | number | boolean>
steps: WizardStep[]
}

export type WizardStep = WizardFieldsStep | WizardRelationsStep | WizardCustomStep

interface WizardStepBase {
title: string
/** Guidance shown above the step's inputs. */
help?: string
}

export interface WizardFieldsStep extends WizardStepBase {
kind: 'fields'
/** Keys of the type's `properties`, plus the built-ins `label` and
* `description`. Hidden properties (`visibleWhen`) are skipped. */
fields: string[]
}

export interface WizardRelationsStep extends WizardStepBase {
kind: 'relations'
/** RelationTypeDef id; its `allowedPairs` decide which nodes are offered. */
relationType: string
/** 'out' — the wizard's node is the source; 'in' — it is the target. */
direction: 'out' | 'in'
}

export interface WizardCustomStep extends WizardStepBase {
kind: 'custom'
/** A built-in editor the generic field/relation steps can't express. */
component: 'ears-quick-entry'
}

export interface RelationPair {
Expand Down
Loading
Loading