refactor(state-machine): support multiple lifecycles per blueprint

Blueprint.stateMachine becomes stateMachines[], the viewer renders each
machine in its own panel, and StateMachine draws self-loops (a === b) for
recurring transitions that don't change state. Existing blueprints move
to the array form unchanged.
This commit is contained in:
Julien Calixte
2026-07-02 23:52:03 +02:00
parent 7a60e57f46
commit 903e5465d5
8 changed files with 47 additions and 13 deletions

View File

@@ -164,15 +164,22 @@ const funcCount = computed(() => props.blueprint.functions.length)
</div>
</template>
<!-- COMPANION 2: state machine -->
<template v-if="blueprint.stateMachine">
<h2 class="section">Lifecycle state machine</h2>
<div class="panel">
<!-- COMPANION 2: state machine(s) -->
<template v-if="blueprint.stateMachines?.length">
<h2 class="section">
Lifecycle state machine{{ blueprint.stateMachines.length > 1 ? "s" : "" }}
</h2>
<div
v-for="(m, i) in blueprint.stateMachines"
:key="i"
class="panel"
:class="{ stacked: i > 0 }"
>
<div class="cap">
<span>{{ blueprint.stateMachine?.caption ?? "Composed lifecycle" }}</span
><span class="id">{{ blueprint.stateMachine?.field ?? "loadState : LoadState" }}</span>
<span>{{ m.caption ?? "Composed lifecycle" }}</span
><span class="id">{{ m.field ?? "loadState : LoadState" }}</span>
</div>
<div class="body svg-wrap"><StateMachine :machine="blueprint.stateMachine" /></div>
<div class="body svg-wrap"><StateMachine :machine="m" /></div>
</div>
</template>
@@ -398,6 +405,10 @@ code {
.svg-wrap {
overflow-x: auto;
}
/* stacked state-machine panels (a blueprint with more than one lifecycle) */
.panel.stacked {
margin-top: 14px;
}
/* full-view readout */
.rblock {

View File

@@ -46,6 +46,26 @@ const edges = computed(() =>
props.machine.edges.map((e) => {
const a = byId.value[e.from]
const b = byId.value[e.to]
// Self-loop (a === b): a small arc off the top edge of the node, arrowhead
// curling back in. Used by the recurring transitions that don't change state
// — BLE's discover(p) on Scanning and read/write/subscribe on Ready.
if (a === b) {
const cx = a.x + a.w / 2
const top = a.y
const r = Math.min(a.w / 3.2, 20)
const h = 30
const sx = cx - r
const tx = cx + r
return {
key: `${e.from}-${e.to}-${e.label}`,
d: `M${sx} ${top} C ${sx} ${top - h} ${tx} ${top - h} ${tx} ${top}`,
color: SM_COLORS[e.kind],
marker: `url(#ar-${e.kind})`,
label: e.label,
lx: cx,
ly: top - h - 3,
}
}
const [acx, acy] = center(a)
const [bcx, bcy] = center(b)
const [sx, sy] = edgePoint(a, bcx, bcy)

View File

@@ -144,6 +144,9 @@ export interface Blueprint {
functions: BlueprintFunction[]
coreInvariants: string[]
composition?: Composition
stateMachine?: StateMachine
// Zero or more lifecycles. Most blueprints have one (or reuse LOADSTATE_MACHINE);
// some — like BLE, which is a central with an adapter/scan lifecycle *and* a
// per-peripheral link lifecycle — render several, each in its own panel.
stateMachines?: StateMachine[]
related?: RelatedRef[]
}

View File

@@ -204,5 +204,5 @@ export const calendar: Blueprint = {
],
},
stateMachine: CALENDAR_MACHINE,
stateMachines: [CALENDAR_MACHINE],
}

View File

@@ -202,5 +202,5 @@ export const grid: Blueprint = {
],
},
stateMachine: LOADSTATE_MACHINE,
stateMachines: [LOADSTATE_MACHINE],
}

View File

@@ -226,5 +226,5 @@ export const list: Blueprint = {
],
},
stateMachine: LOADSTATE_MACHINE,
stateMachines: [LOADSTATE_MACHINE],
}

View File

@@ -333,7 +333,7 @@ export const passwordAuth: Blueprint = {
],
},
stateMachine: PASSWORD_AUTH_MACHINE,
stateMachines: [PASSWORD_AUTH_MACHINE],
related: [
{ name: "Form", relation: "extends · base" },

View File

@@ -301,7 +301,7 @@ export const rememberMe: Blueprint = {
],
},
stateMachine: REMEMBER_ME_MACHINE,
stateMachines: [REMEMBER_ME_MACHINE],
related: [
{ name: "PasswordAuth", relation: "host · requires" },