@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.
@@ -0,0 +1,28 @@
1
+ import { collectEdges, exitLine, kindOf } from './edges.js';
2
+ import { titleOf } from './graph.js';
3
+ const clean = (s) => s.replace(/"/g, "'").replace(/:/g, ' -');
4
+ // Mermaid stateDiagram-v2 text, for PRs and READMEs. The full page is html.ts.
5
+ export function renderMermaid(g, ex) {
6
+ const lines = ['---', `title: ${clean(g.flow.title ?? g.flow.id)}`, '---', 'stateDiagram-v2', ' direction TB'];
7
+ const names = Object.keys(g.flow.states);
8
+ for (const name of names) {
9
+ const st = g.flow.states[name];
10
+ const detail = st.type === 'final' && st.leavesPage !== true ? ` (${exitLine(st)})` : '';
11
+ lines.push(` state "${clean(titleOf(g.flow, name))}${clean(detail)}" as ${name}`);
12
+ }
13
+ lines.push(` [*] --> ${g.flow.initial}`);
14
+ for (const e of collectEdges(g, ex)) {
15
+ const label = e.labels.map(clean).join(', ');
16
+ lines.push(` ${e.from} --> ${e.to}${label === '' ? '' : `: ${label}`}${e.unused ? ' (never taken)' : ''}`);
17
+ }
18
+ lines.push(' classDef server fill:#eeeeec,stroke:#6b6560,stroke-dasharray:4 3');
19
+ lines.push(' classDef screen fill:#ffffff,stroke:#1c1917,stroke-width:2px');
20
+ lines.push(' classDef subflow fill:#ffffff,stroke:#1d4ed8,stroke-width:2px');
21
+ lines.push(' classDef exit fill:#ffffff,stroke:#6b6560,stroke-width:2px');
22
+ for (const kind of ['server', 'screen', 'subflow', 'exit']) {
23
+ const members = names.filter((n) => kindOf(g, n) === kind);
24
+ if (members.length > 0)
25
+ lines.push(` class ${members.join(',')} ${kind}`);
26
+ }
27
+ return lines.join('\n') + '\n';
28
+ }
package/dist/model.js ADDED
@@ -0,0 +1,178 @@
1
+ import { alternatives } from './graph.js';
2
+ import { evaluate, parseExpr } from './expr.js';
3
+ export class ModelError extends Error {
4
+ }
5
+ export class Model {
6
+ flow;
7
+ vars;
8
+ domains;
9
+ constructor(flow) {
10
+ this.flow = flow;
11
+ this.vars = Object.keys(flow.context);
12
+ this.domains = this.vars.map((v) => flow.context[v].values);
13
+ }
14
+ lookup(state, ctx, depth) {
15
+ return (name) => {
16
+ if (name === 'state')
17
+ return state;
18
+ const i = this.vars.indexOf(name);
19
+ if (i >= 0)
20
+ return ctx[i];
21
+ const guard = this.flow.guards[name];
22
+ if (guard === undefined)
23
+ throw new ModelError(`unknown name "${name}" (state ${state})`);
24
+ if (depth > 8)
25
+ throw new ModelError(`guard ${name} nests too deep`);
26
+ return evaluate(parseExpr(guard), this.lookup(state, ctx, depth + 1));
27
+ };
28
+ }
29
+ test(expr, state, ctx) {
30
+ return Boolean(this.value(expr, state, ctx));
31
+ }
32
+ value(expr, state, ctx) {
33
+ return evaluate(parseExpr(expr), this.lookup(state, ctx, 0));
34
+ }
35
+ apply(names, ctx) {
36
+ let outs = [ctx];
37
+ for (const name of names ?? []) {
38
+ const effect = this.flow.effects[name];
39
+ if (effect === undefined)
40
+ throw new ModelError(`unknown effect ${name}`);
41
+ const next = [];
42
+ for (const base of outs) {
43
+ const c = base.slice();
44
+ for (const [k, v] of Object.entries(effect.set ?? {}))
45
+ c[this.vars.indexOf(k)] = v;
46
+ const havoc = effect.havoc ?? [];
47
+ if (havoc.length === 0) {
48
+ next.push(c);
49
+ continue;
50
+ }
51
+ let combos = [[]];
52
+ for (const h of havoc)
53
+ combos = combos.flatMap((p) => this.domains[this.vars.indexOf(h)].map((v) => [...p, v]));
54
+ for (const combo of combos) {
55
+ const c2 = c.slice();
56
+ havoc.forEach((h, j) => {
57
+ c2[this.vars.indexOf(h)] = combo[j];
58
+ });
59
+ next.push(c2);
60
+ }
61
+ }
62
+ outs = next;
63
+ }
64
+ return outs;
65
+ }
66
+ steps(state, ctx) {
67
+ const st = this.flow.states[state];
68
+ const out = [];
69
+ if (st.type === 'final')
70
+ return out;
71
+ if (st.always !== undefined) {
72
+ for (let i = 0; i < st.always.length; i++) {
73
+ const a = st.always[i];
74
+ if (a.guard === undefined || this.test(a.guard, state, ctx)) {
75
+ for (const c of this.apply(a.effect, ctx))
76
+ out.push({ event: null, target: a.target, ctx: c, taken: `${state}|always|${i}` });
77
+ return out;
78
+ }
79
+ }
80
+ throw new ModelError(`\`always\` in ${state} is not exhaustive for ${JSON.stringify(Object.fromEntries(this.vars.map((v, i) => [v, ctx[i]])))}`);
81
+ }
82
+ for (const [event, spec] of Object.entries(st.on ?? {})) {
83
+ const alts = alternatives(spec);
84
+ for (let i = 0; i < alts.length; i++) {
85
+ const a = alts[i];
86
+ if (a.guard === undefined || this.test(a.guard, state, ctx)) {
87
+ for (const c of this.apply(a.effect, ctx))
88
+ out.push({ event, target: a.target, ctx: c, taken: `${state}|${event}|${i}` });
89
+ break;
90
+ }
91
+ }
92
+ }
93
+ return out;
94
+ }
95
+ initials() {
96
+ let combos = [[]];
97
+ this.vars.forEach((v, i) => {
98
+ const initial = this.flow.context[v].initial;
99
+ const values = initial === 'any' ? this.domains[i] : [initial];
100
+ combos = combos.flatMap((p) => values.map((x) => [...p, x]));
101
+ });
102
+ return combos;
103
+ }
104
+ scenario(ctx) {
105
+ const out = {};
106
+ this.vars.forEach((v, i) => {
107
+ if (this.flow.context[v].initial === 'any')
108
+ out[v] = ctx[i];
109
+ });
110
+ return out;
111
+ }
112
+ }
113
+ // Every reachable (state, context) pair, breadth first, so the first hit of a state is a shortest path to it.
114
+ // `from` and `keep` narrow the search: the replay starts only from situations the app is bound to and takes only steps it can drive.
115
+ export function explore(m, from = m.initials(), keep) {
116
+ const index = new Set();
117
+ const configs = [];
118
+ const taken = new Set();
119
+ const atState = new Map();
120
+ const branchCounts = new Map();
121
+ const push = (state, ctx, parent, event) => {
122
+ const key = `${state}|${ctx.join(',')}`;
123
+ if (index.has(key))
124
+ return;
125
+ index.add(key);
126
+ configs.push({ state, ctx, parent, event });
127
+ };
128
+ for (const c of from)
129
+ push(m.flow.initial, c, -1, null);
130
+ const starts = configs.length;
131
+ for (let i = 0; i < configs.length; i++) {
132
+ const { state, ctx } = configs[i];
133
+ atState.set(state, (atState.get(state) ?? 0) + 1);
134
+ const steps = m.steps(state, ctx);
135
+ for (const s of steps) {
136
+ if (keep !== undefined && !keep(configs[i], s))
137
+ continue;
138
+ taken.add(s.taken);
139
+ push(s.target, s.ctx, i, s.event);
140
+ }
141
+ const always = m.flow.states[state].always;
142
+ if (always !== undefined && steps.length > 0) {
143
+ const counts = branchCounts.get(state) ?? always.map(() => 0);
144
+ counts[Number(steps[0].taken.split('|')[2])] += 1;
145
+ branchCounts.set(state, counts);
146
+ }
147
+ }
148
+ return { configs, starts, taken, atState, branchCounts };
149
+ }
150
+ export function pathTo(m, ex, index) {
151
+ const events = [];
152
+ let c = ex.configs[index];
153
+ while (c.parent >= 0) {
154
+ if (c.event !== null)
155
+ events.push(c.event);
156
+ c = ex.configs[c.parent];
157
+ }
158
+ return { events: events.reverse(), start: m.scenario(c.ctx) };
159
+ }
160
+ export function checkAssertions(m, ex) {
161
+ return (m.flow.assertions ?? []).map((a) => {
162
+ const hit = ex.configs.findIndex((c) => m.test(a.never, c.state, c.ctx));
163
+ if (hit < 0)
164
+ return { id: a.id, never: a.never, say: a.say };
165
+ return { id: a.id, never: a.never, say: a.say, violation: { ...pathTo(m, ex, hit), state: ex.configs[hit].state } };
166
+ });
167
+ }
168
+ export function finalPaths(m, ex) {
169
+ const out = [];
170
+ for (const [name, st] of Object.entries(m.flow.states)) {
171
+ if (st.type !== 'final')
172
+ continue;
173
+ const hit = ex.configs.findIndex((c) => c.state === name);
174
+ if (hit >= 0)
175
+ out.push({ state: name, index: hit, ...pathTo(m, ex, hit) });
176
+ }
177
+ return out;
178
+ }
@@ -0,0 +1,271 @@
1
+ import { CSS } from './html.js';
2
+ import { pathTo } from './nav.js';
3
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
4
+ const code = (s) => `<code>${esc(s)}</code>`;
5
+ const NAV_CSS = `
6
+ .persons { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
7
+ .person { border: 1px solid var(--line); border-radius: 10px; background: var(--surface); padding: 12px 14px; }
8
+ .person h3 { margin-bottom: 4px; } .person p { margin: 4px 0 0; } .person .stat { margin-top: 8px; font-size: 14px; }
9
+ .finding { margin: 12px 0; padding: 12px 16px; border: 1px solid var(--line); border-left: 5px solid var(--muted); border-radius: 10px; background: var(--surface); }
10
+ .finding.problem { border-left-color: var(--bad); } .finding p { margin: 0; } .finding ul { margin: 8px 0 0; padding-left: 20px; } .finding li { margin: 3px 0; }
11
+ .finding .tag { margin-right: 6px; }
12
+ table.matrix th, table.matrix td { padding: 6px 8px; } table.matrix th.persona { min-width: 92px; text-transform: none; letter-spacing: 0; font-size: 12px; }
13
+ table.matrix td.cell { text-align: center; white-space: nowrap; } td.cell .why { display: block; font-size: 11px; color: var(--muted); }
14
+ .c-ok { color: var(--ok); font-weight: 600; } .c-refused { color: var(--bad); font-weight: 600; } .c-placeholder { color: var(--muted); font-weight: 600; } .c-missing { color: var(--bad); font-weight: 600; } .c-hidden, .c-none { color: var(--line); }
15
+ tr.grp th { background: var(--accent-bg); color: var(--ink); text-transform: none; letter-spacing: 0; font-size: 13px; }
16
+ .path { display: block; font-size: 12px; color: var(--muted); max-width: 190px; white-space: normal; text-align: left; }
17
+ td.reach { vertical-align: top; } td.reach b { font-size: 15px; }
18
+ .legend-row { display: flex; flex-wrap: wrap; gap: 4px 18px; margin: 10px 0 0; font-size: 14px; color: var(--muted); }
19
+ ul.sitemap { list-style: none; margin: 0; padding: 0; } ul.sitemap li { padding: 6px 0; border-bottom: 1px solid var(--line); } ul.sitemap li:last-child { border-bottom: 0; }
20
+ ul.sitemap .route { margin-left: 8px; color: var(--muted); }
21
+ .checklist li.fail::before { content: "✗"; color: var(--bad); }
22
+ `;
23
+ const STANDING_WORD = { ok: 'opens', placeholder: 'not built', missing: 'not found', refused: 'refuses', unreachable: 'no way' };
24
+ const STANDING_MARK = { ok: '✓', placeholder: '◌', missing: '✗', refused: '⛔', unreachable: '–' };
25
+ function pathWords(entries) {
26
+ return entries
27
+ .filter((e) => e.label !== '')
28
+ .map((e) => e.label)
29
+ .join(' → ');
30
+ }
31
+ function landingStats(g, v, rows, index) {
32
+ if (v.landing === null)
33
+ return '';
34
+ const frame = g.pages[v.landing].frame;
35
+ if (!g.menuFrames.includes(frame))
36
+ return '';
37
+ const shown = rows.filter((r) => r.frame === frame && r.cells[index].shown);
38
+ if (shown.length === 0)
39
+ return `The ${(g.frames[frame].menu ?? frame).toLowerCase()} has no entries.`;
40
+ const by = (s) => shown.filter((r) => r.cells[index].standing === s).length;
41
+ const parts = [`${by('ok')} open`];
42
+ if (by('refused') > 0)
43
+ parts.push(`${by('refused')} refuse them`);
44
+ if (by('placeholder') > 0)
45
+ parts.push(`${by('placeholder')} are not built`);
46
+ if (by('missing') > 0)
47
+ parts.push(`${by('missing')} lead to a page that does not exist`);
48
+ return `The ${(g.frames[frame].menu ?? frame).toLowerCase()} has ${shown.length} ${shown.length === 1 ? 'entry' : 'entries'}: ${parts.join(', ')}.`;
49
+ }
50
+ function findingHtml(f) {
51
+ const detail = f.detail.length === 0 ? '' : `<ul>${f.detail.map((d) => `<li>${esc(d)}</li>`).join('')}</ul>`;
52
+ return `<section class="finding ${f.level}"><p><span class="tag ${f.level === 'problem' ? 'bad' : ''}">${f.level}</span>${esc(f.say)}</p>${detail}</section>`;
53
+ }
54
+ function matrix(g, views, rows) {
55
+ const out = ['<div class="scroll"><table class="matrix"><thead><tr><th>Menu entry</th>'];
56
+ for (const v of views)
57
+ out.push(`<th class="persona">${esc(v.persona.title)}</th>`);
58
+ out.push('</tr></thead><tbody>');
59
+ for (const frame of g.menuFrames) {
60
+ out.push(`<tr class="grp"><th colspan="${views.length + 1}">${esc(g.frames[frame].menu ?? frame)}</th></tr>`);
61
+ for (const r of rows.filter((x) => x.frame === frame)) {
62
+ const target = g.pages[r.entry.to];
63
+ out.push(`<tr><td>${esc(r.entry.label)}<span class="path">${esc(target.title === r.entry.label ? target.route : `${target.title} · ${target.route}`)}</span></td>`);
64
+ r.cells.forEach((c, i) => {
65
+ if (!r.frameKnown[i])
66
+ out.push('<td class="cell c-none" title="this kind of user never has this menu"></td>');
67
+ else if (!c.shown || c.standing === null)
68
+ out.push('<td class="cell c-hidden" title="not in this person’s menu">–</td>');
69
+ else
70
+ out.push(`<td class="cell c-${c.standing}" title="${STANDING_WORD[c.standing]}">${STANDING_MARK[c.standing]}<span class="why">${STANDING_WORD[c.standing]}</span></td>`);
71
+ });
72
+ out.push('</tr>');
73
+ }
74
+ }
75
+ out.push('</tbody></table></div>');
76
+ out.push(`<p class="legend-row"><span><b class="c-ok">✓ opens</b> nothing in the application’s own code refuses it (the server can still say no)</span><span><b class="c-refused">⛔ refuses</b> the entry is drawn, the page says no</span><span><b class="c-placeholder">◌ not built</b> the page says it is not ready</span>${Object.values(g.pages).some((pg) => pg.kind === 'missing') ? '<span><b class="c-missing">✗ not found</b> there is no such page</span>' : ''}<span>– not in this person’s menu</span><span>blank: this kind of user never has this menu</span></p>`);
77
+ return out.join('');
78
+ }
79
+ function reachTable(g, views) {
80
+ const out = ['<div class="scroll"><table class="matrix"><thead><tr><th>Page</th>'];
81
+ for (const v of views)
82
+ out.push(`<th class="persona">${esc(v.persona.title)}</th>`);
83
+ out.push('</tr></thead><tbody>');
84
+ for (const frame of Object.keys(g.frames)) {
85
+ const pages = Object.values(g.pages).filter((p) => p.frame === frame && p.id !== 'start');
86
+ if (pages.length === 0)
87
+ continue;
88
+ out.push(`<tr class="grp"><th colspan="${views.length + 1}">${esc(g.frames[frame].title)}</th></tr>`);
89
+ for (const p of pages) {
90
+ out.push(`<tr><td>${esc(p.title)}${p.kind === 'placeholder' ? ' <span class="tag">not built</span>' : p.kind === 'missing' ? ' <span class="tag">does not exist</span>' : ''}<span class="path">${esc(p.route)}</span></td>`);
91
+ for (const v of views) {
92
+ const st = v.standing[p.id];
93
+ if (st === 'unreachable') {
94
+ out.push('<td class="reach c-hidden">–</td>');
95
+ continue;
96
+ }
97
+ const r = v.reach.get(p.id);
98
+ const steps = r === undefined ? '' : pathWords(pathTo(v.reach, p.id));
99
+ const mark = st === 'refused' ? ' <span class="c-refused">⛔</span>' : st === 'placeholder' ? ' <span class="c-placeholder">◌</span>' : st === 'missing' ? ' <span class="c-missing">✗</span>' : '';
100
+ out.push(`<td class="reach"><b>${r?.clicks ?? 0}</b>${mark}${r?.usesData === true ? ' †' : ''}<span class="path">${esc(steps === '' ? 'automatic' : steps)}</span></td>`);
101
+ }
102
+ out.push('</tr>');
103
+ }
104
+ }
105
+ out.push('</tbody></table></div>');
106
+ out.push('<p class="legend-row"><span>number: fewest clicks from the moment the person signs in and “/” has sent them on</span><span>⛔ the page refuses them</span><span>◌ the page is not built</span><span>† needs an item in a list (a row to pick, a card to open)</span><span>–: no way there</span></p>');
107
+ return out.join('');
108
+ }
109
+ function goalsTable(g, goals) {
110
+ if (goals.length === 0)
111
+ return '';
112
+ const out = ['<h2>Things people come to do</h2><div class="scroll"><table><thead><tr><th>Goal</th><th>Who</th><th>Clicks</th><th>Result</th></tr></thead><tbody>'];
113
+ for (const c of goals) {
114
+ const ok = c.outcome === 'ok';
115
+ out.push(`<tr><td>${esc(c.goal.say)}<span class="path">${esc(g.pages[c.goal.page].route)}</span></td><td>${esc(c.persona.title)}</td><td>${c.clicks === null ? '–' : `${c.clicks} of ${c.goal.within}`}</td><td class="${ok ? 'ok' : 'bad'}">${ok ? 'ok' : esc(c.outcome)}</td></tr>`);
116
+ }
117
+ out.push('</tbody></table></div>');
118
+ return out.join('');
119
+ }
120
+ function siteMap(g) {
121
+ const out = [];
122
+ for (const frame of Object.keys(g.frames)) {
123
+ const pages = Object.values(g.pages).filter((p) => p.frame === frame);
124
+ if (pages.length === 0)
125
+ continue;
126
+ out.push(`<h3 style="margin:16px 0 4px">${esc(g.frames[frame].title)}</h3><ul class="sitemap">`);
127
+ for (const p of pages) {
128
+ const tags = [];
129
+ if (p.kind !== 'screen')
130
+ tags.push(p.kind === 'placeholder' ? 'not built' : p.kind === 'missing' ? 'does not exist' : p.kind);
131
+ if (p.opensFor !== null)
132
+ tags.push(`opens only when ${p.opensFor}`);
133
+ const notes = [p.opensFor !== null && p.refusal !== null ? `Otherwise ${p.refusal}.` : null, p.evidence].filter((n) => n !== null);
134
+ out.push(`<li>${esc(p.title)}<span class="route">${code(p.route)}</span> ${tags.map((t) => `<span class="tag">${esc(t)}</span>`).join(' ')}${notes.map((n) => `<br><span class="muted">${esc(n)}</span>`).join('')}</li>`);
135
+ }
136
+ out.push('</ul>');
137
+ }
138
+ return out.join('');
139
+ }
140
+ function referenceTable(g) {
141
+ const out = ['<div class="scroll" style="margin-top:8px"><table><thead><tr><th>From</th><th>How</th><th>Label</th><th>To</th><th>Only when</th></tr></thead><tbody>'];
142
+ g.specs.forEach((s) => {
143
+ const from = (Array.isArray(s.from) ? s.from : [s.from]).join(', ');
144
+ const target = g.pages[s.to];
145
+ const extra = [s.data === true ? 'needs an item in a list' : '', s.back === true ? 'goes back' : ''].filter((x) => x !== '').join('; ');
146
+ out.push(`<tr><td>${code(from)}</td><td>${esc(s.how)}</td><td>${esc(s.label === undefined ? '' : (g.catalog[s.label] ?? s.label))}</td><td>${esc(target.title)} ${code(target.route)}</td><td>${s.when === undefined ? '' : code(s.when)}${extra === '' ? '' : ` <span class="muted">${esc(extra)}</span>`}</td></tr>`);
147
+ });
148
+ out.push('</tbody></table></div>');
149
+ return out.join('');
150
+ }
151
+ function guardsReference(g) {
152
+ const out = ['<dl class="guards">'];
153
+ for (const [n, expr] of Object.entries(g.guards))
154
+ out.push(`<dt>${code(n)}</dt><dd>${code(expr)}</dd>`);
155
+ out.push('</dl>');
156
+ return out.join('');
157
+ }
158
+ export function renderNavPage(p) {
159
+ const { g, views, rows, goals, found, repo } = p;
160
+ const out = [];
161
+ const problems = found.filter((f) => f.level === 'problem');
162
+ out.push('<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">');
163
+ out.push(`<title>${esc(g.title)} navigation</title><style>${CSS}${NAV_CSS}</style></head><body><main>`);
164
+ out.push(`<p class="eyebrow"><span class="tag">${esc(g.status)}</span> <span class="mono">${esc(g.id)}</span> · navigation graph</p>`);
165
+ out.push(`<h1>${esc(g.title)}: where people go</h1>`);
166
+ if (g.summary !== '')
167
+ out.push(`<p class="lede">${esc(g.summary)}</p>`);
168
+ out.push(`<p class="muted">${Object.keys(g.pages).length} pages · ${g.specs.length} ways to move · ${views.length} kinds of user${g.source.commit === undefined ? '' : ` · read from ${esc(String(g.source.repo ?? 'the app'))} at ${esc(g.source.commit)}`} · wording: ${esc(g.locale)}</p>`);
169
+ out.push('<h2>What stands out</h2>');
170
+ out.push(problems.length === 0 ? '<p>Nothing blocks anyone.</p>' : `<p>${problems.length} problem${problems.length === 1 ? '' : 's'} and ${found.length - problems.length} note${found.length - problems.length === 1 ? '' : 's'}. Problems send a person somewhere that does not work; notes are about words.</p>`);
171
+ for (const f of found)
172
+ out.push(findingHtml(f));
173
+ out.push('<h2>Who uses it</h2><div class="persons">');
174
+ views.forEach((v, i) => {
175
+ const landing = v.landing === null ? null : g.pages[v.landing];
176
+ out.push(`<div class="person"><h3>${esc(v.persona.title)}</h3><p class="muted">${esc(v.persona.say)}</p>`);
177
+ out.push(`<p class="stat">Lands on <b>${esc(landing === null ? '–' : landing.title)}</b>${landing === null ? '' : ` ${code(landing.route)}`}${landing?.kind === 'placeholder' ? ' <span class="tag">not built</span>' : ''}.</p>`);
178
+ const line = landingStats(g, v, rows, i);
179
+ if (line !== '')
180
+ out.push(`<p class="stat">${esc(line)}</p>`);
181
+ out.push('</div>');
182
+ });
183
+ out.push('</div>');
184
+ out.push('<h2>What the menu shows each kind of user</h2>');
185
+ const menus = g.menuFrames.map((f) => (g.frames[f].menu ?? f).toLowerCase());
186
+ out.push(`<p class="muted" style="margin-top:-4px">One row per menu entry.${menus.length > 1 ? ` The ${menus.join(' and the ')} are different menus.` : ''}</p>`);
187
+ out.push(matrix(g, views, rows));
188
+ out.push('<h2>How to get to each page</h2>');
189
+ out.push(reachTable(g, views));
190
+ out.push(goalsTable(g, goals));
191
+ out.push('<h2>The pages</h2>');
192
+ out.push(siteMap(g));
193
+ if (repo !== null) {
194
+ const bad = repo.checks.filter((c) => !c.ok);
195
+ out.push('<h2>Checked against the app</h2>');
196
+ out.push(`<p>${repo.checks.length - bad.length} of ${repo.checks.length} checks hold${repo.head === null ? '' : ` in the checkout at ${esc(repo.head)}`}${repo.head !== null && g.source.commit !== undefined && !repo.head.startsWith(g.source.commit) && !g.source.commit.startsWith(repo.head) ? ` <span class="bad">(the graph was read at ${esc(g.source.commit)})</span>` : ''}. The checks read the route files, the menu registry, the permission table and the translation file; they do not run the app.</p>`);
197
+ const line = (c) => `<li class="${c.ok ? '' : 'fail'}">${esc(c.what)}${c.detail === '' ? '' : ` <span class="muted">— ${esc(c.detail)}</span>`}</li>`;
198
+ if (bad.length > 0)
199
+ out.push(`<ul class="checks checklist">${bad.map(line).join('')}</ul>`);
200
+ out.push(`<details><summary>${repo.checks.length - bad.length} checks that hold</summary><ul class="checks checklist" style="margin-top:8px">${repo.checks.filter((c) => c.ok).map(line).join('')}</ul></details>`);
201
+ }
202
+ out.push('<h2>Not modeled</h2><ul>');
203
+ for (const n of g.notModeled)
204
+ out.push(`<li>${esc(n)}</li>`);
205
+ out.push('</ul>');
206
+ out.push('<h2>Reference</h2>');
207
+ out.push(`<details><summary>Every way to move (${g.specs.length})</summary>${referenceTable(g)}</details>`);
208
+ out.push(`<details><summary>Named rules</summary>${guardsReference(g)}</details>`);
209
+ out.push('<footer>delurio graph-viz · generated from nav.yaml and the catalog · no AI, no network: the same input gives the same bytes.</footer>');
210
+ out.push('</main></body></html>');
211
+ return out.join('\n') + '\n';
212
+ }
213
+ const clean = (s) => s.replace(/"/g, "'").replace(/[<>|]/g, ' ').replace(/\s+/g, ' ').trim();
214
+ // Mermaid flowchart for PRs and READMEs: one menu hub per frame; links, buttons and landing rules as arrows.
215
+ export function renderNavMermaid(g) {
216
+ const lines = ['---', `title: ${clean(g.title)} navigation`, '---', 'flowchart LR'];
217
+ const hubOf = (p) => (g.menuFrames.includes(p.frame) ? `hub_${p.frame}` : null);
218
+ for (const frame of Object.keys(g.frames)) {
219
+ const pages = Object.values(g.pages).filter((p) => p.frame === frame);
220
+ if (pages.length === 0)
221
+ continue;
222
+ lines.push(` subgraph F_${frame}["${clean(g.frames[frame].title.replace(/ \(.*$/, ''))}"]`);
223
+ if (g.menuFrames.includes(frame))
224
+ lines.push(` hub_${frame}(("menu"))`);
225
+ for (const p of pages)
226
+ lines.push(` ${p.id}["${clean(p.title)}<br/>${clean(p.route)}"]`);
227
+ lines.push(' end');
228
+ }
229
+ const seen = new Set();
230
+ const edge = (from, to, label, dotted) => {
231
+ const key = `${from}|${to}|${label}|${dotted}`;
232
+ if (seen.has(key))
233
+ return;
234
+ seen.add(key);
235
+ lines.push(` ${from} ${dotted ? '-.->' : '-->'}${label === '' ? '' : `|"${clean(label)}"|`} ${to}`);
236
+ };
237
+ for (const e of g.entries) {
238
+ const from = g.pages[e.from];
239
+ const hub = hubOf(from);
240
+ if (e.how === 'menu') {
241
+ if (hub !== null)
242
+ edge(hub, e.to, '', false);
243
+ else if (g.frames[from.frame].shows !== undefined)
244
+ continue; // drawn through the hubs
245
+ }
246
+ else if (e.how === 'picker') {
247
+ if (hub !== null)
248
+ edge(hub, e.to, e.label, true);
249
+ }
250
+ else if (e.how === 'user') {
251
+ if (hub !== null)
252
+ edge(hub, e.to, e.label, true);
253
+ }
254
+ else {
255
+ edge(e.from, e.to, e.how === 'auto' ? 'automatic' : e.label, e.how === 'auto');
256
+ }
257
+ }
258
+ lines.push(' classDef placeholder stroke-dasharray:4 3,fill:#f5f5f4,stroke:#78716c');
259
+ lines.push(' classDef hub fill:#eef3ff,stroke:#1d4ed8');
260
+ const placeholders = Object.values(g.pages).filter((p) => p.kind === 'placeholder').map((p) => p.id);
261
+ if (placeholders.length > 0)
262
+ lines.push(` class ${placeholders.join(',')} placeholder`);
263
+ const gone = Object.values(g.pages).filter((p) => p.kind === 'missing').map((p) => p.id);
264
+ if (gone.length > 0) {
265
+ lines.push(' classDef missing stroke-dasharray:2 3,fill:#fff5f5,stroke:#b91c1c');
266
+ lines.push(` class ${gone.join(',')} missing`);
267
+ }
268
+ if (g.menuFrames.length > 0)
269
+ lines.push(` class ${g.menuFrames.map((f) => `hub_${f}`).join(',')} hub`);
270
+ return lines.join('\n') + '\n';
271
+ }