@beehexa/hexasync-template-model 2608.21.4 → 2610.4.3

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.
Files changed (39) hide show
  1. package/dist/canonicalGraph.d.ts +153 -0
  2. package/dist/canonicalGraph.d.ts.map +1 -0
  3. package/dist/canonicalGraph.js +321 -0
  4. package/dist/canonicalGraph.js.map +1 -0
  5. package/dist/collapseRule.d.ts +125 -0
  6. package/dist/collapseRule.d.ts.map +1 -0
  7. package/dist/collapseRule.js +87 -0
  8. package/dist/collapseRule.js.map +1 -0
  9. package/dist/flowRender.d.ts +364 -18
  10. package/dist/flowRender.d.ts.map +1 -1
  11. package/dist/flowRender.js +956 -123
  12. package/dist/flowRender.js.map +1 -1
  13. package/dist/index.d.ts +8 -3
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +10 -3
  16. package/dist/index.js.map +1 -1
  17. package/dist/nodeAddress.d.ts +81 -0
  18. package/dist/nodeAddress.d.ts.map +1 -1
  19. package/dist/nodeAddress.js +70 -0
  20. package/dist/nodeAddress.js.map +1 -1
  21. package/dist/occurrenceExpansion.d.ts +50 -0
  22. package/dist/occurrenceExpansion.d.ts.map +1 -0
  23. package/dist/occurrenceExpansion.js +230 -0
  24. package/dist/occurrenceExpansion.js.map +1 -0
  25. package/dist/stepGlyph.d.ts +242 -29
  26. package/dist/stepGlyph.d.ts.map +1 -1
  27. package/dist/stepGlyph.js +403 -104
  28. package/dist/stepGlyph.js.map +1 -1
  29. package/dist/workerStepVocabularyAudit.d.ts +19 -0
  30. package/dist/workerStepVocabularyAudit.d.ts.map +1 -0
  31. package/dist/workerStepVocabularyAudit.js +134 -0
  32. package/dist/workerStepVocabularyAudit.js.map +1 -0
  33. package/dist/workflowMigration.d.ts +60 -0
  34. package/dist/workflowMigration.d.ts.map +1 -0
  35. package/dist/workflowMigration.js +75 -0
  36. package/dist/workflowMigration.js.map +1 -0
  37. package/manifests/collapse-rule.json +43 -0
  38. package/manifests/worker-step-roster.json +164 -0
  39. package/package.json +6 -2
@@ -17,34 +17,401 @@
17
17
  * **1. Every subgraph emits its OWN direction.** Mermaid ignores the parent chart's direction inside a subgraph, so a
18
18
  * `TD` chart with `LR` stages needs the statement repeated per subgraph. (`PACKAGE-CONTRACT.md` §5 rule 1.)
19
19
  *
20
- * **2. Styling is PER ELEMENT — `style` and `linkStyle`, not `class`/`classDef`.** dashboard's own comment is the
21
- * evidence: *"`classDef` did NOT reliably apply to node shapes in v11 — edges highlighted but nodes/diamonds did
22
- * not."* A class-based overlay silently highlights the arrows and leaves the boxes plain.
20
+ * **2. Styling is PER ELEMENT — `style` and `linkStyle`, not `class`/`classDef`.** The Mermaid 12 definition
21
+ * baseline emits overlays as exact element-addressed statements; traversal classes are absent from those bytes.
23
22
  *
24
23
  * **3. …with ONE exception, and it is not a contradiction.** The VS Code extension uses `classDef` successfully — for
25
- * `stroke-width` only, never a fill — precisely so the **webview's stylesheet** can colour it from theme variables. A
26
- * literal colour would be wrong in one of light or dark. So: per-element `style` for anything carrying a COLOUR (which
27
- * the caller supplies), `classDef` for a shape hint a stylesheet should own. Both are offered; neither is guessed.
24
+ * `stroke-width` only, never a fill — precisely so the **webview's stylesheet** can colour that optional shape hint
25
+ * from theme variables. Run-state colors remain caller-supplied per-element `style` values; approved kind borders and
26
+ * polygon fill use deterministic role defaults which callers may override.
28
27
  *
29
- * **4. A `classDef` inside a `subgraph` is a PARSE ERROR in mermaid 11**, which is why class declarations are hoisted
30
- * to the end of the chart rather than emitted beside the nodes they name.
28
+ * **4. Shape-hint `classDef` declarations are chart-level output.** They are hoisted after every subgraph and emitted
29
+ * once, rather than repeated beside the nodes they name.
31
30
  *
32
- * **5. A colour is VALIDATED, and an unusable one drops the highlight rather than the chart.** Measured with the real
33
- * mermaid 11 parser: `#hex` and a bare identifier (`red`) parse; `rgba(…)`, `rgb(…)`, `hsl(…)` and `var(--x)` are
34
- * **parse errors** in `style` and `linkStyle` alike. dashboard's comment — ported here verbatim at first — said such
35
- * colours *"fall back to a stroke-only highlight"*, and that was false in both directions: a named colour never broke
36
- * anything, and an `rgba` did not degrade, it destroyed the diagram. A fill tint still needs exactly `#rrggbb`,
37
- * because the tint is 8-digit-hex alpha and has nothing to append to otherwise.
31
+ * **5. A colour is VALIDATED, and an unusable one drops the highlight rather than the chart.** The Mermaid 12
32
+ * characterization parses and renders the representative hex form and parses a bare named colour. Other CSS colour
33
+ * forms remain outside this emitter's declared literal grammar; no claim about Mermaid 12 accepting them is needed.
34
+ * A fill tint still needs exactly `#rrggbb`, because the tint has no alpha suffix to append otherwise.
38
35
  *
39
36
  * **6. The same `(model, opts)` always produces the same string.** No clock, no randomness, no ambient state — the
40
37
  * golden-diagram suite depends on it (AD-29), which is also why the overlay is an ARGUMENT and never state.
41
38
  */
42
39
  import { renderedNodeId } from './nodeAddress.js';
