@delurio/cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/html.js ADDED
@@ -0,0 +1,284 @@
1
+ import { alternatives, humanize, titleOf } from './graph.js';
2
+ import { identifiers, parseExpr } from './expr.js';
3
+ import { collectEdges, exitLine, kindOf, pressLabel, responseLine } from './edges.js';
4
+ import { narrate, stepsTo } from './story.js';
5
+ import { kitCss } from './kit.js';
6
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
7
+ const code = (s) => `<code>${esc(s)}</code>`;
8
+ export const CSS = `
9
+ :root { --bg:#fafaf9; --surface:#ffffff; --ink:#1c1917; --muted:#6b6560; --line:#d6d3d1; --accent:#1d4ed8; --accent-bg:#eef3ff; --ok:#15803d; --bad:#b91c1c; --server:#eeeeec; --focus:#1d4ed8; --ai:#7e22ce; --ai-bg:#f5edff; --agent:#c2410c; --agent-bg:#fff1e8; --ci:#0f766e; --ci-bg:#e6f6f3; --ok-bg:#eaf7ee; }
10
+ @media (prefers-color-scheme: dark) { :root { --bg:#161513; --surface:#1f1d1b; --ink:#f2efec; --muted:#a8a29e; --line:#44403c; --accent:#8fb0ff; --accent-bg:#1c2438; --ok:#5fcf85; --bad:#f08080; --server:#2a2826; --focus:#8fb0ff; --ai:#c99bff; --ai-bg:#2a2135; --agent:#ff9a66; --agent-bg:#33241b; --ci:#5fd4c4; --ci-bg:#1a2e2b; --ok-bg:#16291d; } }
11
+ * { box-sizing: border-box; }
12
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 16px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; }
13
+ main { max-width: 1000px; margin: 0 auto; padding: 28px 16px 80px; }
14
+ h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 8px; } h2 { font-size: 21px; margin: 44px 0 12px; } h3 { font-size: 16px; margin: 0; }
15
+ code, pre, .mono { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 13px; }
16
+ .muted { color: var(--muted); } .label { margin: 0 0 6px; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
17
+ .eyebrow { margin: 0; color: var(--muted); } .lede { font-size: 18px; max-width: 46em; margin: 0; }
18
+ .tag { display: inline-block; padding: 1px 8px; border: 1px solid var(--line); border-radius: 99px; font-size: 12px; color: var(--muted); vertical-align: middle; }
19
+ .scroll { overflow-x: auto; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); }
20
+ table { border-collapse: collapse; width: 100%; } th, td { text-align: left; vertical-align: top; padding: 8px 12px; border-bottom: 1px solid var(--line); } th { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); } tr:last-child td { border-bottom: 0; }
21
+ td ol { margin: 0; padding-left: 20px; }
22
+ .ok { color: var(--ok); font-weight: 600; } .bad { color: var(--bad); font-weight: 600; }
23
+ .story { margin-top: 28px; padding: 18px 22px; border: 1px solid var(--line); border-left: 5px solid var(--accent); border-radius: 10px; background: var(--surface); }
24
+ .story h2 { margin: 0 0 6px; } .story .given { margin: 0 0 8px; } .story ol { margin: 0; padding-left: 26px; } .story li { margin: 5px 0; padding-left: 4px; } .story .result { margin: 10px 0 0; font-weight: 600; }
25
+ .views input { margin-right: 8px; accent-color: var(--accent); } .views label { display: inline-block; margin: 0 0 10px; cursor: pointer; }
26
+ .views .panel-full { display: none; } .views input:checked ~ .panel-main { display: none; } .views input:checked ~ .panel-full { display: block; }
27
+ svg.spine { display: block; width: 100%; height: auto; padding: 8px; background: var(--surface); margin: 0 auto; }
28
+ .spine .edge { fill: none; stroke-width: 1.5; } .spine .edge-user { stroke: var(--muted); } .spine .edge-system { stroke: var(--muted); } .spine .edge-route { stroke: var(--muted); stroke-dasharray: 2 4; } .spine .edge-fail { stroke: var(--bad); stroke-dasharray: 7 4; } .spine .edge-unused { stroke: var(--line); }
29
+ .spine .edge-main { stroke: var(--accent); stroke-width: 3.5; stroke-dasharray: none; }
30
+ .spine .arrow { fill: var(--muted); } .spine .arrow-fail { fill: var(--bad); } .spine .arrow-main { fill: var(--accent); }
31
+ .spine .elabel rect { fill: var(--surface); stroke: var(--line); } .spine .elabel text { fill: var(--ink); font: 13px system-ui, sans-serif; } .spine .elabel-fail text { fill: var(--bad); } .spine .elabel-main rect { stroke: var(--accent); }
32
+ .spine .node rect { fill: var(--surface); stroke: var(--ink); stroke-width: 1.6; } .spine .node-server rect { fill: var(--server); stroke: var(--muted); stroke-dasharray: 5 3; }
33
+ .spine .node-subflow rect { stroke: var(--accent); } .spine .node-subflow rect.inner { fill: none; stroke-width: 1; }
34
+ .spine .on-main rect { stroke-width: 3; } .spine .node-server.on-main rect { stroke: var(--accent); }
35
+ .spine .exit-never rect { stroke: var(--bad); stroke-width: 3; } .spine .exit-error rect { stroke-dasharray: 6 3; } .spine .exit-left rect { stroke-dasharray: 2 3; stroke: var(--muted); } .spine .exit-success rect { stroke: var(--ok); stroke-width: 3; }
36
+ .spine .node.by-human rect { fill: var(--surface); stroke: var(--ink); stroke-dasharray: none; }
37
+ .spine .node.by-ai rect { fill: var(--ai-bg); stroke: var(--ai); stroke-dasharray: none; }
38
+ .spine .node.by-agent rect { fill: var(--agent-bg); stroke: var(--agent); stroke-dasharray: none; } .spine .node.by-agent rect.inner { fill: none; stroke-width: 1; }
39
+ .spine .node.by-ci rect { fill: var(--ci-bg); stroke: var(--ci); stroke-dasharray: none; }
40
+ .spine .node.goal rect { fill: var(--ok-bg); stroke: var(--ok); stroke-width: 3.5; stroke-dasharray: none; }
41
+ .spine .node.on-main rect { stroke-width: 3.5; }
42
+ .spine .nmore { fill: var(--muted); font: 600 11px system-ui, sans-serif; }
43
+ .legend .sw { display: inline-flex; align-items: center; gap: 6px; } .legend svg { flex: none; }
44
+ .legend .sw-node rect { stroke-width: 1.6; }
45
+ .legend .sw-human rect { fill: var(--surface); stroke: var(--ink); } .legend .sw-ai rect { fill: var(--ai-bg); stroke: var(--ai); } .legend .sw-agent rect { fill: var(--agent-bg); stroke: var(--agent); } .legend .sw-agent rect.inner { fill: none; stroke-width: 1; }
46
+ .legend .sw-ci rect { fill: var(--ci-bg); stroke: var(--ci); } .legend .sw-goal rect { fill: var(--ok-bg); stroke: var(--ok); stroke-width: 3; } .legend .sw-server rect { fill: var(--server); stroke: var(--muted); stroke-dasharray: 4 2; }
47
+ .legend .sw-screen rect { fill: var(--surface); stroke: var(--ink); } .legend .sw-subflow rect { fill: var(--surface); stroke: var(--accent); }
48
+ .legend .sw-node.sw-fail rect { fill: var(--surface); stroke: var(--bad); stroke-width: 3; }
49
+ .legend .sw-line path { fill: none; stroke-width: 3; } .legend .sw-main path { stroke: var(--accent); stroke-width: 3.5; } .legend .sw-fail path { stroke: var(--bad); stroke-dasharray: 6 3; } .legend .sw-route path { stroke: var(--muted); stroke-dasharray: 2 3; }
50
+ .covers { display: grid; gap: 10px; } .covers .cv { border: 1px solid var(--line); border-radius: 10px; background: var(--surface); padding: 10px 14px; } .covers ul { margin: 4px 0 0; padding-left: 20px; }
51
+ .spine .node-unreachable rect { stroke: var(--bad); fill: none; }
52
+ .spine .nname { fill: var(--ink); font: 600 14.5px system-ui, sans-serif; } .spine .nline { fill: var(--muted); font: 11.5px ui-monospace, Menlo, Consolas, monospace; } .spine .start { fill: var(--ink); }
53
+ .spine a:focus-visible .node rect, .spine a:hover .node rect { stroke: var(--focus); stroke-width: 3; }
54
+ .legend { display: flex; flex-wrap: wrap; gap: 4px 18px; margin: 10px 0 0; font-size: 14px; color: var(--muted); }
55
+ .screens { display: grid; gap: 18px; }
56
+ .screen { border: 1px solid var(--line); border-radius: 10px; background: var(--surface); padding: 14px 16px 16px; scroll-margin-top: 16px; }
57
+ .screen:target { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-bg); }
58
+ .screen header { display: flex; align-items: center; gap: 10px; } .screen > p { margin: 4px 0 10px; }
59
+ .wf { border: 1px dashed var(--line); border-radius: 8px; padding: 14px; display: grid; gap: 10px; background: var(--bg); max-width: 560px; }
60
+ .wf-item { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; padding: 2px; border-radius: 4px; }
61
+ .wf-cond { outline: 1.5px dashed var(--muted); outline-offset: 2px; } .wf-focus { outline: 2.5px solid var(--focus); outline-offset: 2px; }
62
+ .wf-h { margin: 0; font-size: 17px; } .wf-t { margin: 0; color: var(--muted); }
63
+ .wf-row { display: flex; flex-wrap: wrap; gap: 10px; }
64
+ .wf-btn, .wf-link { font: inherit; border-radius: 6px; padding: 6px 14px; border: 1px solid var(--ink); background: var(--surface); color: var(--ink); }
65
+ .wf-confirm { background: var(--ink); color: var(--bg); } .wf-link { border: 0; background: none; color: var(--accent); text-decoration: underline; padding: 0; }
66
+ .wf button:disabled { opacity: .45; }
67
+ .wf-list { margin: 0; padding-left: 20px; display: grid; gap: 2px; }
68
+ .wf-status { color: var(--muted); font-style: italic; } .wf-alert { border: 1.5px solid var(--bad); border-radius: 6px; padding: 6px 10px; }
69
+ .wf-field { display: grid; gap: 4px; } .wf-input { font: inherit; border: 1px solid var(--muted); border-radius: 6px; padding: 6px 10px; background: var(--surface); color: var(--ink); }
70
+ .wf-unmapped { border: 1.5px solid var(--bad); padding: 6px 10px; }
71
+ .chip { font: 11px ui-monospace, Menlo, Consolas, monospace; color: var(--muted); border: 1px solid var(--line); border-radius: 4px; padding: 0 5px; white-space: nowrap; }
72
+ details { margin-top: 12px; } summary { cursor: pointer; color: var(--muted); }
73
+ details.ax-wrap { margin-top: 10px; } pre.ax { margin: 8px 0 0; padding: 10px 12px; border: 1px solid var(--line); border-radius: 8px; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--bg); line-height: 1.55; }
74
+ dl.guards { margin: 10px 0 0; display: grid; grid-template-columns: max-content 1fr; gap: 3px 14px; } dl.guards dt { font-weight: 600; } dl.guards dd { margin: 0; }
75
+ ul.checks { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } ul.checks li { padding-left: 26px; position: relative; } ul.checks li::before { content: "✓"; position: absolute; left: 4px; color: var(--ok); font-weight: 700; } ul.checks li.fail::before { content: "✗"; color: var(--bad); }
76
+ footer { margin-top: 56px; color: var(--muted); font-size: 13px; }
77
+ `;
78
+ function routingSection(g, ex) {
79
+ const out = [];
80
+ for (const [name, st] of Object.entries(g.flow.states)) {
81
+ if (st.always === undefined)
82
+ continue;
83
+ const counts = ex.branchCounts.get(name) ?? [];
84
+ const total = ex.atState.get(name) ?? 0;
85
+ const rules = st.always;
86
+ out.push(`<h3 style="margin:18px 0 8px">${esc(titleOf(g.flow, name))}</h3>`);
87
+ out.push('<div class="scroll"><table><thead><tr><th>#</th><th>If</th><th>Then</th></tr></thead><tbody>');
88
+ rules.forEach((a, i) => {
89
+ const cond = a.say ?? (a.guard === undefined ? 'nothing above applies' : a.guard);
90
+ out.push(`<tr><td>${i + 1}</td><td>${esc(cond)}</td><td>${esc(titleOf(g.flow, a.target))}</td></tr>`);
91
+ });
92
+ out.push('</tbody></table></div>');
93
+ out.push('<p class="muted" style="margin:8px 0 0">The first matching rule wins.</p>');
94
+ out.push('<details><summary>Exact conditions</summary><div class="scroll" style="margin-top:8px"><table><thead><tr><th>#</th><th>Condition</th><th>Goes to</th><th>Situations</th></tr></thead><tbody>');
95
+ rules.forEach((a, i) => {
96
+ out.push(`<tr><td>${i + 1}</td><td>${a.guard === undefined ? '<span class="muted">otherwise</span>' : code(a.guard)}</td><td>${code(a.target)}</td><td class="mono">${counts[i] ?? 0} of ${total}</td></tr>`);
97
+ });
98
+ out.push('</tbody></table></div>');
99
+ const used = new Set();
100
+ for (const a of rules)
101
+ if (a.guard !== undefined)
102
+ identifiers(parseExpr(a.guard)).forEach((n) => used.add(n));
103
+ const named = Object.entries(g.flow.guards).filter(([n]) => used.has(n));
104
+ if (named.length > 0) {
105
+ out.push('<dl class="guards">');
106
+ for (const [n, expr] of named)
107
+ out.push(`<dt>${code(n)}</dt><dd>${code(expr)}</dd>`);
108
+ out.push('</dl>');
109
+ }
110
+ out.push('</details>');
111
+ }
112
+ return out.join('\n');
113
+ }
114
+ function transitionsTable(g, ex) {
115
+ const rows = [];
116
+ for (const [name, st] of Object.entries(g.flow.states)) {
117
+ const add = (event, guard, target, effect, taken) => {
118
+ rows.push(`<tr><td>${code(name)}</td><td>${code(event)}</td><td>${guard === undefined ? '' : code(guard)}</td><td>${code(target)}</td><td>${(effect ?? []).map(code).join(' ')}</td><td class="${taken ? '' : 'bad'}">${taken ? 'yes' : 'never'}</td></tr>`);
119
+ };
120
+ (st.always ?? []).forEach((a, i) => add(`rule ${i + 1}`, a.guard, a.target, a.effect, ex.taken.has(`${name}|always|${i}`)));
121
+ for (const [event, spec] of Object.entries(st.on ?? {})) {
122
+ alternatives(spec).forEach((a, i) => add(event, a.guard, a.target, a.effect, ex.taken.has(`${name}|${event}|${i}`)));
123
+ }
124
+ }
125
+ return `<div class="scroll" style="margin-top:8px"><table><thead><tr><th>From</th><th>Event</th><th>Guard</th><th>To</th><th>Effects</th><th>Taken</th></tr></thead><tbody>${rows.join('\n')}</tbody></table></div>`;
126
+ }
127
+ function legend(g, ex, hasMain) {
128
+ const states = Object.entries(g.flow.states);
129
+ const edges = collectEdges(g, ex);
130
+ const node = (cls, rx, inner = false) => `<svg width="34" height="20" viewBox="0 0 34 20" aria-hidden="true" class="sw-node sw-${cls}"><rect x="1.5" y="1.5" width="31" height="17" rx="${rx}"/>${inner ? '<rect class="inner" x="4.5" y="4.5" width="25" height="11" rx="2"/>' : ''}</svg>`;
131
+ const line = (cls) => `<svg width="34" height="20" viewBox="0 0 34 20" aria-hidden="true" class="sw-line sw-${cls}"><path d="M2 10 L32 10"/></svg>`;
132
+ const items = [];
133
+ const add = (sw, text) => {
134
+ items.push(`<span class="sw">${sw}<span>${esc(text)}</span></span>`);
135
+ };
136
+ if (hasMain)
137
+ add(line('main'), 'blue line: the main story');
138
+ if (edges.some((e) => e.kind === 'fail'))
139
+ add(line('fail'), 'red dashed line: a failure path (shown with “Show failure paths”)');
140
+ if (edges.some((e) => e.kind === 'route'))
141
+ add(line('route'), 'dotted line: decided by a rule, nobody acts');
142
+ const by = new Set(states.map(([, s]) => s.by).filter((b) => b !== undefined));
143
+ if (by.has('human'))
144
+ add(node('human', 4), 'a person does it (editor, review, approval)');
145
+ if (by.has('ai'))
146
+ add(node('ai', 9), 'AI does it');
147
+ if (by.has('agent'))
148
+ add(node('agent', 4, true), 'an agent does it, on the real app or for a user');
149
+ if (by.has('ci'))
150
+ add(node('ci', 0), 'CI does it, automatically');
151
+ if (states.some(([, s]) => s.goal === true))
152
+ add(node('goal', 4), 'green: the goal the flow keeps returning to');
153
+ const kinds = new Set(states.filter(([, s]) => s.by === undefined && s.goal !== true).map(([n]) => kindOf(g, n)));
154
+ if (kinds.has('server'))
155
+ add(node('server', 4), 'dashed grey box: the server decides, nothing is shown');
156
+ if (kinds.has('screen'))
157
+ add(node('screen', 4), 'solid box: the user sees a screen (click it)');
158
+ if (kinds.has('subflow'))
159
+ add(node('subflow', 4, true), 'double border: another flow');
160
+ if (states.some(([, s]) => s.type === 'final' && s.redirect === 'success'))
161
+ add(node('goal', 9), 'green exit: success');
162
+ if (states.some(([, s]) => s.type === 'final' && s.redirect === 'never'))
163
+ add(node('fail', 9), 'red exit: no redirect');
164
+ if (states.some(([, s]) => (s.includes ?? []).length > 0))
165
+ add('<b class="muted">+n</b>', 'the step covers n more things (listed below)');
166
+ return `<p class="legend">${items.join('')}</p>`;
167
+ }
168
+ export function renderPage(p) {
169
+ const { g, m, ex, assertions, finals, main } = p;
170
+ const kit = p.kit ?? null;
171
+ const holds = assertions.filter((a) => a.violation === undefined).length;
172
+ const heading = g.flow.title ?? humanize(g.flow.id);
173
+ const out = [];
174
+ out.push('<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">');
175
+ out.push(`<title>${esc(heading)}</title><style>${CSS}${kit === null ? '' : kitCss(kit, 'page')}</style></head><body><main>`);
176
+ out.push(`<p class="eyebrow">${g.flow.status === undefined ? '' : `<span class="tag">${esc(g.flow.status)}</span> `}<span class="mono">${esc(g.flow.id)}</span></p>`);
177
+ out.push(`<h1>${esc(heading)}</h1>`);
178
+ if (g.flow.summary !== undefined)
179
+ out.push(`<p class="lede">${esc(g.flow.summary.trim())}</p>`);
180
+ const sc = g.flow.scope;
181
+ if (sc !== undefined) {
182
+ out.push('<div class="story" style="border-left-color:var(--muted)"><p class="label">What this view shows</p>');
183
+ if (sc.shows !== undefined)
184
+ out.push(`<p style="margin:0">${esc(sc.shows)}.</p>`);
185
+ if (sc.world === 'open')
186
+ out.push('<p class="muted" style="margin:6px 0 0">Open world: what is not drawn here is not ruled out. Only the guarantees below are binding.</p>');
187
+ if ((sc.outOfView ?? []).length > 0)
188
+ out.push(`<p style="margin:8px 0 0"><b>Left out of this view:</b></p><ul style="margin:4px 0 0">${(sc.outOfView ?? []).map((o) => `<li>${esc(o)}</li>`).join('')}</ul>`);
189
+ out.push('</div>');
190
+ }
191
+ if (main !== null) {
192
+ out.push('<section class="story"><h2>The main story</h2>');
193
+ out.push(`<p class="given"><b>When:</b> ${esc(main.given)}.</p><ol>`);
194
+ for (const s of main.steps)
195
+ out.push(`<li>${esc(s)}</li>`);
196
+ out.push(`</ol><p class="result">Result: ${esc(main.result)}.</p></section>`);
197
+ }
198
+ out.push('<h2>The flow</h2>');
199
+ out.push('<div class="views"><input type="checkbox" id="show-all"><label for="show-all">Show failure paths and “the user closes the page” <span class="muted">(wider: scrolls sideways)</span></label>');
200
+ out.push(`<div class="scroll panel-main">${p.spineMain}</div><div class="scroll panel-full">${p.spineFull}</div></div>`);
201
+ out.push(legend(g, ex, main !== null));
202
+ const covers = Object.entries(g.flow.states).filter(([, s]) => (s.includes ?? []).length > 0);
203
+ if (covers.length > 0) {
204
+ out.push('<h2>What each step covers</h2><div class="covers">');
205
+ for (const [n, s] of covers)
206
+ out.push(`<div class="cv"><b>${esc(titleOf(g.flow, n))}</b><ul>${(s.includes ?? []).map((i) => `<li>${esc(i)}</li>`).join('')}</ul></div>`);
207
+ out.push('</div>');
208
+ }
209
+ if (p.screens.trim() !== '') {
210
+ out.push('<h2>What the user sees</h2>');
211
+ if (kit === null)
212
+ out.push('<p class="muted" style="margin-top:-4px">Wireframes are generated from the UI graph with fixed components, so they show structure, wording and state, not the final look.</p>');
213
+ else
214
+ out.push(`<p class="muted" style="margin-top:-4px">Screens are drawn from the UI graph with the components of ${esc(kit.title)} (kit <span class="mono">${esc(kit.id)}</span>): the look is the kit's, the wording and state are the graph's. A component the kit does not cover is drawn with the fixed one.</p>`);
215
+ out.push(`<div class="screens">${p.screens}</div>`);
216
+ }
217
+ const answers = Object.entries(g.flow.states).filter(([, st]) => st.response !== undefined);
218
+ if (answers.length > 0) {
219
+ out.push('<h2>What each step answers</h2>');
220
+ out.push('<div class="scroll"><table><thead><tr><th>Step</th><th>The outside world sees</th></tr></thead><tbody>');
221
+ for (const [name, st] of answers)
222
+ out.push(`<tr><td>${esc(titleOf(g.flow, name))}<br><span class="mono muted">${esc(name)}</span></td><td>${esc(responseLine(st.response ?? {}))}</td></tr>`);
223
+ out.push('</tbody></table></div>');
224
+ }
225
+ const routing = routingSection(g, ex);
226
+ if (routing !== '') {
227
+ out.push('<h2>How it decides what happens next</h2>');
228
+ out.push(routing);
229
+ }
230
+ if (finals.length === 0)
231
+ out.push('<h2>Every way it can end</h2><p class="muted">It never ends: every path returns to a state the flow keeps.</p>');
232
+ else {
233
+ out.push('<h2>Every way it can end</h2>');
234
+ out.push('<div class="scroll"><table><thead><tr><th>It ends with</th><th>When</th><th>What happens</th></tr></thead><tbody>');
235
+ for (const f of finals) {
236
+ const story = narrate(g, m, stepsTo(m, ex, f.index));
237
+ out.push(`<tr><td><b>${esc(story.result)}</b><br><span class="muted">${esc(exitLine(g.flow.states[f.state]))}</span></td><td>${esc(story.given)}</td><td><ol>${story.steps.map((s) => `<li>${esc(s)}</li>`).join('')}</ol></td></tr>`);
238
+ }
239
+ out.push('</tbody></table></div>');
240
+ out.push('<p class="muted" style="margin:8px 0 0">Each row is also a generated test: set the starting situation, send the events, expect the ending.</p>');
241
+ }
242
+ const hands = p.handOvers ?? [];
243
+ if (hands.length > 0) {
244
+ out.push('<h2>The flows it starts</h2>');
245
+ out.push('<p class="muted" style="margin-top:-4px">This flow starts them and does not wait. Each begins with what this flow knows at that moment.</p>');
246
+ out.push('<div class="scroll"><table><thead><tr><th>From</th><th>It starts</th><th>Which begins with</th><th>Checked</th></tr></thead><tbody>');
247
+ for (const h of hands) {
248
+ const way = h.event === null ? 'a rule' : (pressLabel(g, h.from, h.event) ?? humanize(h.event).toLowerCase());
249
+ const given = Object.entries(h.given).map(([k, v]) => `${code(k)} = ${code(String(v))}`).join('<br>') || '<span class="muted">its own beginning</span>';
250
+ const checked = !h.found ? '<span class="muted">not written yet</span>' : h.problems.length === 0 ? 'fits' : `<span class="bad">${h.problems.map(esc).join('<br>')}</span>`;
251
+ out.push(`<tr><td>${esc(titleOf(g.flow, h.from))}<br><span class="muted">${esc(way)}</span></td><td class="mono">${esc(h.flow)}</td><td>${given}</td><td>${checked}</td></tr>`);
252
+ }
253
+ out.push('</tbody></table></div>');
254
+ }
255
+ out.push('<h2>What is guaranteed</h2>');
256
+ out.push(`<p>These hold in all ${ex.configs.length} situations the model can reach, from ${ex.starts} different starting situations${holds === assertions.length ? '' : ` — <span class="bad">${assertions.length - holds} do not</span>`}.</p>`);
257
+ out.push('<ul class="checks">');
258
+ for (const a of assertions) {
259
+ const v = a.violation;
260
+ const text = esc(a.say ?? humanize(a.id));
261
+ out.push(v === undefined
262
+ ? `<li>${text}</li>`
263
+ : `<li class="fail"><b>${text}</b> <span class="bad">— violated</span> in <code>${esc(v.state)}</code> after <span class="mono">${esc(v.events.join(' > ') || '(start)')}</span></li>`);
264
+ }
265
+ out.push('</ul>');
266
+ out.push('<details><summary>Exact rules</summary><div class="scroll" style="margin-top:8px"><table><thead><tr><th>Id</th><th>Never</th></tr></thead><tbody>');
267
+ for (const a of assertions)
268
+ out.push(`<tr><td>${code(a.id)}</td><td>${code(a.never)}</td></tr>`);
269
+ out.push('</tbody></table></div></details>');
270
+ if (g.flow.decisions !== undefined) {
271
+ out.push('<h2>Still to decide</h2>');
272
+ out.push('<div class="scroll"><table><thead><tr><th>Id</th><th>Question</th><th>Recommendation</th><th>Affects</th></tr></thead><tbody>');
273
+ for (const [id, d] of Object.entries(g.flow.decisions)) {
274
+ out.push(`<tr><td>${esc(id)}</td><td>${esc(d.question)}</td><td>${esc(d.recommend)}</td><td>${d.affects.map((s) => esc(titleOf(g.flow, s))).join(', ')}</td></tr>`);
275
+ }
276
+ out.push('</tbody></table></div>');
277
+ }
278
+ out.push('<h2>Reference</h2>');
279
+ out.push(`<details><summary>All transitions (${Object.values(g.flow.states).reduce((n, s) => n + (s.always?.length ?? 0) + Object.values(s.on ?? {}).reduce((k, spec) => k + alternatives(spec).length, 0), 0)})</summary>${transitionsTable(g, ex)}</details>`);
280
+ const from = g.ui.absent === true ? 'flow.yaml (it has no screens)' : `flow.yaml, ui.yaml${kit === null ? ' and the catalog' : `, the catalog and the kit ${esc(kit.id)}`}`;
281
+ out.push(`<footer>delurio graph-viz · generated from ${from} · no AI, no network: the same input gives the same bytes.</footer>`);
282
+ out.push('</main></body></html>');
283
+ return out.join('\n') + '\n';
284
+ }