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> </div>
</template> </template>
<!-- COMPANION 2: state machine --> <!-- COMPANION 2: state machine(s) -->
<template v-if="blueprint.stateMachine"> <template v-if="blueprint.stateMachines?.length">
<h2 class="section">Lifecycle state machine</h2> <h2 class="section">
<div class="panel"> 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"> <div class="cap">
<span>{{ blueprint.stateMachine?.caption ?? "Composed lifecycle" }}</span <span>{{ m.caption ?? "Composed lifecycle" }}</span
><span class="id">{{ blueprint.stateMachine?.field ?? "loadState : LoadState" }}</span> ><span class="id">{{ m.field ?? "loadState : LoadState" }}</span>
</div> </div>
<div class="body svg-wrap"><StateMachine :machine="blueprint.stateMachine" /></div> <div class="body svg-wrap"><StateMachine :machine="m" /></div>
</div> </div>
</template> </template>
@@ -398,6 +405,10 @@ code {
.svg-wrap { .svg-wrap {
overflow-x: auto; overflow-x: auto;
} }
/* stacked state-machine panels (a blueprint with more than one lifecycle) */
.panel.stacked {
margin-top: 14px;
}
/* full-view readout */ /* full-view readout */
.rblock { .rblock {

View File

@@ -46,6 +46,26 @@ const edges = computed(() =>
props.machine.edges.map((e) => { props.machine.edges.map((e) => {
const a = byId.value[e.from] const a = byId.value[e.from]
const b = byId.value[e.to] 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 [acx, acy] = center(a)
const [bcx, bcy] = center(b) const [bcx, bcy] = center(b)
const [sx, sy] = edgePoint(a, bcx, bcy) const [sx, sy] = edgePoint(a, bcx, bcy)

View File

@@ -144,6 +144,9 @@ export interface Blueprint {
functions: BlueprintFunction[] functions: BlueprintFunction[]
coreInvariants: string[] coreInvariants: string[]
composition?: Composition 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[] 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: [ related: [
{ name: "Form", relation: "extends · base" }, { name: "Form", relation: "extends · base" },

View File

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