40
+ import { expandOccurrences, mergeNodeOverlays } from './occurrenceExpansion.js';
41
+ import { RUN_STATE_ERROR_MARKER, isCountableRepeat, repeatBadgeText, runStateAlternativeText, stepIconClassName, } from './stepGlyph.js';
42
+ /** The complete, ordered shape vocabulary. No renderer path may invent another treatment. */
43
+ export const APPROVED_NODE_TREATMENTS = [
44
+ 'flow-start',
45
+ 'flow-end',
46
+ 'process',
47
+ 'branch',
48
+ 'subroutine',
49
+ 'delay',
50
+ 'exception',
51
+ 'unimplemented',
52
+ ];
43
53
  /**
44
- * Words mermaid's flowchart grammar owns. A node id or class name spelled as one is a parse error.
54
+ * The hard boundary between deterministic definition generation and host DOM decoration.
45
55
  *
46
- * Verified against mermaid 11 by the Story 4.3 review: `end`, `graph`, `subgraph`, `style` and `class` each break the
47
- * parse, while `direction`, `o`, `x` and `0` do not — so this is the measured set rather than a cautious guess.
56
+ * Exported as data so adapters and contract tests can share one inventory instead of describing the boundary in
57
+ * prose that drifts. Stage 3 may make generated nodes operable; every visible or structural byte belongs here.
58
+ */
59
+ export const FLOW_RENDER_STAGE_OWNERSHIP = {
60
+ generate: [
61
+ 'shape',
62
+ 'nodeId',
63
+ 'labelText',
64
+ 'accTitle',
65
+ 'accDescr',
66
+ 'iconClass',
67
+ 'nodeStyle',
68
+ 'edge',
69
+ 'linkStyle',
70
+ 'edgeColor',
71
+ 'edgeDash',
72
+ 'direction',
73
+ 'repeatBadge',
74
+ 'errorMarker',
75
+ ],
76
+ decorate: ['interaction'],
77
+ };
78
+ /**
79
+ * The nine runtime executors whose authoritative Epic 7 evidence records `NotImplementedException`.
80
+ *
81
+ * This is an explicit roster subset, never a name or family regex. `WorkerStepType` keeps every member tied to the
82
+ * vendored 51-row manifest vocabulary. Begin and End remain in the subset but terminal treatment takes precedence.
83
+ */
84
+ export const UNIMPLEMENTED_WORKER_STEP_TYPES = [
85
+ 'Begin',
86
+ 'Composite',
87
+ 'cURL',
88
+ 'End',
89
+ 'GraphQL',
90
+ 'Liquid',
91
+ 'OData',
92
+ 'QueryDSL',
93
+ 'Restful',
94
+ ];
95
+ const UNIMPLEMENTED_WORKER_STEP_TYPE = {
96
+ Begin: true,
97
+ Composite: true,
98
+ cURL: true,
99
+ End: true,
100
+ GraphQL: true,
101
+ Liquid: true,
102
+ OData: true,
103
+ QueryDSL: true,
104
+ Restful: true,
105
+ };
106
+ const SUBROUTINE_WORKER_STEP_TYPE = {
107
+ PULLER: true,
108
+ PUSHER: true,
109
+ ENQUEUER: true,
110
+ };
111
+ /** Classify treatment from model facts only; labels and icon families are deliberately irrelevant. */
112
+ export function classifyNodeTreatment(node) {
113
+ if (node.type === 'Begin')
114
+ return 'flow-start';
115
+ if (node.type === 'End')
116
+ return 'flow-end';
117
+ if (node.decides === true ||
118
+ node.type === 'IF' ||
119
+ node.type === 'SWITCH') {
120
+ return 'branch';
121
+ }
122
+ if (node.type !== undefined &&
123
+ SUBROUTINE_WORKER_STEP_TYPE[node.type] === true) {
124
+ return 'subroutine';
125
+ }
126
+ if (node.type === 'SLEEP')
127
+ return 'delay';
128
+ if (node.type === 'EXCEPTION' || node.type === 'Error')
129
+ return 'exception';
130
+ if (node.type !== undefined &&
131
+ UNIMPLEMENTED_WORKER_STEP_TYPE[node.type] === true) {
132
+ return 'unimplemented';
133
+ }
134
+ if (node.terminal === true)
135
+ return 'flow-end';
136
+ return 'process';
137
+ }
138
+ /** Deterministic approved light palette; valid host-resolved values may override these roles. */
139
+ export const APPROVED_NODE_COLORS = {
140
+ fill: '#ffffff',
141
+ nodeBorder: '#64748b',
142
+ failStroke: '#b91c1c',
143
+ edge: '#64748b',
144
+ notTakenStroke: '#475569',
145
+ edgeLabelText: '#334155',
146
+ edgeLabelBg: '#ffffff',
147
+ };
148
+ /**
149
+ * The declared line-work roles (DESIGN.md `strokes:`). Blueprint carries state in the LINE, so weight and pattern
150
+ * are roles, not values a render path picks.
151
+ *
152
+ * They live here as data because FR59 forbids a width or dash-array literal inside an emitter function: a width literal
153
+ * typed into one of three emit sites is exactly how the shipped emitter ended up with three copies of one decision.
154
+ */
155
+ export const STROKE_ROLES = {
156
+ /** Every node outline and every edge. */
157
+ hairline: '1px',
158
+ /** The failed node's outline, and nothing else on the canvas. */
159
+ emphasis: '2px',
160
+ /** Dash array for the branch-not-taken stroke. RUN STATE ONLY. */
161
+ dash: '6 3',
162
+ };
163
+ /** The four states DESIGN.md's state table draws as rows; `indeterminate` is the fifth reading, not a fifth state. */
164
+ export const RUN_STATES = [
165
+ 'ranOk',
166
+ 'ranFailed',
167
+ 'neverReached',
168
+ 'branchNotTaken',
169
+ ];
170
+ /** The drawn reading for a node the producer could not place. Counted apart from `RUN_STATES` on purpose. */
171
+ export const INDETERMINATE_RUN_STATE = 'indeterminate';
172
+ /**
173
+ * The five drawn readings, resolved from DESIGN.md's closed light palette (`colors:` 9-88, `node-*` components
174
+ * 171-195, the state table 389-400).
175
+ *
176
+ * Data, not code, for the same reason as `STROKE_ROLES`: FR59 forbids a colour, width, dash or opacity literal
177
+ * inside an emitter function. `opacity: '0.7'` is the ONLY numeric opacity in DESIGN.md (`node-unreached` line 187)
178
+ * and it exists once, here.
179
+ *
180
+ * Read the columns against the matrix they came from:
181
+ * - **failed** is the only `emphasis`, the only `marker`, and the only non-white fill. All three unconditional.
182
+ * - **never reached** is SOLID with a fully visible border and recedes by muted text + `opacity` on the card.
183
+ * - **branch not taken** is DASHED at full neutral contrast and is NEVER dimmed.
184
+ * - **indeterminate** is the neutral pair and carries nothing else, so it stays clickable and unremarkable.
185
+ */
186
+ export const STATE_ROLES = {
187
+ ranOk: {
188
+ stroke: '#c2410c',
189
+ fill: APPROVED_NODE_COLORS.fill,
190
+ title: '#0f172a',
191
+ description: '#475569',
192
+ width: 'hairline',
193
+ dash: false,
194
+ opacity: undefined,
195
+ marker: false,
196
+ },
197
+ ranFailed: {
198
+ stroke: APPROVED_NODE_COLORS.failStroke,
199
+ fill: '#fee2e2',
200
+ title: '#991b1b',
201
+ description: '#7f1d1d',
202
+ width: 'emphasis',
203
+ dash: false,
204
+ opacity: undefined,
205
+ marker: true,
206
+ },
207
+ neverReached: {
208
+ stroke: '#7c8899',
209
+ fill: '#f8fafc',
210
+ title: '#64748b',
211
+ description: '#64748b',
212
+ width: 'hairline',
213
+ dash: false,
214
+ opacity: '0.7',
215
+ marker: false,
216
+ },
217
+ branchNotTaken: {
218
+ stroke: APPROVED_NODE_COLORS.notTakenStroke,
219
+ fill: APPROVED_NODE_COLORS.fill,
220
+ title: '#334155',
221
+ description: '#475569',
222
+ width: 'hairline',
223
+ dash: true,
224
+ opacity: undefined,
225
+ marker: false,
226
+ },
227
+ indeterminate: {
228
+ stroke: APPROVED_NODE_COLORS.nodeBorder,
229
+ fill: APPROVED_NODE_COLORS.fill,
230
+ title: '#0f172a',
231
+ description: '#475569',
232
+ width: 'hairline',
233
+ dash: false,
234
+ opacity: undefined,
235
+ marker: false,
236
+ },
237
+ };
238
+ /**
239
+ * The count badge's declared roles (DESIGN.md `count-badge` 218-222, `rounded.full`).
240
+ *
241
+ * Exported as DATA and not emitted as a `style` property, because Mermaid 12 has no way to paint a plate behind one
242
+ * span of a node label: the badge reaches the definition as label TEXT (`×997`) in the node's single label colour.
243
+ * A host that draws its own legend or inspector chip reads the plate roles from here, so the two surfaces cannot
244
+ * disagree about what a badge looks like. Emitting it as label text is what AD-23 requires anyway — run-state
245
+ * content is stage 1's, and `securityLevel: 'strict'` routes a label through DOMPurify.
246
+ */
247
+ export const REPEAT_BADGE_ROLES = {
248
+ background: '#ffffff',
249
+ foreground: '#9a3412',
250
+ border: '#c2410c',
251
+ borderWidth: STROKE_ROLES.hairline,
252
+ radius: '9999px',
253
+ };
254
+ /** An opacity Mermaid will accept in a `style` statement: a decimal in `[0,1]`, nothing else. */
255
+ function usableOpacity(value) {
256
+ if (value === undefined)
257
+ return undefined;
258
+ const trimmed = value.trim();
259
+ if (!/^(?:0?\.\d+|0|1(?:\.0+)?)$/.test(trimmed))
260
+ return undefined;
261
+ /**
262
+ * `0` is rejected on purpose, and it is not a formatting quibble: Mermaid honours node opacity as
263
+ * `opacity:0 !important`, so `opacity:0` draws a node that is present in the DOM, present in the layout, and
264
+ * invisible — label included. A run reading that erases the step it describes is worse than no reading.
265
+ */
266
+ const numeric = Number(trimmed);
267
+ return numeric > 0 && numeric <= 1 ? trimmed : undefined;
268
+ }
269
+ /** Does this entry carry a `state` — the only thing that can give the typed reading chart-wide authority? */
270
+ function carriesState(entry) {
271
+ // `?? undefined` folds a JSON `null` into "no state" without widening the declared type.
272
+ return (entry?.state ?? undefined) !== undefined;
273
+ }
274
+ /**
275
+ * Resolve one node's entry, given the resolved node fill (a roles-only entry's fill default) and whether the typed
276
+ * reading is authoritative for the chart. See `RenderOverlay.nodes` for the rules this implements.
277
+ */
278
+ function resolveNodeState(entry, nodeFill, authoritative) {
279
+ const roles = entry?.roles;
280
+ const requestedRepeat = entry?.repeat;
281
+ const repeat = isCountableRepeat(requestedRepeat) ? requestedRepeat : undefined;
282
+ const declined = roles?.dash === true;
283
+ if (!carriesState(entry)) {
284
+ const stroke = overlayColor(roles?.stroke);
285
+ if (stroke !== undefined) {
286
+ /**
287
+ * ROLES-ONLY: exactly the roles given, and nothing the entry did not ask for.
288
+ *
289
+ * This is what lets a producer that can prove no state — the log-derived reading, where "started and never
290
+ * ended" is not a proven `ranFailed` — draw its caller colours byte-identically: no `color:`, no marker and no
291
+ * chart-wide reading unless it asks for them.
292
+ */
293
+ return {
294
+ state: undefined,
295
+ pass: 'roles',
296
+ style: {
297
+ stroke,
298
+ // `emphasis` or the default `hairline`; any other value — `dash` included, a pattern not a weight — is hairline.
299
+ width: roles?.width === 'emphasis' ? STROKE_ROLES.emphasis : STROKE_ROLES.hairline,
300
+ fill: overlayColor(roles?.fill) ?? nodeFill,
301
+ color: overlayColor(roles?.title),
302
+ opacity: usableOpacity(roles?.opacity),
303
+ dash: declined ? STROKE_ROLES.dash : undefined,
304
+ },
305
+ declined,
306
+ reached: true,
307
+ marker: roles?.marker === true,
308
+ repeat,
309
+ };
310
+ }
311
+ // No state and no drawable stroke: no style of its own, unless the chart-wide reading draws it indeterminate.
312
+ if (!authoritative) {
313
+ return { state: undefined, pass: 'none', style: undefined, declined, reached: false, marker: false, repeat };
314
+ }
315
+ }
316
+ /**
317
+ * An unrecognised `state` READS AS `indeterminate`; it never throws.
318
+ *
319
+ * The union is a compile-time promise and an overlay is runtime data: it arrives as parsed JSON from an evidence
320
+ * producer or as a webview `postMessage` payload, where `state: 'running'` costs one keystroke. Indexed blind,
321
+ * `STATE_ROLES['running']` is `undefined` and the next property read takes down the WHOLE diagram — every node,
322
+ * over one node's bad field.
323
+ */
324
+ const requested = entry?.state;
325
+ const state = requested !== undefined && Object.hasOwn(STATE_ROLES, requested)
326
+ ? requested
327
+ : INDETERMINATE_RUN_STATE;
328
+ const declared = STATE_ROLES[state];
329
+ /**
330
+ * **The three non-chromatic channels follow the STATE, never the override.**
331
+ *
332
+ * `emphasis` width, the error marker and the dimming are the failure signals a reader without colour vision has,
333
+ * and UX-DR52 makes all three unconditional. Honouring an override on them let a caller put the marker and 2px on a
334
+ * `ranOk` node, take both off a `ranFailed` one, or dim a `branchNotTaken` node DESIGN.md says is never dimmed —
335
+ * each of which breaks "exactly one node on this canvas is 2px / marked / dashed / dimmed", the property the whole
336
+ * accessibility argument rests on.
337
+ *
338
+ * So beside a state `roles.width`, `roles.dash` and `roles.marker` are ignored, and `roles.opacity` may only retune
339
+ * a state that is already dimmed. COLOUR overrides stay fully honoured: a host resolving its own theme is exactly
340
+ * what AD-23 stage 2 asks for, and a colour cannot move a node into a channel it does not own.
341
+ */
342
+ const opacity = declared.opacity === undefined
343
+ ? undefined
344
+ : (usableOpacity(roles?.opacity) ?? declared.opacity);
345
+ /**
346
+ * A STATED entry outside the chart-wide reading can only be a DERIVED one (a caller state takes the chart): it keeps
347
+ * its label content, as it always has, and draws no style — only a caller statement restyles the drawing.
348
+ */
349
+ const drawn = authoritative;
350
+ return {
351
+ state,
352
+ pass: drawn ? 'state' : 'none',
353
+ style: drawn
354
+ ? {
355
+ stroke: overlayColor(roles?.stroke) ?? declared.stroke,
356
+ fill: overlayColor(roles?.fill) ?? declared.fill,
357
+ color: overlayColor(roles?.title) ?? declared.title,
358
+ width: STROKE_ROLES[declared.width],
359
+ dash: declared.dash ? STROKE_ROLES.dash : undefined,
360
+ opacity,
361
+ }
362
+ : undefined,
363
+ // A stateless entry drawn indeterminate keeps its declined dash; a stated one declines only as `branchNotTaken`.
364
+ declined: drawn && (carriesState(entry) ? state === 'branchNotTaken' : declined),
365
+ reached: drawn && (state === 'ranOk' || state === 'ranFailed'),
366
+ marker: declared.marker,
367
+ repeat,
368
+ };
369
+ }
370
+ /**
371
+ * Mermaid's own `maxTextSize` default, measured against 12.0.0 rather than read from a changelog.
372
+ *
373
+ * Exported as data because the number is the whole finding: 305 nodes (49 620 chars) of the measurement fixture draw
374
+ * fully and 310 (50 445 chars) draw one node. The ceiling is therefore a CHARACTER budget and scales with label
375
+ * length — a workflow with long step names hits it sooner, and Story 8.7's `accDescr`, which lists every node, spends
376
+ * part of the same budget.
377
+ */
378
+ export const MERMAID_DEFAULT_MAX_TEXT_SIZE = 50_000;
379
+ /**
380
+ * What a definition costs, for a caller that wants to REPORT rather than to be refused.
381
+ *
382
+ * The same arithmetic `renderMermaid` applies to `maxTextSize`, exported so a host can warn, offer to raise its own
383
+ * `maxTextSize`, or split a chart — all of which are better answers than an exception, and none of which the generator
384
+ * can choose on a caller's behalf.
385
+ */
386
+ export function measureDefinitionBudget(definition, budget = MERMAID_DEFAULT_MAX_TEXT_SIZE) {
387
+ return {
388
+ characters: definition.length,
389
+ budget,
390
+ overBudget: definition.length > budget,
391
+ };
392
+ }
393
+ /**
394
+ * The refusal. Carries the numbers, because "too big" without them tells a host nothing it can act on.
395
+ *
396
+ * A throw and not a truncated string: the alternative the measurement documents — hand the host the oversized
397
+ * definition and let Mermaid quietly draw one node — is the exact failure this exists to prevent, and a caller that
398
+ * would rather warn than fail has `measureDefinitionBudget` and simply does not pass `maxTextSize`.
399
+ */
400
+ export class DefinitionOverBudgetError extends Error {
401
+ budgetReport;
402
+ constructor(budgetReport) {
403
+ super(`Mermaid definition is ${budgetReport.characters} characters, over the host's maxTextSize of ${budgetReport.budget}. ` +
404
+ 'Mermaid renders an oversized definition as a single node WITHOUT reporting an error, and mermaid.parse() ' +
405
+ 'resolves on it, so this is refused here rather than drawn wrongly. Raise the host maxTextSize explicitly, ' +
406
+ 'shorten labels, or split the chart.');
407
+ this.budgetReport = budgetReport;
408
+ this.name = 'DefinitionOverBudgetError';
409
+ }
410
+ }
411
+ /**
412
+ * Words reserved by the generator's established flowchart contract.
413
+ *
414
+ * These words remain generator-owned grammar tokens rather than accepted identifiers in this established contract.
48
415
  */
