@beehexa/hexasync-template-model 2608.21.4 → 2610.3.1
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/canonicalGraph.d.ts +153 -0
- package/dist/canonicalGraph.d.ts.map +1 -0
- package/dist/canonicalGraph.js +321 -0
- package/dist/canonicalGraph.js.map +1 -0
- package/dist/collapseRule.d.ts +125 -0
- package/dist/collapseRule.d.ts.map +1 -0
- package/dist/collapseRule.js +87 -0
- package/dist/collapseRule.js.map +1 -0
- package/dist/flowRender.d.ts +364 -18
- package/dist/flowRender.d.ts.map +1 -1
- package/dist/flowRender.js +956 -123
- package/dist/flowRender.js.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/nodeAddress.d.ts +81 -0
- package/dist/nodeAddress.d.ts.map +1 -1
- package/dist/nodeAddress.js +70 -0
- package/dist/nodeAddress.js.map +1 -1
- package/dist/occurrenceExpansion.d.ts +50 -0
- package/dist/occurrenceExpansion.d.ts.map +1 -0
- package/dist/occurrenceExpansion.js +230 -0
- package/dist/occurrenceExpansion.js.map +1 -0
- package/dist/stepGlyph.d.ts +242 -29
- package/dist/stepGlyph.d.ts.map +1 -1
- package/dist/stepGlyph.js +403 -104
- package/dist/stepGlyph.js.map +1 -1
- package/dist/workerStepVocabularyAudit.d.ts +19 -0
- package/dist/workerStepVocabularyAudit.d.ts.map +1 -0
- package/dist/workerStepVocabularyAudit.js +134 -0
- package/dist/workerStepVocabularyAudit.js.map +1 -0
- package/dist/workflowMigration.d.ts +60 -0
- package/dist/workflowMigration.d.ts.map +1 -0
- package/dist/workflowMigration.js +75 -0
- package/dist/workflowMigration.js.map +1 -0
- package/manifests/collapse-rule.json +43 -0
- package/manifests/worker-step-roster.json +164 -0
- package/package.json +6 -2
package/dist/flowRender.js
CHANGED
|
@@ -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`.**
|
|
21
|
-
*
|
|
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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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.
|
|
30
|
-
*
|
|
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.**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
*
|
|
54
|
+
* The hard boundary between deterministic definition generation and host DOM decoration.
|
|
45
55
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
-
|
|
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, '&')
|
|
75
434
|
.replace(/"/g, '"')
|
|
76
435
|
.replace(/</g, '<')
|
|
77
436
|
.replace(/>/g, '>')
|
|
78
|
-
.
|
|
437
|
+
// A public label beginning with a backtick must remain plain text, not opt into Mermaid markdown.
|
|
438
|
+
.replace(/`/g, '`');
|
|
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, '&')
|
|
506
|
+
.replace(/</g, '<')
|
|
507
|
+
.replace(/>/g, '>');
|
|
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
|
|
612
|
+
* A label guaranteed non-empty, so every emitted node has visible text.
|
|
82
613
|
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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 & 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
|
-
*
|
|
106
|
-
*
|
|
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
|
-
//
|
|
122
|
-
return /^#[0-9a-fA-F]{3
|
|
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
|
-
/**
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
754
|
+
* A per-element node run-state style.
|
|
154
755
|
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
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(
|
|
160
|
-
|
|
161
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
234
|
-
*
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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).
|