figdown 0.2.0 → 0.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/figdown.mjs CHANGED
@@ -1,8 +1,8 @@
1
- // figdown.mjs — FigDown embeddable library (0.2.0)
1
+ // figdown.mjs — FigDown embeddable library (0.3.1)
2
2
  // GENERATED FILE, DO NOT EDIT. Built from editor/figdown.html.
3
3
  // Regenerate with: node tools/make-lib.js
4
4
  'use strict';
5
- var VERSION = "0.2.0";
5
+ var VERSION = "0.3.1";
6
6
 
7
7
  // ---- engine (extracted verbatim from editor/figdown.html) ----
8
8
  var __engine = (function () {
@@ -16,7 +16,7 @@ const SHAPES = ['box','rounded','circle','ellipse','diamond','cylinder'];
16
16
  // input to that promise, and under core §13 a 0.x renderer may differ from
17
17
  // the next — which makes the recorded version the only thing that can
18
18
  // explain a diff between two renderings of one source.
19
- const FIGDOWN_VERSION = '0.2.0';
19
+ const FIGDOWN_VERSION = '0.3.1';
20
20
  // `STATECHART-GENRE-SCOPE`: the language number moved for the first time. The dev
21
21
  // counter does NOT reset (core §13.0.4 — `N` counts source states of the
22
22
  // engine and only ever increases), so 0.1 is followed by
@@ -26,13 +26,39 @@ const FIGDOWN_VERSION = '0.2.0';
26
26
  // MUST state this, and stating the release version alone does not satisfy
27
27
  // it). Declared here, in ONE place, so the header check and the documented
28
28
  // interface cannot drift:
29
- const LANG_VERSIONS = ['0.1', '0.2'];
29
+ // `DRAWN-ANNOTATION-FORM`: `figdown 0.3` joins the set. `note=` is a NEW OPTION KEY,
30
+ // and core §13.0 makes a new key a `Y` change and not a `Z` one — "`Z`: Bug
31
+ // fixes only. No new features. The language does not move." Shipping `note=`
32
+ // under `v0.2.z` would make `figdown 0.2` name two different languages: the one
33
+ // `v0.2.0` published and the one with `note=`. So the language number moves.
34
+ const LANG_VERSIONS = ['0.1', '0.2', '0.3'];
30
35
  // Genres per declared language version. `Y` never removes (core §13.0), so
31
36
  // each row is a superset of the one above it, and `figdown 0.1 <anything>`
32
37
  // resolves against exactly the list it resolved against before `STATECHART-GENRE-SCOPE`.
33
38
  const GENRES_BY_VERSION = {
34
39
  '0.1': ['block','topology','flowchart','bitfield','table','timing'],
35
- '0.2': ['block','topology','flowchart','bitfield','table','timing','statechart']
40
+ '0.2': ['block','topology','flowchart','bitfield','table','timing','statechart'],
41
+ '0.3': ['block','topology','flowchart','bitfield','table','timing','statechart']
42
+ };
43
+ // The version an OPTION KEY first becomes legal in — the `CONNECTOR_MIN_VERSION`
44
+ // device, applied to the option namespace. `DRAWN-ANNOTATION-FORM`: `note=` is gated on the
45
+ // declared version, and the gate's reason is specific to THIS key rather than
46
+ // generic to new keys. `note=` has a PRIOR MEANING on the record: it was the
47
+ // retired spelling of `description=` (`DESCRIPTION-KEY-SPELLING`) and its retirement
48
+ // diagnostic actively told authors to write `description=` for a tooltip.
49
+ // Accepting it silently under a `figdown 0.2` header would repaint a document
50
+ // whose author meant a never-drawn tooltip as one that puts ink on the page —
51
+ // core §13.0.1's named hazard, "a figure that looks right and means something
52
+ // else". A key that had never been spelled before would carry no such risk.
53
+ const OPT_MIN_VERSION={note:'0.3'};
54
+ // True when the document's declared version is older than the key's own.
55
+ // A document with no parsable header has already been diagnosed on line 1, so
56
+ // an absent version never gates a second time.
57
+ const belowOptVersion=(key,ver)=>{
58
+ const need=OPT_MIN_VERSION[key];
59
+ if(!need||!ver) return false;
60
+ const i=LANG_VERSIONS.indexOf(ver), j=LANG_VERSIONS.indexOf(need);
61
+ return i>=0 && j>=0 && i<j;
36
62
  };
37
63
  // Retired shape VALUES keep a named diagnostic (PROCESS §5(d)), the same way
38
64
  // retired option keys do: `cloud` was the one value that named a domain
@@ -217,7 +243,7 @@ function splitList(t,off){
217
243
  // - a key=value token with an unregistered key is an "unknown option"
218
244
  // line error (`UNKNOWN-OPTION-DEGRADATION`) — except inside timing `signal` lanes, where bare
219
245
  // tokens may contain '=' and stay positional (laneMode).
220
- // `fill` was registered here until this release solely to power a retired
246
+ // `fill` was registered here until 0.1 solely to power a retired
221
247
  // migration diagnostic on the old `line` directive; it left the registry with
222
248
  // the `fill` → `band` KEYWORD rename, and 0.1 gave the word back to the
223
249
  // option-key namespace as the primary presentation key (`color=` → `fill=`).
@@ -232,11 +258,11 @@ function splitList(t,off){
232
258
  // time the language gains no replacement — v0.1 has no author-facing label
233
259
  // colour at all (the default is derived, `LABEL-COLOUR-SOURCE`; the owner-level key that could
234
260
  // be added today is the wrong shape, core §9 `ANNOTATION-LOCATOR-SPLIT`). It stays registered so
235
- // the message can name BOTH eras: a `color=` written before this release meant
236
- // the FILL, one written meant the LABEL, and only a human
261
+ // the message can name BOTH eras: a `color=` written in one era meant
262
+ // the FILL, one written in another meant the LABEL, and only a human
237
263
  // knows which document this is. `text` and `z` stay registered
238
264
  // as RETIRED keys so each rename gets a named diagnostic. `offset` replaces
239
- // `threshold at=` (the directive was spelled `guide` until this release);
265
+ // `threshold at=` (the directive was spelled `guide` until 0.1);
240
266
  // `at` stays live on `pin`.
241
267
  // 0.1: `level` stays registered as a RETIRED key — the construct was
242
268
  // DELETED (`CHART-LEVEL-KEY`), and a registered-but-retired key is the only way the
@@ -267,7 +293,7 @@ const OPT_KEYS=new Set(['kind','type','shape','fill','color','stroke','text','in
267
293
  // - `external` is NEVER drawn (`EXTERNAL-EDGE-ENDPOINTS`) — no fill, no border, no dash; only its
268
294
  // label exists, so it takes `text=` (plus `plane=`, organizational exactly
269
295
  // as on a node);
270
- // - `band` carried NO label channel at all until this release (`BAND-LABEL-STATUS`); it now
296
+ // - `band` carried NO label channel at all until 0.1 (`BAND-LABEL-STATUS`); it now
271
297
  // takes a mandatory quoted label, so `color=` applies to it like any
272
298
  // other labelled element;
273
299
  // - typed blocks (`bitfield`/`table`/`timing`) stack in document order OUTSIDE
@@ -286,29 +312,48 @@ const OPT_KEYS=new Set(['kind','type','shape','fill','color','stroke','text','in
286
312
  // the key existed only because there was no label to colour.
287
313
  const DIRECTIVE_OPTS={
288
314
  figdown:[],
289
- node:['shape','fill','stroke','style','class','in','plane','width','height'],
315
+ // `DRAWN-ANNOTATION-FORM`: `title` gains its FIRST option key. It took one
316
+ // positional string and nothing else until now, and the key
317
+ // it gains carries the figure-level annotation — 14% of the measured demand,
318
+ // 10 instances that name no single element ("Total: 2 blocks × 8 ways × 1k
319
+ // sets = 16,384 entries", a four-signal legend, a TODO about the figure).
320
+ // The figure HAS a declaration line, so attachment-by-position reaches it and
321
+ // no standalone keyword is needed. `UNIVERSAL-CORE-KEYWORDS` fixes what `title` MEANS across genres;
322
+ // it is not a bar on the directive taking options, so the key exists in every
323
+ // genre at once, which is correct — every genre has figures.
324
+ title:['note'],
325
+ node:['shape','fill','stroke','style','class','in','width','height','note'],
290
326
  // `FLOWCHART-ROLE-KEYWORDS`: the three flowchart role keywords take EXACTLY the
291
327
  // option keys `node` takes — they ARE nodes, with a role recorded. Listing
292
328
  // `width`/`height` mirrors `node` so the same "use a pin line" diagnostic
293
329
  // fires rather than a bare `unknown option`.
294
- process:['shape','fill','stroke','style','class','in','plane','width','height'],
295
- decision:['shape','fill','stroke','style','class','in','plane','width','height'],
296
- terminator:['shape','fill','stroke','style','class','in','plane','width','height'],
330
+ process:['shape','fill','stroke','style','class','in','width','height','note'],
331
+ decision:['shape','fill','stroke','style','class','in','width','height','note'],
332
+ terminator:['shape','fill','stroke','style','class','in','width','height','note'],
297
333
  // `GENRE-NODE-SPELLING`: `state` IS `node` under `statechart` — a rename, not a
298
334
  // new directive, so it takes `node`'s keys exactly and nothing more.
299
- state:['shape','fill','stroke','style','class','in','plane','width','height'],
300
- group:['fill','stroke','style','gap','class','plane'],
301
- external:['plane'],
302
- edge:['style','class','fill','stroke','plane','label','taillabel','headlabel'],
335
+ state:['shape','fill','stroke','style','class','in','width','height','note'],
336
+ group:['fill','stroke','style','gap','class','note'],
337
+ // `PAINT-ORDER-CONSTRUCT`: `external` now takes NO option key at all. `plane=`
338
+ // was its only one — it is never drawn (`EXTERNAL-EDGE-ENDPOINTS`), so it has no fill, no border
339
+ // and no dash to set — and the withdrawal of `plane=` empties the row. An
340
+ // empty array is the declaration: every key falls through to the generic
341
+ // `external does not take <k>=`.
342
+ external:[],
343
+ edge:['style','class','fill','stroke','label','taillabel','headlabel','note'],
303
344
  // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: same rename argument — the connector's option set is one set
304
345
  // under three spellings, listed three times only because the tables are
305
346
  // keyed by the surface word an author actually wrote.
306
- flowline:['style','class','fill','stroke','plane','label','taillabel','headlabel'],
307
- transition:['style','class','fill','stroke','plane','label','taillabel','headlabel'],
308
- plane:['z','z-index'], flow:[], rank:[],
309
- bundle:['fill','stroke','style','plane'],
310
- threshold:['in','at','offset','fill','stroke','style','plane'],
311
- band:['in','extend','fill','stroke','style','plane','from','to'],
347
+ flowline:['style','class','fill','stroke','label','taillabel','headlabel','note'],
348
+ transition:['style','class','fill','stroke','label','taillabel','headlabel','note'],
349
+ // `PAINT-ORDER-CONSTRUCT`: the `plane` row is GONE, not emptied — the keyword is
350
+ // withdrawn from the language, so it has no acceptor row at all, the shape
351
+ // `path`/`routing` left behind. `z-index=` goes with it: it
352
+ // was legal on `plane` and on nothing else.
353
+ flow:[], rank:[],
354
+ bundle:['fill','stroke','style'],
355
+ threshold:['in','at','offset','fill','stroke','style'],
356
+ band:['in','extend','fill','stroke','style','from','to'],
312
357
  // `ELEMENT-GEOMETRY-DIRECTIVE`: `size` merged into `pin`. ONE directive carries an
313
358
  // element's whole DECLARED geometry — `at=` places it, `width=`/`height=`
314
359
  // extend it — and one model object records it. All three keys are optional
@@ -319,7 +364,7 @@ const DIRECTIVE_OPTS={
319
364
  // derives its geometry from its content).
320
365
  pin:['at','width','height'],
321
366
  layout:[],
322
- 'class':['fill','stroke','style','plane'],
367
+ 'class':['fill','stroke','style'],
323
368
  // 0.1: `class=` is NOT accepted on the typed-block OPENERS. The
324
369
  // normative registry (core §10) lists its acceptors as node/group/edge/
325
370
  // field/cell — the block openers were an engine-only extra with 0 uses in
@@ -343,7 +388,7 @@ const DIRECTIVE_OPTS={
343
388
  // the field is ONE ELEMENT of a repeated run and gives the run's index
344
389
  // range; the engine derives the elision row and the index labels from it,
345
390
  // exactly as it derives the dash and the caption from `present=`.
346
- field:['fill','stroke','class','description','present','index'], 'break':[],
391
+ field:['fill','stroke','class','description','present','index','note'], 'break':[],
347
392
  cell:['fill','stroke','class'], width:[],
348
393
  signal:['data','fill','stroke'], gap:[]
349
394
  };
@@ -393,8 +438,8 @@ const RETIRED_OPT_KEYS={
393
438
  text:'text= has been retired: v0.1 has NO label-colour key — the label colour is DERIVED from the fill it sits on (core §5), and the owner-level key that could replace it would colour an edge\'s [tail]/[mid]/[head] labels identically, which is the wrong shape (core §9 `ANNOTATION-LOCATOR-SPLIT`). Delete the key; if the distinction was knowledge, write it in the label or a class= meaning (§5, `PRESENTATION-AS-MEANING-CARRIER`) (MIGRATIONS 0.1)',
394
439
  // `COLOUR-KEY-STATUS`. This is the ONLY key in the language whose diagnostic
395
440
  // must name two eras and refuse to choose between them: the same six
396
- // characters meant the FILL before this release and the LABEL after
397
- // 0.1, and no engine can tell the two source files apart. Retiring
441
+ // characters meant the FILL in one era and the LABEL
442
+ // in another, and no engine can tell the two source files apart. Retiring
398
443
  // the key is what makes the difference DIAGNOSABLE at all — while it was
399
444
  // live, a pre-0.1 document parsed and drew a legal, wrong figure in
400
445
  // silence.
@@ -411,8 +456,17 @@ const RETIRED_OPT_KEYS={
411
456
  // `color=` family, which reads the same evidence to decide its refusals.
412
457
  color:'color= has been retired: the same six characters set the box FILL in one era of this language and the LABEL colour in another, and this line does not say which — which is why the key is gone rather than renamed. READ IT OFF THE REST OF THE DOCUMENT. A file that also writes fill= cannot be from the FILL era (the two keys never coexisted), so its color= was a LABEL colour: delete it and let the derived default apply (core §5). A file still writing the spellings that were retired before the LABEL era (w= h= unit= via= dir= kind= layer= boundary wrap optional) cannot be from that era, so its color= was a FILL: write fill= instead. A file with NEITHER carries no evidence at all, and the two readings then differ only in what was DRAWN — as a FILL the value painted the box interior, as a LABEL colour it painted only the text. If the colour carried meaning, put that meaning in the label or a class= (§5, `PRESENTATION-AS-MEANING-CARRIER`). tools/migrate-figdown.js reads this evidence for you and REFUSES the wrong --color-means=fill|text (MIGRATIONS 0.1)',
413
458
  kind:'kind= has been renamed: on a node use shape= (geometric; the label text carries the device semantics — MIGRATIONS 0.1), on a chart use type= (Vega, Chart.js and ECharts all spell the chart-type key "type" — MIGRATIONS 0.1). One spelling was retired on node and live on plot at the same time, inside one namespace; 0.1 closed that.',
414
- layer:'layer= has been renamed: use plane= (mxGraph makes a layer a containment parent that establishes coordinates; Inkscape layers can carry a transform; OGC WMS layers carry an SRS; CSS @layer is cascade priority. None of those is what this key does, and SVG has no layer at all) (MIGRATIONS 0.1)',
459
+ // `PAINT-ORDER-CONSTRUCT`: `layer=` was renamed `plane=`, and
460
+ // `plane=` has since been WITHDRAWN, so this message can no longer end at
461
+ // the rename — the `route`→`path` precedent, where a message
462
+ // pointing at a spelling that no longer exists had to state the whole chain.
463
+ layer:'layer= has been WITHDRAWN: it was renamed plane=, and plane= was withdrawn with the `plane` keyword (`PAINT-ORDER-CONSTRUCT`). There is no replacement spelling. Delete the key: everything paints in one plane, in document order. If the element is on a distinct logical layer of the SUBJECT — an overlay, a control plane — say so with a class= whose label states it, which is where that meaning belongs (core §5, `PRESENTATION-AS-MEANING-CARRIER`) (MIGRATIONS 0.3)',
415
464
  labels:'labels= has been renamed: use data= (WaveDrom\'s own key for exactly this is `data`, "an array of signal labels" — one per value cell of the lane) (MIGRATIONS 0.1)',
465
+ // `PAINT-ORDER-CONSTRUCT`. `plane=` referenced a declared `plane`; with the
466
+ // keyword withdrawn from every genre the key would keep exactly ONE legal
467
+ // value — the implicit `base` — so it is withdrawn with it rather than left
468
+ // as a key that can only ever restate the default.
469
+ plane:'plane= has been WITHDRAWN with the `plane` keyword (`PAINT-ORDER-CONSTRUCT`): the construct is removed from the language, not renamed, so there is no spelling to migrate to. `plane=` named a declared plane, and with no way to declare one the key had a single legal value — `base`, the implicit plane every element is already on. Delete the key. What it did was PAINT ORDER, and paint order is document order: a later line paints on top. The measurement: stripping `plane` and `plane=` from examples/evpn-fabric.fd left the drawn SVG byte-identical but for one `data-edge` index, because the overlay meaning was carried by `class=overlay` throughout — which is where a logical layer of the SUBJECT belongs (core §5, `PRESENTATION-AS-MEANING-CARRIER`) (MIGRATIONS 0.3)',
416
470
  // 0.1 (`EDGE-GEOMETRY-CONSTRUCTS`). These six keys end in a WITHDRAWAL, not a rename, so
417
471
  // their messages have a shape no earlier retirement in this table has: they
418
472
  // name no replacement spelling, because there is none. `via=`/`src=`/`dst=`
@@ -427,14 +481,80 @@ const RETIRED_OPT_KEYS={
427
481
  headport:'headport= has been WITHDRAWN with the `path` directive (`EDGE-GEOMETRY-CONSTRUCTS`): the construct is removed from the language, not renamed, so there is no spelling to migrate to. Attachment to a named site addressed by semantic role IS inside the stable prior-art intersection; FigDown\'s realisation was not (a fraction on the EDGE is mxGraph-only, and written-order attachment has zero prior art in any surveyed system). Restoring it needs an edge-identity construct first. Delete the line; the edge draws under auto layout. The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md',
428
482
  routing:'routing= has been WITHDRAWN with the `path` directive (`EDGE-GEOMETRY-CONSTRUCTS`): the construct is removed from the language, not renamed, so there is no spelling to migrate to. The per-edge routing SCOPE was inside the stable prior-art intersection and is deliberately lost with its host line — an override needs an edge to address, and FigDown has no edge-identity construct. Delete the line. The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md',
429
483
  unit:'unit= has been renamed: use word= (RFC 2360 §3.1: "a sequence of long words in network byte order, with each word horizontal on the page"; RFC 791 §3.1 measures the header in "32 bit words". Mermaid names the identical setting bitsPerRow — semantically right, camelCase barred. `unit=32` also inverts count-vs-unit, reading as "the unit is 32", and C\'s "unit" is the addressable storage unit, not the row width) (MIGRATIONS 0.1)',
430
- z:'z= has been renamed: use z-index= (CSS spells the stacking concept z-index, and RULE 4.2 takes the standard spelling in full; a single-letter key is structurally risky next to the closed timing lane alphabet, `LANE-ALPHABET-KEY-RESERVATION`) (MIGRATIONS 0.1)',
431
- // `DESCRIPTION-KEY-SPELLING`. The spelling leaves the LANGUAGE, so the message fires
432
- // wherever it appears (the `w=`/`h=`/`unit=` placement test, RULE 6.2).
433
- note:'note= has been renamed: use description= (IEEE 1685-2022 spells this channel `description`; SystemRDL\'s `desc` is barred by RULE 4.2 as an abbreviation. The rename is defensive: `ANNOTATION-LOCATOR-SPLIT` files `note` as the highest-demand v0.2 annotation construct ~66 figure-identities, 20 independent reinventions and that one will be a DRAWN callout, so a never-drawing `note=` beside an always-drawing `note` would be one spelling with two opposite behaviours) (MIGRATIONS 0.1)',
484
+ // `PAINT-ORDER-CONSTRUCT`: `z-index=` itself. Its ONLY acceptor was `plane`, so
485
+ // with the keyword withdrawn the key has no directive left to sit on. Left
486
+ // in OPT_KEYS with no acceptor row it would have produced `<directive> does
487
+ // not take z-index=`true, but it tells an author holding a 0.2 document
488
+ // that they picked the wrong host, when in fact there is no host. RULE 6.2
489
+ // placement: the spelling left the LANGUAGE, so it is reported wherever it
490
+ // appears.
491
+ 'z-index':'z-index= has been WITHDRAWN with the `plane` keyword (`PAINT-ORDER-CONSTRUCT`): it was legal on `plane` and on nothing else, so it left with its only acceptor. There is no replacement spelling and no other directive to move it to. Delete the key: paint order is document order, and a later line paints on top (MIGRATIONS 0.3)',
492
+ z:'z= has been WITHDRAWN: it was renamed z-index=, and z-index= was withdrawn with the `plane` keyword (`PAINT-ORDER-CONSTRUCT`) — it was legal on `plane` and on nothing else. There is no replacement spelling. Delete the key: paint order is document order, a later line paints on top (MIGRATIONS 0.3)',
493
+ // `note` was HERE (`DESCRIPTION-KEY-SPELLING`) until this release (`DRAWN-ANNOTATION-FORM`), and its
494
+ // row is gone because the key is LIVE again — SYNTAX-STYLE RULE 4.9
495
+ // obligation 3 forbids leaving the retirement message standing past the
496
+ // revival, on the ground that a message telling an author to write
497
+ // `description=` where `note=` is now the correct key is worse than no
498
+ // message: it is the language actively misinforming its user. What replaces
499
+ // it is not silence but two NARROWER messages — NOTE_VERSION for a document
500
+ // that declares a language version older than the key, and NOTE_ON_FIELD for
501
+ // the one directive that keeps refusing it. See OPT_MIN_VERSION above.
434
502
  level:'level= has been DELETED, not renamed: it drew a reference plane through a 3-D bar chart, has zero uses in either downstream corpus and zero 3-D bar charts to draw it on, was the only construct whose caption the ENGINE wrote rather than the author, and its parseFloat grammar uniquely accepted 1e3 where every other number in the language is \\d+(\\.\\d+)? — delete the key (MIGRATIONS 0.1)'
435
503
  };
504
+ // `DRAWN-ANNOTATION-FORM`. The two messages that REPLACE the `note=` retirement
505
+ // diagnostic. SYNTAX-STYLE RULE 4.9 obligation 3 requires the retirement
506
+ // message to be reversed in the same release that revives the spelling, and
507
+ // "reversed" does not mean "deleted": each of the two situations the old
508
+ // message used to cover keeps a named diagnostic of its own.
509
+ //
510
+ // (a) The document declares a language version older than the key. The gate is
511
+ // `KEYWORD-RENAME-SCOPE`'s device — name the version, offer the one-step fix — and its
512
+ // reason is stated in the message because `note=`'s prior meaning is what
513
+ // makes the gate necessary rather than merely tidy.
514
+ const NOTE_VERSION=(have)=>
515
+ 'note= requires figdown 0.3 (this document declares '+have+'): under figdown '+
516
+ have+' the spelling is still the RETIRED one that meant description=, and an '+
517
+ 'engine that accepted it here would repaint a tooltip as ink — a figure that '+
518
+ 'looks right and means something else (core §13.0.1). note= is the DRAWN '+
519
+ 'annotation: an explanation the human reader must SEE. Raise the header to '+
520
+ 'figdown 0.3, or write description= if you meant prose only a machine reads '+
521
+ '(MIGRATIONS 0.3)';
522
+ // (b) The directive is `field`, which refuses the key at EVERY version. The
523
+ // bitfield genre already has `description=` for machine-facing prose, and
524
+ // no measured figure needs a DRAWN per-field aside — granting a directive
525
+ // both keys with no evidence spends the distinction before anyone needs it.
526
+ // The message states the distinction rather than naming a replacement,
527
+ // because `description=` is not a replacement: it reaches a different
528
+ // reader.
529
+ const NOTE_ON_FIELD=
530
+ 'note= draws and is not accepted on field; use description= for prose a '+
531
+ 'machine reads. The two keys divide by AUDIENCE, not by length: description= '+
532
+ 'reaches the reading agent as an SVG <title> and puts no ink on the page, '+
533
+ 'while note= is an explanation the human must see. A field\'s presence '+
534
+ 'condition is present=, not either of them (MIGRATIONS 0.3)';
436
535
  // `PLANE-KEYWORD-SPELLING`: the keyword `plane`/`plane=` was spelled `layer`/`layer=`.
437
- const RETIRED_LAYER='layer has been renamed: use plane (in mxGraph — the geometry model FigDown adopted — a layer is a CONTAINMENT PARENT that establishes coordinates, so layer=overlay reads as "reparent and re-origin this element", which FigDown does not do; Inkscape layers are <g> and may carry a transform, OGC WMS layers each carry an SRS, and CSS @layer is cascade priority with no visual meaning. SVG has no layer concept at all. `plane` is claimed by no standard for a conflicting meaning and removes the layout/layer near-miss) (MIGRATIONS 0.1)';
536
+ // `PAINT-ORDER-CONSTRUCT`: `plane` is WITHDRAWN, so `layer`'s message states the
537
+ // whole chain and ends where `route`'s does — the precedent,
538
+ // when `path` was withdrawn out from under the spelling `route` pointed at.
539
+ // `PAINT-ORDER-CONSTRUCT`: `plane` is WITHDRAWN from the language. The construct
540
+ // left because every genre that could write it lost it at once, and for two
541
+ // different reasons that happen to converge:
542
+ // - `block` and `flowchart` had ZERO authored uses. Every authored use in
543
+ // the tree was a `topology` document.
544
+ // - `topology` had two, and they are the worst domain collision measured in
545
+ // the language: in networking a PLANE is the control / data / management
546
+ // partition of a device — one of the first distinctions the field teaches
547
+ // — and `topology` is precisely the genre network engineers author in.
548
+ // `examples/evpn-fabric.fd` showed the trap already closed: it wrote
549
+ // `plane overlay "VXLAN tunnels" z-index=2`, where `overlay` is itself a
550
+ // networking term, so the line read as a network-architectural assertion
551
+ // and was in fact a paint order.
552
+ // What replaced it was already there. Stripping both writings from that file
553
+ // left the drawn SVG byte-identical apart from one `data-edge` index, because
554
+ // `class=overlay` carried the meaning the whole time.
555
+ const WITHDRAWN_PLANE_WHERE=' The decision and its evidence: MIGRATIONS 0.3, decisions/registry.md.';
556
+ const RETIRED_PLANE='plane has been WITHDRAWN from the language (`PAINT-ORDER-CONSTRUCT`) — removed, not renamed, so there is no replacement spelling. It declared a DRAWING LAYER (a z-order), and in the genre that actually used it "plane" means the control / data / management partition of a network device, so the one word said the wrong thing to exactly the readers who write the figure. Delete the line and delete every plane= that referenced it: paint order is document order, a later line paints on top. If the elements form a logical layer of the SUBJECT, that is a class= whose label states it (core §5, `PRESENTATION-AS-MEANING-CARRIER`) — which is what the two authored uses were already doing alongside it.'+WITHDRAWN_PLANE_WHERE;
557
+ const RETIRED_LAYER='layer has been WITHDRAWN: it was renamed plane, and plane was withdrawn from the language (`PAINT-ORDER-CONSTRUCT`). There is no replacement spelling. Delete the line: everything paints in one plane and paint order is document order (a later line paints on top). A logical layer of the SUBJECT — an overlay, a control plane — is a class= whose label says so (core §5, `PRESENTATION-AS-MEANING-CARRIER`).'+WITHDRAWN_PLANE_WHERE;
438
558
  // `THRESHOLD-KEYWORD-SPELLING`: the scene keyword `guide` became `threshold`.
439
559
  const RETIRED_GUIDE='guide has been renamed: use threshold (in Illustrator, Inkscape, Figma and draw.io a "guide" is an author-only construction line that is NEVER rendered, while FigDown\'s is drawn output — an INVERTED name, which `UNSAFE-DEFAULT-ELIMINATION` rates worse than an unfamiliar one, and no counter-example was found where "guide" names rendered output. `guide` was also a FigDown coinage, and `SIZE-AND-DIRECTION-KEY-NAMING` makes coining a last resort; `threshold` comes whole from Grafana, whose "Show thresholds" render option offers "As lines", "As filled regions" and "As filled regions and lines" — FigDown\'s marker + region pair, split the same way — with IETF RED/AQM as the secondary source (RFC 2309: "Two RED parameters, minth (minimum threshold) and maxth (maximum threshold)"; RFC 7567: "an AQM algorithm configured with a threshold"). 78% of the measured corpus marks are thresholds; target/mean/reference marks: 0) (MIGRATIONS 0.1)';
440
560
  // `EXTERNAL-ENDPOINT-NAMING`: the scene keyword `boundary` became `external`.
@@ -456,7 +576,7 @@ const RETIRED_FIELD_CONDITIONAL='the field flag "conditional" has been retired:
456
576
  // prose the model may not read. `present` is the attested spelling: X.680
457
577
  // PRESENT, IP-XACT isPresent, SystemRDL ispresent, RFC 2784 "present only
458
578
  // if", draft-mcquistin "present only when".
459
- const RETIRED_FIELD_OPTIONAL='the field flag "optional" has been retired and replaced by an option key that carries the CONDITION: write present="<the condition>" (e.g. field "Checksum" 16 present="C = 1"), or present="" when the condition is not stated. The bare flag could say only THAT the field was conditional, so the condition had to live in note= — invisible to the human reading the figure, and prose the model may not parse (`BITFIELD-CONDITIONAL-OFFSETS`). present= DRAWS: the field stays dashed and a stated condition becomes a caption under the block (MIGRATIONS 0.1)';
579
+ const RETIRED_FIELD_OPTIONAL='the field flag "optional" has been retired and replaced by an option key that carries the CONDITION: write present="<the condition>" (e.g. field "Checksum" 16 present="C = 1"), or present="" when the condition is not stated. The bare flag could say only THAT the field was conditional, so the condition had to live in the field\'s documentation prose — the key spelled note= at the time and description= where it was invisible to the human reading the figure, and prose the model may not parse (`BITFIELD-CONDITIONAL-OFFSETS`). (Today\'s note= is a different key: it is the DRAWN annotation revived, it is refused on field, and it is not where a presence condition belongs either.) present= DRAWS: the field stays dashed and a stated condition becomes a caption under the block (MIGRATIONS 0.1)';
460
580
  // `TIMING-GENRE-NAMING`: the EXPERIMENTAL genre `wave` became `timing`, both as
461
581
  // the header genre token and as the block opener. The old name was WaveDrom's
462
582
  // MEMBER KEY, not its figure name: in WaveJSON `signal` is the root object and
@@ -488,7 +608,7 @@ const RETIRED_WAVE='wave has been renamed: use timing (in WaveJSON `signal` is t
488
608
  // not "use X"), says what an author should do instead (delete the line and let
489
609
  // auto layout draw it, with the content-zone means named), and points at where
490
610
  // the decision is RECORDED so the reasoning is one lookup away.
491
- const WITHDRAWN_WHERE=' The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md';
611
+ const WITHDRAWN_WHERE=' The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md.';
492
612
  const RETIRED_PATH='path has been WITHDRAWN from the language (`EDGE-GEOMETRY-CONSTRUCTS`) — removed, not renamed, so there is no replacement spelling. A prior-art study of Visio, draw.io/mxGraph, Graphviz and ELK found author waypoints OUTSIDE the stable intersection: only 2 of the 4 model them, and those 2 disagree on what happens when an endpoint moves. The dock realisation was outside it too — written-order attachment has zero prior art in any surveyed system. Delete the line: the edge draws under auto layout, and `rank`, `flow`, declaration order and `pin` are the content-zone means of shaping it.'+WITHDRAWN_WHERE;
493
613
  const RETIRED_ROUTING='routing has been WITHDRAWN from the language (`EDGE-GEOMETRY-CONSTRUCTS`) — removed, not renamed, so there is no replacement spelling. Two routing modes and two scopes ARE inside the stable prior-art intersection, so the need is recognised and its shape is known; what is missing is the evidence and the implementation (6 of the 8 in-repo `routing=orthogonal` writings were provable no-ops, and downstream adoption was zero), and the per-edge scope cannot be restored without an edge-identity construct FigDown does not have. Delete the line; the edges draw straight.'+WITHDRAWN_WHERE;
494
614
  // `TIMING-LANE-ALPHABET`: the timing lane digits `2`-`9` left the closed alphabet.
@@ -515,7 +635,7 @@ const CELL_HL_ON_CELL='highlight is a ROW mark and takes the single-valued row f
515
635
  const CELL_HL_ROW_CONFLICT=(r,c)=>'cell ('+r+','+c+') resolves to a fill on row '+r+', which is highlighted — the cell fill overrides the row tint, so the model says "row '+r+' is highlighted" while the drawing shows only part of the row tinted (`PRESENTATION-AS-MEANING-CARRIER`: presentation may render meaning, never delete it). Drop the row highlight, or move the cell fill to a row that carries none (MIGRATIONS 0.1)';
516
636
  const NO_ITEM_STYLE=new Set(['field','cell','signal']);
517
637
  const STYLE_NO_ITEM=k=>k+' does not take style= — '+(k==='field'
518
- ? 'on a field the dash IS conditional presence (`present=`, spelled `optional` until this release), and style=solid erased it while the model still recorded the field as conditionally present (`PRESENTATION-AS-MEANING-CARRIER`: presentation may render meaning, never be its only carrier)'
638
+ ? 'on a field the dash IS conditional presence (`present=`, spelled `optional` until 0.1), and style=solid erased it while the model still recorded the field as conditionally present (`PRESENTATION-AS-MEANING-CARRIER`: presentation may render meaning, never be its only carrier)'
519
639
  : 'a dash on one '+k+' carried no meaning the block does not already carry, and 0 documents outside this repository wrote it')
520
640
  // `DESCRIPTION-KEY-SPELLING` corrected the second half of this message. It used to
521
641
  // offer `note=` as a place to put knowledge, which was wrong twice over:
@@ -783,9 +903,53 @@ const LAYOUT_KW=['pin']; // `LAYOUT-ZONE-NAMESPACE`, NORMA
783
903
  const GENRE_FREE_KW=CORE_KW.concat(LAYOUT_KW);
784
904
  // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: the NODE and CONNECTOR spellings are per genre, so
785
905
  // they are NOT in the shared list — every scene genre concats its own two.
786
- const SCENE_KW_TOP=['group','external','class','flow','rank'];
787
- const SCENE_EXP_KW=['threshold','band','bundle','plane'];
788
- const SCENE_HOST_KW=GENRE_FREE_KW.concat(SCENE_KW_TOP, SCENE_EXP_KW, ['bitfield','table','timing','chart']);
906
+ //
907
+ // `SUBJECT-VOCABULARY-SCOPE`: SUBJECT VOCABULARY IS PER GENRE, AND THERE IS NO SHARED
908
+ // LIST OF IT. `SCENE_KW_TOP` and `SCENE_EXP_KW` are gone. They held the words
909
+ // that say what a figure is OF — `group`, `external`, `threshold`, `band`,
910
+ // `bundle`, `plane` — in one array concatenated into four genres, which is
911
+ // the same defect core §3's "scene keywords" sentence recorded: an
912
+ // INTERSECTION written down as if it were a namespace. Under `GENRE-VOCABULARY-OBLIGATION` a genre owns
913
+ // its words, so each scene genre now names its own subject vocabulary in its
914
+ // own array below. Two arrays agreeing is TWO DECLARATIONS that agree today,
915
+ // never one declaration inherited, and either may be withdrawn, renamed or
916
+ // constrained without touching the other.
917
+ //
918
+ // What stays shared, and why that is not a contradiction:
919
+ // - `class` is STYLING declaration and `flow`/`rank` are LAYOUT INTENT.
920
+ // None of the three describes a referent, so no genre's domain holds a
921
+ // competing meaning for them and no genre can independently earn or lose
922
+ // one. They are nearer `LAYOUT-ZONE-NAMESPACE`'s genre-independent layout namespace than `GENRE-VOCABULARY-OBLIGATION`.
923
+ // - `bitfield`/`table`/`timing`/`chart` are `GENRE-COMPOSITION` REGION OPENERS: composition,
924
+ // not subject vocabulary. The region's own namespace is the nested
925
+ // genre's.
926
+ const SCENE_STYLE_KW=['class','flow','rank'];
927
+ const SCENE_REGION_KW=['bitfield','table','timing','chart'];
928
+ const SCENE_HOST_KW=GENRE_FREE_KW.concat(SCENE_STYLE_KW, SCENE_REGION_KW);
929
+ // --- Each scene genre's OWN subject vocabulary. One array per genre. ---
930
+ // `block` (NORMATIVE): `group` and `external` are normative; `threshold` and
931
+ // `band` are EXPERIMENTAL and are the `GENRE-EARNING-THRESHOLD` INTERIM scalar-marker pair, held here
932
+ // deliberately unfrozen so the future scalar-marker genre can name them once
933
+ // WITH a scale. They are not renamed now — a rename would hand that genre a
934
+ // retired word.
935
+ const BLOCK_SUBJECT_KW=['group','external','threshold','band'];
936
+ // `topology` (EXPERIMENTAL): `bundle` is the one construct whose domain
937
+ // reading and drawn reading are the same reading — a LAG (IEEE 802.1AX), an
938
+ // ECMP set, an EVPN Ethernet Segment. `group`/`external` keep their block
939
+ // spellings because every networking synonym is more taken (`zone` DNS and
940
+ // firewall, `cluster` RFC 4456, `domain` RFC 7926, `area` OSPF, `site` EVPN)
941
+ // and their collisions are SOFT — the picture contradicts the wrong reading.
942
+ const TOPOLOGY_SUBJECT_KW=['group','external','bundle'];
943
+ // `flowchart` (EXPERIMENTAL): `external` only — the off-page terminus, ISO
944
+ // 5807 §9.4.2 *Terminator* being ISO's word for the concept and already this
945
+ // genre's live keyword, so the spelling stays. `group` had one occurrence in
946
+ // the whole tree and it was this genre's own reference figure.
947
+ const FLOWCHART_SUBJECT_KW=['external'];
948
+ // `statechart` (EXPERIMENTAL): NONE, and the empty array is the declaration.
949
+ // Three authored statechart figures, all transcribed from RFCs, reach for
950
+ // none of the six; `external` is additionally UML 2.5.1 §14's own
951
+ // `TransitionKind` literal and is reserved for it (`RESERVED-SPELLINGS`).
952
+ const STATECHART_SUBJECT_KW=[];
789
953
  // `FLOWCHART-ROLE-KEYWORDS`: the flowchart ROLE vocabulary — the FIRST exercise of
790
954
  // `GENRE-NAMESPACE` `GENRE-VOCABULARY-OBLIGATION` ("a genre owns its words"). These three are legal ONLY under
791
955
  // `figdown 0.1 flowchart`; `GENRE-NAMESPACE`'s allowlist is what makes `decision x` a line
@@ -858,7 +1022,7 @@ const WRONG_VERSION_WORD=(surf,want,genre,need,have)=>
858
1022
  // "not allowed in genre X" tells an author nothing about what to write.
859
1023
  const WORD_WHY={
860
1024
  edge:'a block or topology figure is a graph, and `edge` is the graph word (DOT)',
861
- flowline:'the connecting line in a flowchart is a FLOWLINE — the term ISO 5807 uses for it',
1025
+ flowline:'the connecting line in a flowchart is a FLOWLINE — the term the flowchart domain commonly uses for the symbol ISO 5807 §9.3.1 names "Line"',
862
1026
  transition:'the connecting line in a statechart is a TRANSITION — the term UML 2.5.1 §14 uses for it',
863
1027
  node:'this genre has more kinds of thing than it has words for, so `node` is the general one',
864
1028
  state:'a statechart has exactly ONE kind of node and it is a STATE (UML 2.5.1 §14)'
@@ -869,10 +1033,82 @@ const WORD_WHY={
869
1033
  const WRONG_WORD=(surf,want,genre)=>
870
1034
  '"'+surf+'" is not the word genre '+genre+' uses for this — write "'+want+'": '+WORD_WHY[want]+
871
1035
  '. Each scene genre takes the term its own domain uses (block/topology `node` `edge`, flowchart `node` `flowline`, statechart `state` `transition`) — run tools/migrate-figdown.js to rewrite it (MIGRATIONS 0.2)';
1036
+ // `SCENE-KEYWORD-MEMBERSHIP`: a word WITHDRAWN FROM ONE GENRE is not an unknown word,
1037
+ // and `"threshold" is not allowed in genre topology` would send an author
1038
+ // looking for a typo. Each cell below was legal until this release and states
1039
+ // WHY that genre no longer declares it — the ruling's own ground, per cell,
1040
+ // because the grounds differ and a single sentence could not carry them.
1041
+ // Every one of these withdrawals was FREE: `topology`, `flowchart` and
1042
+ // `statechart` are EXPERIMENTAL genres outside the compatibility promise, and
1043
+ // in `block` the two withdrawn words were EXPERIMENTAL keywords (`EDGE-GEOMETRY-CONSTRUCTS` precedent:
1044
+ // experimental withdrawal, no gate, no rewrite owed).
1045
+ const WITHDREW_AT=' (withdrawn, `SCENE-KEYWORD-MEMBERSHIP`; MIGRATIONS 0.3)';
1046
+ const GENRE_WITHDRAWN={
1047
+ block:{
1048
+ bundle:'`bundle` is now declared by `topology` only. It had ZERO authored uses under `block` — every authored link bundle in the corpus is a topology document — and the construct is defined by a REFERENT that only that genre has: a LAG (IEEE 802.1AX), an ECMP set, an EVPN Ethernet Segment. Under `block` it was a ring around parallel edges with nothing to name.'
1049
+ },
1050
+ topology:{
1051
+ threshold:'`threshold` is now declared by `block` only. It had ZERO occurrences under `topology` in the whole corpus, and in this genre\'s domain a threshold is a QUEUE DEPTH WITH A NUMERIC VALUE (RFC 2309 minth/maxth, RFC 7567) — while FigDown\'s takes no value= and its offset= is a fraction of the target\'s rendered extent, not a quantity. Author the figure as `block`, where the `GENRE-EARNING-THRESHOLD` scalar-marker evidence lives.',
1052
+ band:'`band` is now declared by `block` only. Its two occurrences under `topology` were both conformance fixtures, never a figure anyone needed, and in this genre a BAND is a frequency band — radio, wireless, microwave, optical transport — which is exactly the kind of figure a topology document draws. Author the figure as `block`.'
1053
+ },
1054
+ flowchart:{
1055
+ group:'`flowchart` no longer declares `group`. Its one occurrence in the corpus was this genre\'s own reference figure, which exists to demonstrate every form of every keyword — so citing it as evidence of need is circular. There were no authored uses.',
1056
+ threshold:'`flowchart` no longer declares `threshold`. Zero occurrences, and the construct does not apply: a threshold is a labelled reference value drawn at a percentage of the target\'s RENDERED EXTENT, and a process box\'s extent is an artifact of its label length, so the line asserts nothing a reader can read.',
1057
+ band:'`flowchart` no longer declares `band`. Zero occurrences, and a band is a RANGE over that same meaningless extent.',
1058
+ bundle:'`flowchart` no longer declares `bundle`. Zero occurrences, and parallel flowlines between the same two stages are different CONDITIONS; drawing a ring round them hides what the figure is for.'
1059
+ },
1060
+ statechart:{
1061
+ group:'`statechart` declares NO subject vocabulary at all. UML\'s grouping construct is the COMPOSITE STATE and its REGIONS, and under the single-source-vocabulary rule a statechart that needed grouping should take UML 2.5.1 §14\'s word for it — declared in this genre\'s own document — rather than inherit another genre\'s.',
1062
+ external:'`statechart` declares NO subject vocabulary at all, and `external` is additionally RESERVED here: UML 2.5.1 §14 defines TransitionKind as `external | internal | local`, so in this genre\'s own source standard "external" already names A TRANSITION THAT EXITS AND RE-ENTERS ITS SOURCE STATE. FigDown\'s `external` means an endpoint outside the figure that is never drawn — same word, same genre, same standard, unrelated meanings.',
1063
+ threshold:'`statechart` declares NO subject vocabulary at all. A state has no extent that means anything — its box is sized by its label — so a reference value drawn 60% down it asserts nothing.',
1064
+ band:'`statechart` declares NO subject vocabulary at all, and a band is a range over that same meaningless extent.',
1065
+ bundle:'`statechart` declares NO subject vocabulary at all, and here `bundle` is an ANTI-FEATURE: two transitions between the same pair of states are two different TRIGGERS, and the trigger is the whole content of the arc. Bundling them draws away exactly what the figure is for.'
1066
+ }
1067
+ };
1068
+ const WITHDRAWN_FROM_GENRE=(kw,genre)=>
1069
+ '"'+kw+'" is not allowed in genre '+genre+' — it was WITHDRAWN from this genre, not misspelled: '+
1070
+ GENRE_WITHDRAWN[genre][kw]+
1071
+ ' Subject vocabulary is per genre (core §3, `GENRE-VOCABULARY-OBLIGATION`): a spelling accepted by several genres is several '+
1072
+ 'independent declarations, and this genre\'s was withdrawn without touching any other\'s.'+WITHDREW_AT;
1073
+ // `MEMBERSHIP-KEY-ACCEPTANCE`: THE OPTION-KEY HALF OF `SCENE-KEYWORD-MEMBERSHIP`. A per-genre withdrawal can
1074
+ // strand an option KEY as easily as it strands a keyword: `in=` states
1075
+ // membership and its ONLY value domain is the id of a containing `group`, so
1076
+ // once `SCENE-KEYWORD-MEMBERSHIP` stopped `flowchart` and `statechart` from declaring a `group` the
1077
+ // key stayed accepted with nothing it could name. The measured symptom was
1078
+ // `process a "A" in=g` answering `unknown group "g"` with NO spelling that
1079
+ // succeeds — a dangling reference every author reaches by writing the key at
1080
+ // all. The other acceptors of `in=` in the language are `threshold` and
1081
+ // `band`, whose domain `MARKER-TARGET-KINDS` widened to REGION ids; neither is a keyword of
1082
+ // either genre since `SCENE-KEYWORD-MEMBERSHIP`, and the widening never reached `node`, so a
1083
+ // `flowchart` document that declares `table q` still answers `unknown group
1084
+ // "q"` for `node a "A" in=q`. Nothing in either genre was left un-stranded,
1085
+ // which is why the withdrawal is by KEY here and not directive by directive.
1086
+ //
1087
+ // The grounds differ per genre and are stated per cell, as `SCENE-KEYWORD-MEMBERSHIP`'s are:
1088
+ // `flowchart`'s is that every value is a dead end, `statechart`'s is that the
1089
+ // spelling is RESERVED for a different domain. Both genres are EXPERIMENTAL,
1090
+ // so both withdrawals are free — the `EDGE-GEOMETRY-CONSTRUCTS` precedent, no gate and no rewrite
1091
+ // owed — which is also what makes re-adding `in=` to `statechart` later with
1092
+ // a `state`-id domain cost nothing.
1093
+ const WITHDREW_OPT_AT=' (withdrawn, `MEMBERSHIP-KEY-ACCEPTANCE`; MIGRATIONS 0.3)';
1094
+ const GENRE_WITHDRAWN_OPT={
1095
+ flowchart:{
1096
+ in:'`flowchart` no longer accepts `in=`. Its only value domain was the id of a containing `group`, and this genre has not declared `group` (`SCENE-KEYWORD-MEMBERSHIP`) — so EVERY value was a dead end: `in=x` answered `unknown group "x"` and no spelling succeeded. An unknown-option error that names the reason beats a dangling reference no author can satisfy. What expresses membership TODAY is `class=`: declare `class ingress "Ingress phase"` and write `class=ingress` on each stage — it earns a legend entry and applies to every member at once. Containment in a flowchart is an OPEN question and the construct the need is waiting on is a swimlane, not a box (spec/genres/experimental/flowchart.md, What is excluded).'
1097
+ },
1098
+ statechart:{
1099
+ in:'`statechart` no longer accepts `in=`, and the spelling is RESERVED rather than merely dropped. This genre declares NO subject vocabulary (`SUBJECT-VOCABULARY-SCOPE`), so the `group` id that was `in=`\'s only value domain cannot exist here and every value was a dead end. The reason for withdrawing rather than leaving it is the RESERVATION: `in=`\'s future domain in this genre is a STATE id — UML 2.5.1 §14.2.3.4 composite states would arrive as nesting on `state`, through this exact key — so a live key with a group-id domain taught the WRONG model using the very spelling reserved for the right one. Delete the key: a category shared by several states is a `class=` meaning. `in=` is expected back with a state-id domain when composite-state nesting is earned (`MEMBERSHIP-KEY-ACCEPTANCE`).'
1100
+ }
1101
+ };
1102
+ const WITHDRAWN_OPT_FROM_GENRE=(key,genre)=>
1103
+ key+'= is not allowed in genre '+genre+' — it was WITHDRAWN from this genre, not misspelled: '+
1104
+ GENRE_WITHDRAWN_OPT[genre][key]+
1105
+ ' An option key is per genre for the same reason a keyword is (core §3, `GENRE-VOCABULARY-OBLIGATION`): the key is accepted '+
1106
+ 'by the directive AND by the genre, and this genre\'s acceptance was withdrawn without touching any other\'s.'+
1107
+ WITHDREW_OPT_AT;
872
1108
  const GENRE_KW={
873
- block:new Set(SCENE_HOST_KW.concat(['node','edge'])),
874
- topology:new Set(SCENE_HOST_KW.concat(['node','edge'])),
875
- flowchart:new Set(SCENE_HOST_KW.concat(['node','flowline'], FLOWCHART_ROLE_KW)),
1109
+ block:new Set(SCENE_HOST_KW.concat(BLOCK_SUBJECT_KW, ['node','edge'])),
1110
+ topology:new Set(SCENE_HOST_KW.concat(TOPOLOGY_SUBJECT_KW, ['node','edge'])),
1111
+ flowchart:new Set(SCENE_HOST_KW.concat(FLOWCHART_SUBJECT_KW, ['node','flowline'], FLOWCHART_ROLE_KW)),
876
1112
  // `STATECHART-GENRE-SCOPE`: `statechart` added no keyword of its own — it was the
877
1113
  // scene host set and nothing else. `GENRE-NODE-SPELLING` gives it its two: the
878
1114
  // scene host set with `state` and `transition` in the slots `node` and
@@ -880,7 +1116,10 @@ const GENRE_KW={
880
1116
  // `terminator`: those are flowchart's words (`GENRE-NAMESPACE` `GENRE-VOCABULARY-OBLIGATION`), and a `decision` in a
881
1117
  // statechart is a category error, not a shorthand. The allowlist is what
882
1118
  // makes that a line error with no extra code.
883
- statechart:new Set(SCENE_HOST_KW.concat(['state','transition'])),
1119
+ // `SUBJECT-VOCABULARY-SCOPE`: its subject vocabulary is the empty array above, so
1120
+ // `state` + `transition` + core + layout + styling + region openers is now
1121
+ // the WHOLE of what a statechart document may write at top level.
1122
+ statechart:new Set(SCENE_HOST_KW.concat(STATECHART_SUBJECT_KW, ['state','transition'])),
884
1123
  bitfield:new Set(GENRE_FREE_KW.concat(['class','bitfield'])),
885
1124
  // chart is experimental and attaches to a table id in the same document
886
1125
  table:new Set(GENRE_FREE_KW.concat(['class','table','chart'])),
@@ -943,7 +1182,10 @@ function parseOne(text){
943
1182
  // `EMPTY-LABEL-STATE`: `title` and a plane label start ABSENT (null), never as an empty
944
1183
  // string — an author who writes `title ""` has made a distinction the model
945
1184
  // must keep, and the implicit `base` plane wrote no label at all.
946
- const doc={title:null,nodes:[],groups:[],edges:[],planes:[{id:'base',label:null,z:0}],
1185
+ // `DRAWN-ANNOTATION-FORM`: `note` sits beside `title` and starts ABSENT (null), on
1186
+ // `EMPTY-LABEL-STATE`'s rule for `title` itself — an author who writes `note=""` has made
1187
+ // a distinction the model must keep.
1188
+ const doc={title:null,note:null,nodes:[],groups:[],edges:[],planes:[{id:'base',label:null,z:0}],
947
1189
  flow:'right',ranks:[],pins:{},blocks:[],trunks:[],thresholds:[],bands:[],
948
1190
  classes:[],boundaries:[]};
949
1191
  const nodeIds=new Set(), groupIds=new Set(), planeIds=new Set(['base']), classIds=new Set(),
@@ -1096,6 +1338,16 @@ function parseOne(text){
1096
1338
  const e=idErr(o2.plane, optHasQ(oT2,'plane'), null);
1097
1339
  if(e){ err(n,e); return; }
1098
1340
  }
1341
+ // `DRAWN-ANNOTATION-FORM`: the connector's copy of the `note=` version gate and
1342
+ // of the `QUOTING-RULES` quoted-prose rule. A connector is the acceptor the ruling
1343
+ // called decisive — an edge has no id, so an attribute is the ONLY form
1344
+ // that can reach it — and this scanner has to carry every language-wide
1345
+ // check itself or the one construct that most needs the key is the one
1346
+ // construct where the key is unchecked.
1347
+ if(o2.note!==undefined){
1348
+ if(belowOptVersion('note',doc.version)){ err(n,NOTE_VERSION(doc.version)); return; }
1349
+ if(!optQ(oT2,'note')){ err(n,'note= must be quoted: note="'+o2.note+'" — '+Q_WHY); return; }
1350
+ }
1099
1351
  // `RULE-POSITION-ENUMERATION`: and the enum half of RULE 2.4, for the one enum key `edge` takes.
1100
1352
  // Checked before the value, exactly as `badOpts` does it.
1101
1353
  if(o2.style!==undefined && optHasQ(oT2,'style')){ err(n,ENUM_BARE('style='+o2.style)); return; }
@@ -1110,7 +1362,7 @@ function parseOne(text){
1110
1362
  // and `fill=` name the same channel (`stroke=` wins when both are
1111
1363
  // written); `text=` colours the [tail]/[mid]/[head] labels.
1112
1364
  doc.edges.push({a,b,op,tail,mid,head,style:o2.style,cls:ecls,
1113
- stroke:o2.stroke,
1365
+ stroke:o2.stroke,note:o2.note,
1114
1366
  plane:o2.plane||'base',line:n});
1115
1367
  }
1116
1368
 
@@ -1236,6 +1488,19 @@ function parseOne(text){
1236
1488
  // same-line repeated option key (last-wins was silent data loss)
1237
1489
  if(dup){ err(n,'duplicate option "'+dup+'=" on one line'); bad=true; }
1238
1490
  for(const u of unk){ err(n,'unknown option "'+u+'="'); bad=true; }
1491
+ // `MEMBERSHIP-KEY-ACCEPTANCE`: the PER-GENRE option-key withdrawal, checked here —
1492
+ // after `unknown option`, so a key the LANGUAGE does not have keeps its
1493
+ // own answer, and before every value check, so a withdrawn key is never
1494
+ // told what its value would have meant. `gwHit` suppresses the id-value
1495
+ // rule below for the same key: ONE token, ONE error, the convention
1496
+ // `enumQ` already follows. The line is abandoned by the caller
1497
+ // (`if(badOpts(kw)) continue;`), so no cascade reaches the resolver and
1498
+ // the author never sees the `unknown group "…"` this ruling removes.
1499
+ const gwOpt=(doc.genre&&GENRE_WITHDRAWN_OPT[doc.genre])||null;
1500
+ const gwHit=new Set();
1501
+ if(gwOpt) for(const o in opts)
1502
+ if(gwOpt[o]!==undefined && allowed.includes(o)){
1503
+ err(n,WITHDRAWN_OPT_FROM_GENRE(o,doc.genre)); gwHit.add(o); bad=true; }
1239
1504
  // Retired spelling: `color=` → `fill=`. Fires only where
1240
1505
  // the key was accepted; on a directive that never took it the existing
1241
1506
  // `<directive> does not take color=` is still the right answer.
@@ -1245,6 +1510,19 @@ function parseOne(text){
1245
1510
  // spelling left the language rather than moving between directives.
1246
1511
  for(const rk in RETIRED_OPT_KEYS)
1247
1512
  if(opts[rk]!==undefined){ err(n,RETIRED_OPT_KEYS[rk]); bad=true; }
1513
+ // `DRAWN-ANNOTATION-FORM`: the two `note=` refusals, in the order that gives
1514
+ // ONE error per line. `field` is checked first and unconditionally,
1515
+ // because it refuses the key at every version — telling a `figdown 0.2`
1516
+ // bitfield author to raise their header would send them to a version
1517
+ // that still refuses them. Every directive that does NOT list `note` in
1518
+ // its row falls through to the generic `<directive> does not take note=`
1519
+ // below, which is the right answer for `external`, `threshold`, `band`,
1520
+ // `bundle`, `plane`, `class` and `cell`: the key is in OPT_KEYS, so none
1521
+ // of them can report `unknown option` for a spelling the language has.
1522
+ if(opts.note!==undefined && allowed.includes('note')){
1523
+ if(k==='field'){ err(n,NOTE_ON_FIELD); bad=true; }
1524
+ else if(belowOptVersion('note',doc.version)){ err(n,NOTE_VERSION(doc.version)); bad=true; }
1525
+ }
1248
1526
  // `RULE-POSITION-ENUMERATION`: RULE 2.4's enum half on the OPTION keys. One loop
1249
1527
  // for every enum-valued key, the same device the id-valued keys below
1250
1528
  // use — so a key that gains an enum grammar later is covered by
@@ -1293,7 +1571,11 @@ function parseOne(text){
1293
1571
  // until then) and `present=` (`PRESENCE-CONDITION-EXPRESSION`). `present=""` is legal and is the
1294
1572
  // "conditional, condition not stated" form — an EMPTY quoted value,
1295
1573
  // not an unquoted one, so the same rule admits it.
1296
- for(const sk of ['description','present'])
1574
+ // `DRAWN-ANNOTATION-FORM`: THREE since `note=` revived. It is prose in the
1575
+ // same sense, and it revives with the value shape it retired with —
1576
+ // which is RULE 4.9 obligation 2 satisfied in the parser rather than
1577
+ // only on paper.
1578
+ for(const sk of ['description','present','note'])
1297
1579
  if(opts[sk]!==undefined && allowed.includes(sk) && !optQ(optT,sk)){
1298
1580
  err(n,sk+'= must be quoted: '+sk+'="'+opts[sk]+'" — '+Q_WHY); bad=true; }
1299
1581
  // `QUOTED-IDS`: `in=` and `plane=` are ID-VALUED options, so the
@@ -1301,7 +1583,7 @@ function parseOne(text){
1301
1583
  // keeps its directive-specific message (`threshold needs in=…`); a written
1302
1584
  // one that is quoted or not a legal id gets the ID RULE.
1303
1585
  for(const k of ['in','plane'])
1304
- if(opts[k]!==undefined && allowed.includes(k)){
1586
+ if(opts[k]!==undefined && allowed.includes(k) && !gwHit.has(k)){
1305
1587
  const e=idErr(opts[k], optHasQ(optT,k), null);
1306
1588
  if(e){ err(n,e); bad=true; }
1307
1589
  }
@@ -1410,7 +1692,7 @@ function parseOne(text){
1410
1692
  // noun in a topology figure (`SHAPE-ENUM-VOCABULARY`: no domain nouns in the presentation
1411
1693
  // vocabulary) while the directive means geometric waypoints;
1412
1694
  // - `render` was a verb naming a zone that admits only geometry
1413
- // (`pin` — and, until this release, `path` and `routing`), and it
1695
+ // (`pin` — and, until 0.1, `path` and `routing`), and it
1414
1696
  // collided with the renderer
1415
1697
  // and the render options of §7. `layout` is the cross-tool word for
1416
1698
  // this half of a diagram language, and the zone it opens carried the
@@ -1428,6 +1710,12 @@ function parseOne(text){
1428
1710
  if(kw==='wrap'){ err(n,RETIRED_WRAP); continue; }
1429
1711
  if(kw==='boundary'){ err(n,RETIRED_BOUNDARY); continue; }
1430
1712
  if(kw==='layer'){ err(n,RETIRED_LAYER); continue; }
1713
+ // `PAINT-ORDER-CONSTRUCT`: `plane` joins this block. RULE 6.2 placement — the
1714
+ // spelling left the LANGUAGE, not one genre, so it fires wherever it
1715
+ // appears at line start, in every genre, AHEAD of the `GENRE-KEYWORD-ALLOWLIST` allowlist. A
1716
+ // `plane` line under `bitfield` gets the withdrawal, not "not allowed in
1717
+ // genre bitfield", which would be true and useless.
1718
+ if(kw==='plane'){ err(n,RETIRED_PLANE); continue; }
1431
1719
  if(kw==='guide'){ err(n,RETIRED_GUIDE); continue; }
1432
1720
  if(kw==='wave'){ err(n,RETIRED_WAVE); continue; }
1433
1721
  if(kw==='size'){ err(n,RETIRED_SIZE); continue; }
@@ -1442,7 +1730,7 @@ function parseOne(text){
1442
1730
  // per-field options. Classic form: field <name> <width> [options].
1443
1731
  // Classic form: field <name> <width-in-bits|*> [fill=] [description=]
1444
1732
  // [present=]
1445
- // Conditional presence was a POSITIONAL FLAG until this release:
1733
+ // Conditional presence was a POSITIONAL FLAG until 0.1:
1446
1734
  // `optional` (…0.1), `conditional` (0.1…0.1),
1447
1735
  // `optional` again (`PRESENCE-FLAG-SPELLING`). `PRESENCE-CONDITION-EXPRESSION` replaces the flag with
1448
1736
  // `present=`, an option key whose value is the presence CONDITION as
@@ -1529,7 +1817,7 @@ function parseOne(text){
1529
1817
  // the overflow.
1530
1818
  if(m[2]!=='*' && +m[2]>cur.word){
1531
1819
  // The suggestion spells the CLASSIC form, and the classic name is
1532
- // QUOTED (`QUOTING-RULES`). Until this release this string said
1820
+ // QUOTED (`QUOTING-RULES`). Until 0.1 this string said
1533
1821
  // `write "field P 64"` — a second line error, so a user who
1534
1822
  // followed the diagnostic was told off twice.
1535
1823
  bad='"'+nm+':'+m[2]+'" is wider than word='+cur.word+' — a compact item must fit one row; write it in the classic form to span rows: field "'+nm+'" '+m[2]; break; }
@@ -1555,7 +1843,7 @@ function parseOne(text){
1555
1843
  // `break` ends the row after the fields declared since the block
1556
1844
  // opened (or since the previous break). With none there is nothing to
1557
1845
  // break — genre doc: "break with no preceding field in the current row".
1558
- // Spelled `wrap` until this release (`ROW-BREAK-NAMING`): in CSS/typography `wrap` is
1846
+ // Spelled `wrap` until 0.1 (`ROW-BREAK-NAMING`): in CSS/typography `wrap` is
1559
1847
  // AUTOMATIC reflow — a mode — while this is an EXPLICIT break, an
1560
1848
  // event; CSS Fragmentation calls exactly this "a forced break …
1561
1849
  // explicitly indicated by the … author" and HTML spells it `br`.
@@ -1654,7 +1942,7 @@ function parseOne(text){
1654
1942
  // / `TYPED-BLOCK-SILENT-FALLBACK`: data= is ABSENCE vs presence. An empty value, empty
1655
1943
  // members (a,b), or a count that does not match the lane's `=`
1656
1944
  // cells are all line errors — never silent drop or shift.
1657
- // Spelled `labels=` until this release (`SIGNAL-DATA-KEY-SPELLING`): WaveDrom's own key is
1945
+ // Spelled `labels=` until 0.1 (`SIGNAL-DATA-KEY-SPELLING`): WaveDrom's own key is
1658
1946
  // `data`, "an array of signal labels" naming every value cell, and
1659
1947
  // after the `2`-`9` retirement (`TIMING-LANE-ALPHABET`) the two scopes coincide exactly.
1660
1948
  let labels;
@@ -1706,6 +1994,11 @@ function parseOne(text){
1706
1994
  // dispatched by their own scanner above.)
1707
1995
  if(NODE_SPELLINGS.has(kw) && GENRE_NODE_KW[doc.genre])
1708
1996
  err(n, WRONG_WORD(kw, GENRE_NODE_KW[doc.genre], doc.genre));
1997
+ // `SCENE-KEYWORD-MEMBERSHIP`: same argument one step further. A word this genre
1998
+ // WITHDREW is not an unknown word either, and the author holding it
1999
+ // needs the ground, not a spellcheck.
2000
+ else if(GENRE_WITHDRAWN[doc.genre] && GENRE_WITHDRAWN[doc.genre][kw])
2001
+ err(n, WITHDRAWN_FROM_GENRE(kw, doc.genre));
1709
2002
  else
1710
2003
  err(n,'"'+kw+'" is not allowed in genre '+doc.genre);
1711
2004
  continue;
@@ -1732,10 +2025,23 @@ function parseOne(text){
1732
2025
  // different values for the same visible text. One form, one
1733
2026
  // meaning: the token is a normal quoted string and the generic
1734
2027
  // tokenizer above has already resolved its escapes.
1735
- const t0=tk.toks[1];
1736
- if(!t0||!t0.q){ err(n,'title needs a quoted string: title "<text>" (MIGRATIONS 0.1)'); break; }
1737
- if(tk.toks.length>2){ err(n,'unexpected argument "'+tk.toks[2].v+'"'); break; }
1738
- doc.title=t0.v; sawTitle=true; break;
2028
+ // `DRAWN-ANNOTATION-FORM`: read the POSITIONALS, not the raw token stream.
2029
+ // `title` took no options until this release, so `tk.toks[1]` and
2030
+ // `tk.toks.length>2` were the same thing as `pos[1]` and
2031
+ // `pos.length>2`. They stop being the same thing the moment the line
2032
+ // may carry `note=`, and testing the raw stream would report the
2033
+ // annotation as a surplus positional. This is `OPTION-POSITION-PARSING`'s lesson (`bundle`
2034
+ // and `threshold` read `posq` for exactly this reason) applied to the
2035
+ // one directive that had never needed it.
2036
+ const t0v=pos[1], t0q=posq[1];
2037
+ if(t0v===undefined||!t0q){ err(n,'title needs a quoted string: title "<text>" (MIGRATIONS 0.1)'); break; }
2038
+ if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
2039
+ doc.title=t0v; sawTitle=true;
2040
+ // The figure-level note lives on the document, not on an element —
2041
+ // there is no element for it to live on, which is the whole reason
2042
+ // `title` is an acceptor.
2043
+ if(opts.note!==undefined) doc.note=opts.note;
2044
+ break;
1739
2045
  }
1740
2046
  case 'class': {
1741
2047
  // semantic class (`CATEGORICAL-MEANING-MAPPING`): meaning + presentation defaults declared
@@ -1758,30 +2064,11 @@ function parseOne(text){
1758
2064
  if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
1759
2065
  classIds.add(id);
1760
2066
  // `plane=` on a class is the members' default plane (an element's own
1761
- // plane= wins, rigidity `LAYOUT-STABILITY`) — the class carries all five §5 attributes.
2067
+ // `PAINT-ORDER-CONSTRUCT`: `plane=` is withdrawn, so a class carries the
2068
+ // FOUR §5 attributes it can still set. The paint-order attribute is
2069
+ // gone from the language, not merely off this directive.
1762
2070
  doc.classes.push({id,label:pos[2],fill:opts.fill,stroke:opts.stroke,
1763
- style:opts.style,plane:opts.plane,line:n});
1764
- break;
1765
- }
1766
- case 'plane': {
1767
- const id=pos[1];
1768
- { const e=idErr(id,posq[1],'plane needs an id'); if(e){ err(n,e); break; } }
1769
- if(planeIds.has(id)){ err(n,'duplicate plane id "'+id+'"'); break; }
1770
- if(pos[2]!==undefined&&!posq[2]){ err(n,'plane label must be quoted: plane '+id+' "'+pos[2]+'" — '+Q_WHY); break; }
1771
- if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
1772
- // 0.1: `z=` -> `z-index=` (CSS's own spelling for the
1773
- // stacking concept, taken in full per RULE 4.2). The retired `z=`
1774
- // is caught language-wide in RETIRED_OPT_KEYS before this runs.
1775
- let z=doc.planes.length;
1776
- const zi=opts['z-index'];
1777
- if(zi!==undefined){
1778
- if(!/^-?\d+$/.test(zi)){ err(n,'z-index must be a number'); break; }
1779
- z=parseInt(zi,10);
1780
- }
1781
- planeIds.add(id);
1782
- // `EMPTY-LABEL-STATE`: absent is absent, `""` is a written value — same
1783
- // non-collapsing form as node/group/bundle and the typed blocks.
1784
- doc.planes.push({id,label:pos[2]!==undefined?pos[2]:null,z});
2071
+ style:opts.style,line:n});
1785
2072
  break;
1786
2073
  }
1787
2074
  // `FLOWCHART-ROLE-KEYWORDS`: `process` / `decision` / `terminator` DESUGAR to
@@ -1824,6 +2111,7 @@ function parseOne(text){
1824
2111
  // Display falls back to the id in render(), so the figure is unchanged.
1825
2112
  doc.nodes.push({id,label:pos[2]!==undefined?pos[2]:null,shape,role,fill:opts.fill,stroke:opts.stroke,
1826
2113
  style:opts.style,cls:parseClassList(opts['class'],optList(optT,'class')).ids,
2114
+ note:opts.note,
1827
2115
  group:opts['in']||null,plane:opts.plane||'base',line:n});
1828
2116
  break;
1829
2117
  }
@@ -1843,6 +2131,7 @@ function parseOne(text){
1843
2131
  }
1844
2132
  doc.groups.push({id,label:pos[2]!==undefined?pos[2]:null,fill:opts.fill,stroke:opts.stroke,
1845
2133
  style:opts.style,gap:ggap,cls:parseClassList(opts['class'],optList(optT,'class')).ids,
2134
+ note:opts.note,
1846
2135
  plane:opts.plane||null,line:n});
1847
2136
  break;
1848
2137
  }
@@ -1854,7 +2143,7 @@ function parseOne(text){
1854
2143
  // Shares the node/group/block id namespace. Of the §5 attributes it
1855
2144
  // can carry only the two that need no drawn shape: `text=` (the label
1856
2145
  // colour) and `plane=` (organizational, exactly as on a node).
1857
- // Spelled `boundary` until this release (`EXTERNAL-ENDPOINT-NAMING`): three standards claim that
2146
+ // Spelled `boundary` until 0.1 (`EXTERNAL-ENDPOINT-NAMING`): three standards claim that
1858
2147
  // word for the OPPOSITE meaning (UML ECB «boundary» is an internal
1859
2148
  // interface object, C4 System_Boundary is a dashed grouping container,
1860
2149
  // BPMN's is an event), and this spec's own prose had already stopped
@@ -2009,7 +2298,7 @@ function parseOne(text){
2009
2298
  case 'chart': {
2010
2299
  // chart family: chart <table-id> [type=bar3d]
2011
2300
  // rows -> X, columns -> Y, numeric cells -> Z (the table IS the data)
2012
- // Spelled `plot` with `kind=bars3d` until this release (`CHART-BLOCK-NAMING`).
2301
+ // Spelled `plot` with `kind=bars3d` until 0.1 (`CHART-BLOCK-NAMING`).
2013
2302
  // 0.1 (`CHART-LEVEL-KEY`): `level=` is DELETED. Zero uses corpus-wide, zero
2014
2303
  // 3-D bar charts, zero requests; one in-repo example and two fixtures.
2015
2304
  // It was the only construct whose caption the ENGINE wrote rather than
@@ -2050,7 +2339,7 @@ function parseOne(text){
2050
2339
  if(!opts['in']){ err(n,'band needs in=<node-or-group-id>'); break; }
2051
2340
  // 0.1: the `%` is MANDATORY, matching `threshold offset=` (`BARE-FRACTION-VALUES`).
2052
2341
  // `band 15`, `band 15-35` and `band 15%-35` all parsed before (the
2053
- // separator was a hyphen until this release); one concept in one
2342
+ // separator was a hyphen until 0.1); one concept in one
2054
2343
  // document must not have two value grammars (RULE 4.4).
2055
2344
  // `RANGE-SPELLING`: the separator is `..`, and the HYPHEN form it
2056
2345
  // replaces gets its own named diagnostic. `15-35%` reads as
@@ -2148,7 +2437,7 @@ function parseOne(text){
2148
2437
  // the cell border colour, `text=` the block caption colour.
2149
2438
  // `TYPED-BLOCK-SILENT-FALLBACK`: word= empty or non-integer was a silent fallback/truncation
2150
2439
  // (word= → 32, word=8.5 → 8). Positive integer only; absence → 32.
2151
- // Spelled `unit=` until this release (`BITS-PER-ROW-KEY-NAMING`).
2440
+ // Spelled `unit=` until 0.1 (`BITS-PER-ROW-KEY-NAMING`).
2152
2441
  let word=32;
2153
2442
  if(opts.word!==undefined){
2154
2443
  if(opts.word===''||!/^\d+$/.test(opts.word)||+opts.word<1){
@@ -2225,11 +2514,39 @@ function parseOne(text){
2225
2514
  }
2226
2515
  for(const r of doc.ranks) for(const id of r.ids)
2227
2516
  if(!nodeIds.has(id)) errs.push('Line '+r.line+': unknown node "'+id+'" in rank');
2517
+ // `MARKER-TARGET-KINDS`: `in=` on `threshold`/`band` also resolves a REGION id —
2518
+ // a `bitfield`, `table` or `timing` block. This is a WIDENING of the value
2519
+ // domain, not a third sense of `in=` and not a new spelling: the relation is
2520
+ // sense 2 verbatim, *the element this one is drawn across*, and what changes
2521
+ // is only which declared ids the resolver will bind.
2522
+ //
2523
+ // It is UNGATED, and that is argued rather than assumed. A region-targeted
2524
+ // threshold did not merely mean something else before this release — it did
2525
+ // not PARSE. `threshold "Max" in=q offset=50%` over a `table q` answered
2526
+ // `unknown target "q" for threshold`, the same error a nonexistent id gets,
2527
+ // because this set was hard-coded to nodes and groups while `table <id>`
2528
+ // makes the id mandatory and `chart <table-id>` already consumes it from
2529
+ // another directive. So no `figdown 0.1` or `figdown 0.2` document changes
2530
+ // meaning and none becomes invalid; the only documents affected are ones
2531
+ // that produced no figure at all. Core §13.0.1's hazard — "a figure that
2532
+ // looks right and means something else" — needs two readings to choose
2533
+ // between, and here the alternative reading was an error message. That is
2534
+ // exactly why `note=` IS gated a few hundred lines up and this is not: the
2535
+ // key had a prior meaning, this had none. Nothing is added to the option
2536
+ // registry, so a reader of `figdown 0.2` consulting core §10 finds the same
2537
+ // 45 rows either way.
2538
+ //
2539
+ // The two WRED figures this unblocks are the whole of the measured demand
2540
+ // (core §9 `ANNOTATION-LOCATOR-SPLIT`). The locator COORDINATE grammar — `in=q(3)`, addressing a
2541
+ // row inside the region — is designed and deliberately NOT built: it has no
2542
+ // shipping consumer, and RULE 4.7 argues against spending a grammar before
2543
+ // one exists.
2544
+ const regionTarget=id=>blockIds.has(id);
2228
2545
  for(const gl of doc.thresholds)
2229
- if(!groupIds.has(gl.target)&&!nodeIds.has(gl.target))
2546
+ if(!groupIds.has(gl.target)&&!nodeIds.has(gl.target)&&!regionTarget(gl.target))
2230
2547
  errs.push('Line '+gl.line+': unknown target "'+gl.target+'" for threshold');
2231
2548
  for(const f of doc.bands)
2232
- if(!groupIds.has(f.target)&&!nodeIds.has(f.target))
2549
+ if(!groupIds.has(f.target)&&!nodeIds.has(f.target)&&!regionTarget(f.target))
2233
2550
  errs.push('Line '+f.line+': unknown target "'+f.target+'" for band');
2234
2551
  for(const t of doc.trunks) for(const [a,b] of t.pairs){
2235
2552
  if((!nodeIds.has(a)&&!boundaryIds.has(a))||(!nodeIds.has(b)&&!boundaryIds.has(b))){ errs.push('Line '+t.line+': unknown endpoint in "'+a+'--'+b+'"'); continue; }
@@ -2288,7 +2605,7 @@ function parseOne(text){
2288
2605
  // b class=p` was accepted, drew a #555 line, and rendered a legend swatch
2289
2606
  // that showed nothing, so the class's meaning was invisible in its own
2290
2607
  // derived legend. With `color=` retired (`COLOUR-KEY-STATUS`) the remaining shape of the
2291
- // hole is a class carrying only `style=` and/or `plane=`, or nothing at
2608
+ // hole is a class carrying only `style=`, or nothing at
2292
2609
  // all: the edge silently takes the default colour and the author who
2293
2610
  // declared a class to CLASSIFY the edge gets no colour and no warning.
2294
2611
  // Both halves are the same rule — a class an edge joins must declare at
@@ -2458,7 +2775,7 @@ function stackSectionSvgs(results){
2458
2775
  // became the node's LABEL. It is now a line error wherever it is part of the
2459
2776
  // GRAMMAR — that is, everywhere except the FOUR verbatim regions (this
2460
2777
  // function handles three of them; the pipe row is the caller's, see below).
2461
- // The count read "three" until this release, listing four:
2778
+ // The count read "three" until 0.1, listing four:
2462
2779
  // - inside a quoted string ("…;…"),
2463
2780
  // - inside a comment (already stripped before this runs),
2464
2781
  // - inside an edge label (edge a -[packet arrives; TMR != 0]-> b),
@@ -2604,6 +2921,92 @@ function cwMax(s){ return Math.max(...String(s).split('\n').map(cw)); }
2604
2921
  // §5 style= → SVG dash pattern. `def` is the construct's conventional
2605
2922
  // default (the bundle ring and the threshold line are dashed by convention);
2606
2923
  // an explicit style= always wins.
2924
+ // ── the note box (`DRAWN-ANNOTATION-FORM`) ───────────────────────────────────────
2925
+ // The drawn annotation's whole appearance lives in these three functions,
2926
+ // because `DOMAIN-CONVENTION-DIRECTIVES` gives the engine the drawing convention outright: `note=` takes
2927
+ // no `at=`, no `side=`, no colour and no size, so there is exactly one look and
2928
+ // it is decided here rather than by an author.
2929
+ //
2930
+ // The look is the UML note symbol — a rectangle with a folded top-right corner
2931
+ // — which is the notation of the metaclass the SPELLING is borrowed from
2932
+ // (UML 2.5.1's `Comment`; RULE 4.1 takes the standard's word, and taking its
2933
+ // glyph with it is what lets a reader recognise the box as an aside without a
2934
+ // legend entry). It is deliberately unlike a `node`: no rounded corners, a
2935
+ // paler wash, smaller type, and a corner no node shape has.
2936
+ const NOTE_FS=10, NOTE_PAD=6, NOTE_FOLD=9, NOTE_MAXCH=30;
2937
+ const NOTE_FILL='#fdfaf0', NOTE_STROKE='#c9c4b2', NOTE_INK='#5c584c';
2938
+ // The leader is deliberately DARKER than the box outline. Drawn in the box's
2939
+ // own stroke it was legible in the SVG and invisible on the page at 1x — a
2940
+ // leader nobody can see is a leader that is not there, and the note then reads
2941
+ // as annotating whatever it happens to sit above. Checked by eye, not by a
2942
+ // contrast number: the box is a surface and may recede, the leader is a
2943
+ // statement of attachment and may not.
2944
+ const NOTE_LEADER='#9c968a';
2945
+ // Deterministic greedy word wrap. Author newlines are honoured and never
2946
+ // merged; a run longer than the wrap width is broken only between words, so a
2947
+ // long identifier keeps its shape and simply widens the box.
2948
+ function noteWrap(text){
2949
+ const out=[];
2950
+ for(const para of String(text).split('\n')){
2951
+ const words=para.split(/ +/).filter(w=>w.length);
2952
+ if(!words.length){ out.push(''); continue; }
2953
+ let cur=words[0];
2954
+ for(let i=1;i<words.length;i++){
2955
+ if((cur+' '+words[i]).length<=NOTE_MAXCH) cur+=' '+words[i];
2956
+ else { out.push(cur); cur=words[i]; }
2957
+ }
2958
+ out.push(cur);
2959
+ }
2960
+ return out;
2961
+ }
2962
+ function noteBox(text){
2963
+ const lines=noteWrap(text);
2964
+ const lh=NOTE_FS*1.35;
2965
+ const w=Math.max(28, Math.max.apply(null,lines.map(tw))+NOTE_PAD*2+NOTE_FOLD);
2966
+ const h=lines.length*lh+NOTE_PAD*2-lh*0.15;
2967
+ return {w:Math.round(w*100)/100, h:Math.round(h*100)/100, lines, lh};
2968
+ }
2969
+ // The folded-corner outline, plus the small triangle that reads as the back of
2970
+ // the fold. One path each, so the shape is one primitive and the output is
2971
+ // byte-stable.
2972
+ function noteSvg(x,y,box,carrier){
2973
+ const F=NOTE_FOLD, w=box.w, h=box.h;
2974
+ const d='M'+x+' '+y+' H'+(x+w-F)+' L'+(x+w)+' '+(y+F)+' V'+(y+h)+' H'+x+' Z';
2975
+ const fold='M'+(x+w-F)+' '+y+' V'+(y+F)+' H'+(x+w)+' Z';
2976
+ const out=['<g class="fd-note"'+(carrier&&carrier.kind?' data-note-on="'+carrier.kind+'"':'')+'>',
2977
+ '<path d="'+d+'" fill="'+NOTE_FILL+'" stroke="'+NOTE_STROKE+'" stroke-width="1"/>',
2978
+ '<path d="'+fold+'" fill="'+NOTE_STROKE+'" fill-opacity="0.35" stroke="'+NOTE_STROKE+'" stroke-width="1"/>'];
2979
+ const first=y+NOTE_PAD+NOTE_FS*0.85;
2980
+ box.lines.forEach((ln,i)=>{
2981
+ out.push('<text x="'+(x+NOTE_PAD)+'" y="'+Math.round((first+i*box.lh)*100)/100+
2982
+ '" font-size="'+NOTE_FS+'" text-anchor="start" fill="'+NOTE_INK+'">'+esc(ln)+'</text>');
2983
+ });
2984
+ out.push('</g>');
2985
+ return out.join('');
2986
+ }
2987
+ // The leader is drawn ONLY when adjacency failed, and it is drawn AFTER the
2988
+ // box is placed, so it is correct by construction: it runs from the box edge
2989
+ // facing the carrier to the carrier's nearest point, and cannot be stale.
2990
+ function noteLeader(best,box,rect){
2991
+ const bx=best.x, by=best.y, bw=box.w, bh=box.h;
2992
+ const cx=rect.x+rect.w/2, cy=rect.y+rect.h/2;
2993
+ let x1,y1;
2994
+ if(best.side==='right') { x1=bx; y1=by+bh/2; }
2995
+ else if(best.side==='left') { x1=bx+bw; y1=by+bh/2; }
2996
+ else if(best.side==='below') { x1=bx+bw/2; y1=by; }
2997
+ else { x1=bx+bw/2; y1=by+bh; }
2998
+ // land on the carrier's border, not its centre, so the line stops at the
2999
+ // thing it points at
3000
+ const x2=Math.max(rect.x, Math.min(rect.x+rect.w, x1));
3001
+ const y2=Math.max(rect.y, Math.min(rect.y+rect.h, y1));
3002
+ return '<line x1="'+x1+'" y1="'+y1+'" x2="'+(rect.w||rect.h?x2:cx)+'" y2="'+(rect.w||rect.h?y2:cy)+
3003
+ '" stroke="'+NOTE_LEADER+'" stroke-width="1" stroke-dasharray="4 3"/>';
3004
+ }
3005
+ // A band's optional edge stroke. `renderScene` has had this as a local since
3006
+ // 0.1; `MARKER-TARGET-KINDS` needs the same rule for a REGION-scope band, which is drawn
3007
+ // outside the scene, so the one expression moves to module scope rather than
3008
+ // being written twice with a chance to drift.
3009
+ const bandEdgeOf=f=>(f.stroke||f.style)?' stroke="'+(f.stroke||'#8a8880')+'"'+dashOf(f.style,''):'';
2607
3010
  function dashOf(style,def){
2608
3011
  const p = style==='dashed'?'6 4' : style==='dotted'?'2 4' : style==='solid'?'' : def;
2609
3012
  return p?' stroke-dasharray="'+p+'"':'';
@@ -2799,21 +3202,17 @@ function render(doc,ropts){
2799
3202
  if(C[id] && C[id][k]!==undefined) x[k]=C[id][k];
2800
3203
  }
2801
3204
  };
2802
- // a class carries all five §5 attributes; the element's own value wins (`LAYOUT-STABILITY`).
2803
- // `plane` is materialized to 'base' at parse time, so "unset" is 'base' here.
2804
- const rsl=(x)=>{
2805
- if(!(x.plane===undefined||x.plane===null||x.plane==='base')) return;
2806
- for(const id of classIdsOf(x)){
2807
- if(C[id] && C[id].plane!==undefined) x.plane=C[id].plane;
2808
- }
2809
- };
2810
- const rsAll=(x)=>{ rs(x,'fill'); rs(x,'stroke'); rs(x,'style'); rsl(x); };
3205
+ // `PAINT-ORDER-CONSTRUCT`: a class carries the FOUR §5 attributes that survive.
3206
+ // The fifth was `plane`, and its cascade helper (`rsl`) is gone with the
3207
+ // key — a class can no longer set a paint order because the language has
3208
+ // no paint order to set. Everything is on the implicit `base` plane.
3209
+ const rsAll=(x)=>{ rs(x,'fill'); rs(x,'stroke'); rs(x,'style'); };
2811
3210
  for(const n of doc.nodes){ rsAll(n); if(n.style===undefined) n.style='solid'; }
2812
3211
  for(const g of doc.groups){ rsAll(g); }
2813
3212
  // 0.1 (§8.4): an edge has no interior, so it takes every class
2814
3213
  // channel EXCEPT `fill` — which the parser has already guaranteed is
2815
3214
  // accompanied by a `stroke` on any class an edge joins.
2816
- for(const e of doc.edges){ rs(e,'stroke'); rs(e,'style'); rsl(e); if(e.style===undefined) e.style='solid'; }
3215
+ for(const e of doc.edges){ rs(e,'stroke'); rs(e,'style'); if(e.style===undefined) e.style='solid'; }
2817
3216
  for(const b of doc.blocks){
2818
3217
  rsAll(b);
2819
3218
  if(b.fields) for(const f of b.fields) rsAll(f);
@@ -2827,13 +3226,54 @@ function render(doc,ropts){
2827
3226
  const s=renderScene(doc,y); parts.push(s.svg); y=s.y; maxW=Math.max(maxW,s.w);
2828
3227
  sceneMeta=s.meta;
2829
3228
  }
3229
+ // `MARKER-TARGET-KINDS`: a region-scope `threshold`/`band` is drawn HERE and not
3230
+ // in `renderScene`, because a region is not in the scene. Typed blocks stack
3231
+ // in document order OUTSIDE the scene (core §2, the `plane=` carve-out says
3232
+ // so in as many words), so at the moment `renderScene` emits its own
3233
+ // thresholds the region has no geometry yet and sits at a `y` the scene never
3234
+ // sees. The mark therefore travels with its target: each block reports its
3235
+ // box, and the marks that name it are painted over that box in the same
3236
+ // coordinate shape (`x0`/`x1`/`yA`/`yB`) the scene uses for a group.
3237
+ const regionBox={};
2830
3238
  for(const b of doc.blocks){
2831
3239
  let s;
2832
3240
  if(b.type==='bitfield') s=renderBitfield(b,y);
2833
3241
  else if(b.type==='table') s=renderTable(b,y);
2834
3242
  else if(b.type==='chart') s=renderChart(b,y,doc);
2835
3243
  else s=renderTiming(b,y);
2836
- parts.push(s.svg); y=s.y+24; maxW=Math.max(maxW,s.w);
3244
+ parts.push(s.svg);
3245
+ if(s.box) regionBox[b.id]=s.box;
3246
+ y=s.y+24; maxW=Math.max(maxW,s.w);
3247
+ }
3248
+ {
3249
+ const rsvg=[];
3250
+ for(const f of (doc.bands||[])){
3251
+ const B=regionBox[f.target]; if(!B) continue;
3252
+ const w=B.x1-B.x0, h=B.yB-B.yA;
3253
+ let bx,by,bw,bh;
3254
+ if(f.dir==='up') { bx=B.x0; by=B.yB-h*f.to/100; bw=w; bh=h*(f.to-f.from)/100; }
3255
+ else if(f.dir==='down') { bx=B.x0; by=B.yA+h*f.from/100; bw=w; bh=h*(f.to-f.from)/100; }
3256
+ else if(f.dir==='right'){ bx=B.x0+w*f.from/100; by=B.yA; bw=w*(f.to-f.from)/100; bh=h; }
3257
+ else { bx=B.x1-w*f.to/100; by=B.yA; bw=w*(f.to-f.from)/100; bh=h; }
3258
+ rsvg.push('<rect x="'+bx+'" y="'+by+'" width="'+bw+'" height="'+bh+'" fill="'+f.fill+'" opacity="0.35"'+bandEdgeOf(f)+'/>');
3259
+ rsvg.push(textEl(bx+bw/2, by+bh/2+4, 11, 'middle', labelInk(f.fill,'#334155'), f.label,
3260
+ ' paint-order="stroke" stroke="#fff" stroke-width="3"'));
3261
+ }
3262
+ for(const gl of (doc.thresholds||[])){
3263
+ const B=regionBox[gl.target]; if(!B) continue;
3264
+ const ly=B.yB-(B.yB-B.yA)*gl.pct/100;
3265
+ const col=gl.stroke||'#ef4444';
3266
+ rsvg.push('<line x1="'+B.x0+'" y1="'+ly+'" x2="'+B.x1+'" y2="'+ly+'" stroke="'+col+
3267
+ '" stroke-width="'+(gl.pct>=100?4:2)+'"'+dashOf(gl.style,'7 4')+'/>');
3268
+ rsvg.push(textEl(B.x1+8, ly+4, 11, 'start', col, gl.label,' paint-order="stroke" stroke="#fff" stroke-width="3"'));
3269
+ maxW=Math.max(maxW, B.x1+8+tw(gl.label));
3270
+ }
3271
+ // A band is a translucent wash UNDER the grid ink; a threshold is a mark
3272
+ // OVER it. The region has already been pushed, so both go after it and the
3273
+ // band leans on opacity rather than paint order for the "under" reading —
3274
+ // the same compromise `renderChart` makes, and the reason the opacity here
3275
+ // is lower than the scene's 0.9.
3276
+ if(rsvg.length) parts.push(rsvg.join(''));
2837
3277
  }
2838
3278
  // 0.1 (`CLASS-EMPTY-MEANING`): a class whose meaning is the EMPTY string claims no
2839
3279
  // meaning, so it has nothing to explain and draws NO legend entry — it is
@@ -2871,6 +3311,30 @@ function render(doc,ropts){
2871
3311
  parts.push(es.join(''));
2872
3312
  y=ly+rowH;
2873
3313
  }
3314
+ // `DRAWN-ANNOTATION-FORM`: the FIGURE-level note — `title "…" note="…"`. It carries
3315
+ // the 14% of measured annotations that name no single element ("Total: 8k
3316
+ // tunnel indexes", a four-signal legend, a TODO about the whole figure), and
3317
+ // it is the acceptor that removes the last argument for a standalone `note`
3318
+ // keyword: the figure HAS a declaration line, so attachment-by-position
3319
+ // reaches it too.
3320
+ //
3321
+ // It has no geometry to sit beside, so it takes no candidates and NEVER takes
3322
+ // a leader — a leader must point at something, and "the figure" is not a
3323
+ // thing on the canvas. It is placed with the caption, at the bottom, after
3324
+ // the scene, the regions and the derived legend. That is a placement rule and
3325
+ // not an author's choice (`DOMAIN-CONVENTION-DIRECTIVES`), and it is deterministic by construction:
3326
+ // there is exactly one figure-level note and exactly one place for it.
3327
+ //
3328
+ // The title itself is NOT drawn by default (`DEFAULT-VALUE-SELECTION` — an embedded figure sits
3329
+ // under a host caption), and the note does not follow it: the note is the
3330
+ // thing that draws. An author who wants the sentence in the picture writes
3331
+ // it here whether or not the renderer is showing the title.
3332
+ if(doc.note!==null&&doc.note!==undefined){
3333
+ const nb=noteBox(doc.note);
3334
+ const ny=y+8;
3335
+ parts.push(noteSvg(0,ny,nb,{kind:'title'}));
3336
+ y=ny+nb.h+2; maxW=Math.max(maxW,nb.w);
3337
+ }
2874
3338
  const PADL=18, PADT=6;
2875
3339
  const W=Math.ceil(maxW)+PADL+8, H=Math.ceil(y)+PADT+4;
2876
3340
  return {svg:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" font-family="system-ui,sans-serif">'
@@ -2966,6 +3430,44 @@ function renderScene(doc,y0){
2966
3430
  return rk[u]=r;
2967
3431
  };
2968
3432
  nodes.forEach(n=>n.rank=rankOf(find(n.id)));
3433
+ // SPINE CHAIN (item 27, ordering phase). A figure whose reading order is a
3434
+ // chain must draw that chain in ONE lane; the incumbent barycenter sweep
3435
+ // cannot, because a chain node and its branch sibling share one desired
3436
+ // position and the sibling — declared first — takes the slot, so the chain
3437
+ // steps aside once per rank and a logical column renders as a staircase
3438
+ // (spine.fd: +165 px per rank, 2208 px wide for a 200 px column).
3439
+ //
3440
+ // The chain is found HERE, before any coordinate exists, and it is the
3441
+ // longest rank-consecutive path of real nodes. Deterministic throughout:
3442
+ // longest-path DP taken in decreasing rank, every tie broken by document
3443
+ // order, so one document has exactly one chain.
3444
+ const chainNext=new Map(), chainPrev=new Map();
3445
+ const CHAIN_MIN=4; // nodes; below this a figure has no spine to
3446
+ { // hold and the incumbent sweep is left alone
3447
+ const sc=new Map();
3448
+ for(const e of doc.edges){
3449
+ const A=byId[e.a],B=byId[e.b];
3450
+ if(!A||!B||isBack.has(e)||e.a===e.b) continue;
3451
+ if(e.op!=='->') continue; // a chain is a READING order, and
3452
+ // only a directed edge states one
3453
+ if(B.rank!==A.rank+1) continue; // a chain is rank-consecutive
3454
+ if(pinned(e.a)||pinned(e.b)) continue; // a pin is the author's word
3455
+ if(!sc.has(A)) sc.set(A,[]); sc.get(A).push(B);
3456
+ }
3457
+ const len=new Map(), nxt=new Map();
3458
+ for(const n of [...nodes].sort((p,q)=>q.rank-p.rank||p.di-q.di)){
3459
+ let best=null,bl=0;
3460
+ for(const s of sc.get(n)||[]){
3461
+ const l=len.get(s)||1;
3462
+ if(l>bl||(l===bl&&best&&s.di<best.di)){ bl=l; best=s; }
3463
+ }
3464
+ len.set(n,bl+1); if(best) nxt.set(n,best);
3465
+ }
3466
+ let head=null;
3467
+ for(const n of nodes) if(!head||len.get(n)>len.get(head)) head=n; // ties: doc order
3468
+ if(head&&len.get(head)>=CHAIN_MIN)
3469
+ for(let n=head,m=nxt.get(n);m;n=m,m=nxt.get(n)){ chainNext.set(n,m); chainPrev.set(m,n); }
3470
+ }
2969
3471
  // positions: children spread around their parents' lane (barycenter
2970
3472
  // sweeps down/up/down). Edges that span multiple layers get invisible
2971
3473
  // waypoint slots so they no longer cut through intermediate nodes.
@@ -2975,6 +3477,33 @@ function renderScene(doc,y0){
2975
3477
  const lblPx=s=>cwMax(s)*6.5;
2976
3478
  const lay=[...nodes]; // layout participants
2977
3479
  const chains=new Map(); // edge -> [A, ...waypoints, B]
3480
+ // Bus eligibility, TOPOLOGICAL half (item 26 stage 1). Three or more forward
3481
+ // `->` edges arriving at one target with one label, one stroke and one dash,
3482
+ // no endpoint labels and no pinned endpoint. It is computed here, before the
3483
+ // geometry, because the render pass needs the group SET whole: the figure
3484
+ // decides all-or-none, so it has to know how many groups it is deciding for.
3485
+ //
3486
+ // Placement is deliberately NOT touched. A member still reserves its
3487
+ // waypoint column, which is the cost item 27 records; suppressing those
3488
+ // columns was implemented and measured (spine.fd 2208 -> 1318 px and a
3489
+ // visibly better drawing) and is NOT landed here, because the suppression
3490
+ // has to be decided before coordinates exist while adoption can only be
3491
+ // decided after, and a figure that suppresses and then declines draws
3492
+ // straight through its own boxes (bfd-session: score 30 -> 35 with a new
3493
+ // `thru`). That belongs with item 27's ordering change, priced.
3494
+ const busGroups=[];
3495
+ {
3496
+ const g=new Map();
3497
+ for(const e of doc.edges){
3498
+ const A=byId[e.a], B=byId[e.b];
3499
+ if(!A||!B||e.a===e.b||isBack.has(e)) continue;
3500
+ if(e.op!=='->'||e.tail||e.head) continue;
3501
+ if(pinned(e.a)||pinned(e.b)||B.rank<=A.rank) continue;
3502
+ const k=e.b+' '+(e.mid||'')+' '+(e.stroke||'')+' '+(e.style||'');
3503
+ if(!g.has(k)) g.set(k,[]); g.get(k).push(e);
3504
+ }
3505
+ for(const [,m] of g) if(m.length>=3) busGroups.push(m);
3506
+ }
2978
3507
  for(const e of doc.edges){
2979
3508
  const A=byId[e.a], B=byId[e.b];
2980
3509
  if(!A||!B||isBack.has(e)) continue;
@@ -3041,7 +3570,92 @@ function renderScene(doc,y0){
3041
3570
  const center=n=>n.cross+cs(n)/2;
3042
3571
  ranksArr.forEach(lane=>{ if(!lane) return; let c=0; // seed: doc order
3043
3572
  lane.forEach((n,k)=>{ n.cross=c; c+=cs(n)+(k<lane.length-1?gapOf(n,lane[k+1]):0); }); });
3044
- const place=(lane,des)=>{ // order by desired center, resolve overlaps,
3573
+ // WHERE THE HOLD YIELDS, WHICH IS MOST OF THE RULE. Holding a chain node on
3574
+ // its chain neighbour puts every OTHER neighbour of that node on one side of
3575
+ // it, and where the figure diverges or converges that is the wrong drawing:
3576
+ // item 27's Brandes-Köpf rejection measured this exact mechanism from the
3577
+ // other end — aligning on ONE neighbour where the barycentre uses the AVERAGE
3578
+ // made 11 of 19 figures worse, and "the average is what a human draws". So
3579
+ // the hold is dropped wherever it would displace a spread the reader reads.
3580
+ //
3581
+ // `realDeg` is degree as the READER sees it at one rank boundary: real
3582
+ // neighbours, plus the waypoints of long edges whose far end is OFF the
3583
+ // chain. A long edge that leaves the chain and rejoins it later is not a
3584
+ // spread — counting it would drop the hold on exactly the columns this pass
3585
+ // exists to create (bfd-session's ADMINDOWN is entered by UP and by two
3586
+ // waypoints of edges that left DOWN and INIT) — while a long edge arriving
3587
+ // from elsewhere is one, and its target belongs at the average (that is
3588
+ // packet-ingress's `Forward`, entered by `IPv4 checksum OK?` beside it and by
3589
+ // two waypoints from the IPv6 and ARP branches).
3590
+ const onChain=n=>chainNext.has(n)||chainPrev.has(n);
3591
+ const realDeg=(m,side)=>{
3592
+ let k=0;
3593
+ for(const s of (side===1?succs:preds).get(m)||[]){
3594
+ if(!s.virtual){ k++; continue; }
3595
+ const o=side===1?s.homeB:s.homeA;
3596
+ if(o&&!onChain(o)) k++;
3597
+ }
3598
+ return k;
3599
+ };
3600
+ const realFan=(n,dir)=>{
3601
+ const m=(dir===1?chainPrev:chainNext).get(n); return m?realDeg(m,dir):0;
3602
+ };
3603
+ // PROSPECTIVE BUSES, AND WHY THE HOLD YIELDS TO THEM RATHER THAN SERVING
3604
+ // THEM. Item 26 records the trap this pass had to answer: a bus member's
3605
+ // waypoint column can be suppressed only BEFORE coordinates exist, while the
3606
+ // bus is adopted only AFTER, so a figure that suppresses and then declines
3607
+ // routes through its own boxes (bfd-session 30 -> 35 with a new `thru`).
3608
+ // Nothing here suppresses anything. It takes the one direction of that
3609
+ // decision which is safe under a decline: it WITHHOLDS the hold from the
3610
+ // source of a bus group that is topologically eligible, and withholding is
3611
+ // the incumbent behaviour — a figure that declines is drawn exactly as it is
3612
+ // drawn today, with nothing to undo. Holding them is the unsafe direction:
3613
+ // it stacks the sources of one convergence into a single column, and a bus
3614
+ // leg dropping from the earliest then pierces the latest — patterns/
3615
+ // flowchart-a loses the trunk it gained that way, measured.
3616
+ //
3617
+ // ...and only for a group that could ever BE a rail. A bus drops every source
3618
+ // onto one cross-axis rail, so a group whose sources sit on top of each other
3619
+ // along the FLOW axis — one source an ancestor of another — is unadoptable
3620
+ // whatever ordering does, and withholding there would cost the column and buy
3621
+ // nothing (bfd-session's three `admin disable` edges leave DOWN, INIT and UP,
3622
+ // and DOWN reaches both of the others).
3623
+ const busSrc=new Set();
3624
+ {
3625
+ const fwd=new Map();
3626
+ for(const e of doc.edges){
3627
+ if(!byId[e.a]||!byId[e.b]||isBack.has(e)||e.a===e.b) continue;
3628
+ if(!fwd.has(e.a)) fwd.set(e.a,[]); fwd.get(e.a).push(e.b);
3629
+ }
3630
+ const reaches=(u,v)=>{ // forward-DAG reachability
3631
+ const seen=new Set([u]), st=[u];
3632
+ while(st.length){ const x=st.pop();
3633
+ for(const y of fwd.get(x)||[]){ if(y===v) return true;
3634
+ if(!seen.has(y)){ seen.add(y); st.push(y); } } }
3635
+ return false;
3636
+ };
3637
+ for(const m of busGroups){
3638
+ const s=m.map(e=>e.a);
3639
+ let stacked=false;
3640
+ for(const a of s) for(const b of s) if(a!==b&&reaches(a,b)) stacked=true;
3641
+ if(!stacked) for(const a of s) busSrc.add(a);
3642
+ }
3643
+ }
3644
+ // A chain node is HELD — it follows its chain neighbour rather than the
3645
+ // average of all of them — unless it is a bus source (above), unless the
3646
+ // neighbour it would follow spreads three or more ways into this rank, or
3647
+ // unless the node itself is where three or more come together (block-a's
3648
+ // Collector, lifted off the middle lane by BK, is the recorded instance of
3649
+ // the latter).
3650
+ const held=(n,dir)=>onChain(n)
3651
+ &&!(n.id&&busSrc.has(n.id))
3652
+ &&realFan(n,dir)<3&&realDeg(n,-dir)<3;
3653
+ // A whole LANE keeps its barycentre recentring if anything in it converges,
3654
+ // even where the chain node itself does not: recentring on the chain node
3655
+ // moves every other member of that lane, and a convergence is read from the
3656
+ // spread of its inputs.
3657
+ const laneConverges=(lane,dir)=>lane.some(n=>!n.virtual&&realDeg(n,-dir)>=3);
3658
+ const place=(lane,des,dir)=>{ // order by desired center, resolve overlaps,
3045
3659
  const arr=lane.map(n=>({n,d:des.get(n)})); // recenter the lane
3046
3660
  arr.sort((p,q)=>p.d-q.d||p.n.di-q.n.di);
3047
3661
  let cEnd=-Infinity;
@@ -3049,7 +3663,25 @@ function renderScene(doc,y0){
3049
3663
  x.n.cross=Math.max(x.d-cs(x.n)/2, cEnd);
3050
3664
  cEnd=x.n.cross+cs(x.n)+(i<arr.length-1?gapOf(x.n,arr[i+1].n):0);
3051
3665
  });
3052
- const err=arr.reduce((s,x)=>s+center(x.n)-x.d,0)/arr.length;
3666
+ // Recentre. Normally on the lane's MEAN error, which shares the packing
3667
+ // displacement out over every member — and that is exactly what walks a
3668
+ // chain sideways, since the chain node is one member among many. When the
3669
+ // lane carries the chain (at most one node per rank, by construction) the
3670
+ // lane is recentred on THAT node instead: it lands on its desired position
3671
+ // exactly, its siblings keep the order and spacing the sort gave them, and
3672
+ // the chain is straight by construction rather than by iteration.
3673
+ // Two more lanes keep the mean. A lane holding a PINNED node, because the
3674
+ // pin's coordinate is the author's word and does not move with the lane, so
3675
+ // sliding the lane against it can only put free nodes on a fixed one
3676
+ // (reference/block's `Drop?` diamond landed on the pinned `Rule set` that
3677
+ // way, `novlp 1`). And the chain's LAST lane in the sweep direction, where
3678
+ // there is no next step to keep aligned, so the hold buys no straightness
3679
+ // and only redistributes that lane's other members (annotated-datapath
3680
+ // redrew for no gain until this clause was added).
3681
+ const anc=(lane.some(n=>!n.virtual&&pinned(n.id))||laneConverges(lane,dir))
3682
+ ?null:arr.find(x=>held(x.n,dir)&&(dir===1?chainNext:chainPrev).has(x.n));
3683
+ const err=anc?center(anc.n)-anc.d
3684
+ :arr.reduce((s,x)=>s+center(x.n)-x.d,0)/arr.length;
3053
3685
  arr.forEach(x=>{ x.n.cross-=err; });
3054
3686
  lane.length=0; arr.forEach(x=>lane.push(x.n));
3055
3687
  };
@@ -3064,6 +3696,13 @@ function renderScene(doc,y0){
3064
3696
  for(const n of lane){
3065
3697
  const ref=(dir===1?preds:succs).get(n);
3066
3698
  let d=ref&&ref.length ? ref.reduce((s,m)=>s+center(m),0)/ref.length : center(n);
3699
+ // A chain node follows its CHAIN neighbour alone, not the average of
3700
+ // its neighbours: a branch that leaves the chain and rejoins it later
3701
+ // otherwise drags the chain off its own lane, which is the drift this
3702
+ // pass exists to remove. Its other neighbours still order themselves
3703
+ // around it in the sweep below.
3704
+ const cn=(dir===1?chainPrev:chainNext).get(n);
3705
+ if(cn&&held(n,dir)) d=center(cn);
3067
3706
  // Waypoint excursion bound (item 17): a multi-rank forward edge's dummy
3068
3707
  // vertices may follow the barycenter freely WITHIN the cross-axis band
3069
3708
  // their own endpoints span — that is where the ordering that separates
@@ -3084,7 +3723,7 @@ function renderScene(doc,y0){
3084
3723
  }
3085
3724
  des.set(n,d);
3086
3725
  }
3087
- place(lane,des);
3726
+ place(lane,des,dir);
3088
3727
  }
3089
3728
  };
3090
3729
  sweep(1); sweep(-1); sweep(1);
@@ -3106,24 +3745,44 @@ function renderScene(doc,y0){
3106
3745
  if(horiz) n.x=M-n.x-n.w; else n.y=y0+20+(M-(n.y-y0-20))-n.h; }
3107
3746
  }
3108
3747
  // Two-level coordinates (`PIN-COORDINATE-SCOPE`): a pinned GROUP anchors its local origin in
3109
- // canvas px; a pinned MEMBER is group-local (relative to that origin);
3748
+ // canvas px; a pinned MEMBER of it is group-local (relative to that origin);
3110
3749
  // ungrouped pins are canvas px. Moving a group = editing one pin line.
3750
+ //
3751
+ // A member of an UNPINNED group has NO anchored origin to be relative to, so
3752
+ // its pin is canvas px exactly like an ungrouped node's. This is `LAYOUT-STABILITY` rigidity:
3753
+ // the pin is the author's word and MUST land where written, whether or not
3754
+ // the node is a group member. The prior code derived an unpinned group's
3755
+ // origin from its members' AUTO-LAYOUT extent and then added the member pin
3756
+ // to it, so the pin was neither honoured (it read canvas 400 as origin+400)
3757
+ // nor stable (the origin moved whenever an unrelated edit reshaped the auto
3758
+ // layout — a pinned member drifted 160.9px under a synthetic added edge,
3759
+ // violating `RENDERING-DETERMINISM` stability; task #47). The pin now wins and the group BOX grows
3760
+ // to CONTAIN the member wherever it lands (box is measured from final member
3761
+ // positions below), rather than the member being repositioned to fit the box.
3111
3762
  const gOrigin={};
3763
+ // Pass 1: a pinned group anchors its origin in canvas px (`ELEMENT-GEOMETRY-DIRECTIVE`: only a pin
3764
+ // carrying `at=` anchors one). An unpinned group gets no origin here, so its
3765
+ // members fall to the canvas-px branch below.
3112
3766
  for(const g of doc.groups){
3113
3767
  const p=doc.pins[g.id];
3114
- // `ELEMENT-GEOMETRY-DIRECTIVE`: only a pin that carries `at=` anchors an origin.
3115
- if(p&&p.fx!==null){ gOrigin[g.id]={x:p.fx, y:y0+20+p.fy}; }
3116
- else{
3117
- const mem=nodes.filter(n=>n.group===g.id);
3118
- if(mem.length) gOrigin[g.id]={x:Math.min(...mem.map(n=>n.x)),
3119
- y:Math.min(...mem.map(n=>n.y))};
3120
- }
3768
+ if(p&&p.fx!==null) gOrigin[g.id]={x:p.fx, y:y0+20+p.fy};
3121
3769
  }
3770
+ // Pass 2: place pinned nodes. A member of a PINNED group is group-local; an
3771
+ // ungrouped node OR a member of an UNPINNED group is canvas px.
3122
3772
  for(const n of nodes){ const p=doc.pins[n.id]; if(!p||p.fx===null) continue;
3123
3773
  const o=n.group?gOrigin[n.group]:null;
3124
3774
  if(o){ n.x=o.x+p.fx; n.y=o.y+p.fy; }
3125
3775
  else { n.x=p.fx; n.y=y0+20+p.fy; }
3126
3776
  }
3777
+ // Pass 3: an unpinned group has no anchor of its own; its display origin
3778
+ // (drag anchor / data-gx,gy) is the top-left of its members' FINAL positions,
3779
+ // so it reflects any pinned members and matches the group box drawn below.
3780
+ for(const g of doc.groups){
3781
+ if(gOrigin[g.id]) continue;
3782
+ const mem=nodes.filter(n=>n.group===g.id);
3783
+ if(mem.length) gOrigin[g.id]={x:Math.min(...mem.map(n=>n.x)),
3784
+ y:Math.min(...mem.map(n=>n.y))};
3785
+ }
3127
3786
  // Boundary adjacency in pinned scenes (presentation-only): auto-layout ranks
3128
3787
  // a degree-1 boundary relative to the free lanes, so in a scene where the
3129
3788
  // real content is pinned to a compact box the boundary can drift to a far
@@ -3225,6 +3884,27 @@ function renderScene(doc,y0){
3225
3884
  const B=byId[t], m=g.length;
3226
3885
  g.forEach((e,k)=>{ chPlan.get(e).ex=B.x+B.w*(m-k)/(m+1); });
3227
3886
  }
3887
+ // RETURN LANES — the other axis. A back edge got a lane in ONE axis and
3888
+ // not the other: each route was handed its own COLUMN out in the channel
3889
+ // and then every route into one target came home along that target's
3890
+ // CENTRE line, so N returns drew as one line. bfd-session put three of
3891
+ // them (452 px, 263 px, 263 px of shared ink) on y=46, and the figure
3892
+ // showed one horizontal stroke with three arrowheads stacked on it.
3893
+ // The entry now fans across the target's border exactly as a ring hub
3894
+ // entry fans across its top, and the ORDER is what keeps the returns from
3895
+ // crossing one another: an outer return has to pass every inner column on
3896
+ // its way in, so it must arrive BEYOND where those columns stop —
3897
+ // innermost ring takes the lane furthest from the channel's turn-in side,
3898
+ // outermost the nearest. The fraction is stored, not the coordinate,
3899
+ // because the three entry forms need it on different edges of the box
3900
+ // (right border, bottom border, detour into the bottom). One back edge
3901
+ // into a target still lands on the centre line (m=1 -> 1/2), so every
3902
+ // figure without a fan-in is byte-unchanged.
3903
+ for(const t in byT){
3904
+ const g=byT[t].filter(e=>!chPlan.get(e).ringOK&&e.a!==e.b);
3905
+ const m=g.length;
3906
+ g.forEach((e,k)=>{ chPlan.get(e).ef=(m-k)/(m+1); });
3907
+ }
3228
3908
  // ring return rows run above the top rank; shift the whole scene down
3229
3909
  // when they would spill into the title band. The shift is uniform
3230
3910
  // (relative geometry, incl. pins, is preserved) and meta.top reports
@@ -3335,6 +4015,167 @@ function renderScene(doc,y0){
3335
4015
  const v=chain[1+Math.floor((chain.length-3)/2)];
3336
4016
  occR=Math.max(occR, v.x+v.w/2+9+lblPx(e.mid));
3337
4017
  });
4018
+ // ── merge bus (item 26 stage 1) ──────────────────────────────────────────
4019
+ // Three or more edges that arrive at the SAME target carrying the SAME
4020
+ // (or no) label are one statement — "all of these go there" — and a drawing
4021
+ // tool draws it once: each source drops to a shared rail, the rail runs to
4022
+ // one trunk, the trunk enters the target with ONE arrowhead and ONE label,
4023
+ // and the joins are marked with junction dots. Drawing three lines to one
4024
+ // box and repeating one label three times is what this removes.
4025
+ //
4026
+ // Each member still emits its OWN full path from its source outline to the
4027
+ // target outline — shape-check asserts exactly that, and `data-edge` carries
4028
+ // one source line — so the shared trunk is stroked once per member. That
4029
+ // coincidence is the convention and not a defect, and the members say so:
4030
+ // every bus path carries `data-bus="<target>"`, which is what lets a reader
4031
+ // (and layout-lint) tell a deliberate trunk from two edges hidden under each
4032
+ // other.
4033
+ //
4034
+ // ── THE FIGURE-LEVEL STYLE DECISION (item 26's unresolved tension) ────────
4035
+ // A bus is axis-aligned by construction, so a figure that takes one has
4036
+ // taken an orthogonal convention. Item 26 records the failure mode: keeping
4037
+ // the incumbent PER EDGE leaves a figure with diagonal and orthogonal routes
4038
+ // mixed, and the mixture itself reads unprofessional (`dhcp-client` was
4039
+ // rejected on exactly that). So the decision is taken ONCE PER FIGURE and it
4040
+ // is ALL-OR-NONE:
4041
+ //
4042
+ // 1. enumerate every eligible group (the topological test above: three or
4043
+ // more forward `->` edges, one target, one label, one stroke and dash,
4044
+ // no endpoint labels, no pinned endpoint, no source an ancestor of
4045
+ // another source);
4046
+ // 2. build and test each one — every leg must clear every node it does not
4047
+ // touch and every group box it does not belong to, the sources must all
4048
+ // lie on one side of the target along the flow axis with room for a
4049
+ // rail, and the bus must not cross more of the figure than the routes
4050
+ // it replaces (item 26's "kept unless strictly beaten", moved from the
4051
+ // edge to the group);
4052
+ // 3. IF ANY ELIGIBLE GROUP FAILS, THE FIGURE ADOPTS NO BUS AT ALL.
4053
+ //
4054
+ // Clause 3 is the whole of the style rule. A figure with one convergence
4055
+ // merged into a trunk and another left as a fan is the mixed drawing; a
4056
+ // figure where every convergence is a trunk, or none is, is one drawing
4057
+ // either way. There is deliberately no per-edge escape.
4058
+ const busRoute=new Map();
4059
+ {
4060
+ const RAIL_GAP=22, RAIL_CLEAR=12, RAIL_ROOM=30;
4061
+ const fLo=n=>horiz?n.x:n.y, fHi=n=>horiz?n.x+n.w:n.y+n.h;
4062
+ const cC =n=>horiz?n.y+n.h/2:n.x+n.w/2;
4063
+ const P=(f,c)=>horiz?[f,c]:[c,f]; // (flow,cross) -> [x,y]
4064
+ const gObs=[];
4065
+ for(const k in gBox){ const b=gBox[k]; gObs.push({x:b.x0,y:b.yA,w:b.x1-b.x0,h:b.yB-b.yA}); }
4066
+ const obsFor=(s,t)=>{
4067
+ const o=nodes.filter(n=>n!==s&&n!==t&&!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}));
4068
+ const inG=(b,q)=>q[0]>b.x&&q[0]<b.x+b.w&&q[1]>b.y&&q[1]<b.y+b.h;
4069
+ const ps=[s.x+s.w/2,s.y+s.h/2], pt=[t.x+t.w/2,t.y+t.h/2];
4070
+ for(const b of gObs) if(!inG(b,ps)&&!inG(b,pt)) o.push(b);
4071
+ return o;
4072
+ };
4073
+ // The incumbent a bus is measured against, reconstructed exactly as the
4074
+ // edge loop would draw it in THIS layout: a multi-rank edge follows its
4075
+ // waypoint chain, everything else is the straight border-to-border line.
4076
+ // Placement is untouched by the bus, so this is a like-for-like comparison
4077
+ // inside one drawing — not a comparison across two layouts, which is the
4078
+ // mistake item 27 was rejected for.
4079
+ const incumbent=e=>{
4080
+ const A=byId[e.a], B=byId[e.b], ch=chains.get(e);
4081
+ const pp=[];
4082
+ if(ch) for(const v of ch.slice(1,-1)) pp.push([v.x+v.w/2,v.y+v.h/2]);
4083
+ const first=pp.length?pp[0]:[B.x+B.w/2,B.y+B.h/2];
4084
+ const last =pp.length?pp[pp.length-1]:[A.x+A.w/2,A.y+A.h/2];
4085
+ return [borderPoint(A,first[0],first[1]),...pp,borderPoint(B,last[0],last[1])];
4086
+ };
4087
+ // crossing count of a polyline against the rest of the figure's incumbent
4088
+ // geometry — the term item 26's score weights highest, and the only one on
4089
+ // which "never worse" is worth promising for a construct whose whole point
4090
+ // is to share ink.
4091
+ const busMem=new Set(); for(const m of busGroups) for(const e of m) busMem.add(e);
4092
+ const others=[];
4093
+ for(const e of edges){
4094
+ if(!byId[e.a]||!byId[e.b]||e.a===e.b) continue;
4095
+ if(busMem.has(e)) continue;
4096
+ if(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)) continue; // channel routes: not reconstructible here
4097
+ others.push(incumbent(e));
4098
+ }
4099
+ const xseg=(a,b,c,d)=>{
4100
+ const rx=b[0]-a[0], ry=b[1]-a[1], sx=d[0]-c[0], sy=d[1]-c[1];
4101
+ const den=rx*sy-ry*sx; if(Math.abs(den)<1e-9) return false;
4102
+ const t=((c[0]-a[0])*sy-(c[1]-a[1])*sx)/den, u=((c[0]-a[0])*ry-(c[1]-a[1])*rx)/den;
4103
+ return t>1e-6&&t<1-1e-6&&u>1e-6&&u<1-1e-6;
4104
+ };
4105
+ // ...and a route that pierces a box counts the same as a crossing, because
4106
+ // a short crossing-free line that goes straight through a node is not a
4107
+ // better drawing than a long one that goes round it.
4108
+ const pierceCount=(rts,mem)=>{
4109
+ let n=0;
4110
+ rts.forEach((r,i)=>{
4111
+ const A=byId[mem[i].a], B=byId[mem[i].b];
4112
+ const obs=obsFor(A,B);
4113
+ for(let k=0;k+1<r.length;k++) if(segHitsObs(r[k],r[k+1],obs)){ n++; break; }
4114
+ });
4115
+ return n;
4116
+ };
4117
+ const crossCount=rts=>{
4118
+ let n=0;
4119
+ const pairs=rts.map(r=>r).concat(others);
4120
+ for(let i=0;i<rts.length;i++) for(let j=0;j<pairs.length;j++){
4121
+ if(pairs[j]===rts[i]) continue;
4122
+ if(j<rts.length&&j<i) continue; // count each member pair once
4123
+ for(let a=0;a+1<rts[i].length;a++) for(let b=0;b+1<pairs[j].length;b++)
4124
+ if(xseg(rts[i][a],rts[i][a+1],pairs[j][b],pairs[j][b+1])) n++;
4125
+ }
4126
+ return n;
4127
+ };
4128
+ const built=[];
4129
+ let figureOK=busGroups.length>0;
4130
+ for(const mem of busGroups){
4131
+ if(!figureOK) break;
4132
+ const T=byId[mem[0].b], src=mem.map(e=>byId[e.a]);
4133
+ let dir=0;
4134
+ if(src.every(s=>fLo(T)-fHi(s)>=RAIL_ROOM)) dir=1;
4135
+ else if(src.every(s=>fLo(s)-fHi(T)>=RAIL_ROOM)) dir=-1;
4136
+ else { figureOK=false; break; } // no room for a rail
4137
+ const railF=dir>0
4138
+ ? Math.min(fLo(T)-RAIL_CLEAR, Math.max(fLo(T)-RAIL_GAP, Math.max(...src.map(fHi))+RAIL_CLEAR))
4139
+ : Math.max(fHi(T)+RAIL_CLEAR, Math.min(fHi(T)+RAIL_GAP, Math.min(...src.map(fLo))-RAIL_CLEAR));
4140
+ const tc=cC(T);
4141
+ const cand=[]; let ok=true;
4142
+ for(const e of mem){
4143
+ const s=byId[e.a], cs=cC(s);
4144
+ const j=P(railF,cs), h=P(railF,tc);
4145
+ const pts=Math.abs(cs-tc)<0.5
4146
+ ? [borderPoint(s,h[0],h[1]), h, borderPoint(T,h[0],h[1])]
4147
+ : [borderPoint(s,j[0],j[1]), j, h, borderPoint(T,h[0],h[1])];
4148
+ const obs=obsFor(s,T);
4149
+ for(let i=0;i+1<pts.length;i++) if(segHitsObs(pts[i],pts[i+1],obs)) ok=false;
4150
+ if(!ok) break;
4151
+ cand.push({e,cs,pts});
4152
+ }
4153
+ if(!ok){ figureOK=false; break; } // a leg pierces something
4154
+ const bpts=cand.map(c=>c.pts), ipts=mem.map(incumbent);
4155
+ const bc=crossCount(bpts)+pierceCount(bpts,mem);
4156
+ const ic=crossCount(ipts)+pierceCount(ipts,mem);
4157
+ if(bc>ic){
4158
+ figureOK=false; break; // not beaten: keep the incumbents
4159
+ }
4160
+ // junction dots mark the interior joins only: the two ends of the rail
4161
+ // are corners, not junctions, and a dot on a corner is wrong.
4162
+ const xs=cand.map(c=>c.cs).concat([tc]);
4163
+ const cLo=Math.min(...xs), cHi=Math.max(...xs);
4164
+ const dots=[];
4165
+ for(const c of cand) if(c.cs>cLo+0.5&&c.cs<cHi-0.5) dots.push(P(railF,c.cs));
4166
+ if(tc>cLo+0.5&&tc<cHi-0.5&&!dots.some(d=>Math.abs(d[horiz?1:0]-tc)<0.5)) dots.push(P(railF,tc));
4167
+ // one label and one arrowhead for the whole bus: the member whose rail
4168
+ // run is longest carries the label, document order breaks the tie.
4169
+ let lead=cand[0], best=-1;
4170
+ for(const c of cand){ const d=Math.abs(c.cs-tc); if(d>best+0.5){ best=d; lead=c; } }
4171
+ built.push({T,cand,dots,lead});
4172
+ }
4173
+ // Nothing to undo when the figure declines: the bus is a routing pass and
4174
+ // the layout it declines is the layout it already had.
4175
+ if(figureOK) for(const g of built)
4176
+ g.cand.forEach((c,i)=>busRoute.set(c.e,{pts:c.pts,bus:g.T.id,lead:c===g.lead,
4177
+ dots:i===g.cand.length-1?g.dots:null, arrow:i===g.cand.length-1}));
4178
+ }
3338
4179
  for(const e of edges){
3339
4180
  const A=byId[e.a], B=byId[e.b]; if(!A||!B) continue;
3340
4181
  // an edge is pure stroke: `stroke=` and `fill=` name the same channel
@@ -3354,8 +4195,31 @@ function renderScene(doc,y0){
3354
4195
  const m1='', m2=''; // markers removed — arrowTri() paints triangles above nodes in lblsvg
3355
4196
  const halo=' paint-order="stroke" stroke="#fff" stroke-width="3"';
3356
4197
  const seg=(p,q,t,lbl,fs)=>reqLabel({p,q,t0:t,text:lbl,fs,col:ecol,halo,e,A,B,kind:'end'});
4198
+ const bus=busRoute.get(e);
4199
+ if(bus){
4200
+ const pts=bus.pts;
4201
+ // data-bus is written LAST so every reader that keys on the
4202
+ // `d=… fill=none stroke=… stroke-width=1.6` prefix is unaffected.
4203
+ esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+' data-bus="'+esc(bus.bus)+'"/>');
4204
+ noteSegs(e,pts);
4205
+ for(const p of pts){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+4-y0-20); }
4206
+ if(bus.dots) for(const d of bus.dots)
4207
+ lblsvg.push('<circle cx="'+d[0]+'" cy="'+d[1]+'" r="3" fill="'+col+'" stroke="none"/>');
4208
+ // the trunk is drawn once by every member; the label and the arrowhead
4209
+ // are drawn ONCE for the bus, which is the whole point of merging it.
4210
+ if(bus.lead&&e.mid){ // longest rail run carries the one label
4211
+ let bi=0,bl=-1;
4212
+ for(let i=0;i+1<pts.length;i++){
4213
+ const l=Math.hypot(pts[i+1][0]-pts[i][0],pts[i+1][1]-pts[i][1]);
4214
+ if(l>bl){ bl=l; bi=i; }
4215
+ }
4216
+ reqLabel({p:pts[bi],q:pts[bi+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:bi===0});
4217
+ }
4218
+ if(bus.arrow&&wantsEnd) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
4219
+ continue;
4220
+ }
3357
4221
  if(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)){
3358
- // ── ROUTING-CHANGE ARCHITECTURE NOTE ──────────
4222
+ // ── ROUTING-CHANGE ARCHITECTURE NOTE (`SELF-EDGE-DRAWING`/`EDGE-BEND-RETENTION`) ──────────
3359
4223
  // Edge labels are DEFERRED: every label is registered against its
3360
4224
  // FINAL segment geometry (reqLabel/lblReq above) and placed by ONE
3361
4225
  // greedy pass after all edges are drawn; arrowheads are computed from
@@ -3367,10 +4231,36 @@ function renderScene(doc,y0){
3367
4231
  // arrowheads, orphaned labels): that was external splicing, not an
3368
4232
  // engine gap. Patch routing here; do not "fix" the label machinery.
3369
4233
  if(A===B){
3370
- // Self-transition: a small side loop on the node, the
4234
+ // Self-transition (`SELF-EDGE-DRAWING`): a small side loop on the node, the
3371
4235
  // convention of every drawing tool — never a lap of the figure
3372
4236
  // through the back-edge channel. Side order r,l,b,t; first side
3373
4237
  // whose loop box overlaps no other node wins (deterministic).
4238
+ //
4239
+ // THE LOOP AND THE CHANNEL SHARE THIS SIDE, AND THAT IS A KNOWN,
4240
+ // MEASURED, UNFIXED DEFECT. A loop hangs off one side of the box on
4241
+ // the box's MID line; a channel back edge leaves and enters on the
4242
+ // SAME side (right under vertical flow, bottom under horizontal) at
4243
+ // rows near that same mid line — so a state that both loops and takes
4244
+ // a channel route has a line drawn across a 20 px ornament. It is
4245
+ // CROSSING, not shared ink: measured over the whole corpus, no
4246
+ // self-loop shares more than 0 px of collinear ink with anything.
4247
+ // bfd-session is the only figure where it bites (turnstile's two loops
4248
+ // are clean), and there it is 12 crossings over four loops.
4249
+ //
4250
+ // THE OBVIOUS FIX WAS BUILT AND REJECTED, so it is not re-attempted
4251
+ // blind: treat the channel side as occupied and take the next free
4252
+ // side. bfd-session's crossings fall 15 -> 4 and every loop comes
4253
+ // clean — but DOWN, INIT and UP have only 'l' free (their 'b' and 't'
4254
+ // boxes sit on the spine, which loopHit does not test), and the left
4255
+ // of a scene is only PADL=18 px wide. Their three trigger labels were
4256
+ // placed at x = -102.6, -73.4 and -57.1 and CLIPPED OFF THE CANVAS —
4257
+ // three labels lost to buy eleven crossings, which is the wrong trade
4258
+ // in the direction label placement has been moving all week.
4259
+ // WHAT WOULD REOPEN IT: a left-margin mechanism for the scene (the
4260
+ // uniform-shift pattern bShift/chShift already use, applied before the
4261
+ // label pass), so a loop and its label can hang off the left at all.
4262
+ // Until then the loop stays on the channel side and the crossing is
4263
+ // recorded rather than papered over.
3374
4264
  const scy=A.y+A.h/2, scx=A.x+A.w/2;
3375
4265
  const mkLoop=sd=>sd==='r'?[[A.x+A.w,scy-8],[A.x+A.w+20,scy-8],[A.x+A.w+20,scy+8],[A.x+A.w,scy+8]]
3376
4266
  :sd==='l'?[[A.x,scy-8],[A.x-20,scy-8],[A.x-20,scy+8],[A.x,scy+8]]
@@ -3387,7 +4277,15 @@ function renderScene(doc,y0){
3387
4277
  for(const p of sp){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+16-y0-20); }
3388
4278
  esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(sp)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
3389
4279
  noteSegs(e,sp);
3390
- if(e.mid) reqLabel({p:sp[1],q:sp[2],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
4280
+ // A self-loop's outer run is 16 px long, so sliding the label ALONG it
4281
+ // buys ~15 px and no escape at all from a line crossing it — and a
4282
+ // back edge leaves the same node on the same side at the same mid-y,
4283
+ // which is how bfd-session drew three self-loop labels with a line
4284
+ // through them. Parameters outside [0,1] are offered too: they park the
4285
+ // box just above or just below the loop, still hard against it, which
4286
+ // is a placement a reader still reads as belonging to the loop.
4287
+ if(e.mid) reqLabel({p:sp[1],q:sp[2],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false,
4288
+ ts:[0.5,0.2,0.8,-0.7,1.7,-1.4,2.4],tw:10});
3391
4289
  if(e.tail) seg(sp[0],sp[1],0.5,e.tail,10);
3392
4290
  if(e.head) seg(sp[3],sp[2],0.5,e.head,10);
3393
4291
  if(wantsStart) arrowTri(sp[0],sp[1],col);
@@ -3403,47 +4301,68 @@ function renderScene(doc,y0){
3403
4301
  const lane=r=>(ranksArr[r]||[]).filter(n=>!n.virtual);
3404
4302
  const P=chPlan.get(e), ring=P.ring;
3405
4303
  const pts=[];
4304
+ // WHERE A BACK-EDGE LABEL GOES. It used to be registered on
4305
+ // the CHANNEL run — the long leg out in the side channel, past every node
4306
+ // in the figure. That is the furthest point on the route from either
4307
+ // endpoint, and every back edge's channel run is in the same channel, so
4308
+ // the labels landed in one column with nothing but proximity to say which
4309
+ // line each named (bfd-session parked three of them around x=1100 while
4310
+ // its four states occupied x 57-200). The label now rides the first
4311
+ // stretch of the route AS IT LEAVES THE SOURCE, where the reader can see
4312
+ // which box the line comes out of. The stub is capped so the candidate
4313
+ // parameters land the box beside the source rather than halfway to the
4314
+ // channel; a shorter first leg just uses all of itself. The cap has to
4315
+ // scale with the LABEL, not be a constant: at the middle of a stub the
4316
+ // box spans the midpoint plus and minus half its width, so a stub
4317
+ // shorter than the label puts the box back on top of the source box
4318
+ // whatever parameter is chosen (bfd-session's "Detect expired, Echo
4319
+ // failed" is 169 px wide and a fixed 64 px stub buried it in INIT).
4320
+ const srcStub=(pp,wpx)=>{
4321
+ const a=pp[0], b=pp[1], L=Math.hypot(b[0]-a[0],b[1]-a[1])||1;
4322
+ const k=Math.min(1,Math.max(64,wpx+24)/L);
4323
+ return [a,[a[0]+(b[0]-a[0])*k, a[1]+(b[1]-a[1])*k]];
4324
+ };
3406
4325
  if(horiz){ // channel runs below the lanes
3407
4326
  const chY=occB+28+P.slot; // labels ride ON the channel
3408
4327
  const colR=r=>Math.max(...lane(r).map(n=>n.x+n.w));
3409
4328
  const blockedV=(y1,y2,xx,skip)=>nodes.some(n=>n!==skip&&!n.boundary&&n.x<xx&&n.x+n.w>xx&&n.y+n.h>y1&&n.y<y2);
3410
- const sx=A===B?A.x+A.w*0.3:A.x+A.w/2, tx=A===B?B.x+B.w*0.7:B.x+B.w/2;
4329
+ const sx=A===B?A.x+A.w*0.3:A.x+A.w/2, tx=A===B?B.x+B.w*0.7:B.x+B.w*P.ef;
3411
4330
  if(A!==B&&blockedV(A.y+A.h,chY,sx,A)){
3412
4331
  const gx=colR(A.rank)+10+ring*7;
3413
4332
  pts.push([outSide(A,'r'),A.y+A.h/2],[gx,A.y+A.h/2],[gx,chY]);
3414
4333
  } else pts.push([sx,outSide(A,'b')],[sx,chY]);
3415
4334
  if(A!==B&&blockedV(B.y+B.h,chY,tx,B)){
3416
4335
  const gx=colR(B.rank)+10+ring*7;
3417
- pts.push([gx,chY],[gx,B.y+B.h/2],[outSide(B,'r'),B.y+B.h/2]);
4336
+ pts.push([gx,chY],[gx,B.y+B.h*P.ef],[outSide(B,'r'),B.y+B.h*P.ef]);
3418
4337
  } else pts.push([tx,chY],[tx,outSide(B,'b')]);
3419
4338
  if(e.mid){
3420
- const c1=pts.findIndex(p=>p[1]===chY);
3421
- reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
4339
+ const ss=srcStub(pts,lblPx(e.mid)), c1=pts.findIndex(p=>p[1]===chY);
4340
+ reqLabel({p:ss[0],q:ss[1],alt:[pts[c1],pts[c1+1]],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
3422
4341
  }
3423
4342
  } else if(P.ringOK){ // concentric ring: under, around, over, in
3424
4343
  const sx=A.x+A.w/2;
3425
4344
  const gy=occB+14+ring*12, chX=occR+28+P.slot, topY=chTop-14-ring*12;
3426
4345
  pts.push([sx,outSide(A,'b')],[sx,gy],[chX,gy],[chX,topY],[P.ex,topY],[P.ex,outSide(B,'t')]);
3427
4346
  if(e.mid){
3428
- const c1=pts.findIndex(p=>p[0]===chX);
3429
- reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
4347
+ const ss=srcStub(pts,lblPx(e.mid)), c1=pts.findIndex(p=>p[0]===chX);
4348
+ reqLabel({p:ss[0],q:ss[1],alt:[pts[c1],pts[c1+1]],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
3430
4349
  }
3431
4350
  } else { // channel runs right of the lanes
3432
4351
  const chX=occR+28+P.slot;
3433
4352
  const laneB=r=>Math.max(...lane(r).map(n=>n.y+n.h));
3434
4353
  const blockedH=(x1,x2,yy,skip)=>nodes.some(n=>n!==skip&&!n.boundary&&n.y<yy&&n.y+n.h>yy&&n.x+n.w>x1&&n.x<x2);
3435
- const sy=A===B?A.y+A.h*0.3:A.y+A.h/2, ty=A===B?B.y+B.h*0.7:B.y+B.h/2;
4354
+ const sy=A===B?A.y+A.h*0.3:A.y+A.h/2, ty=A===B?B.y+B.h*0.7:B.y+B.h*P.ef;
3436
4355
  if(A!==B&&blockedH(A.x+A.w,chX,sy,A)){
3437
4356
  const gy=laneB(A.rank)+10+ring*7;
3438
4357
  pts.push([A.x+A.w/2,outSide(A,'b')],[A.x+A.w/2,gy],[chX,gy]);
3439
4358
  } else pts.push([outSide(A,'r'),sy],[chX,sy]);
3440
4359
  if(A!==B&&blockedH(B.x+B.w,chX,ty,B)){
3441
4360
  const gy=laneB(B.rank)+10+ring*7;
3442
- pts.push([chX,gy],[B.x+B.w/2,gy],[B.x+B.w/2,outSide(B,'b')]);
4361
+ pts.push([chX,gy],[B.x+B.w*P.ef,gy],[B.x+B.w*P.ef,outSide(B,'b')]);
3443
4362
  } else pts.push([chX,ty],[outSide(B,'r'),ty]);
3444
4363
  if(e.mid){
3445
- const c1=pts.findIndex(p=>p[0]===chX);
3446
- reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
4364
+ const ss=srcStub(pts,lblPx(e.mid)), c1=pts.findIndex(p=>p[0]===chX);
4365
+ reqLabel({p:ss[0],q:ss[1],alt:[pts[c1],pts[c1+1]],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
3447
4366
  }
3448
4367
  }
3449
4368
  // non-incident nodes are obstacles for the channel runs too: a run
@@ -3520,7 +4439,7 @@ function renderScene(doc,y0){
3520
4439
  // their neighbours turns a 40-point staircase into the 2–4 bends a
3521
4440
  // dummy-vertex chain should have, without moving the drawn line.
3522
4441
  simplifyPts(pts);
3523
- // Waypoint prune: after collinear simplification a
4442
+ // Waypoint prune (`EDGE-BEND-RETENTION`): after collinear simplification a
3524
4443
  // chain can still carry a staircase of near-collinear jogs — the drift
3525
4444
  // clamp allows only a few px of sideways movement per rank, so a run
3526
4445
  // that wants to move 35px sideways alternates short diagonals and
@@ -3632,8 +4551,29 @@ function renderScene(doc,y0){
3632
4551
  // the arrowheads, and the other edges — plus a pull back toward the
3633
4552
  // preferred point on the segment. The lowest score wins. No randomness,
3634
4553
  // no iteration to a fixed point: one deterministic pass.
3635
- if(lblReq.length){
4554
+ // `DRAWN-ANNOTATION-FORM`: this block used to be guarded by `if(lblReq.length)`,
4555
+ // with `obst`, `ovl`, `segHit` and `placed` local to it. The note pass below
4556
+ // is a SECOND claimant on exactly that machinery and must see exactly the
4557
+ // same `placed` list — a note that did not know where the edge labels went
4558
+ // could not yield to them, which is the first of the four placement rules.
4559
+ // So the scaffolding is hoisted and only the LOOP keeps the guard.
4560
+ {
3636
4561
  const obst=nodes.filter(n=>!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h,n}));
4562
+ // An `external` is never DRAWN as a shape, so its 12x12 anchor is not ink
4563
+ // and is rightly excluded above — but its LABEL is ink, and this pass could
4564
+ // not see it. arp-resolution put a 234 px edge label straight through
4565
+ // "rest of the LAN / (hosts C, D, ...)". The label box is added here with
4566
+ // the same geometry the node pass below emits it at, so the obstacle and
4567
+ // the drawing cannot disagree.
4568
+ for(const n of nodes){
4569
+ if(!n.boundary||!n.label) continue;
4570
+ const cx=n.x+n.w/2, cy=n.y+n.h/2, [bdx,bdy]=bDir(n);
4571
+ const bw=lblPx(n.label), bl=String(n.label).split('\n').length, bh=13*bl;
4572
+ let ox,oy;
4573
+ if(Math.abs(bdx)>=Math.abs(bdy)){ ox=bdx>=0?cx+10:cx-10-bw; oy=cy+3.5-13*bl/2-1.5; }
4574
+ else { ox=cx-bw/2; oy=(bdy>=0?cy+17:cy-10)-13*bl/2-1.5; }
4575
+ obst.push({x:ox,y:oy,w:bw,h:bh,n:null});
4576
+ }
3637
4577
  const ovl=(a,b)=>{
3638
4578
  const ix=Math.min(a.x+a.w,b.x+b.w)-Math.max(a.x,b.x);
3639
4579
  const iy=Math.min(a.y+a.h,b.y+b.h)-Math.max(a.y,b.y);
@@ -3654,26 +4594,54 @@ function renderScene(doc,y0){
3654
4594
  return t1>t0;
3655
4595
  };
3656
4596
  const CLAMP=t=>Math.max(0.06,Math.min(0.94,t));
3657
- const cand=(r,t,side)=>{
4597
+ // SLOPE CLEARANCE (`cl`): "3 px above the line" clears the line only where
4598
+ // the box touches it. The offsets are axis-aligned while the segment is
4599
+ // not, so on a diagonal the line keeps climbing across the box's WIDTH and
4600
+ // re-enters it — which is why a label could sit squarely across its own
4601
+ // edge and the drawing showed a strikethrough. Over half a box the line
4602
+ // rises |dy/dx|*w/2, so that much extra offset is exactly what puts the
4603
+ // whole box on one side of the line. It is offered as a SECOND candidate
4604
+ // per side (cl=1) rather than imposed, priced per pixel of displacement
4605
+ // below: a label 7 px further out to stop being struck is worth it, a
4606
+ // 90 px shove for a long label on a 45 degree line is not, and the scorer
4607
+ // decides which case it is holding.
4608
+ const cand=(r,t,side,cl)=>{
3658
4609
  const lines=String(r.text).split('\n'), n=lines.length;
3659
4610
  const w=Math.max(...lines.map(cw))*6.5*r.fs/11;
3660
4611
  const lh=r.fs*1.3, h=(n-1)*lh+r.fs*1.1;
3661
4612
  const up=(n-1)*lh/2+r.fs*0.85; // baseline y = box top + up
3662
4613
  const mx=r.p[0]+(r.q[0]-r.p[0])*t, my=r.p[1]+(r.q[1]-r.p[1])*t;
4614
+ const sdx=Math.abs(r.q[0]-r.p[0]), sdy=Math.abs(r.q[1]-r.p[1]);
4615
+ let ex=0;
4616
+ if(cl){
4617
+ if(side==='above'||side==='below') ex=sdx>1e-9?Math.min(1,sdy/sdx)*w/2:0;
4618
+ else if(side==='right'||side==='left') ex=sdy>1e-9?Math.min(1,sdx/sdy)*h/2:0;
4619
+ }
3663
4620
  let bx,by,x,anchor=n>1?'middle':'start';
3664
4621
  if(side==='on') { bx=mx-w/2; by=my-4-up; anchor='middle'; }
3665
- else if(side==='above') { bx=mx-w/2; by=my-3-h; anchor='middle'; }
3666
- else if(side==='below') { bx=mx-w/2; by=my+3; anchor='middle'; }
3667
- else if(side==='right') { bx=mx+6; by=my-h/2; }
3668
- else { bx=mx-6-w; by=my-h/2; }
4622
+ else if(side==='above') { bx=mx-w/2; by=my-3-h-ex; anchor='middle'; }
4623
+ else if(side==='below') { bx=mx-w/2; by=my+3+ex; anchor='middle'; }
4624
+ else if(side==='right') { bx=mx+6+ex; by=my-h/2; }
4625
+ else { bx=mx-6-w-ex; by=my-h/2; }
3669
4626
  x=anchor==='middle'?bx+w/2:bx;
3670
- return {x,y:by+up,anchor,t,side,box:{x:bx,y:by,w,h}};
4627
+ return {x,y:by+up,anchor,t,side,ex,box:{x:bx,y:by,w,h}};
3671
4628
  };
3672
4629
  const placed=[];
3673
- for(const r of lblReq){
4630
+ // A request may name a SECOND carrying segment (`alt`). Back edges do: the
4631
+ // stub leaving the source is the preferred carrier because it says which
4632
+ // box the line comes out of, but on a figure where two edges leave the same
4633
+ // node the stub can only put the label where an earlier one already sits
4634
+ // (flowchart-b drew "no" twice, one under the other, and neither said which
4635
+ // line it named). The alternate carrier — the channel run — is offered at a
4636
+ // flat surcharge so it is taken only when the stub really has nowhere.
4637
+ if(lblReq.length) for(const r0 of lblReq){
4638
+ const carriers=[[r0.p,r0.q]].concat(r0.alt?[r0.alt]:[]);
4639
+ let best=null,bestS=Infinity;
4640
+ for(let ci=0;ci<carriers.length;ci++){
4641
+ const r=ci?Object.assign({},r0,{p:carriers[ci][0],q:carriers[ci][1]}):r0;
3674
4642
  const dx=r.q[0]-r.p[0], dy=r.q[1]-r.p[1];
3675
4643
  const across=Math.abs(dx)>=Math.abs(dy);
3676
- let sides, ts, tPref;
4644
+ let sides, ts, tPref, apWant=null;
3677
4645
  if(r.kind==='end'){
3678
4646
  // endpoint labels keep their historical spot as first choice
3679
4647
  sides=['on'].concat(across?['above','below']:['right','left']);
@@ -3681,6 +4649,30 @@ function renderScene(doc,y0){
3681
4649
  ts=[r.t0,r.t0-0.06,r.t0+0.06,r.t0-0.12,r.t0+0.12].map(CLAMP);
3682
4650
  } else {
3683
4651
  sides=across?['above','below']:['right','left'];
4652
+ // ANTI-PARALLEL PAIRS: the label belongs on the OUTSIDE of its own
4653
+ // stroke. `apOff` moved the two strokes of an A->B / B->A pair to
4654
+ // opposite sides of the pair's centre line so they stop coinciding —
4655
+ // "so opposite directions land on opposite sides" — and the label
4656
+ // rides its own offset segment. But which SIDE of that segment the
4657
+ // text lands on was decided here, independently, by score, and the two
4658
+ // strokes are only 7 px apart, so the two candidate sets are nearly
4659
+ // identical. Both labels took the same side and the pair drew as two
4660
+ // lines of text stacked 1.6 px apart (tcp-state-machine: "passive OPEN
4661
+ // / create TCB" directly over "CLOSE / delete TCB", 117 px of shared
4662
+ // width, one of them lying across the partner's stroke).
4663
+ // The offset vector IS the index that decided which side the stroke
4664
+ // took, so `apWant` is read straight off it. It is not merely ORDERED
4665
+ // first: measured on that pair, the outside candidate cost 52 and the
4666
+ // stacked one 36, because a stack that does not actually OVERLAP costs
4667
+ // the scorer NOTHING while the outside position crossed one edge (26).
4668
+ // Ordering is worth 10 and could not move it. The wrong side is
4669
+ // therefore PRICED, in the band the identical-text term already uses
4670
+ // (34): an anti-parallel pair is exactly two lines a reader must tell
4671
+ // apart, and a label on the inside of its own stroke — between the two,
4672
+ // or beyond the partner — has stopped saying which one it names, which
4673
+ // is the same defect that term exists to charge for.
4674
+ const apv=apOff.get(r.e);
4675
+ apWant=apv?(across?(apv[1]<0?'above':'below'):(apv[0]<0?'left':'right')):null;
3684
4676
  // flowchart convention: a short branch marker leaving a decision node
3685
4677
  // reads as that branch's name only if it sits next to the decision.
3686
4678
  // `FLOWCHART-ROLE-KEYWORDS`: the test is the ROLE, not the geometry. Until
@@ -3691,25 +4683,184 @@ function renderScene(doc,y0){
3691
4683
  const branch=r.first && r.A && r.A.role==='decision' &&
3692
4684
  String(r.text).length<=3 && !String(r.text).includes('\n');
3693
4685
  tPref=branch?0.22:0.5;
3694
- ts=branch?[0.22,0.3,0.16,0.4,0.5,0.62]:[0.5,0.38,0.62,0.28,0.72];
4686
+ ts=r.ts?r.ts:(branch?[0.22,0.3,0.16,0.4,0.5,0.62]:[0.5,0.38,0.62,0.28,0.72]);
3695
4687
  }
3696
- let best=null,bestS=Infinity;
3697
- for(let si=0;si<sides.length;si++) for(const t of ts){
3698
- const c=cand(r,t,sides[si]);
4688
+ for(let si=0;si<sides.length;si++) for(const t of ts) for(const cl of [0,1]){
4689
+ const c=cand(r,t,sides[si],cl);
3699
4690
  let s=0;
3700
4691
  for(const b of placed) s+=3*ovl(c.box,b);
3701
4692
  for(const o of obst) s+=(o.n===r.A||o.n===r.B?6:2.4)*ovl(c.box,o);
3702
4693
  for(const a of arrowBox) s+=4*ovl(c.box,a);
3703
- for(const g of edgeSegs) if(g.e!==r.e && segHit(g.p,g.q,c.box)) s+=26;
3704
- s+=70*Math.abs(t-tPref)+si*10;
4694
+ // The label's OWN edge is charged like any other. It used to be exempt
4695
+ // (`g.e!==r.e`), which made a label lying across the line it names FREE
4696
+ // — and that is the single commonest way a label stops saying which
4697
+ // line it belongs to, so the exemption was paying for the defect.
4698
+ for(const g of edgeSegs) if(segHit(g.p,g.q,c.box)) s+=26;
4699
+ s+=0.35*c.ex; // price of the slope-clearance displacement
4700
+ s+=ci*30; // price of leaving the preferred carrier
4701
+ // Two identical texts sitting side by side is the defect in its purest
4702
+ // form: neither of them says which line it belongs to, and no overlap
4703
+ // test can see it because they do not overlap.
4704
+ for(const b of placed) if(b.text===r.text &&
4705
+ Math.hypot(b.x+b.w/2-c.box.x-c.box.w/2, b.y+b.h/2-c.box.y-c.box.h/2)<64) s+=34;
4706
+ // the inside of an anti-parallel pair — see `apWant` above
4707
+ if(apWant&&c.side!==apWant) s+=34;
4708
+ // The pull back toward the preferred point is priced in PARAMETER
4709
+ // units, so the same number means 70/L per pixel: cheap along a 900 px
4710
+ // channel leg, ruinous along a 16 px self-loop run. A request that
4711
+ // offers parameters outside [0,1] states its own weight so its escape
4712
+ // positions cost what they are worth in pixels rather than being
4713
+ // priced out by the length of the thing they slide along.
4714
+ s+=(r.tw||70)*Math.abs(t-tPref)+si*10;
3705
4715
  if(c.box.x<2) s+=400; // would fall off the left margin
3706
4716
  if(s<bestS-1e-9){ bestS=s; best=c; }
3707
4717
  }
3708
- lblsvg[r.idx]=textEl(best.x,best.y,r.fs,best.anchor,r.col,r.text,r.halo);
3709
- placed.push(best.box);
4718
+ }
4719
+ lblsvg[r0.idx]=textEl(best.x,best.y,r0.fs,best.anchor,r0.col,r0.text,r0.halo);
4720
+ placed.push(Object.assign({text:r0.text},best.box));
3710
4721
  W=Math.max(W, best.box.x+best.box.w+4);
3711
4722
  Hh=Math.max(Hh, best.box.y+best.box.h+4-y0-20);
3712
4723
  }
4724
+ // ── note placement (`DRAWN-ANNOTATION-FORM`) — the SAME pass, entered LAST ────────────────
4725
+ // Four rules, and they are in the spec rather than only here because two
4726
+ // engines have to agree on them:
4727
+ //
4728
+ // 1. Notes register LAST, after every edge label and arrowhead. The
4729
+ // reason is semantic, not convenient: a label is ON the thing it names
4730
+ // and an arrowhead IS part of the connector, whereas a note is BESIDE
4731
+ // what it is about. A NOTE YIELDS; NOTHING YIELDS TO A NOTE. That is
4732
+ // why this loop runs after the one above, reads the same `placed`, and
4733
+ // is read by nothing after it.
4734
+ // 2. Candidates are generated around the CARRIER's final geometry — a
4735
+ // node box, a group rect, or the edge's segment list, all of which are
4736
+ // rects or segments by the time this pass runs — and scored by the same
4737
+ // overlap function against `placed`, `obst`, `arrowBox` and `edgeSegs`.
4738
+ // 3. A LEADER LINE is drawn ONLY when the box could not be placed adjacent
4739
+ // to its carrier. This is where attachment-by-syntax pays off twice:
4740
+ // the carrier is known from the line, so the PREFERRED position is
4741
+ // always adjacency and the leader is a fallback the engine reaches for
4742
+ // rather than a permanent part of the construct. It is drawn AFTER
4743
+ // placement, so it is correct by construction — the property `SELF-EDGE-DRAWING`/`EDGE-BEND-RETENTION`
4744
+ // already records for arrowheads.
4745
+ // 4. Determinism is not optional. `RENDERING-DETERMINISM` promises byte-reproducible output,
4746
+ // and one greedy pass in registration order with no iteration is what
4747
+ // delivers it. Registration order here is DOCUMENT order — carriers are
4748
+ // sorted by source line, across kinds — so moving a `group` line above
4749
+ // a `node` line moves the notes with it and nothing else changes.
4750
+ //
4751
+ // `DOMAIN-CONVENTION-DIRECTIVES` binds throughout: `note=` accepts no `at=`, no `side=`, no
4752
+ // `left of`/`right of`. The author names the meaning; the engine owns the
4753
+ // drawing convention. The convention is the UML note symbol — a rectangle
4754
+ // with a folded top-right corner — which is what makes a note readable AS
4755
+ // a note without a legend entry, and is the notation belonging to the
4756
+ // metaclass the spelling is borrowed from (RULE 4.1).
4757
+ const noteCarriers=[];
4758
+ for(const n of nodes) if(n.note!==undefined&&n.note!==null&&!n.boundary)
4759
+ noteCarriers.push({line:n.line,text:n.note,kind:'node',rect:{x:n.x,y:n.y,w:n.w,h:n.h},n});
4760
+ for(const g of doc.groups) if(g.note!==undefined&&g.note!==null){
4761
+ const B=gBox[g.id]; if(!B) continue;
4762
+ noteCarriers.push({line:g.line,text:g.note,kind:'group',
4763
+ rect:{x:B.x0,y:B.yA,w:B.x1-B.x0,h:B.yB-B.yA}});
4764
+ }
4765
+ for(const e of doc.edges) if(e.note!==undefined&&e.note!==null){
4766
+ // An edge is a polyline, not a rect. Its carrier POINT is the midpoint of
4767
+ // the middle registered segment — deterministic, and it is the same
4768
+ // "middle of the run" an author means when they annotate a wire. A note
4769
+ // whose edge never made it to the canvas (an endpoint that did not
4770
+ // resolve) simply has no carrier and is not drawn; the missing endpoint
4771
+ // is already its own line error.
4772
+ const segs=edgeSegs.filter(g=>g.e===e); if(!segs.length) continue;
4773
+ const m=segs[Math.floor((segs.length-1)/2)];
4774
+ const cx=(m.p[0]+m.q[0])/2, cy=(m.p[1]+m.q[1])/2;
4775
+ noteCarriers.push({line:e.line,text:e.note,kind:'edge',rect:{x:cx,y:cy,w:0,h:0},e});
4776
+ }
4777
+ noteCarriers.sort((a,b)=>a.line-b.line);
4778
+ for(const c of noteCarriers){
4779
+ const box=noteBox(c.text);
4780
+ // Adjacency first, then the same four sides pushed out far enough that a
4781
+ // leader is legible. `si` orders the sides; `far` is what decides the
4782
+ // leader, and it costs enough that adjacency wins every time adjacency is
4783
+ // merely imperfect rather than blocked.
4784
+ // Sides in preference order, and for the two that straddle the carrier
4785
+ // an ALIGNMENT as well. Centring a wide note over a narrow carrier at the
4786
+ // left edge of the canvas puts the box off it; aligning the box's left
4787
+ // edge with the carrier's is the same "beside this thing" reading and
4788
+ // stays on the page. Found by eye — see the off-canvas note below.
4789
+ const SIDES=[['right','c'],['left','c'],
4790
+ ['below','c'],['below','l'],['below','r'],
4791
+ ['above','c'],['above','l'],['above','r']];
4792
+ // Three distance tiers, not two. `near` is adjacency and takes no leader;
4793
+ // the two `far` tiers do. The third exists because a crowded figure can
4794
+ // have NO free space within one leader length of the carrier — a note
4795
+ // then had to sit on top of an edge label, which is the exact inversion
4796
+ // of rule 1 (a note yields; nothing yields to a note). Given somewhere
4797
+ // further to go, it goes there and the canvas grows to fit.
4798
+ let best=null,bestS=Infinity, fallback=null,fallbackS=Infinity;
4799
+ for(const tier of [0,1,2]){
4800
+ const far=tier>0, gap=[10,46,96][tier];
4801
+ for(let si=0;si<SIDES.length;si++){
4802
+ const side=SIDES[si][0], al=SIDES[si][1];
4803
+ let bx,by;
4804
+ if(side==='right'){ bx=c.rect.x+c.rect.w+gap; by=c.rect.y+c.rect.h/2-box.h/2; }
4805
+ else if(side==='left'){ bx=c.rect.x-gap-box.w; by=c.rect.y+c.rect.h/2-box.h/2; }
4806
+ else {
4807
+ bx=al==='l'?c.rect.x
4808
+ :al==='r'?c.rect.x+c.rect.w-box.w
4809
+ :c.rect.x+c.rect.w/2-box.w/2;
4810
+ by=side==='below'?c.rect.y+c.rect.h+gap:c.rect.y-gap-box.h;
4811
+ }
4812
+ const cb={x:bx,y:by,w:box.w,h:box.h};
4813
+ let s=0;
4814
+ // A NOTE YIELDS; NOTHING YIELDS TO A NOTE. `placed` holds the edge
4815
+ // labels and the notes already sited, and its weight is the HIGHEST
4816
+ // of the three — higher than a node's — because a label is a small
4817
+ // box and an AREA-weighted penalty would otherwise let a note sit on
4818
+ // one for less than it costs to clip a node's corner. Found by eye: a
4819
+ // transition note landed across two edge labels while a node overlap
4820
+ // three times the area scored higher.
4821
+ for(const b of placed) s+=10*ovl(cb,b);
4822
+ for(const o of obst) s+=6*ovl(cb,o);
4823
+ for(const a of arrowBox) s+=8*ovl(cb,a);
4824
+ for(const g of edgeSegs) if(segHit(g.p,g.q,cb)) s+=26;
4825
+ for(const g of doc.groups){ const B=gBox[g.id];
4826
+ if(B) s+=1.2*ovl(cb,{x:B.x0,y:B.yA,w:B.x1-B.x0,h:B.yB-B.yA}); }
4827
+ s+=si*12;
4828
+ s+=tier*900; // the leader is a LAST resort
4829
+ // Falling off the top or the left margin is not a BAD placement, it
4830
+ // is NO placement. The canvas grows right and down but has no
4831
+ // mechanism here to grow up or left, so such a box is clipped away
4832
+ // and the annotation VANISHES — the author wrote a sentence and the
4833
+ // reader never sees it, which is the worst outcome available. So it
4834
+ // is a HARD FILTER and not a score term: every other penalty is an
4835
+ // AREA and grows without bound, so no constant can outrank one
4836
+ // reliably. Found by eye on a statechart, where the note on the
4837
+ // leftmost state was emitted at x=-267.8 and simply did not appear —
4838
+ // and then found AGAIN when a large constant was tried first and the
4839
+ // box landed on top of two states instead.
4840
+ // `<0`, not `<2`. The edge-label pass keeps a 2px margin because a
4841
+ // label is loose text; a note is a BOX whose left edge at x=0 IS the
4842
+ // canvas origin and is perfectly placed. With the label pass's
4843
+ // threshold copied over, the one candidate that rescues a wide note
4844
+ // on a leftmost element — below, left-aligned, at exactly x=0 — was
4845
+ // filtered as off-canvas, and the note went to the only survivor: on
4846
+ // top of the next two states.
4847
+ if(cb.x<0||cb.y<y0){
4848
+ if(s<fallbackS-1e-9){ fallbackS=s; fallback={x:bx,y:by,far,side}; }
4849
+ continue;
4850
+ }
4851
+ if(s<bestS-1e-9){ bestS=s; best={x:bx,y:by,far,side}; }
4852
+ }
4853
+ }
4854
+ // Only if EVERY candidate was off-canvas: take the least-bad one and
4855
+ // clamp it on. It may overlap something; visible and overlapping is
4856
+ // recoverable by an author, invisible is not.
4857
+ if(!best){ best=fallback; best.x=Math.max(0,best.x); best.y=Math.max(y0,best.y); }
4858
+ lblsvg.push(noteSvg(best.x,best.y,box,c));
4859
+ if(best.far) lblsvg.push(noteLeader(best,box,c.rect));
4860
+ placed.push({x:best.x,y:best.y,w:box.w,h:box.h});
4861
+ W=Math.max(W, best.x+box.w+4);
4862
+ Hh=Math.max(Hh, best.y+box.h+4-y0-20);
4863
+ }
3713
4864
  }
3714
4865
  // nodes on top (each wrapped in a draggable, identifiable group)
3715
4866
  const nsvg=[];
@@ -4022,7 +5173,7 @@ function renderBitfield(b,y0){
4022
5173
  //
4023
5174
  // and 0.1 already ruled that a spanning field follows the RFC's
4024
5175
  // drawing rather than a FigDown one; this is the same ruling applied to the
4025
- // other construct in the same figure. Until this release the engine drew ONE
5176
+ // other construct in the same figure. Until 0.1 the engine drew ONE
4026
5177
  // occurrence and hung `[first] … [last]` on the strip — a FigDown invention
4027
5178
  // where a convention already existed.
4028
5179
  //
@@ -4069,7 +5220,7 @@ function renderBitfield(b,y0){
4069
5220
  const shiftFor=(row)=>elisBands(row)*EL_H;
4070
5221
  // `FIELD-WIDER-THAN-WORD`: ONE FIELD IS ONE BOX.
4071
5222
  //
4072
- // A field wider than `word=` occupies several rows. Until this release each
5223
+ // A field wider than `word=` occupies several rows. Until 0.1 each
4073
5224
  // row was a separate fully-bordered <rect> carrying the full label, so a
4074
5225
  // 128-bit address at word=32 drew as FOUR captioned boxes and a reader saw
4075
5226
  // four fields where the model has one. In examples/srh.fd it was worse: the
@@ -4265,7 +5416,7 @@ function renderBitfield(b,y0){
4265
5416
  }
4266
5417
  boxes.forEach(function(bx,bi){
4267
5418
  // `DESCRIPTION-KEY-SPELLING`: the `<title>` is a CHILD of the shape it names, not
4268
- // a sibling. Until this release it was pushed into the block's stream
5419
+ // a sibling. Until 0.1 it was pushed into the block's stream
4269
5420
  // after the rect and the label, so it landed under the figure's single
4270
5421
  // <g> — and SVG says a <title> names its PARENT, so every description in
4271
5422
  // a figure named the same <g> and a conforming UA showed one arbitrary
@@ -4424,7 +5575,8 @@ function renderBitfield(b,y0){
4424
5575
  }
4425
5576
  yb+=2;
4426
5577
  }
4427
- return {svg:svg.join(''), y:yb, w:wb};
5578
+ return {svg:svg.join(''), y:yb, w:wb,
5579
+ box:{x0:0, x1:wb, yA:y0+18, yB:yb}};
4428
5580
  }
4429
5581
 
4430
5582
  // ---- table (with ^ rowspan / < colspan merging and per-cell marks) ----
@@ -4520,7 +5672,30 @@ function renderTable(t,y0){
4520
5672
  i=>[xAt[i], xAt[i+1]], i=>[cellAt(r-1,i), cellAt(r,i)]));
4521
5673
  svg.push(edgeSvg(EDG, DEF));
4522
5674
  const yEnd=yTop+yAt[grid.length];
4523
- return {svg:svg.join(''), y:yEnd+6, w:totalW+2};
5675
+ // `MARKER-TARGET-KINDS`: the GRID's box, so a region-scope `threshold`/`band`
5676
+ // can be drawn across it. It is the grid and not the returned slot: the slot
5677
+ // includes the caption row and the trailing gap, and `offset=50%` on a table
5678
+ // must mean half way down the ROWS, not half way down the whitespace.
5679
+ // `MARKER-TARGET-KINDS`: the box a region-scope `threshold`/`band` is measured
5680
+ // against spans the DATA ROWS, not the whole grid. Measured over the grid,
5681
+ // `offset=85%` on a three-row table lands on the COLUMN HEADINGS and strikes
5682
+ // through them — found by eye on the WRED figure this widening exists for.
5683
+ // The header tiers are chrome: they name the columns, they are not values,
5684
+ // and a threshold is a statement about values. `h1..hN` and `1..` are already
5685
+ // separate address spaces in this genre (genres/table.md), so the split is
5686
+ // the genre's own and not invented here.
5687
+ // THE SECTION IS AS WIDE AS ITS WIDEST INK, AND THE CAPTION IS INK.
5688
+ // `w` was the GRID's width alone, so a caption longer than the table it names
5689
+ // ran past the right edge of the section and was CLIPPED — patterns/table-b
5690
+ // shipped as "Feature Matrix — rowspan/colspan merges with c", losing 86 px
5691
+ // of a sentence that is the only place the figure says what it is about. The
5692
+ // grid is not the figure; the caption is not decoration.
5693
+ // Bold at 13 px is wider than `CH` (a regular-weight advance), so the caption
5694
+ // is measured with the same 8% allowance the raster needed — verified by
5695
+ // rendering, not assumed.
5696
+ const capW=cwMax(t.label)*CH*1.08+2;
5697
+ return {svg:svg.join(''), y:yEnd+6, w:Math.max(totalW+2,capW),
5698
+ box:{x0:0, x1:totalW, yA:yTop+yAt[H], yB:yEnd}};
4524
5699
  }
4525
5700
 
4526
5701
  // ---- chart bar3d: deterministic isometric projection of a table ----
@@ -4537,7 +5712,26 @@ function renderChart(b,y0,doc){
4537
5712
  const R=rows.length, C=cLab.length;
4538
5713
  const zmax=Math.max(...rows.flat(), 1);
4539
5714
  const W2=20,H2=10,ZS=130/zmax,BAR=0.72;
4540
- const ox=R*W2+8, oy=y0+18+ZS*zmax+6;
5715
+ // LEFT GUTTER for the row labels. They are anchored `end` at the floor's
5716
+ // left corner and hang LEFTWARD from it, and nothing reserved room for them:
5717
+ // the section's width is measured from the floor's RIGHT corner, and a
5718
+ // section has no mechanism to grow leftwards, so any row label wider than
5719
+ // its corner's own offset was clipped away at x<0 and the reader saw a
5720
+ // sliver or nothing. Measured on the shipped corpus: telemetry-export lost
5721
+ // 16.0 px of "Export ring" (24.6% of the box) and 41.9 px of "gRPC encoder"
5722
+ // (59.1%), and table-experimental shaved "00:05".
5723
+ // The LABEL is not moved. A row label belongs beside its row — that
5724
+ // adjacency is what makes it a row label rather than a caption — so the
5725
+ // ORIGIN moves right instead, by exactly what the widest label overhangs.
5726
+ // That is the "grow the canvas" answer, and it is the right one here
5727
+ // because the space is genuinely needed: no placement of a right-anchored
5728
+ // label at the left edge of the floor can avoid needing a margin, and the
5729
+ // gutter costs only the width it actually uses (0 when no label overhangs,
5730
+ // so every chart whose labels already fitted is byte-unchanged).
5731
+ const rLabPx=l=>cwMax(l)*6.5*10/11; // textEl draws these at font-size 10
5732
+ const ox0=R*W2+8;
5733
+ const gut=Math.max(0,...rLab.map((l,r)=>rLabPx(l)+4-(ox0-(r+0.65)*W2)));
5734
+ const ox=ox0+gut, oy=y0+18+ZS*zmax+6;
4541
5735
  const P=(r,c,z)=>[ox+(c-r)*W2, oy+(c+r)*H2-z*ZS];
4542
5736
  const svg=[];
4543
5737
  svg.push('<text x="0" y="'+(y0+14)+'" font-size="13" font-weight="600">'+esc(t.label)+' — bar3d</text>');
@@ -4624,7 +5818,8 @@ function renderTiming(w,y0){
4624
5818
  svg.push('<path d="M'+x+','+(y+4)+' q4,'+(hTotal/4)+' 0,'+(hTotal/2)+' q-4,'+(hTotal/4)+' 0,'+(hTotal/2)+'" fill="none" stroke="#999" stroke-width="2"/>');
4625
5819
  }
4626
5820
  const H=y+8+w.signals.length*(laneH+laneGap);
4627
- return {svg:svg.join(''), y:H, w:nameW+cycles*cycleW+2};
5821
+ return {svg:svg.join(''), y:H, w:nameW+cycles*cycleW+2,
5822
+ box:{x0:nameW, x1:nameW+cycles*cycleW, yA:y0+18, yB:H}};
4628
5823
  }
4629
5824
 
4630
5825
  // ============================================================