49
416
  const MERMAID_RESERVED = new Set([
50
417
  'end',
@@ -59,30 +426,193 @@ const MERMAID_RESERVED = new Set([
59
426
  ]);
60
427
  /** Mermaid's own spelling: a chart is `TD`, a subgraph is `TB`. */
61
428
  const subgraphDirection = (direction) => direction === 'TD' ? 'TB' : 'LR';
62
- /**
63
- * A label, safe inside `["…"]`.
64
- *
65
- * ⚠️ Escaping lives HERE, and Story 4.2's review is why it is written down: the frontend port moved escaping out of
66
- * the model layer without recording that it had moved, and **4 real labels carry a raw newline**
67
- * (`ShoplinePublicApp.yaml`). A renderer that forgets breaks a real connector's diagram.
68
- *
69
- * The `&` replacement comes FIRST, or the entities the later rules produce would themselves be re-escaped.
70
- */
71
- export function escapeLabel(value) {
429
+ /** Escape authored text for an ordinary quoted Mermaid label. */
430
+ function escapeLabelText(value) {
72
431
  return value
73
432
  .replace(/[\r\n]+/g, ' ')
74
433
  .replace(/&/g, '&amp;')
75
434
  .replace(/"/g, '&quot;')
76
435
  .replace(/</g, '&lt;')
77
436
  .replace(/>/g, '&gt;')
78
- .trim();
437
+ // A public label beginning with a backtick must remain plain text, not opt into Mermaid markdown.
438
+ .replace(/`/g, '&#96;');
439
+ }
440
+ /** Avoid `#` inside Mermaid's HTML-label style attributes; its Markdown pass rewrites a leading hash. */
441
+ function htmlStyleColor(color) {
442
+ if (!color.startsWith('#'))
443
+ return color;
444
+ const hex = color.slice(1);
445
+ const expanded = hex.length === 3 || hex.length === 4
446
+ ? [...hex].map((digit) => `${digit}${digit}`).join('')
447
+ : hex;
448
+ if (expanded.length !== 6 && expanded.length !== 8)
449
+ return color;
450
+ const red = Number.parseInt(expanded.slice(0, 2), 16);
451
+ const green = Number.parseInt(expanded.slice(2, 4), 16);
452
+ const blue = Number.parseInt(expanded.slice(4, 6), 16);
453
+ if (expanded.length === 6)
454
+ return `rgb(${red},${green},${blue})`;
455
+ const alpha = Number.parseInt(expanded.slice(6, 8), 16) / 255;
456
+ return `rgba(${red},${green},${blue},${alpha})`;
457
+ }
458
+ function renderLabelAnatomy(anatomy, accent) {
459
+ /**
460
+ * Renderer-owned HTML is the only way to give Mermaid a measurable two-column card. Authored bytes remain text:
461
+ * every heading and description is escaped before entering the markup, and callers cannot opt into this path by
462
+ * forging public `label` bytes because `labelAnatomy` is the trust boundary.
463
+ */
464
+ const icon = anatomy.icon ?? '';
465
+ // Geometric diamonds are optically centered by their own ink box. Arrow/operator glyphs sit low on the
466
+ // alphabetic baseline in VS Code's UI font, so loose hosts may optically correct those without moving the tile.
467
+ const iconClass = icon === '◆'
468
+ ? 'hx-node-card__icon'
469
+ : 'hx-node-card__icon hx-node-card__icon--baseline';
470
+ const marker = anatomy.marker === undefined
471
+ ? ''
472
+ : `<span class='hx-node-card__marker' style='margin-right:4px'>${anatomy.marker}</span>`;
473
+ const badge = anatomy.badge === undefined
474
+ ? ''
475
+ : `<span class='hx-node-card__badge' style='margin-left:4px'>${anatomy.badge}</span>`;
476
+ const description = anatomy.description === undefined
477
+ ? ''
478
+ : `<span class='hx-node-card__subtitle' style='display:block;min-width:0;overflow-wrap:anywhere;font-size:11px;line-height:1.35'>${escapeLabelText(anatomy.description)}</span>`;
479
+ const iconColor = htmlStyleColor(accent);
480
+ const tint = `color-mix(in srgb,${iconColor} 16%,transparent)`;
481
+ const rim = `color-mix(in srgb,${iconColor} 38%,transparent)`;
482
+ return `<span class='hx-node-card' style='display:grid;width:240px;min-width:240px;box-sizing:border-box;grid-template-columns:44px minmax(0,1fr);align-items:stretch;column-gap:10px;font-family:inherit;font-size:11px;line-height:1.35;text-align:left'>` +
483
+ `<span class='${iconClass}' style='display:inline-flex;width:44px;min-height:44px;align-items:center;justify-content:center;align-self:stretch;box-sizing:border-box;border-radius:9px;background:${tint};box-shadow:inset 0 0 0 1px ${rim};color:${iconColor};font-size:32px;line-height:1'>${icon}</span>` +
484
+ `<span class='hx-node-card__copy' style='display:grid;min-width:0;grid-template-rows:auto auto;row-gap:2px'>` +
485
+ `<span class='hx-node-card__title' style='display:block;min-width:0;font-size:12px;font-weight:650;line-height:1.3'>${marker}<strong>${escapeLabelText(anatomy.heading)}</strong>${badge}</span>` +
486
+ description +
487
+ `</span>` +
488
+ `</span>`;
489
+ }
490
+ /** A public label escaped as plain text; only structural anatomy can enable Mermaid markdown. */
491
+ export function escapeLabel(value) {
492
+ return escapeLabelText(value).trim();
493
+ }
494
+ /**
495
+ * A single-line Mermaid accessibility value.
496
+ *
497
+ * Accessibility directives are grammar, not quoted labels: every line/control separator must be collapsed before
498
+ * authored text reaches them. Ordinary punctuation and Unicode remain visible instead of being stripped.
499
+ */
500
+ function alternativeText(value) {
501
+ return value
502
+ .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ')
503
+ .replace(/\s+/g, ' ')
504
+ .trim()
505
+ .replace(/&/g, '&amp;')
506
+ .replace(/</g, '&lt;')
507
+ .replace(/>/g, '&gt;');
508
+ }
509
+ /** Meaningful node text, independent of strict/loose presentation markup and icon glyphs. */
510
+ function semanticNodeText(node, entry) {
511
+ /**
512
+ * The run reaches the text alternative or it reaches this reader not at all (NFR13).
513
+ *
514
+ * A screen reader sees no stroke, no weight, no dash, no opacity and no marker — every channel the four states are
515
+ * drawn in. Appended only for a node the overlay actually NAMES, so the no-overlay bytes cannot move, and
516
+ * `indeterminate` contributes nothing because "we could not tell" is not a fact about the run.
517
+ */
518
+ const run = runStateAlternativeText(entry);
519
+ const withRun = (text) => run === '' ? text : `${text} — ${run}`;
520
+ const anatomy = node.labelAnatomy;
521
+ if (anatomy === undefined) {
522
+ return withRun(alternativeText(node.label) || alternativeText(node.address) || 'Unnamed step');
523
+ }
524
+ const heading = alternativeText(anatomy.heading);
525
+ const description = anatomy.description === undefined ? '' : alternativeText(anatomy.description);
526
+ return withRun([heading || alternativeText(node.address) || 'Unnamed step', description]
527
+ .filter((part) => part !== '')
528
+ .join(' — '));
529
+ }
530
+ /** Exactly one title and description for every chart, in authored group/node order. */
531
+ function accessibilityAlternative(groups, emptyLabel, overlayNodes,
532
+ /** Present exactly when the typed reading is chart-wide; the SAME resolution the style passes read. */
533
+ readingOf) {
534
+ const populated = groups.filter((group) => group.nodes.length > 0);
535
+ if (populated.length === 0) {
536
+ const absent = alternativeText(emptyLabel) || 'No steps';
537
+ return ['Empty workflow', `${absent}.`];
538
+ }
539
+ const titled = populated
540
+ .map((group) => group.title === undefined ? '' : alternativeText(group.title))
541
+ .filter((title) => title !== '');
542
+ const title = titled.length === 0
543
+ ? 'Workflow'
544
+ : titled.length === 1
545
+ ? `${titled[0]} workflow`
546
+ : `${titled.join(', ')} workflows`;
547
+ const seenNodes = new Set();
548
+ const description = populated
549
+ .map((group) => {
550
+ const groupTitle = group.title === undefined ? '' : alternativeText(group.title);
551
+ const nodes = group.nodes
552
+ .filter((node) => {
553
+ if (seenNodes.has(node.address))
554
+ return false;
555
+ seenNodes.add(node.address);
556
+ return true;
557
+ })
558
+ /**
559
+ * The text channel is resolved through `resolveNodeState`, the SAME resolution the `style` pass uses.
560
+ *
561
+ * Not a tidiness point: while the typed overlay is active the drawing gives every node a reading, so the text
562
+ * has to as well, and an unrecognised state has to degrade to `indeterminate` identically in both. Sharing the
563
+ * resolver is the only way the two channels cannot drift apart. A roles-only entry claims no state, so under
564
+ * the chart-wide reading it is stated as the indeterminate one.
565
+ */
566
+ .map((node) => {
567
+ if (readingOf === undefined)
568
+ return semanticNodeText(node, overlayNodes[node.address]);
569
+ const resolved = readingOf(node.address);
570
+ return semanticNodeText(node, {
571
+ state: resolved.state ?? INDETERMINATE_RUN_STATE,
572
+ repeat: resolved.repeat,
573
+ });
574
+ })
575
+ .join('; ');
576
+ return groupTitle === '' ? nodes : `${groupTitle}: ${nodes}`;
577
+ })
578
+ .join('. ');
579
+ return [title, description];
580
+ }
581
+ /**
582
+ * Validate the many-to-one Mermaid-id projection once, before any node, group or edge bytes are emitted.
583
+ *
584
+ * Duplicate appearances of one node address remain harmless and are still de-duplicated during emission. A group id
585
+ * occupies Mermaid's same identifier namespace, so it is checked even when its canonical text equals a node address.
586
+ */
587
+ function assertUniqueRenderedIds(groups, multiStage) {
588
+ const claimed = new Map();
589
+ const seenNodes = new Set();
590
+ const claim = (kind, address) => {
591
+ const rendered = renderedNodeId(address);
592
+ const existing = claimed.get(rendered);
593
+ if (existing !== undefined) {
594
+ if (kind === 'node' && existing.kind === 'node' && existing.address === address)
595
+ return;
596
+ throw new Error(`Mermaid rendered-id collision for "${rendered}": ${existing.kind} "${existing.address}" and ${kind} "${address}"`);
597
+ }
598
+ claimed.set(rendered, { kind, address });
599
+ };
600
+ for (const group of groups) {
601
+ if (multiStage && group.title !== undefined)
602
+ claim('group', group.key);
603
+ for (const node of group.nodes) {
604
+ if (seenNodes.has(node.address))
605
+ continue;
606
+ seenNodes.add(node.address);
607
+ claim('node', node.address);
608
+ }
609
+ }
79
610
  }
80
611
  /**
81
- * A label guaranteed non-empty — because an empty one is a **mermaid parse error that kills the whole diagram**.
612
+ * A label guaranteed non-empty, so every emitted node has visible text.
82
613
  *
83
- * ⛔ Story 4.3 review, CRITICAL-1, found with the real mermaid 11 parser. `escapeLabel` ends in `.trim()`, so a label
84
- * that is non-empty in the model can become `''`, and `["${''}"]` is rejected outright: *"Expecting …, got
85
- * 'STADIUMEND'"*. Not one blank node — **no chart at all**.
614
+ * `escapeLabel` ends in `.trim()`, so a label that is non-empty in the model can become `''`; the fallback prevents
615
+ * the emitter from producing an empty node label.
86
616
  *
87
617
  * Four reachable inputs, each verified end to end through the real builders: `if: ""` on an IF step (the route graph
88
618
  * maps it to the label `''`, since only `null`/`undefined` become `'?'`), `next: ' '`, `next: '\n'`, and a SWITCH
@@ -99,14 +629,25 @@ function safeLabel(value, fallback) {
99
629
  const escapedFallback = escapeLabel(fallback);
100
630
  return escapedFallback === '' ? '?' : escapedFallback;
101
631
  }
632
+ /**
633
+ * The same choice `safeLabel` makes — label, else fallback, else `'?'` — but on the RAW text.
634
+ *
635
+ * `safeLabel` returns an ESCAPED string. Handing that to `renderLabelAnatomy`, which escapes what it is given,
636
+ * escaped it twice: a node with no `stepKey`/`type` labelled `Load & save` drew the visible text `Load &amp; save`
637
+ * the moment an overlay gave it a marker or a badge. Anatomy carries raw text by contract; only the emitter escapes.
638
+ */
639
+ function rawHeading(value, fallback) {
640
+ const trimmed = value.trim();
641
+ if (trimmed !== '')
642
+ return trimmed;
643
+ const trimmedFallback = fallback.trim();
644
+ return trimmedFallback === '' ? '?' : trimmedFallback;
645
+ }
102
646
  /**
103
647
  * A colour mermaid's grammar accepts in a `style` / `linkStyle` declaration.
104
648
  *
105
- * ⛔ Story 4.3 review, CRITICAL-2. Rule 5 below used to claim a non-hex colour *"falls back to a stroke-only
106
- * highlight, which is still clearly visible"*. Measured against the real parser, that is false in **both**
107
- * directions: a bare identifier like `red` parses fine (so withholding its fill achieved nothing), while
108
- * `rgba(255,0,0,.5)`, `rgb(…)`, `hsl(…)` and `var(--x)` are **parse errors** — in `style` AND in `linkStyle`, which
109
- * had no guard at all. So the documented safety net did not exist, and the chart died instead of degrading.
649
+ * The Mermaid 12 characterization proves a bare identifier like `red` parses and is emitted stroke-only.
650
+ * Function-valued colours and CSS variables are outside the emitter's literal grammar and are withheld.
110
651
  *
111
652
  * It matters more here than in the Vue caller it was ported from: this is a published library whose contract invites
112
653
  * the caller to supply a colour, and a theme token (`var(--vscode-focusBorder)`, an `rgba` from a palette) is the most
@@ -118,22 +659,82 @@ function usableColor(color) {
118
659
  if (color === undefined)
119
660
  return undefined;
120
661
  const trimmed = color.trim();
121
- // `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, or a bare identifier — the two forms the grammar takes.
122
- return /^#[0-9a-fA-F]{3,8}$/.test(trimmed) ||
662
+ // Mermaid accepts only CSS's legal hex lengths: RGB, RGBA, RRGGBB and RRGGBBAA.
663
+ return /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(trimmed) ||
123
664
  /^[A-Za-z][A-Za-z0-9-]*$/.test(trimmed)
124
665
  ? trimmed
125
666
  : undefined;
126
667
  }
127
- /** One node line, shaped by what the node is. */
128
- function nodeLine(node) {
668
+ /**
669
+ * The OVERLAY colour grammar — narrower than `usableColor`, and deliberately so (Story 8.7, FR57/FR58).
670
+ *
671
+ * Every `fill:` the emitter writes must be opaque six-digit hex, and a run-state colour reaches both a stroke and a
672
+ * polygon fill, so six-digit hex is the only form the emitter can honour completely. `#f00`, `#f00a`, `#ff000080`
673
+ * and a bare identifier like `orange` parse but cannot be honoured, and the shipped behaviour — keep the stroke,
674
+ * silently drop the fill — is a half-applied highlight that reads as a different run state.
675
+ *
676
+ * So the whole highlight for that dimension is dropped: no node `style`, no `linkStyle`. AD-23 stage 2 and Story
677
+ * 10.3 both require a host to resolve its theme token to literal hex before calling, so nothing loses a capability
678
+ * it was meant to have.
679
+ */
680
+ function overlayColor(color) {
681
+ if (color === undefined)
682
+ return undefined;
683
+ const trimmed = color.trim();
684
+ return /^#[0-9a-fA-F]{6}$/.test(trimmed) ? trimmed : undefined;
685
+ }
686
+ /** One node line, shaped by its approved treatment and labelled for the requested host fidelity. */
687
+ function nodeLine(node, fidelity, accent, content) {
129
688
  const id = renderedNodeId(node.address);
130
- // The address is the fallback: never empty, and it names the node a reader can look up.
131
- const label = safeLabel(node.label, node.address);
132
- if (node.decides === true)
133
- return ` ${id}{"${label}"}`;
134
- if (node.terminal === true)
135
- return ` ${id}(["${label}"])`;
136
- return ` ${id}["${label}"]`;
689
+ const sourceLabel = fidelity === 'loose' ? (node.looseLabel ?? node.label) : node.label;
690
+ const baseAnatomy = fidelity === 'loose'
691
+ ? (node.looseLabelAnatomy ?? (node.looseLabel === undefined ? node.labelAnatomy : undefined))
692
+ : node.labelAnatomy;
693
+ const marker = content?.marker === true ? RUN_STATE_ERROR_MARKER.glyph : undefined;
694
+ const badge = repeatBadgeText(content?.repeat);
695
+ /**
696
+ * Run-state content is grafted onto the anatomy HERE rather than in the composer, because the composer runs in the
697
+ * host that builds the model and the overlay is an ARGUMENT to this render (AD-29). `composeNodeLabelModel` takes
698
+ * the same content for a caller that composes its own labels; this path covers every model already built.
699
+ *
700
+ * A node with no anatomy — `composeNodeLabelModel`'s early return, a node with neither `stepKey` nor `type` — gets
701
+ * one synthesised from its plain label the moment it carries a marker or a badge. Dropping a run fact because a
702
+ * step had no key would hide a failure.
703
+ */
704
+ const anatomy = marker === undefined && badge === undefined
705
+ ? baseAnatomy
706
+ : {
707
+ ...(baseAnatomy ?? { heading: rawHeading(sourceLabel, node.address) }),
708
+ ...(marker ? { marker } : {}),
709
+ ...(badge ? { badge } : {}),
710
+ };
711
+ const treatment = classifyNodeTreatment(node);
712
+ const label = anatomy
713
+ ? renderLabelAnatomy(anatomy, accent)
714
+ : safeLabel(sourceLabel, node.address);
715
+ const iconFamily = treatment === 'exception'
716
+ ? 'terminal.error'
717
+ : treatment === 'unimplemented'
718
+ ? 'unmapped'
719
+ : treatment === 'delay'
720
+ ? 'wait.sleep'
721
+ : node.iconFamily;
722
+ const iconClass = `:::${stepIconClassName(iconFamily)}`;
723
+ switch (treatment) {
724
+ case 'flow-start':
725
+ case 'flow-end':
726
+ return ` ${id}(["${label}"])${iconClass}`;
727
+ case 'branch':
728
+ return ` ${id}{{"${label}"}}${iconClass}`;
729
+ case 'subroutine':
730
+ return ` ${id}[["${label}"]]${iconClass}`;
731
+ case 'delay':
732
+ return ` ${id}@{ shape: delay, label: "${label}" }`;
733
+ case 'process':
734
+ case 'exception':
735
+ case 'unimplemented':
736
+ return ` ${id}("${label}")${iconClass}`;
737
+ }
137
738
  }
138
739
  function edgeLine(edge) {
139
740
  const from = renderedNodeId(edge.from);
@@ -150,26 +751,160 @@ function edgeLine(edge) {
150
751
  : ` ${from} -->|"${label}"| ${to}`;
151
752
  }
152
753
  /**
153
- * A per-element node style: a strong border, plus a fill tint only when the colour admits one.
754
+ * A per-element node run-state style.
154
755
  *
155
- * The fill is 8-digit-hex alpha, so it needs exactly 6 hex digits to append to — which is the REAL reason a named
156
- * colour gets no fill. (Rule 5's comma story is true of `rgba(…)` and was never true of `red`; the caller's colour is
157
- * validated by `usableColor` before it reaches here, so the comma case can no longer arrive at all.)
756
+ * Width comes from the declared role table, never a literal, and the fill is the opaque node fill. The
757
+ * alpha-suffixed `fill:#rrggbb24` tint the shipped emitter wrote is gone: Mermaid does not apply an 8-digit fill
758
+ * reliably to a polygon, and FR58 caps a `fill:` value at seven characters.
158
759
  */
159
- function nodeStyle(address, color) {
160
- const fill = /^#[0-9a-fA-F]{6}$/.test(color) ? `,fill:${color}24` : '';
161
- return ` style ${renderedNodeId(address)} stroke:${color},stroke-width:3px${fill}`;
760
+ function nodeStyle(node, color, fill, width, extra) {
761
+ /**
762
+ * The three Story 8.8 properties are appended CONDITIONALLY, each measured against Mermaid 12.0.0 on 2026-09-28 at
763
+ * `securityLevel: 'strict'`:
764
+ *
765
+ * 1. `opacity:0.7` is honoured on a plain rect AND on a hexagon polygon — it reaches the SVG as
766
+ * `opacity:0.7 !important`.
767
+ * 2. `color:#hex` is honoured and reaches the label, but paints the WHOLE label one colour (see `resolveNodeState`).
768
+ * 3. `stroke-dasharray:6 3` is honoured on a NODE, not only on a `linkStyle`.
769
+ *
770
+ * Conditional and not unconditional because an always-emitted property would write `opacity:1` onto every node and
771
+ * move every overlay-free golden — the byte-identity AD-23 requires.
772
+ */
773
+ const properties = [
774
+ `stroke:${color}`,
775
+ `stroke-width:${width}`,
776
+ `fill:${fill}`,
777
+ ...(extra?.color === undefined ? [] : [`color:${extra.color}`]),
778
+ ...(extra?.opacity === undefined ? [] : [`opacity:${extra.opacity}`]),
779
+ ...(extra?.dash === undefined ? [] : [`stroke-dasharray:${extra.dash}`]),
780
+ ];
781
+ return ` style ${renderedNodeId(node.address)} ${properties.join(',')}`;
782
+ }
783
+ /** A node `style` statement from one resolved reading — the state pass and the roles-only pass both write this. */
784
+ function resolvedNodeStyle(node, style) {
785
+ return nodeStyle(node, style.stroke, style.fill, style.width, {
786
+ color: style.color,
787
+ opacity: style.opacity,
788
+ dash: style.dash,
789
+ });
790
+ }
791
+ /** The base and kind roles, each host override checked and every unusable one falling back to the approved role. */
792
+ function resolveNodeColors(requested) {
793
+ return {
794
+ // A fill reaches a polygon, so it obeys FR58's six-digit form rather than the looser stroke grammar.
795
+ fill: overlayColor(requested?.fill) ?? APPROVED_NODE_COLORS.fill,
796
+ nodeBorder: usableColor(requested?.nodeBorder) ?? APPROVED_NODE_COLORS.nodeBorder,
797
+ failStroke: usableColor(requested?.failStroke) ?? APPROVED_NODE_COLORS.failStroke,
798
+ edge: usableColor(requested?.edge) ?? APPROVED_NODE_COLORS.edge,
799
+ notTakenStroke: usableColor(requested?.notTakenStroke) ?? APPROVED_NODE_COLORS.notTakenStroke,
800
+ edgeLabelText: usableColor(requested?.edgeLabelText) ?? APPROVED_NODE_COLORS.edgeLabelText,
801
+ // The plate is a FILL, so it obeys FR58 rather than the looser stroke grammar.
802
+ edgeLabelBg: overlayColor(requested?.edgeLabelBg) ?? APPROVED_NODE_COLORS.edgeLabelBg,
803
+ };
804
+ }
805
+ /** Kind/polygon styling. Run state suppresses this complete statement when both dimensions collide. */
806
+ function kindStyle(node, colors) {
807
+ const fill = colors.fill;
808
+ const treatment = classifyNodeTreatment(node);
809
+ if (treatment === 'branch') {
810
+ return ` style ${renderedNodeId(node.address)} fill:${fill}`;
811
+ }
812
+ const stroke = treatment === 'exception'
813
+ ? usableColor(colors.failStroke)
814
+ : treatment === 'unimplemented'
815
+ ? usableColor(colors.nodeBorder)
816
+ : undefined;
817
+ if (stroke === undefined)
818
+ return undefined;
819
+ return ` style ${renderedNodeId(node.address)} stroke:${stroke},fill:${fill}`;
162
820
  }
163
821
  /**
164
822
  * The diagram.
165
823
  *
166
824
  * Deterministic for a given `(groups, edges, opts)`: every list is emitted in the order given, duplicates are
167
825
  * collapsed by first appearance, and nothing consults the clock or the environment.
826
+ *
827
+ * The font each host must pin (AD-32(a)): this generator emits no font. A host picks one font string and passes
828
+ * it to mermaid as both `fontFamily` and `themeVariables.fontFamily` (mermaid copies the former into the latter
829
+ * only when the latter is unset), and makes the canvas container's CSS `font-family` resolve to that same string,
830
+ * so the boxes mermaid measures match the text the host paints. Write the string in CSS-canonical form (unquoted
831
+ * family names) so the resolved CSS value is byte-equal to what mermaid received. Known hosts:
832
+ * - dashboard-v2 flow canvas: `Inter, -apple-system, system-ui, sans-serif` (`typography.fontFamily.sans`), set
833
+ * on both mermaid keys and on the render container.
834
+ * - VS Code webview (`src/webview/flowDiagram.ts`): `var(--vscode-font-family)`, which is also the preview body
835
+ * CSS (`previewHtml.ts`).
836
+ * - Report page (`hexasync-template-report-render` `renderHtml.ts`): not pinned yet — it passes no font, so mermaid
837
+ * uses its default while the body paints `-apple-system, Segoe UI, …`.
168
838
  */
169
- export function renderMermaid(groups, edges, opts = {}) {
839
+ export function renderMermaid(authoredGroups, authoredEdges, opts = {}) {
840
+ return renderMermaidWithOccurrences(authoredGroups, authoredEdges, opts).definition;
841
+ }
842
+ /** `renderMermaid`, for a caller that also needs the occurrence facts the drawing carries. */
843
+ export function renderMermaidWithOccurrences(authoredGroups, authoredEdges, opts = {}) {
844
+ /**
845
+ * The occurrence expansion runs FIRST — before the collision check, before a single byte (FR64).
846
+ *
847
+ * Not merely early: there is no later point at which it could run. `assertUniqueRenderedIds` is what proves two
848
+ * occurrence addresses do not project onto one mermaid id, and it can only see nodes that already exist. A host
849
+ * doing this afterwards would be rewriting a validated definition, which AD-32(b) forbids outright.
850
+ *
851
+ * With no plan `expandOccurrences` returns the very arrays passed in, so the whole feature costs a
852
+ * `plan === undefined` test on every no-run render.
853
+ */
854
+ const expansion = expandOccurrences(authoredGroups, authoredEdges, opts.occurrences);
855
+ const groups = expansion.groups;
856
+ const edges = expansion.edges;
857
+ // Empty when the caller expanded first (the two flow adapters do); each of those returns its OWN expansion's set.
858
+ const collapsedRanges = expansion.collapsedRanges;
170
859
  const direction = opts.direction ?? 'TD';
860
+ const fidelity = opts.fidelity ?? 'strict';
171
861
  const multiStage = opts.multiStage ?? groups.length > 1;
172
- const lines = [`flowchart ${direction}`];
862
+ assertUniqueRenderedIds(groups, multiStage);
863
+ const emptyLabel = opts.emptyLabel ?? 'No steps';
864
+ /**
865
+ * TWO maps, and the distinction is the whole of Story 8.9's regression fix.
866
+ *
867
+ * - `callerOverlayNodes` is what the CALLER stated, and it alone decides whether the typed overlay is in charge.
868
+ * - `overlayNodes` is what each node's content and style are read from: the caller's statement over the derived
869
+ * occurrence badge, merged FIELD BY FIELD so a failed range keeps both its state and its `×N`.
870
+ *
871
+ * Deciding activation from the merged map made a plan on one step restyle the whole chart — see
872
+ * `RenderOptions.occurrenceOverlay`.
873
+ */
874
+ const callerOverlayNodes = opts.overlay?.nodes;
875
+ const derivedOverlayNodes = mergeNodeOverlays(opts.occurrenceOverlay, expansion.nodes);
876
+ const overlayNodes = mergeNodeOverlays(derivedOverlayNodes, callerOverlayNodes);
877
+ const nodeColors = resolveNodeColors(opts.nodeColors);
878
+ /**
879
+ * Is the TYPED reading chart-wide? Decided ONCE, here, and read by every channel that depends on it.
880
+ *
881
+ * Only a CALLER entry that carries a `state` and names an address a group actually holds decides it — an entry
882
+ * naming nothing on the canvas is ignored in silence, and a roles-only or badge-only entry makes no state claim to
883
+ * be authoritative about. Once it is, EVERY drawn node draws a state and EVERY drawn node is described as one: a
884
+ * chart where some nodes carry a run reading and others carry none would leave a reader unable to tell "not
885
+ * reached" from "not described", which is the distinction CAP-7 exists for. The unnamed ones read `indeterminate`.
886
+ *
887
+ * Computed before the accessibility text because `accTitle`/`accDescr` are the first bytes emitted; every node in
888
+ * `groups` is drawn, so this is the same answer the style pass would reach from `drawn`.
889
+ */
890
+ const groupAddresses = new Set(groups.flatMap((group) => group.nodes.map((node) => node.address)));
891
+ const typedOverlayActive = Object.entries(callerOverlayNodes ?? {}).some(([address, entry]) => carriesState(entry) && groupAddresses.has(address));
892
+ const readings = new Map();
893
+ /** Each node's ONE resolution, shared by the label, `accDescr`, every style pass and `linkStyle`. */
894
+ const readingOf = (address) => {
895
+ let reading = readings.get(address);
896
+ if (reading === undefined) {
897
+ reading = resolveNodeState(overlayNodes[address], nodeColors.fill, typedOverlayActive);
898
+ readings.set(address, reading);
899
+ }
900
+ return reading;
901
+ };
902
+ const [accTitle, accDescr] = accessibilityAlternative(groups, emptyLabel, overlayNodes, typedOverlayActive ? readingOf : undefined);
903
+ const lines = [
904
+ `flowchart ${direction}`,
905
+ ` accTitle: ${accTitle}`,
906
+ ` accDescr: ${accDescr}`,
907
+ ];
173
908
  /**
174
909
  * Nothing to draw ⇒ SAY SO (Story 4.3 review, MED-1 / MEDIUM-3).
175
910
  *
@@ -178,10 +913,13 @@ export function renderMermaid(groups, edges, opts = {}) {
178
913
  * fourth answer where the note says one of three must be chosen.
179
914
  */
180
915
  if (groups.every((group) => group.nodes.length === 0)) {
181
- return [
182
- ...lines,
183
- ` empty["${safeLabel(opts.emptyLabel ?? 'No steps', 'No steps')}"]`,
184
- ].join('\n');
916
+ return {
917
+ definition: withinBudget([
918
+ ...lines,
919
+ ` empty("${safeLabel(emptyLabel, 'No steps')}"):::${stepIconClassName(undefined)}`,
920
+ ].join('\n'), opts.maxTextSize),
921
+ collapsedRanges,
922
+ };
185
923
  }
186
924
  const emitted = new Set();
187
925
  const push = (line) => {
@@ -191,6 +929,7 @@ export function renderMermaid(groups, edges, opts = {}) {
191
929
  lines.push(line);
192
930
  };
193
931
  const drawn = new Set();
932
+ const drawnNodes = new Map();
194
933
  for (const group of groups) {
195
934
  if (multiStage && group.title !== undefined) {
196
935
  lines.push(` subgraph ${renderedNodeId(group.key)}["${escapeLabel(group.title)}"]`);
@@ -198,96 +937,190 @@ export function renderMermaid(groups, edges, opts = {}) {
198
937
  lines.push(` direction ${subgraphDirection(direction)}`);
199
938
  }
200
939
  for (const node of group.nodes) {
201
- push(nodeLine(node));
940
+ if (drawn.has(node.address))
941
+ continue;
942
+ const entry = overlayNodes[node.address];
943
+ // Both slots come from the shared resolution, so the label can never claim a marker the style pass did not draw.
944
+ const resolved = entry === undefined ? undefined : readingOf(node.address);
945
+ const content = resolved === undefined
946
+ ? undefined
947
+ : { marker: resolved.marker, ...(resolved.repeat === undefined ? {} : { repeat: resolved.repeat }) };
948
+ const accent = resolved?.style?.stroke ??
949
+ (classifyNodeTreatment(node) === 'exception' ? nodeColors.failStroke : nodeColors.nodeBorder);
950
+ push(nodeLine(node, fidelity, accent, content));
202
951
  drawn.add(node.address);
952
+ drawnNodes.set(node.address, node);
203
953
  }
204
954
  if (multiStage && group.title !== undefined)
205
955
  lines.push(' end');
206
956
  }
957
+ // Mermaid 12 rejects the `:::class` suffix on general-shape syntax, so delay hooks use its class statement form.
958
+ for (const node of drawnNodes.values()) {
959
+ if (classifyNodeTreatment(node) !== 'delay')
960
+ continue;
961
+ push(` class ${renderedNodeId(node.address)} ${stepIconClassName('wait.sleep')}`);
962
+ }
963
+ let labelledEdgeDrawn = false;
207
964
  for (const edge of edges) {
208
965
  // An edge naming a node no group drew would render mermaid's own placeholder box, silently inventing a node.
209
966
  if (!drawn.has(edge.from) || !drawn.has(edge.to))
210
967
  continue;
211
- push(edgeLine(edge));
968
+ const line = edgeLine(edge);
969
+ // Tested on the EMITTED line, so a label that escapes to nothing does not buy a plate it never shows.
970
+ if (line.includes('-->|'))
971
+ labelledEdgeDrawn = true;
972
+ push(line);
212
973
  }
213
974
  /**
214
975
  * The overlay — per element, and AFTER the nodes so a style statement always names something already drawn.
215
976
  *
216
- * `errored` is applied second deliberately: a node that both ran and failed should read as failed.
217
- */
218
- /**
219
- * Class declarations come BEFORE the overlay (Story 4.3 review, MED-4).
220
- *
221
- * The commit claimed *"a chart rendered with an overlay still begins with the chart rendered without one"* — true
222
- * only while `shapeClasses` was absent, because the class block was emitted after the overlay and so shifted when an
223
- * overlay appeared. Classes first makes the overlay genuinely the last thing in the file, so the additive property
224
- * holds for every combination rather than the subset the tests happened to cover.
225
- *
226
- * Still hoisted out of every subgraph, which is what rule 4 is about, and still declared once per class name.
977
+ * Class declarations precede base/kind styles and the run-state overlay (Story 4.3 review, MED-4). They remain
978
+ * hoisted out of every subgraph and declared once per class name.
227
979
  */
228
980
  for (const shapeClass of opts.shapeClasses ?? []) {
229
981
  const members = shapeClass.addresses.filter((a) => drawn.has(a));
230
982
  if (members.length === 0)
231
983
  continue;
232
984
  /**
233
- * The class NAME reaches mermaid verbatim, so a space or a RESERVED WORD is a parse error (review LOW). Refused
234
- * rather than emitted, because one bad class name would cost the whole chart rather than one style.
235
- *
236
- * `end` is the one a caller would plausibly reach for — a terminal-node class — and it is exactly the word that
237
- * closes a subgraph.
985
+ * The class NAME reaches Mermaid verbatim, so a space or a reserved word is a parse error. The `ic-` prefix is
986
+ * reserved for the asset-backed icon hook; allowing a caller-defined `classDef` there would make one class mean
987
+ * both shape and icon styling. Invalid or reserved names are refused rather than costing the whole chart.
238
988
  */
239
989
  if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(shapeClass.name) ||
240
- MERMAID_RESERVED.has(shapeClass.name.toLowerCase())) {
990
+ MERMAID_RESERVED.has(shapeClass.name.toLowerCase()) ||
991
+ shapeClass.name.toLowerCase().startsWith('ic-')) {
241
992
  continue;
242
993
  }
243
994
  push(` classDef ${shapeClass.name} ${shapeClass.declaration}`);
244
995
  push(` class ${members.map((a) => renderedNodeId(a)).join(',')} ${shapeClass.name}`);
245
996
  }
246
- const overlay = opts.overlay;
247
- if (overlay) {
248
- const traversedColor = usableColor(overlay.colors?.traversed);
249
- const erroredColor = usableColor(overlay.colors?.errored);
250
- if (traversedColor !== undefined) {
251
- for (const address of overlay.traversed ?? []) {
252
- if (drawn.has(address))
253
- push(nodeStyle(address, traversedColor));
254
- }
255
- }
256
- if (erroredColor !== undefined) {
257
- for (const address of overlay.errored ?? []) {
258
- if (drawn.has(address))
259
- push(nodeStyle(address, erroredColor));
260
- }
261
- }
262
- /**
263
- * Edges are styled by INDEX (`linkStyle 3 …`), which is why the index has to be the one mermaid will assign — the
264
- * position of the edge among those actually emitted, not among those the model holds.
265
- */
266
- const highlight = new Set([
267
- ...(overlay.traversed ?? []),
268
- ...(overlay.errored ?? []),
269
- ]);
270
- const drawnEdges = edges.filter((e) => drawn.has(e.from) && drawn.has(e.to));
271
- const seenEdge = new Set();
272
- let index = -1;
273
- for (const edge of drawnEdges) {
274
- const key = edgeLine(edge);
275
- if (seenEdge.has(key))
276
- continue;
277
- seenEdge.add(key);
278
- index += 1;
279
- if (!highlight.has(edge.from) || !highlight.has(edge.to))
280
- continue;
281
- const color = (overlay.errored ?? []).includes(edge.to) ||
282
- (overlay.errored ?? []).includes(edge.from)
283
- ? erroredColor
284
- : traversedColor;
285
- if (color !== undefined) {
286
- push(` linkStyle ${index} stroke:${color},stroke-width:3px`);
287
- }
997
+ /**
998
+ * Kind styling is suppressed for every node once the typed overlay is in charge, not only for the named ones, and
999
+ * for a node a roles-only entry draws.
1000
+ *
1001
+ * DESIGN.md `node-exception-kind` / `node-stub-kind`: *"Where the node also has a run state, the run state stroke
1002
+ * wins and only the icon carries the kind."* Under the typed overlay every node has a run state, so every node's
1003
+ * kind stroke yields and the icon family alone carries the kind — which is also the only way a node never ends up
1004
+ * with two competing `style` statements.
1005
+ */
1006
+ for (const node of drawnNodes.values()) {
1007
+ if (typedOverlayActive || readingOf(node.address).pass === 'roles')
1008
+ continue;
1009
+ const style = kindStyle(node, nodeColors);
1010
+ if (style !== undefined)
1011
+ push(style);
1012
+ }
1013
+ // One `style` statement per stated (or indeterminate) node, in DRAWING order.
1014
+ for (const node of drawnNodes.values()) {
1015
+ const reading = readingOf(node.address);
1016
+ if (reading.pass === 'state' && reading.style !== undefined)
1017
+ push(resolvedNodeStyle(node, reading.style));
1018
+ }
1019
+ /**
1020
+ * Roles-only statements, in the OVERLAY's own entry order — the caller's entries first, then any derived ones.
1021
+ *
1022
+ * Not drawing order: the retired lists wrote every traversed style and then every errored one, so a producer that
1023
+ * lists its entries the same way keeps those bytes even where a failing step is drawn before a reached one. The
1024
+ * overlay is part of AD-22's tuple, so its entry order is a deterministic input.
1025
+ *
1026
+ * The failed node is the ONE element the GENERATOR draws at `emphasis` in the log reading. A caller-supplied
1027
+ * `shapeClasses` declaration reaches Mermaid verbatim and is NOT role-checked, so a `classDef` body may carry its
1028
+ * own `stroke-width` — that is the caller's statement about its own class, not a second run-state emphasis.
1029
+ */
1030
+ for (const address of new Set([...Object.keys(callerOverlayNodes ?? {}), ...Object.keys(overlayNodes)])) {
1031
+ const node = drawnNodes.get(address);
1032
+ if (node === undefined)
1033
+ continue;
1034
+ const reading = readingOf(address);
1035
+ if (reading.pass === 'roles' && reading.style !== undefined)
1036
+ push(resolvedNodeStyle(node, reading.style));
1037
+ }
1038
+ /**
1039
+ * A `linkStyle` for EVERY drawn edge, not only the highlighted ones — DERIVED from the per-node readings.
1040
+ *
1041
+ * AD-23 makes stage 1 the sole owner of edge colour, so a neutral edge that carried no declaration was leaving its
1042
+ * colour to whatever theme the host happened to load — three surfaces, three answers. Edges are styled by INDEX
1043
+ * (`linkStyle 3 …`), which is why the index has to be the one mermaid will assign: the position of the edge among
1044
+ * those actually EMITTED and deduped, not among those the model holds.
1045
+ *
1046
+ * The three-way rule (UX-DR16):
1047
+ * 1. an edge entering a DECLINED node — `notTakenStroke`, dashed; the only dash on the canvas. Every inbound edge
1048
+ * of it, because a node the run never entered was entered by no route, and it outranks a walk because reading
1049
+ * a contradiction as declined cannot invent a traversal;
1050
+ * 2. an edge whose both ends were REACHED — walked, in the stroke of the end drawn at `emphasis` (target first),
1051
+ * else the target's stroke, so an edge touching the failed node reads as failed;
1052
+ * 3. everything else — the neutral `edge` role, solid.
1053
+ *
1054
+ * A roles-only entry with an unusable stroke is not reached, so its edges fall to rule 3 — exactly the bytes the
1055
+ * no-overlay render produces.
1056
+ */
1057
+ const drawnEdges = edges.filter((e) => drawn.has(e.from) && drawn.has(e.to));
1058
+ const seenEdge = new Set();
1059
+ let index = -1;
1060
+ for (const edge of drawnEdges) {
1061
+ const key = edgeLine(edge);
1062
+ if (seenEdge.has(key))
1063
+ continue;
1064
+ seenEdge.add(key);
1065
+ index += 1;
1066
+ const target = readingOf(edge.to);
1067
+ if (target.declined) {
1068
+ push(` linkStyle ${index} stroke:${nodeColors.notTakenStroke},stroke-width:${STROKE_ROLES.hairline},stroke-dasharray:${STROKE_ROLES.dash}`);
1069
+ continue;
288
1070
  }
1071
+ const source = readingOf(edge.from);
1072
+ const walked = target.reached && source.reached
1073
+ ? ([target, source].find((end) => end.style?.width === STROKE_ROLES.emphasis) ?? target).style?.stroke
1074
+ : undefined;
1075
+ push(` linkStyle ${index} stroke:${walked ?? nodeColors.edge},stroke-width:${STROKE_ROLES.hairline}`);
1076
+ }
1077
+ /**
1078
+ * The edge-label plate — a definition byte, not a host stylesheet (AD-23).
1079
+ *
1080
+ * MEASURED against Mermaid 12.0.0 in `story87RoleDrivenStyles.atdd.spec.ts`: `themeVariables.edgeLabelBackground`
1081
+ * alone is NOT enough, because the shipped theme emits `.edgeLabel rect{opacity:0.5}` and `.labelBkg` as an
1082
+ * `rgba(…, 0.5)` — the line still shows through the text. `themeCSS` is appended AFTER the theme's own rules, so
1083
+ * it is what actually makes the plate opaque. `pointer-events:none` keeps the label non-interactive (stage 3 owns
1084
+ * interaction, and a label is not a target).
1085
+ *
1086
+ * It rides an `%%{init}%%` directive placed after the `flowchart` line rather than YAML frontmatter, measured the
1087
+ * same way: both apply, and only the directive keeps `^flowchart <dir>` as the definition's first bytes, which
1088
+ * three host surfaces and every golden already depend on.
1089
+ *
1090
+ * Emitted only when a labelled edge was actually drawn: a chart with no label must not carry configuration for
1091
+ * one.
1092
+ */
1093
+ if (labelledEdgeDrawn) {
1094
+ const css = `.edgeLabel rect { opacity: 1; } ` +
1095
+ `.labelBkg { background-color: ${nodeColors.edgeLabelBg}; } ` +
1096
+ `.edgeLabel, .edgeLabel p, .edgeLabel span { background-color: ${nodeColors.edgeLabelBg}; color: ${nodeColors.edgeLabelText}; pointer-events: none; }`;
1097
+ lines.splice(1, 0, `%%{init: {"themeVariables": {"edgeLabelBackground": "${nodeColors.edgeLabelBg}"}, "themeCSS": "${css}"} }%%`);
1098
+ }
1099
+ return { definition: withinBudget(lines.join('\n'), opts.maxTextSize), collapsedRanges };
1100
+ }
1101
+ /**
1102
+ * The last gate before the definition leaves the generator.
1103
+ *
1104
+ * Applied to BOTH return paths, including the empty-flow one — not because a one-node chart can exceed a budget, but
1105
+ * because a budget that some return paths honour is a budget a caller cannot rely on.
1106
+ */
1107
+ function withinBudget(definition, maxTextSize) {
1108
+ if (maxTextSize === undefined)
1109
+ return definition;
1110
+ /**
1111
+ * A budget that is not a positive finite number is REFUSED, not ignored.
1112
+ *
1113
+ * `NaN > n` is false, so a non-finite budget silently disabled the check — and `maxTextSize: NaN` is exactly what a
1114
+ * host reaches by passing `Number(config.get('maxTextSize'))` on an unset setting. A budget that cannot be compared
1115
+ * is a caller error, and treating it as "no budget" is how the silent truncation this guards comes back.
1116
+ */
1117
+ if (!Number.isFinite(maxTextSize) || maxTextSize <= 0) {
1118
+ throw new RangeError(`maxTextSize must be a positive finite number of characters; received ${String(maxTextSize)}.`);
289
1119
  }
290
- return lines.join('\n');
1120
+ const report = measureDefinitionBudget(definition, maxTextSize);
1121
+ if (report.overBudget)
1122
+ throw new DefinitionOverBudgetError(report);
1123
+ return definition;
291
1124
  }
292
1125
  /**
293
1126
  * A titled section with a fenced diagram, and nothing else (rule 3).