figdown 0.4.1 → 0.5.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.4.1)
1
+ // figdown.mjs — FigDown embeddable library (0.5.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.4.1";
5
+ var VERSION = "0.5.1";
6
6
 
7
7
  // ---- engine (extracted verbatim from editor/figdown.html) ----
8
8
  var __engine = (function () {
@@ -16,7 +16,38 @@ 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.4.1';
19
+ const FIGDOWN_VERSION = '0.5.1';
20
+ // `TYPED-BLOCK-TITLE-CANVAS`: a `Z`-only dev bump — RENDERER ONLY, no keyword, no
21
+ // option key, no model field, so `figdown 0.5` still names one language. A
22
+ // typed block's own title (bitfield/table/timing/chart) now joins the
23
+ // section's canvas computation: the returned width is the union of the data
24
+ // extent and the title extent (`typedBlockTitleW`, shared by all four
25
+ // renderers), so a title wider than the data widens the canvas instead of
26
+ // running off it at x=0. Fixes backlog item 66, raised downstream. Corpus impact
27
+ // surveyed first: zero shipped drawings change (every typed-block title in
28
+ // the corpus already fit its data-derived canvas); the fix only reaches
29
+ // fixtures the corpus does not yet have. See spec/migrations.md.
30
+ // 0.5: EDITOR ONLY — no keyword, no option key, no model field moves.
31
+ // A genre-aware property inspector (buildInspector) for document/node/edge/
32
+ // group; edge/message editing now goes through the engine's own
33
+ // scanConnectorLine spans instead of a second grammar; quote-aware option
34
+ // targeting (authoredOptionSpan/optionSpanOutsideLabel) closes the
35
+ // label-corruption class; every layout emitter gains a sectionIndex
36
+ // parameter; tools/editor-check.js grows from 91 to 140 checks; and canvas
37
+ // hit-testing now excludes `[data-lasso]` (`LASSO-ENCLOSURE-TRUTH`) beside `[data-port-sq]`
38
+ // in the stopPropagation loop and both closest(...) exclusion
39
+ // lists — the lasso ellipse landed after this batch's own hit-test baseline
40
+ // was written. See spec/migrations.md for the full entry.
41
+ // `LASSO-ENCLOSURE-TRUTH`: a `Z`-only dev bump. One new geometry-time diagnostic (a
42
+ // bundle's lasso enclosing a non-member), one new `data-*` attribute on the
43
+ // output (`data-lasso`), and one more constraint on the separation pass — no
44
+ // keyword, no option key, no model field, so `figdown 0.5` still names one
45
+ // language. See the lasso-containment rule beside the group-band pass below.
46
+ // `CLASS-CHANNEL-COLLISION`: a `Z`-only dev bump, the same shape as `MEMBER-LIST-DUPLICATION`'s — one new
47
+ // diagnostic (two carried classes binding the same paint channel on one
48
+ // element), no keyword, no option key, no model field, so `figdown 0.5`
49
+ // still names one language. See the `clsChan` collision check below for the
50
+ // rule itself.
20
51
  // `STATECHART-GENRE-SCOPE`: the language number moved for the first time. The dev
21
52
  // counter does NOT reset (core §13.0.4 — `N` counts source states of the
22
53
  // engine and only ever increases), so 0.1 is followed by
@@ -36,7 +67,14 @@ const FIGDOWN_VERSION = '0.4.1';
36
67
  // makes added surface a `Y` and never a `Z`. `sequence` is that token. It adds
37
68
  // no keyword yet (see GENRES_BY_VERSION below), which is exactly `STATECHART-GENRE-SCOPE`'s shape:
38
69
  // the dispatch point lands first and the vocabulary follows it.
39
- const LANG_VERSIONS = ['0.1', '0.2', '0.3', '0.4'];
70
+ // `CONNECTOR-IDENTITY-KEY`: `figdown 0.5` joins the set, and the dev line crosses to
71
+ // 0.5 exactly the way it crossed to 0.2 at `STATECHART-GENRE-SCOPE` — the counter does NOT reset
72
+ // (core §13.0.4: `N` counts source states of the engine and only ever
73
+ // increases), so 0.4 is followed by 0.5. What moves the
74
+ // language number is `id=` on the four scene connectors: core §13.0 makes an
75
+ // added option key a `Y` and never a `Z`, because `figdown 0.4` must not name
76
+ // two languages — the one v0.4 published and the one with `id=`.
77
+ const LANG_VERSIONS = ['0.1', '0.2', '0.3', '0.4', '0.5'];
40
78
  // Genres per declared language version. `Y` never removes (core §13.0), so
41
79
  // each row is a superset of the one above it, and `figdown 0.1 <anything>`
42
80
  // resolves against exactly the list it resolved against before `STATECHART-GENRE-SCOPE`.
@@ -58,7 +96,13 @@ const GENRES_BY_VERSION = {
58
96
  // it. The genre still has NO RENDERER: a valid `sequence` document parses to
59
97
  // a model and draws an empty canvas, which is the state this increment means
60
98
  // to land and is pinned by a fixture rather than left to be noticed.
61
- '0.4': ['block','topology','flowchart','bitfield','table','timing','statechart','sequence']
99
+ '0.4': ['block','topology','flowchart','bitfield','table','timing','statechart','sequence'],
100
+ // `CONNECTOR-IDENTITY-KEY`: `0.5` adds NO genre. It is here because the row must
101
+ // exist for every accepted version — a genre list is looked up by the
102
+ // declared version and an absent row would narrow nothing — and it is a copy
103
+ // of `0.4`'s because `Y` never removes and this release adds an OPTION KEY,
104
+ // not a dispatch point. The version moved for `id=`, and `id=` is genre-free.
105
+ '0.5': ['block','topology','flowchart','bitfield','table','timing','statechart','sequence']
62
106
  };
63
107
  // The version an OPTION KEY first becomes legal in — the `CONNECTOR_MIN_VERSION`
64
108
  // device, applied to the option namespace. `DRAWN-ANNOTATION-FORM`: `note=` is gated on the
@@ -69,8 +113,23 @@ const GENRES_BY_VERSION = {
69
113
  // Accepting it silently under a `figdown 0.2` header would repaint a document
70
114
  // whose author meant a never-drawn tooltip as one that puts ink on the page —
71
115
  // core §13.0.1's named hazard, "a figure that looks right and means something
72
- // else". A key that had never been spelled before would carry no such risk.
73
- const OPT_MIN_VERSION={note:'0.3'};
116
+ // else".
117
+ //
118
+ // THE SENTENCE THAT USED TO CLOSE THIS COMMENT IS WITHDRAWN (`CONNECTOR-IDENTITY-KEY`,
119
+ // 0.5). It read: "A key that had never been spelled before would carry
120
+ // no such risk." It is kept visible here, as core §13.7.2 keeps it, because a
121
+ // deleted mistake teaches nothing — but it is not the rule. `note=`'s prior
122
+ // meaning is why its DIAGNOSTIC says what it says; it was never why the gate
123
+ // exists. The gate engages on ADDING A SPELLING TO THE ACCEPTED SURFACE: a
124
+ // document's header is the contract it is read against, `read/<X.Y>/` is the
125
+ // frozen text of that contract, and `archive/`'s own engine for a version would
126
+ // refuse a key that version never named. Silently accepting a newer key under
127
+ // an older header makes the declared version stop determining the surface,
128
+ // which is the declaration decaying into a comment. So EVERY new option key
129
+ // gates, and `id=` (`CONNECTOR-IDENTITY-KEY`) is the first key gated under the restated rule.
130
+ // `CONNECTOR-IDENTITY-KEY`: `id=` — the connector's optional handle. It has no prior
131
+ // meaning of any kind; it is gated because it is a new spelling.
132
+ const OPT_MIN_VERSION={note:'0.3',id:'0.5'};
74
133
  // True when the document's declared version is older than the key's own.
75
134
  // A document with no parsable header has already been diagnosed on line 1, so
76
135
  // an absent version never gates a second time.
@@ -297,7 +356,13 @@ function splitList(t,off){
297
356
  // value grammar is a range, so it is also the key that fixes the language's
298
357
  // ONE range spelling at `..` (`RANGE-SPELLING` moves `band` off the hyphen in the same
299
358
  // release).
300
- const OPT_KEYS=new Set(['kind','type','shape','fill','color','stroke','text','in','plane','layer','label',
359
+ // `CONNECTOR-IDENTITY-KEY`: `id` joins the registry as the CONNECTOR's optional
360
+ // handle — the one element kind in the language that had no way to be named.
361
+ // RULE 4.1 is satisfied without a coinage: `id` is already the EBNF production
362
+ // name and the model field name for every other element's handle, so minting a
363
+ // second word for it would have been the violation. Its acceptors are the four
364
+ // scene connectors and nothing else (DIRECTIVE_OPTS below).
365
+ const OPT_KEYS=new Set(['id','kind','type','shape','fill','color','stroke','text','in','plane','layer','label',
301
366
  'style','z','z-index','at','offset','w','h','width','height','unit','word','note','description','present','index','labels','data','numbering','from','to','gap',
302
367
  'dir','extend','level','taillabel','headlabel','class','via','points','routing','src','dst','tailport','headport']);
303
368
  // Applicable option keys per directive. Keys with dedicated diagnostics
@@ -360,12 +425,16 @@ const DIRECTIVE_OPTS={
360
425
  // empty array is the declaration: every key falls through to the generic
361
426
  // `external does not take <k>=`.
362
427
  external:[],
363
- edge:['style','class','fill','stroke','label','taillabel','headlabel','note'],
428
+ // `CONNECTOR-IDENTITY-KEY`: `id` is on all four connector rows. Under `GENRE-VOCABULARY-OBLIGATION`/`SUBJECT-VOCABULARY-SCOPE` an
429
+ // option key is declared per genre, so the acceptor list is four separate
430
+ // declarations of one key — the `note=` and `class=` pattern exactly, and for
431
+ // the same reason: the referent is THIS CONNECTOR, which is genre-independent.
432
+ edge:['style','class','fill','stroke','label','taillabel','headlabel','note','id'],
364
433
  // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: same rename argument — the connector's option set is one set
365
434
  // under three spellings, listed three times only because the tables are
366
435
  // keyed by the surface word an author actually wrote.
367
- flowline:['style','class','fill','stroke','label','taillabel','headlabel','note'],
368
- transition:['style','class','fill','stroke','label','taillabel','headlabel','note'],
436
+ flowline:['style','class','fill','stroke','label','taillabel','headlabel','note','id'],
437
+ transition:['style','class','fill','stroke','label','taillabel','headlabel','note','id'],
369
438
  // `SEQUENCE-GENRE-VOCABULARY`: the `sequence` genre's four own rows. `message` is
370
439
  // the fourth connector spelling and takes the connector set — `fill=` and
371
440
  // the three retired label keys stay listed for the same reason they are
@@ -374,7 +443,14 @@ const DIRECTIVE_OPTS={
374
443
  // message occurs inside) and `description=`. It does NOT gain a key of its
375
444
  // own: `lost=` was proposed and refused (`UNDELIVERED-MESSAGE-MARKING`), and `OPT_KEYS` is unchanged
376
445
  // by this whole increment.
377
- message:['style','class','fill','stroke','label','taillabel','headlabel','note','in','description'],
446
+ // `CONNECTOR-IDENTITY-KEY`: `message` is the FOURTH connector and takes `id=` with
447
+ // the other three. The proposal recommended refusing it here; the ruling
448
+ // widened the acceptor list, and the reason it can is that an id is a HANDLE
449
+ // and nothing else. It states no order, no equivalence and no identity
450
+ // between two occurrences — §6.4's occurrence-identity question stays shut,
451
+ // and the diff still aligns messages as a multiset, because naming a thing is
452
+ // not the same act as claiming two things are one.
453
+ message:['style','class','fill','stroke','label','taillabel','headlabel','note','in','description','id'],
378
454
  // A lifeline is drawn as a head box over a dashed line, so it has an
379
455
  // interior and takes `fill=`. `in=` is sense 1.
380
456
  lifeline:['class','fill','stroke','style','in','note','description'],
@@ -584,6 +660,22 @@ const NOTE_VERSION=(have)=>
584
660
  'annotation: an explanation the human reader must SEE. Raise the header to '+
585
661
  'figdown 0.3, or write description= if you meant prose only a machine reads '+
586
662
  '(MIGRATIONS 0.3)';
663
+ // `CONNECTOR-IDENTITY-KEY`: the SAME device for `id=`, and the message says what the
664
+ // restated rule says. `note=`'s gate names a prior meaning because `note=` HAS
665
+ // one; `id=` has none, and it is gated anyway — the gate engages on adding a
666
+ // spelling to the accepted surface, not on what the spelling used to mean. So
667
+ // this message argues from the declaration rather than from repainting: the
668
+ // header is the contract, `read/<X.Y>/` is that contract's frozen text, and the
669
+ // archived engine for a version would refuse a key that version never named.
670
+ // The one-step fix is named, exactly as `KEYWORD-RENAME-SCOPE`'s device requires.
671
+ const ID_VERSION=(have)=>
672
+ 'id= requires figdown 0.5 (this document declares '+have+'): a connector has '+
673
+ 'no id= spelling under figdown '+have+', so an engine held to the version '+
674
+ 'this document declares — the archived '+have+' engine, or a reader working '+
675
+ 'from the frozen read/'+have+' contract — would refuse this line. A header '+
676
+ 'that stops determining what the language accepts is a comment, not a '+
677
+ 'declaration. Raise the header to figdown 0.5, or delete the key: an '+
678
+ 'anonymous connector stays legal and claims nothing less (MIGRATIONS 0.5)';
587
679
  // (b) The directive is `field`, which refuses the key at EVERY version. The
588
680
  // bitfield genre already has `description=` for machine-facing prose, and
589
681
  // no measured figure needs a DRAWN per-field aside — granting a directive
@@ -770,7 +862,7 @@ const DD_ID='"--" is not allowed inside an id — it is the link operator (edge
770
862
  // gates necessity.
771
863
  // whitespace-delimited string position -> quotes MANDATORY
772
864
  // comma-delimited list element -> quotes only when the element
773
- // contains whitespace , " or #
865
+ // contains whitespace , " ( ) or #
774
866
  // (redundant quotes stay legal)
775
867
  // [ ] edge label -> quotes only to enable escapes
776
868
  // The whitespace row is not a style preference: whitespace is ALSO the
@@ -782,6 +874,20 @@ const Q_WHY='whitespace also separates positionals, so a bare token cannot expre
782
874
  const ID_RULE='ids are bare and match [A-Za-z_][A-Za-z0-9_-]* — text with spaces or punctuation belongs in the label: node <id> "your text"';
783
875
  // isId: the whole id test, used at every id position in the language.
784
876
  const isId=v=>typeof v==='string'&&ID_RE.test(v)&&!v.includes('--');
877
+ // `CONNECTOR-IDENTITY-KEY`: what `data-edge` carries (core §7). It is the AUTHORED
878
+ // id where the connector has one, and the 1-based source line where it does
879
+ // not. The two can never be confused: an id must start with a letter or an
880
+ // underscore, so no id is a decimal number and no line number is an id.
881
+ //
882
+ // The change is to the VALUE, not to the attribute: `data-edge` stays the one
883
+ // name in the profile's `data-*` enumeration and `gate:safesvg` is untouched.
884
+ // What it buys is the thing ADV-20 said the channel could not honestly supply —
885
+ // inserting a line at the top of a document changes every source-line
886
+ // `data-edge` in it, which is a handle that moves when nothing about the figure
887
+ // moved. For a named connector it no longer does; for an anonymous one the
888
+ // attribute keeps its old job and its old caveat, and the fallback is stated
889
+ // rather than implied.
890
+ const edgeRef=e=>(e && e.id!==undefined && e.id!==null) ? e.id : e.line;
785
891
  // idErr(value, quotedFlag, missingMsg) -> the message for an id position, or
786
892
  // null when the id is well formed. `missing` fires only when nothing at all
787
893
  // was written; a written-but-illegal id always gets ID_RULE or DD_ID, never
@@ -888,7 +994,7 @@ function parseClassList(v,els){
888
994
  // core §12.2 and genres/bitfield.md, pinned by golden 420.
889
995
  //
890
996
  // Returns {ok, val, err}. `val` is the model shape: an object with `first`
891
- // and `last` being a NUMBER when the run is determinate and a STRING
997
+ // and `last`, `last` being a NUMBER when the run is determinate and a STRING
892
998
  // when it is prose. `index=""` yields `{}` — a written value that claims
893
999
  // repetition and states no index at all.
894
1000
  const IDX_INT=/^\d+$/;
@@ -1184,6 +1290,35 @@ const WORD_WHY={
1184
1290
  const WRONG_WORD=(surf,want,genre)=>
1185
1291
  '"'+surf+'" is not the word genre '+genre+' uses for this — write "'+want+'": '+WORD_WHY[want]+
1186
1292
  '. Each genre takes the term its own domain uses (block/topology `node` `edge`, flowchart `node` `flowline`, statechart `state` `transition`, sequence `lifeline` `message`) — run tools/migrate-figdown.js to rewrite it (MIGRATIONS 0.2)';
1293
+ // `LANGUAGE-EXTENSION-POLICY`/`RESERVED-PREFIX-ENFORCEMENT` (2026-08-23): the `x-` prefix is RESERVED against FigDown's own
1294
+ // vocabulary and is NOT an extension namespace. Until this entry it was the
1295
+ // one reservation core §10 wrote down by name and NOTHING enforced: `x-note`
1296
+ // earned `"x-note" is not allowed in genre block` — the same words a typo
1297
+ // gets — while `page`, `;` and `step` each got a diagnostic naming their
1298
+ // reservation. `SEMICOLON-STATUS`'s own title says a reservation that is not enforced is not
1299
+ // a reservation, so this is what makes `x-` one.
1300
+ //
1301
+ // The message says three things, and the THIRD is the whole value: `LANGUAGE-EXTENSION-POLICY`
1302
+ // RETIRED the promise of a future extension mechanism, so an author who reads
1303
+ // core §10's pre-0.5 wording is being told the opposite of the rule. It must
1304
+ // say the prefix will not open, not merely that it is taken.
1305
+ //
1306
+ // IT MUST NOT REACH IDENTIFIERS. The reservation is about the language's own
1307
+ // vocabulary; an id is the author's. `node x-foo "A"` and `class x-bar
1308
+ // "meaning"` parsed and rendered before this entry and still do, which is why
1309
+ // both call sites below are in KEYWORD position and OPTION-KEY position and
1310
+ // nowhere else.
1311
+ const X_RESERVED_WHY =
1312
+ ': the "x-" prefix is reserved against FigDown\'s OWN vocabulary — no standard '+
1313
+ 'keyword or option key may begin "x-" — and it is NOT an extension namespace. '+
1314
+ 'The language is CLOSED, so this does not become legal by waiting (core §10, `LANGUAGE-EXTENSION-POLICY`). '+
1315
+ 'Record the fact in a publication manifest instead, spelled x-<owner>-<key> '+
1316
+ '(spec/figdown-manifest.md §2.1); propose the construct through .github/CONTRIBUTING.md §2; '+
1317
+ 'or fork with your own header token. Identifiers are untouched: node x-foo "A" and '+
1318
+ 'class x-bar "meaning" both parse';
1319
+ const X_RESERVED_KW = kw => '"'+kw+'" is RESERVED, not an extension keyword'+X_RESERVED_WHY;
1320
+ const X_RESERVED_OPT = k => 'option "'+k+'=" is RESERVED, not an extension option key'+X_RESERVED_WHY;
1321
+ const isXReserved = s => typeof s==='string' && s.slice(0,2)==='x-';
1187
1322
  // `SCENE-KEYWORD-MEMBERSHIP`: a word WITHDRAWN FROM ONE GENRE is not an unknown word,
1188
1323
  // and `"threshold" is not allowed in genre topology` would send an author
1189
1324
  // looking for a typo. Each cell below was legal until 0.3 and states
@@ -1418,6 +1553,74 @@ function seqModel(doc){
1418
1553
  return {rows,cont,owned,extent,chain,cycles};
1419
1554
  }
1420
1555
 
1556
+ // `XML-CHARACTER-LEGALITY` (core §15.5) — XML-ILLEGAL CHARACTERS ARE A LINE ERROR.
1557
+ //
1558
+ // Every artifact is an XML document (core §7 embeds the source in one), and
1559
+ // XML 1.0 forbids a fixed set of code points OUTRIGHT: they cannot appear
1560
+ // literally, and they cannot be written as a character reference either, so
1561
+ // there is no escaping that rescues them. Until this rule the engine passed
1562
+ // them through: a label containing a raw U+0000 parsed clean, rendered clean,
1563
+ // and produced an `.svg` that NO conforming XML reader will open. Nothing
1564
+ // caught it — not the parser, which had no opinion, and not any gate, because
1565
+ // every document in the tree was clean.
1566
+ //
1567
+ // Refusing is the §8 principle, not a new one: a document with errors renders
1568
+ // NOTHING, because a drawing the engine cannot stand behind is worse than no
1569
+ // drawing. An artifact that is not well-formed is exactly that case — the
1570
+ // figure is unreadable by the machine reader the whole format exists to serve
1571
+ // — so it costs a line error, at parse time, before anything is drawn.
1572
+ // Determinism over convenience.
1573
+ //
1574
+ // THE SET, and why it is stated as ranges rather than "control characters":
1575
+ // U+0000–U+0008, U+000B, U+000C, U+000E–U+001F C0 controls, EXCEPT
1576
+ // U+0009 tab, U+000A LF and
1577
+ // U+000D CR, which are legal
1578
+ // and stay legal
1579
+ // U+FFFE, U+FFFF permanently unassigned
1580
+ // an UNPAIRED surrogate (U+D800–U+DFFF) not a character at all; a
1581
+ // correctly paired astral
1582
+ // character is legal and is
1583
+ // NOT flagged
1584
+ // U+007F DEL and the C1 range are legal XML 1.0 characters and are NOT
1585
+ // refused — this rule enforces XML's list, not a taste for printability.
1586
+ const XML_ILLEGAL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/;
1587
+ function xmlIllegalErrors(text){
1588
+ const out=[];
1589
+ const lines=String(text).split('\n');
1590
+ for(let li=0; li<lines.length; li++){
1591
+ const line=lines[li];
1592
+ const seen=new Set();
1593
+ for(let i=0;i<line.length;i++){
1594
+ const c=line.charCodeAt(i);
1595
+ let bad=false;
1596
+ if(XML_ILLEGAL.test(line[i])) bad=true;
1597
+ else if(c>=0xD800&&c<=0xDBFF){ // high surrogate: needs a low one
1598
+ const n=line.charCodeAt(i+1);
1599
+ if(!(n>=0xDC00&&n<=0xDFFF)) bad=true; else i++;
1600
+ }
1601
+ else if(c>=0xDC00&&c<=0xDFFF) bad=true; // lone low surrogate
1602
+ if(!bad) continue;
1603
+ // One error per DISTINCT character per line. A 1 MB label of NULs is one
1604
+ // defect and one fix, and 1 000 000 identical lines would bury every
1605
+ // other diagnostic in the pass — the same reason §8.1 caps duplication.
1606
+ const cp='U+'+c.toString(16).toUpperCase().padStart(4,'0');
1607
+ if(seen.has(cp)) continue;
1608
+ seen.add(cp);
1609
+ // The tail names the RULE the character broke, and there are two rules,
1610
+ // so there are two tails. One generic sentence would be wrong for half
1611
+ // the set — "only tab, newline and carriage return are allowed" says
1612
+ // nothing true about U+FFFF — and a diagnostic that misdescribes the
1613
+ // rule sends the author to the wrong fix.
1614
+ const tail = c<0x20
1615
+ ? ' (below U+0020 only tab, newline and carriage return are allowed)'
1616
+ : ' (U+FFFE, U+FFFF and unpaired surrogates are not characters — no escape writes one)';
1617
+ out.push('Line '+(li+1)+': illegal character '+cp+
1618
+ ' — XML forbids it, so the artifact could not be well-formed'+tail);
1619
+ }
1620
+ }
1621
+ return out;
1622
+ }
1623
+
1421
1624
  // parse(text) -> {doc, errs, docs}
1422
1625
  // Single-section: docs=[doc] (backward-compatible doc/errs).
1423
1626
  // Multi-section: one doc per figdown header; errs use full-file line numbers;
@@ -1430,12 +1633,20 @@ function parse(text){
1430
1633
  // makes the normative rule ("a BOM at the very start of the document is
1431
1634
  // ignored") implementable from the spec alone. Behaviour is unchanged.
1432
1635
  text=String(text).replace(/^\uFEFF/,'');
1636
+ // `XML-CHARACTER-LEGALITY`: the XML-illegal-character sweep runs HERE \u2014 over the whole document,
1637
+ // before it is split into sections \u2014 for two reasons. Its line numbers are
1638
+ // already full-file, which is what every other message is re-based to below;
1639
+ // and the rule is about the SOURCE, not about any directive, so it must see
1640
+ // comments and blank lines too. Its findings JOIN the pass rather than
1641
+ // replacing it (\u00A78: all errors report in one pass), so an author fixing a
1642
+ // NUL still sees the typo on the next line in the same run.
1643
+ const xmlErrs=xmlIllegalErrors(text);
1433
1644
  const secs=splitFigdownSections(text);
1434
1645
  if(!secs){
1435
1646
  const r=parseOne(String(text));
1436
- return {doc:r.doc, errs:r.errs, docs:[r.doc]};
1647
+ return {doc:r.doc, errs:xmlErrs.concat(r.errs), docs:[r.doc]};
1437
1648
  }
1438
- const docs=[]; const errs=[];
1649
+ const docs=[]; const errs=xmlErrs.slice();
1439
1650
  for(const sec of secs){
1440
1651
  const r=parseOne(sec.text);
1441
1652
  // A section's element `.line` values are section-local, and a GEOMETRY-time
@@ -1453,6 +1664,197 @@ function parse(text){
1453
1664
  return {doc:docs[0], errs, docs};
1454
1665
  }
1455
1666
 
1667
+ // ── THE CONNECTOR SCANNER, WITH A RETURN VALUE ────────────────
1668
+ // `EDGE-LABEL-PLACEMENT`/`REVERSE-ARROW-OPERATOR`'s connector grammar — `<kw> <id> [tail] <op> [head] <id>` — used to
1669
+ // live entirely INSIDE `parseOne`, as `parseEdgeLine`'s local scanner, and it
1670
+ // reported only through `err()` and a push into `doc.edges`/`doc.messages`.
1671
+ // That put it out of reach of the editor's GUI half: the code in the UI
1672
+ // section needs the SOURCE OFFSETS of each part of a connector line to edit
1673
+ // one label, one operator or one endpoint in place, leaving every other byte of
1674
+ // line alone (the editor's edge-editing requirement). A GUI cannot answer that
1675
+ // with a pattern of its own — a second grammar is a second answer to
1676
+ // `edge a -[hop #1]-> b`, to `edge a--b`, to `["]"]`, to `-[x]->` and to every
1677
+ // other case this scanner already settles, and the two answers would drift the
1678
+ // first time either side moved. That is the drift class `NODE_KW_ALT` was
1679
+ // created to close for node lines and it is the same class here.
1680
+ //
1681
+ // So the tokenizer moved OUT and grew a return value; NOTHING about the
1682
+ // grammar changed. There is still exactly ONE connector grammar, now with two
1683
+ // callers: `parseEdgeLine`, which adds the option pass, every semantic check
1684
+ // and the model write, and `scanConnectorLine`, which adds nothing and writes
1685
+ // nothing. Every syntax diagnostic is produced HERE and merely relayed by the
1686
+ // parser, so the two callers cannot disagree about what is legal.
1687
+ //
1688
+ // Offsets are UTF-16 indices into the string passed in — the parser passes the
1689
+ // line already trimmed, exactly as it did before.
1690
+ function scanConnector(s,kw){
1691
+ kw=kw||'edge';
1692
+ let i=kw.length; // past the connector keyword
1693
+ const bad=(m)=>({ok:false,error:m});
1694
+ const ws=()=>{ while(i<s.length&&/\s/.test(s[i])) i++; };
1695
+ // `LINK-OPERATOR-IN-IDS`: a hyphen is an id character only when it is NOT
1696
+ // followed by a second one, because `--` is the link operator. This is
1697
+ // what lets `edge a--b` mean the same thing as `bundle t1 a--b`; before
1698
+ // the ban the greedy scanner ate `a--b` as one id and then reported
1699
+ // "edge needs an operator", so the same token read oppositely two lines
1700
+ // apart (SYNTAX-STYLE §6.3).
1701
+ const readId=()=>{ const m=/^[A-Za-z_](?:[A-Za-z0-9_]|-(?!-))*/.exec(s.slice(i));
1702
+ if(!m) return null; i+=m[0].length; return m[0]; };
1703
+ // `QUOTED-IDS`: an endpoint is an id position. A quoted token or a spelling that
1704
+ // is not an id gets the ID RULE — not "edge needs <id> …", which named
1705
+ // the wrong thing when the operator was plainly there.
1706
+ const idHere=()=>i<s.length&&!/[\s[\-<>]/.test(s[i]);
1707
+ const readLbl=()=>{ // called at '['
1708
+ i++;
1709
+ if(s[i]==='"'){ // ["..."] — quoted content
1710
+ i++; let v='';
1711
+ while(i<s.length&&s[i]!=='"'){
1712
+ if(s[i]==='\\'){ const e=s[i+1];
1713
+ if(e==='n'){ v+='\n'; i+=2; continue; }
1714
+ if(e==='"'){ v+='"'; i+=2; continue; }
1715
+ if(e==='\\'){ v+='\\'; i+=2; continue; }
1716
+ return {error:'unknown escape "\\'+(e||'')+'" (allowed: \\n \\" \\\\)'}; }
1717
+ v+=s[i]; i++;
1718
+ }
1719
+ if(i>=s.length) return {error:'unterminated string in [label]'};
1720
+ i++;
1721
+ if(s[i]!==']') return {error:'expected ] after quoted label'};
1722
+ i++;
1723
+ if(!v) return {error:'empty [label]'};
1724
+ return {v};
1725
+ }
1726
+ let depth=1,v='';
1727
+ while(i<s.length){
1728
+ const c=s[i];
1729
+ if(c==='[') depth++;
1730
+ else if(c===']'){ depth--; if(!depth){ i++;
1731
+ v=v.trim();
1732
+ if(!v) return {error:'empty [label]'};
1733
+ return {v}; } }
1734
+ v+=c; i++;
1735
+ }
1736
+ return {error:'unterminated [label] — for unbalanced brackets use ["..."]'};
1737
+ };
1738
+ // Spans are recorded in SOURCE ORDER and are half-open [start,end). A label
1739
+ // span covers its BRACKETS as well as its text, because the edit a GUI makes
1740
+ // to a written label is a replacement of the whole `[...]` (deleting one
1741
+ // means deleting its brackets too); `mid` is the one span nested inside
1742
+ // another, since `-[x]->` is one operator with a label in the middle of it.
1743
+ // An ABSENT label gets no span at all, which is how a caller tells
1744
+ // `edge a -> b` from `edge a [t] -> b` — the value is `null` in both the
1745
+ // field and the span, and an empty `[]` is a line error, never a third state.
1746
+ const sp={};
1747
+ ws();
1748
+ if(s[i]==='"') return bad(ID_RULE);
1749
+ const aAt=i;
1750
+ const a=readId();
1751
+ if(!a) return bad(idHere()?ID_RULE:kw+' needs <id> ->|<-|--|<-> <id>');
1752
+ if(idHere()) return bad(ID_RULE);
1753
+ sp.a={start:aAt,end:i};
1754
+ ws(); let tail=null;
1755
+ if(s[i]==='['){ const at=i; const r=readLbl(); if(r.error) return bad(r.error);
1756
+ tail=r.v; sp.tail={start:at,end:i}; }
1757
+ ws();
1758
+ const opAt=i;
1759
+ let lh=null;
1760
+ if(s.startsWith('<-',i)){ lh='<-'; i+=2; }
1761
+ else if(s[i]==='-'){ lh='-'; i++; }
1762
+ else return bad(kw+' needs an operator: -> <- -- <-> (a [mid] label splits it: -[x]->)');
1763
+ let mid=null, op=null;
1764
+ if(s[i]==='['){
1765
+ const at=i; const r=readLbl(); if(r.error) return bad(r.error);
1766
+ mid=r.v; sp.mid={start:at,end:i};
1767
+ if(s.startsWith('->',i)){ op=lh==='<-'?'<->':'->'; i+=2; }
1768
+ else if(s[i]==='-'){ op=lh==='<-'?'<-':'--'; i++; }
1769
+ else return bad('expected - or -> to close the operator after [label]');
1770
+ } else if(lh==='<-'){
1771
+ if(s[i]==='>'){ op='<->'; i++; } else op='<-';
1772
+ } else {
1773
+ if(s[i]==='-'){ op='--'; i++; }
1774
+ else if(s[i]==='>'){ op='->'; i++; }
1775
+ else return bad(kw+' needs an operator: -> <- -- <->');
1776
+ }
1777
+ sp.connector={start:opAt,end:i};
1778
+ ws(); let head=null;
1779
+ if(s[i]==='['){ const at=i; const r=readLbl(); if(r.error) return bad(r.error);
1780
+ head=r.v; sp.head={start:at,end:i}; }
1781
+ ws();
1782
+ if(s[i]==='"') return bad(ID_RULE);
1783
+ const bAt=i;
1784
+ const b=readId();
1785
+ if(!b) return bad(idHere()?ID_RULE:kw+' needs a target id after the operator');
1786
+ if(idHere()) return bad(ID_RULE);
1787
+ sp.b={start:bAt,end:i};
1788
+ // Everything after endpoint `b` is the OPTION REGION, and it starts at `b`'s
1789
+ // last byte, not at the next non-space: an option edit that began anywhere
1790
+ // earlier could reach back into `[head]`, which is the one thing the caller
1791
+ // asked this span to make impossible. The parser tokenizes exactly this
1792
+ // slice, and `message`'s trailing quoted label lives in it too.
1793
+ sp.options={start:i,end:s.length};
1794
+ return {ok:true,keyword:kw,a,b,op,tail,mid,head,spans:sp};
1795
+ }
1796
+
1797
+ // The connector word this genre spells at this version, or the diagnostic that
1798
+ // says so. `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`'s answer lives here rather than at the dispatch below
1799
+ // because the parser and `scanConnectorLine` must give the same one: a GUI
1800
+ // that offered to edit a `flowline` line in a `block` document would be
1801
+ // offering to edit a line error.
1802
+ const connectorWordError=(surf,genre,version)=>{
1803
+ const want=connectorKwAt(genre,version);
1804
+ if(!want) return '"'+surf+'" is not allowed in genre '+genre;
1805
+ if(surf===want) return null;
1806
+ const need=CONNECTOR_MIN_VERSION[surf];
1807
+ // The author wrote a word this genre really does use — just not in
1808
+ // the version they declared. That is a different mistake from the
1809
+ // wrong domain's word, and it has a different one-step fix.
1810
+ if(need && version && need>version && GENRE_CONNECTOR_KW[genre]===surf)
1811
+ return WRONG_VERSION_WORD(surf,want,genre,need,version);
1812
+ return WRONG_WORD(surf,want,genre);
1813
+ };
1814
+
1815
+ // scanConnectorLine(code, genre, version) — the connector grammar, READ-ONLY,
1816
+ // for a caller that has one line and wants to know what is where in it.
1817
+ //
1818
+ // {ok:true, keyword, a, b, op, tail, mid, head,
1819
+ // spans:{a, tail?, connector, mid?, head?, b, options}}
1820
+ // {ok:false, error}
1821
+ //
1822
+ // `code` is a CODE-ONLY line: the caller has already cut any trailing comment
1823
+ // (`findComment`), because a `#` inside `[a hop #1]` is not a comment and only
1824
+ // the caller knows where its line came from. Leading and trailing whitespace
1825
+ // are tolerated and the offsets account for them, so a span can be spliced
1826
+ // straight back into the string that was passed in.
1827
+ //
1828
+ // It mutates nothing, appends to no error array, and never throws for input:
1829
+ // a line that is not a connector at all — or is one the genre does not use, or
1830
+ // is malformed — comes back as `{ok:false,error}` carrying the same sentence
1831
+ // the parser would have reported for it.
1832
+ function scanConnectorLine(code, genre, version){
1833
+ if(typeof code!=='string') return {ok:false,error:'connector line must be a string'};
1834
+ const s=code.trim();
1835
+ const off=code.length-code.replace(/^\s+/,'').length;
1836
+ const m=CONN_LINE_RE.exec(s);
1837
+ if(!m) return {ok:false,error:'not a connector line — it does not begin with '+
1838
+ [...CONNECTOR_SPELLINGS].join(', ')};
1839
+ const surf=m[1];
1840
+ // The genre gate is OPTIONAL: with no genre named, any of the four spellings
1841
+ // scans, which is what a caller inspecting a fragment out of context needs.
1842
+ if(genre && GENRE_KW[genre]){
1843
+ const e=connectorWordError(surf,genre,version);
1844
+ if(e) return {ok:false,error:e};
1845
+ }
1846
+ const r=scanConnector(s,surf);
1847
+ if(!r.ok) return r;
1848
+ for(const k in r.spans){
1849
+ r.spans[k]={start:r.spans[k].start+off,end:r.spans[k].end+off};
1850
+ }
1851
+ // The option region runs to the END of the line the caller passed, trailing
1852
+ // whitespace included, so replacing that span replaces every option and
1853
+ // nothing else.
1854
+ r.spans.options.end=code.length;
1855
+ return r;
1856
+ }
1857
+
1456
1858
  function parseOne(text){
1457
1859
  const errs=[];
1458
1860
  // `EMPTY-LABEL-STATE`: `title` and a plane label start ABSENT (null), never as an empty
@@ -1485,13 +1887,21 @@ function parseOne(text){
1485
1887
  // rule), so no existing golden moves a byte.
1486
1888
  lifelines:[],messages:[],states:[],fragments:[],operands:[]};
1487
1889
  const nodeIds=new Set(), groupIds=new Set(), planeIds=new Set(['base']), classIds=new Set(),
1488
- bundleIds=new Set(), boundaryIds=new Set(), blockIds=new Set();
1890
+ bundleIds=new Set(), boundaryIds=new Set(), blockIds=new Set(),
1891
+ // `CONNECTOR-IDENTITY-KEY`: the connector ids written with `id=`. They are a
1892
+ // SET beside the others only for bookkeeping — for UNIQUENESS they are
1893
+ // part of the one shared namespace `dupId` tests, because a `bundle`
1894
+ // member that is a bare token must resolve to exactly one kind of
1895
+ // thing. A separate namespace would have made `bundle t1 "T" x` mean
1896
+ // one thing when `x` is a node and another when it is a connector, and
1897
+ // both at once when it is both.
1898
+ edgeIds=new Set();
1489
1899
  // §1: "IDs are ... unique per document" — nodes, groups, boundaries AND the
1490
1900
  // typed blocks (bitfield/table/timing) share ONE namespace, so a bare id in
1491
1901
  // `edge`/`pin`/`chart` can never be ambiguous. `plane`, `class` and
1492
1902
  // `bundle` keep their own namespaces: each is referenced through a dedicated
1493
1903
  // option or keyword, never as a bare id.
1494
- const dupId=id=>nodeIds.has(id)||groupIds.has(id)||boundaryIds.has(id)||blockIds.has(id);
1904
+ const dupId=id=>nodeIds.has(id)||groupIds.has(id)||boundaryIds.has(id)||blockIds.has(id)||edgeIds.has(id);
1495
1905
  let cur=null; // current typed block (bitfield/table/timing)
1496
1906
  // `REPEATED-DIRECTIVE-HANDLING`: `title`, `flow`, `layout` and a per-id `pin` are SINGLE-VALUED — a
1497
1907
  // repetition is a line error on the second occurrence, never a silent
@@ -1525,86 +1935,19 @@ function parseOne(text){
1525
1935
  // `edge`, `flowline` or `transition`. One scanner, three words: every
1526
1936
  // message names the word on the line, and nothing downstream of here knows
1527
1937
  // the difference (the model records a connector, not a spelling).
1938
+ //
1939
+ // 0.4: the scanner itself is `scanConnector`, at top level, so the
1940
+ // editor's GUI half can read the same grammar through `scanConnectorLine`.
1941
+ // What is left here is everything a SCAN cannot do: the option pass, the
1942
+ // semantic checks, and the model write. Every syntax diagnostic below the
1943
+ // call is relayed verbatim from the scanner, so this function reports
1944
+ // exactly what it reported when the scanner was local to it.
1528
1945
  function parseEdgeLine(s,n,kw){
1529
1946
  kw=kw||'edge';
1530
- let i=kw.length; // past the connector keyword
1531
- const ws=()=>{ while(i<s.length&&/\s/.test(s[i])) i++; };
1532
- // `LINK-OPERATOR-IN-IDS`: a hyphen is an id character only when it is NOT
1533
- // followed by a second one, because `--` is the link operator. This is
1534
- // what lets `edge a--b` mean the same thing as `bundle t1 a--b`; before
1535
- // the ban the greedy scanner ate `a--b` as one id and then reported
1536
- // "edge needs an operator", so the same token read oppositely two lines
1537
- // apart (SYNTAX-STYLE §6.3).
1538
- const readId=()=>{ const m=/^[A-Za-z_](?:[A-Za-z0-9_]|-(?!-))*/.exec(s.slice(i));
1539
- if(!m) return null; i+=m[0].length; return m[0]; };
1540
- // `QUOTED-IDS`: an endpoint is an id position. A quoted token or a spelling that
1541
- // is not an id gets the ID RULE — not "edge needs <id> …", which named
1542
- // the wrong thing when the operator was plainly there.
1543
- const idHere=()=>i<s.length&&!/[\s[\-<>]/.test(s[i]);
1544
- const readLbl=()=>{ // called at '['
1545
- i++;
1546
- if(s[i]==='"'){ // ["..."] — quoted content
1547
- i++; let v='';
1548
- while(i<s.length&&s[i]!=='"'){
1549
- if(s[i]==='\\'){ const e=s[i+1];
1550
- if(e==='n'){ v+='\n'; i+=2; continue; }
1551
- if(e==='"'){ v+='"'; i+=2; continue; }
1552
- if(e==='\\'){ v+='\\'; i+=2; continue; }
1553
- return {error:'unknown escape "\\'+(e||'')+'" (allowed: \\n \\" \\\\)'}; }
1554
- v+=s[i]; i++;
1555
- }
1556
- if(i>=s.length) return {error:'unterminated string in [label]'};
1557
- i++;
1558
- if(s[i]!==']') return {error:'expected ] after quoted label'};
1559
- i++;
1560
- if(!v) return {error:'empty [label]'};
1561
- return {v};
1562
- }
1563
- let depth=1,v='';
1564
- while(i<s.length){
1565
- const c=s[i];
1566
- if(c==='[') depth++;
1567
- else if(c===']'){ depth--; if(!depth){ i++;
1568
- v=v.trim();
1569
- if(!v) return {error:'empty [label]'};
1570
- return {v}; } }
1571
- v+=c; i++;
1572
- }
1573
- return {error:'unterminated [label] — for unbalanced brackets use ["..."]'};
1574
- };
1575
- ws();
1576
- if(s[i]==='"'){ err(n,ID_RULE); return; }
1577
- const a=readId();
1578
- if(!a){ err(n, idHere()?ID_RULE:kw+' needs <id> ->|<-|--|<-> <id>'); return; }
1579
- if(idHere()){ err(n,ID_RULE); return; }
1580
- ws(); let tail=null;
1581
- if(s[i]==='['){ const r=readLbl(); if(r.error){ err(n,r.error); return; } tail=r.v; }
1582
- ws();
1583
- let lh=null;
1584
- if(s.startsWith('<-',i)){ lh='<-'; i+=2; }
1585
- else if(s[i]==='-'){ lh='-'; i++; }
1586
- else { err(n,kw+' needs an operator: -> <- -- <-> (a [mid] label splits it: -[x]->)'); return; }
1587
- let mid=null, op=null;
1588
- if(s[i]==='['){
1589
- const r=readLbl(); if(r.error){ err(n,r.error); return; } mid=r.v;
1590
- if(s.startsWith('->',i)){ op=lh==='<-'?'<->':'->'; i+=2; }
1591
- else if(s[i]==='-'){ op=lh==='<-'?'<-':'--'; i++; }
1592
- else { err(n,'expected - or -> to close the operator after [label]'); return; }
1593
- } else if(lh==='<-'){
1594
- if(s[i]==='>'){ op='<->'; i++; } else op='<-';
1595
- } else {
1596
- if(s[i]==='-'){ op='--'; i++; }
1597
- else if(s[i]==='>'){ op='->'; i++; }
1598
- else { err(n,kw+' needs an operator: -> <- -- <->'); return; }
1599
- }
1600
- ws(); let head=null;
1601
- if(s[i]==='['){ const r=readLbl(); if(r.error){ err(n,r.error); return; } head=r.v; }
1602
- ws();
1603
- if(s[i]==='"'){ err(n,ID_RULE); return; }
1604
- const b=readId();
1605
- if(!b){ err(n, idHere()?ID_RULE:kw+' needs a target id after the operator'); return; }
1606
- if(idHere()){ err(n,ID_RULE); return; }
1607
- const tk2=tokenize(s.slice(i).trim());
1947
+ const sc=scanConnector(s,kw);
1948
+ if(!sc.ok){ err(n,sc.error); return; }
1949
+ const {a,b,op,tail,mid,head}=sc;
1950
+ const tk2=tokenize(s.slice(sc.spans.options.start).trim());
1608
1951
  if(tk2.error){ err(n,tk2.error); return; }
1609
1952
  const {pos:p2,posq:pq2,opts:o2,optT:oT2,unk:u2,dup:d2}=splitOpts(tk2.toks);
1610
1953
  if(d2){ err(n,'duplicate option "'+d2+'=" on one line'); return; }
@@ -1615,7 +1958,12 @@ function parseOne(text){
1615
1958
  // `message` is scanned by this function and never reaches that one.
1616
1959
  if(u2.length){
1617
1960
  const ro=(doc.genre&&REFUSED_OPT_IN[doc.genre])||null;
1618
- if(ro && ro[u2[0]]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u2[0],doc.genre));
1961
+ // `RESERVED-PREFIX-ENFORCEMENT` (2026-08-23): CALL SITE 2a of 2 — the reserved `x-` prefix in
1962
+ // OPTION-KEY position, on the connector path. `message` is scanned by
1963
+ // this function and never reaches `badOpts`, which is why the check has
1964
+ // to exist in both places (the same reason `UNDELIVERED-MESSAGE-MARKING`'s genre refusal does).
1965
+ if(isXReserved(u2[0])) err(n,X_RESERVED_OPT(u2[0]));
1966
+ else if(ro && ro[u2[0]]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u2[0],doc.genre));
1619
1967
  else err(n,'unknown option "'+u2[0]+'="');
1620
1968
  return; }
1621
1969
  // `SEQUENCE-GENRE-VOCABULARY`: `message` adds ONE trailing positional — the
@@ -1667,6 +2015,26 @@ function parseOne(text){
1667
2015
  if(belowOptVersion('note',doc.version)){ err(n,NOTE_VERSION(doc.version)); return; }
1668
2016
  if(!optQ(oT2,'note')){ err(n,'note= must be quoted: note="'+o2.note+'" — '+Q_WHY); return; }
1669
2017
  }
2018
+ // `CONNECTOR-IDENTITY-KEY`: `id=` — the connector's optional handle. Three checks
2019
+ // in the order an author's next move is decided by:
2020
+ // 1. the VERSION GATE, first, so a 0.4 document is told the one thing it
2021
+ // can do about the line rather than being told its id is malformed;
2022
+ // 2. the ID LEXIS, `QUOTED-IDS`'s one wording for every id position, so a
2023
+ // connector id is spelled exactly as a node id is (bare, no `--`);
2024
+ // 3. UNIQUENESS. A connector id joins the SECTION's existing
2025
+ // node/group/external/region namespace, which is what makes a bare
2026
+ // single-token `bundle` member decidable — a member either resolves to
2027
+ // one connector or names something that is not one, and never both. So
2028
+ // the diagnostic is `duplicate id`, the one §8.1 already has, and not a
2029
+ // new one: this is the same namespace, not a parallel one.
2030
+ let eid;
2031
+ if(o2.id!==undefined){
2032
+ if(belowOptVersion('id',doc.version)){ err(n,ID_VERSION(doc.version)); return; }
2033
+ const e=idErr(o2.id, optHasQ(oT2,'id'), 'id= needs an id: '+kw+' '+a+' '+op+' '+b+' id=<id>');
2034
+ if(e){ err(n,e); return; }
2035
+ if(dupId(o2.id)){ err(n,'duplicate id "'+o2.id+'"'); return; }
2036
+ eid=o2.id;
2037
+ }
1670
2038
  // `RULE-POSITION-ENUMERATION`: and the enum half of RULE 2.4, for the one enum key `edge` takes.
1671
2039
  // Checked before the value, exactly as `badOpts` does it.
1672
2040
  if(o2.style!==undefined && optHasQ(oT2,'style')){ err(n,ENUM_BARE('style='+o2.style)); return; }
@@ -1703,7 +2071,8 @@ function parseOne(text){
1703
2071
  // ruling taken here: the draft settles the trailing form and says
1704
2072
  // nothing about the brackets. `[tail]` and `[head]` are kept — they are
1705
2073
  // different positions, not a second spelling of the same one.
1706
- doc.messages.push({a,b,op,tail,head,
2074
+ if(eid!==undefined) edgeIds.add(eid);
2075
+ doc.messages.push({id:eid,a,b,op,tail,head,
1707
2076
  label:seqLabel!==null?seqLabel:mid,
1708
2077
  style:o2.style,cls:ecls,stroke:o2.stroke,note:o2.note,
1709
2078
  desc:o2.description,in:o2['in']||null,line:n});
@@ -1712,7 +2081,8 @@ function parseOne(text){
1712
2081
  // §5 on an edge: the line IS a stroke and has no interior, so `stroke=`
1713
2082
  // and `fill=` name the same channel (`stroke=` wins when both are
1714
2083
  // written); `text=` colours the [tail]/[mid]/[head] labels.
1715
- doc.edges.push({a,b,op,tail,mid,head,style:o2.style,cls:ecls,
2084
+ if(eid!==undefined) edgeIds.add(eid);
2085
+ doc.edges.push({id:eid,a,b,op,tail,mid,head,style:o2.style,cls:ecls,
1716
2086
  stroke:o2.stroke,note:o2.note,
1717
2087
  plane:o2.plane||'base',line:n});
1718
2088
  }
@@ -1897,20 +2267,13 @@ function parseOne(text){
1897
2267
  // `KEYWORD-RENAME-SCOPE`: the word this genre uses is read AT THE DECLARED VERSION, not
1898
2268
  // at the newest one. A genre with no connector at all (bitfield, table,
1899
2269
  // timing) still gets the allowlist message.
2270
+ // 0.4: the three answers this gate can give are spelled once, in
2271
+ // `connectorWordError`, because `scanConnectorLine` has to give the same
2272
+ // three to the GUI. The `sawHeader` guard stays here: it is about where
2273
+ // the parser is in the document, which no scanner of one line can know.
1900
2274
  if(sawHeader && doc.genre && GENRE_KW[doc.genre]){
1901
- const want=connectorKwAt(doc.genre, doc.version);
1902
- if(!want){ err(n,'"'+ckw+'" is not allowed in genre '+doc.genre); continue; }
1903
- if(ckw!==want){
1904
- const need=CONNECTOR_MIN_VERSION[ckw];
1905
- // The author wrote a word this genre really does use — just not in
1906
- // the version they declared. That is a different mistake from the
1907
- // wrong domain's word, and it has a different one-step fix.
1908
- if(need && doc.version && need>doc.version &&
1909
- GENRE_CONNECTOR_KW[doc.genre]===ckw)
1910
- err(n, WRONG_VERSION_WORD(ckw,want,doc.genre,need,doc.version));
1911
- else
1912
- err(n, WRONG_WORD(ckw,want,doc.genre));
1913
- continue; }
2275
+ const e=connectorWordError(ckw, doc.genre, doc.version);
2276
+ if(e){ err(n,e); continue; }
1914
2277
  }
1915
2278
  parseEdgeLine(raw.trim(),n,ckw);
1916
2279
  continue;
@@ -1941,7 +2304,12 @@ function parseOne(text){
1941
2304
  // ground and the replacement spelling instead of a spellcheck.
1942
2305
  const roOpt=(doc.genre&&REFUSED_OPT_IN[doc.genre])||null;
1943
2306
  for(const u of unk){
1944
- if(roOpt && roOpt[u]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u,doc.genre));
2307
+ // `RESERVED-PREFIX-ENFORCEMENT` (2026-08-23): CALL SITE 2b of 2 — the reserved `x-` prefix in
2308
+ // OPTION-KEY position, on the ordinary directive path. Checked before
2309
+ // the genre refusal and before the generic message, because a key the
2310
+ // language will never register is a stronger fact than either.
2311
+ if(isXReserved(u)) err(n,X_RESERVED_OPT(u));
2312
+ else if(roOpt && roOpt[u]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u,doc.genre));
1945
2313
  else err(n,'unknown option "'+u+'="');
1946
2314
  bad=true; }
1947
2315
  // `MEMBERSHIP-KEY-ACCEPTANCE`: the PER-GENRE option-key withdrawal, checked here —
@@ -2123,6 +2491,18 @@ function parseOne(text){
2123
2491
  } else { err(n,'first line must be "figdown 0.1 <genre>"'); }
2124
2492
  } else if(kw==='figdown'){ err(n,'duplicate version header'); continue; }
2125
2493
 
2494
+ // `RESERVED-PREFIX-ENFORCEMENT` (2026-08-23): CALL SITE 1 of 2 — the reserved `x-` prefix in
2495
+ // KEYWORD position. It sits here, above every other keyword answer, for
2496
+ // two reasons: no registered keyword begins `x-`, so it can pre-empt
2497
+ // nothing legitimate; and every downstream answer this would otherwise
2498
+ // reach (`not allowed in genre <g>`, `not valid inside <block>`,
2499
+ // `unrecognized line`) is a message about a typo, which is what §8.4 of
2500
+ // decisions/registry.md measured and what this replaces.
2501
+ // It sits BELOW the first-line header check deliberately: "first line
2502
+ // must be figdown …" is the more important thing to tell an author, and
2503
+ // an `x-` first line reports both, exactly as it reported two before.
2504
+ if(isXReserved(kw)){ err(n,X_RESERVED_KW(kw)); continue; }
2505
+
2126
2506
  // Retired spelling: `colw` → `width`. Keyword naming
2127
2507
  // discipline — one lowercase word, borrowed standard terminology; `colw`
2128
2508
  // was the only invented abbreviation in the registry. Reported wherever
@@ -2396,7 +2776,7 @@ function parseOne(text){
2396
2776
  if(/[2-9]/.test(lane)){ err(n,RETIRED_LANE_DIGIT); continue; }
2397
2777
  if(!/^[01pnx=.]+$/.test(lane)){ err(n,'lane may contain only 0 1 p n x = .'); continue; }
2398
2778
  // / `TYPED-BLOCK-SILENT-FALLBACK`: data= is ABSENCE vs presence. An empty value, empty
2399
- // members (a,b), or a count that does not match the lane's `=`
2779
+ // members (a,,b), or a count that does not match the lane's `=`
2400
2780
  // cells are all line errors — never silent drop or shift.
2401
2781
  // Spelled `labels=` until 0.1 (`SIGNAL-DATA-KEY-SPELLING`): WaveDrom's own key is
2402
2782
  // `data`, "an array of signal labels" naming every value cell, and
@@ -2718,26 +3098,53 @@ function parseOne(text){
2718
3098
  // the half-converted line the old tolerance could not report.
2719
3099
  if(restT.length>1){
2720
3100
  err(n,'bundle members take ONE comma-delimited token: write bundle '+id+(tlabel===null?'':' "'+tlabel+'"')+' '+joinListForm(restT.map(t=>t.v))+' — the space form is retired (MIGRATIONS 0.1)'); break; }
2721
- const pairs=[]; let badp=null;
3101
+ // `CONNECTOR-IDENTITY-KEY`: a member is an endpoint PAIR or a connector ID,
3102
+ // in the same comma list, told apart by LEXIS and not by a flag: `--`
3103
+ // cannot occur inside an id (`LINK-OPERATOR-IN-IDS`), so a member either contains it and
3104
+ // is a pair or does not and is an id. RULE 5 is satisfied because each
3105
+ // form reaches input the other cannot — the pair form reaches ANONYMOUS
3106
+ // connectors, which have no id to name; the id form reaches PARALLEL
3107
+ // connectors, which no pair can address — so neither is a spelling
3108
+ // variant of the other. Mixed lists need no rule of their own: every
3109
+ // member resolves independently.
3110
+ //
3111
+ // `members` is the AUTHORED list in source order and is what the model
3112
+ // projects. `pairs` stays what it always was — the endpoint pairs the
3113
+ // ring geometry reads — and an id member appends its resolved pair to
3114
+ // it in the semantic pass below, once the connector it names exists.
3115
+ const pairs=[], members=[]; let badp=null;
2722
3116
  outerB:
2723
3117
  for(const t of restT){
2724
3118
  for(const s of splitList(t,0)){
2725
3119
  const mem=s.v.trim(); if(!mem) continue;
2726
3120
  if(s.h){ badp=ID_RULE; break outerB; }
3121
+ if(!mem.includes('--')){
3122
+ // A bare token. Under figdown 0.4 and below there is no id form,
3123
+ // so the answer is the one it always was: this is a malformed
3124
+ // pair. Naming the id form to a document that cannot write it
3125
+ // would send the author to a key their header does not have.
3126
+ if(belowOptVersion('id',doc.version)){
3127
+ badp='bad member "'+mem+'" (expected A--B)'; break outerB; }
3128
+ if(!isId(mem)){
3129
+ badp='bad member "'+mem+'" (expected A--B or a connector id)'; break outerB; }
3130
+ members.push({id:mem});
3131
+ continue;
3132
+ }
2727
3133
  const parts=mem.split('--');
2728
3134
  if(parts.length!==2||!isId(parts[0])||!isId(parts[1])){
2729
3135
  badp='bad member "'+mem+'" (expected A--B)'; break outerB; }
2730
3136
  pairs.push([parts[0],parts[1]]);
3137
+ members.push({a:parts[0],b:parts[1]});
2731
3138
  }
2732
3139
  }
2733
3140
  if(badp){ err(n,badp); break; }
2734
- if(!pairs.length){ err(n,'bundle needs at least one member link A--B'); break; }
3141
+ if(!members.length){ err(n,'bundle needs at least one member link A--B'); break; }
2735
3142
  bundleIds.add(id);
2736
3143
  // §5 on the derived ring: `fill=` stays the ring colour (stroke +
2737
3144
  // label) as before, `stroke=`/`text=` split it, `style=` picks the
2738
3145
  // dash (the conventional default is dashed), `plane=` orders this
2739
3146
  // ring against the other rings.
2740
- doc.trunks.push({id,label:tlabel,pairs,stroke:opts.stroke,
3147
+ doc.trunks.push({id,label:tlabel,pairs,members,stroke:opts.stroke,
2741
3148
  style:opts.style,plane:opts.plane,line:n});
2742
3149
  break;
2743
3150
  }
@@ -3181,13 +3588,85 @@ function parseOne(text){
3181
3588
  for(const f of doc.bands)
3182
3589
  if(!groupIds.has(f.target)&&!nodeIds.has(f.target)&&!regionTarget(f.target))
3183
3590
  errs.push('Line '+f.line+': unknown target "'+f.target+'" for band');
3184
- for(const t of doc.trunks) for(const [a,b] of t.pairs){
3185
- if((!nodeIds.has(a)&&!boundaryIds.has(a))||(!nodeIds.has(b)&&!boundaryIds.has(b))){ errs.push('Line '+t.line+': unknown endpoint in "'+a+'--'+b+'"'); continue; }
3186
- const matches=doc.edges.filter(e=>(e.a===a&&e.b===b)||(e.a===b&&e.b===a)).length;
3187
- if(matches===0)
3188
- errs.push('Line '+t.line+': no edge between "'+a+'" and "'+b+'" for bundle member');
3189
- else if(matches>1)
3190
- errs.push('Line '+t.line+': "'+a+'--'+b+'" is ambiguous ('+matches+' parallel edges); parallel edges are out of scope for v0.1');
3591
+ // `MEMBER-LIST-DUPLICATION`: a bundle member repeated in ONE member list is a
3592
+ // line error the identity-model-proposal's probe 5, a live defect
3593
+ // independent of edge identity. `bundle t1 "T" a--b,a--b` used to parse
3594
+ // silently into a two-member bundle over one link. The duplicate test is
3595
+ // on the RESOLVED edge (the same *Edge* object each pair member resolves
3596
+ // to), not on the spelling, so `a--b,b--a` is caught too — two spellings
3597
+ // of an undirected pair that resolve to one edge are one member named
3598
+ // twice, not two. Stating the rule this way, rather than as "the same
3599
+ // string written twice", is deliberate: it is the form that survives the
3600
+ // future id-form member (§4.1 of the proposal) without a rewrite, because
3601
+ // an id and a pair spelling can resolve to the same edge too. `seen` is
3602
+ // per BUNDLE — each trunk's member list is checked against itself, not
3603
+ // against any other bundle's.
3604
+ //
3605
+ // `CONNECTOR-IDENTITY-KEY`: the loop walks the AUTHORED member list, so a member is
3606
+ // resolved by the form it was written in and the diagnostic names what the
3607
+ // author wrote. The duplicate test does not change and did not need to: it
3608
+ // was already bound to the resolved *Edge* object, which is exactly why an id
3609
+ // member and a pair member naming ONE connector are caught by it without a
3610
+ // second rule — the whole reason `MEMBER-LIST-DUPLICATION` was written that way.
3611
+ for(const t of doc.trunks){
3612
+ const seen=new Set();
3613
+ for(const m of (t.members||t.pairs.map(p=>({a:p[0],b:p[1]})))){
3614
+ // The written spelling, for every message this loop can produce.
3615
+ const spelled=m.id!==undefined?m.id:m.a+'--'+m.b;
3616
+ let hit=null;
3617
+ if(m.id!==undefined){
3618
+ // The id form. It resolves against the connectors of THIS SECTION and
3619
+ // nothing else: ids are unique per section (core §1) and a
3620
+ // cross-section reference is a line error by design, so an id from a
3621
+ // neighbouring section arrives here as an id this section never
3622
+ // declared — which is what the second message says, in the words that
3623
+ // are true for it.
3624
+ const named=doc.edges.filter(e=>e.id===m.id);
3625
+ // What the id DOES name, when it does not name a connector. The kind is
3626
+ // in the message because it is the whole of the author's next move: a
3627
+ // node id in a member list is a `bundle b "B" a--b` written short, and
3628
+ // a class id there is a different mistake entirely.
3629
+ const kind=nodeIds.has(m.id)&&!edgeIds.has(m.id)?'a node':
3630
+ groupIds.has(m.id)?'a group':
3631
+ boundaryIds.has(m.id)?'an external endpoint':
3632
+ blockIds.has(m.id)?'a region':
3633
+ classIds.has(m.id)?'a class':
3634
+ bundleIds.has(m.id)?'a bundle':null;
3635
+ if(named.length===1) hit=named[0];
3636
+ else if(kind){
3637
+ errs.push('Line '+t.line+': bundle member "'+m.id+'" is '+kind+', not a connector — a bundle collects LINKS, so write the pair A--B or give the connector you mean an id= and name that');
3638
+ continue;
3639
+ } else {
3640
+ errs.push('Line '+t.line+': bundle member "'+m.id+'" is not a declared connector in this section (ids are unique per section, and a bare member names a connector by its id=)');
3641
+ continue;
3642
+ }
3643
+ // The ring reads endpoint pairs, so a resolved id member contributes
3644
+ // the pair its connector runs between. Nothing about the drawing is
3645
+ // authored here (`DOMAIN-CONVENTION-DIRECTIVES`) — which links the ring encloses is.
3646
+ t.pairs.push([hit.a,hit.b]);
3647
+ } else {
3648
+ const a=m.a, b=m.b;
3649
+ if((!nodeIds.has(a)&&!boundaryIds.has(a))||(!nodeIds.has(b)&&!boundaryIds.has(b))){ errs.push('Line '+t.line+': unknown endpoint in "'+a+'--'+b+'"'); continue; }
3650
+ const matches=doc.edges.filter(e=>(e.a===a&&e.b===b)||(e.a===b&&e.b===a));
3651
+ if(matches.length===0){
3652
+ errs.push('Line '+t.line+': no edge between "'+a+'" and "'+b+'" for bundle member'); continue; }
3653
+ if(matches.length>1){
3654
+ // `CONNECTOR-IDENTITY-KEY` REPLACES probe 6's message. The old one cited `v0.1` in a 0.4
3655
+ // engine, stated a SCOPE EXCLUSION where the truth is "say which one
3656
+ // you mean", and named no remedy. Parallel links are the defining
3657
+ // case of a LAG, so a bundle that cannot name one member of three is
3658
+ // a construct whose definition and whose grammar disagree; now there
3659
+ // is a way out and the message is the place the author meets it.
3660
+ errs.push('Line '+t.line+': "'+a+'--'+b+'" matches '+matches.length+
3661
+ ' edges — the pair form addresses a connector only where ONE runs between the two nodes. Give the connector you mean an id= and write that id as the member (figdown 0.5)');
3662
+ continue; }
3663
+ hit=matches[0];
3664
+ }
3665
+ if(seen.has(hit))
3666
+ errs.push('Line '+t.line+': duplicate bundle member "'+spelled+'" — each member is stated once');
3667
+ else
3668
+ seen.add(hit);
3669
+ }
3191
3670
  }
3192
3671
  // `ELEMENT-GEOMETRY-DIRECTIVE`: `pin` has a SPLIT DOMAIN, and both halves are checked
3193
3672
  // here because both need the finished id sets.
@@ -3252,7 +3731,7 @@ function parseOne(text){
3252
3731
  // is that genre's designed idiom, not an oversight.
3253
3732
  //
3254
3733
  // `INTERIOR-LESS-ELEMENT-PAINT`'s half stands and now reaches EVERY collection that accepts
3255
- // `class=` (`CLASS-CHANNEL-REACH`). Until this release the loop below ran over `doc.edges`
3734
+ // `class=` (`CLASS-CHANNEL-REACH`). Until 0.4 the loop below ran over `doc.edges`
3256
3735
  // alone, so `class k "K" fill=#eee` plus `message c -> s "m" class=k` was
3257
3736
  // accepted, painted nothing, and put the class in the legend — a message
3258
3737
  // has its own collection because it has a position in time (`SEQUENCE-ORDER-MODEL`), and
@@ -3303,6 +3782,7 @@ function parseOne(text){
3303
3782
  };
3304
3783
  const clsChan=(x,kind)=>{
3305
3784
  const K=CLASS_CHANNELS[kind];
3785
+ const claimed={}; // channel -> class id that already set it on THIS element (`CLASS-CHANNEL-COLLISION`)
3306
3786
  for(const cid of (x.cls===undefined||x.cls===null?[]:(Array.isArray(x.cls)?x.cls:[x.cls]))){
3307
3787
  const c=doc.classes.find(y=>y.id===cid);
3308
3788
  if(!c) continue; // unknown id: its own error
@@ -3311,8 +3791,29 @@ function parseOne(text){
3311
3791
  if(!K.has.includes('fill')&&c.fill!==undefined&&c.stroke===undefined){
3312
3792
  errs.push('Line '+x.line+': class "'+cid+'" sets fill= but no stroke=, and '+K.a+' has no interior — add stroke= to the class (it paints '+K.a.replace(/^an? /,'the ')+'; fill= keeps painting members that have an interior) (MIGRATIONS 0.1)');
3313
3793
  continue; }
3314
- if(!decl.some(k=>K.has.includes(k)))
3794
+ if(!decl.some(k=>K.has.includes(k))){
3315
3795
  errs.push('Line '+x.line+': class "'+cid+'" declares only '+decl.map(k=>k+'=').join(' and ')+', and '+K.a+' has no such channel — add '+K.has.map(k=>k+'=').join(' or ')+' to the class (they paint '+K.a.replace(/^an? /,'the ')+'; the key it declares keeps painting members that have that channel) (MIGRATIONS 0.4)');
3796
+ continue; }
3797
+ // `CLASS-CHANNEL-COLLISION`: two CARRIED CLASSES binding the SAME paint
3798
+ // channel on one element is a line error — §8's own rule ("a repeated
3799
+ // option key on ONE line is a line error, never last-wins") one level
3800
+ // up, closing the hole a class boundary opened in it. The check is
3801
+ // per element, per channel the member actually has (K.has): the
3802
+ // first carried class to set a channel CLAIMS it, and a later class
3803
+ // repeating a claimed channel is refused by name. Element-direct
3804
+ // `fill=`/`stroke=`/`style=` is untouched — it already overrides
3805
+ // every class (rigidity, `LAYOUT-STABILITY`) and is not a second class SOURCE.
3806
+ // `class=hot,deprecated` in conformance case 308 is the shape this
3807
+ // must keep legal: each channel comes from exactly one class.
3808
+ for(const k of K.has){
3809
+ if(c[k]===undefined) continue;
3810
+ if(claimed[k]!==undefined){
3811
+ const other=K.has.filter(o=>o!==k).map(o=>o+'=').join('/');
3812
+ errs.push('Line '+x.line+': classes "'+claimed[k]+'" and "'+cid+'" both set '+k+'= on this element — one channel, one class: move one class\'s paint to a different channel ('+other+'), or carry only one of them');
3813
+ } else {
3814
+ claimed[k]=cid;
3815
+ }
3816
+ }
3316
3817
  }
3317
3818
  };
3318
3819
  for(const x of doc.nodes) clsChan(x,'node');
@@ -3472,6 +3973,163 @@ function stackSectionSvgs(results){
3472
3973
  +chunks.join('')+'</svg>';
3473
3974
  }
3474
3975
 
3976
+ // ---- the accessibility profile's emission (spec/figdown-a11y.md) ----------
3977
+ // `ACCESSIBILITY-PROFILE`/`ACCESSIBLE-DESCRIPTION-SOURCES`/`ACCESSIBLE-TEXT-EMISSION` (decisions/registry.md).
3978
+ // The publication profile wants three things in a published artifact: a role,
3979
+ // a NON-VISUAL name, and a description whose review state a machine can read.
3980
+ // None of it is emitted by default and none of it is a language construct —
3981
+ // the `with-a11y` RENDER OPTION (core §7, `ACCESSIBLE-TEXT-EMISSION` route 1) is the only way in,
3982
+ // exactly as `with-title` (`TITLE-RENDER-DEFAULT`) is the only way to draw the title as ink.
3983
+ //
3984
+ // WHY THIS SITS OUTSIDE `render` AND NOT INSIDE IT. Two reasons, and the
3985
+ // second is the load-bearing one:
3986
+ // 1. Core §7/`RENDERING-DETERMINISM` make an artifact a pure function of (source, recorded
3987
+ // options) and promise byte-identical output for one engine version.
3988
+ // Code that never runs cannot move a byte, and `render` is left exactly
3989
+ // as it was so that every existing artifact and golden is untouched.
3990
+ // 2. `ACCESSIBILITY-PROFILE` rules ONE artifact, ONE root, ONE name — the FIRST section's
3991
+ // `title`. A multi-section artifact's root is built by
3992
+ // `stackSectionSvgs` from N per-section renders, so a `<title>` emitted
3993
+ // inside `render` would land inside a `<g>` (naming the group, per `DESCRIPTION-KEY-SPELLING`
3994
+ // and SVG 1.1 §5.4) and there would be N of them. The name belongs to
3995
+ // the finished root, which only the artifact layer holds.
3996
+ // So the artifact layer (tools/build-svg.js, dist/figdown.js's `artifact()`)
3997
+ // calls `a11yApply(svg, docs[0])` on the finished root and records
3998
+ // `with-a11y` in `data-render-options`.
3999
+ const A11Y_ROLE = 'graphics-document';
4000
+
4001
+ // `ACCESSIBILITY-PROFILE` item 3: the accessible name is the document's `title` string,
4002
+ // UNMODIFIED — not truncated, not prefixed, never invented. `title` is
4003
+ // optional in the grammar and stays optional (`ACCESSIBILITY-PROFILE` is explicit that the
4004
+ // untitled figure still parses and still renders): a document with no
4005
+ // `title` line gets NO root `<title>`, and whether such a figure may be
4006
+ // PUBLISHED is the profile's question and the verifier's, never the
4007
+ // engine's. The test is ABSENCE, not truthiness (`EMPTY-LABEL-STATE`): `title ""` is a
4008
+ // written empty name and is emitted as one.
4009
+ function a11yTitleOf(doc){
4010
+ return (doc && doc.title!==null && doc.title!==undefined) ? String(doc.title) : null;
4011
+ }
4012
+
4013
+ // ---- the derived description (`ACCESSIBLE-DESCRIPTION-SOURCES` item 7) --------------------------------
4014
+ // A `derived` description is a deterministic projection of the MODEL into
4015
+ // prose — "exactly as good as the model, and no better" — and `ACCESSIBLE-DESCRIPTION-SOURCES`'s
4016
+ // operative half is the NEGATIVE rule: it MUST NOT state what the model does
4017
+ // not assert. No causality, no "this shows how X works", no count the model
4018
+ // cannot produce. Every sentence below is an inventory statement over a
4019
+ // collection the model actually carries.
4020
+ //
4021
+ // COVERED GENRES ARE EXACTLY THE THREE `ACCESSIBLE-DESCRIPTION-SOURCES` RULED CONTENT RULES FOR:
4022
+ // `topology`, `bitfield`, `sequence`. Every other genre the engine supports
4023
+ // (`block`, `flowchart`, `statechart`, `table`, `timing`, and the
4024
+ // experimental `chart` region) gets NO derived description — the state stays
4025
+ // `absent` — because inventing content rules for them here would be design,
4026
+ // not implementation. That boundary is stated, not silent: spec/figdown-a11y.md
4027
+ // §4.4 records which genres are covered, which are not, and why.
4028
+ const A11Y_DERIVED_GENRES = ['topology','bitfield','sequence'];
4029
+ function a11yQ(s){ return '"'+String(s)+'"'; }
4030
+ function a11yCount(n,word){ return n+' '+word+(n===1?'':'s'); }
4031
+ // id, plus the label the model carries WHEN it carries one. The renderer's
4032
+ // id-for-absent-label fallback (`OMITTED-LABEL-RECORDING`) is a DISPLAY rule and deliberately does
4033
+ // not run here: the model records absence, and a derived description that
4034
+ // silently promoted an id to a name would assert a name nobody wrote.
4035
+ function a11yNamed(x){
4036
+ return (x.label===null||x.label===undefined) ? String(x.id) : String(x.id)+' '+a11yQ(x.label);
4037
+ }
4038
+ function a11yDeriveTopology(doc){
4039
+ const s=[];
4040
+ s.push('Topology figure'+(a11yTitleOf(doc)!==null?' '+a11yQ(doc.title):'')+'.');
4041
+ s.push(a11yCount(doc.nodes.length,'node')+', '+a11yCount(doc.edges.length,'connector')
4042
+ +', '+a11yCount(doc.groups.length,'group')+'.');
4043
+ s.push('Flow direction: '+String(doc.flow)+'.');
4044
+ if(doc.nodes.length) s.push('Nodes: '+doc.nodes.map(a11yNamed).join(', ')+'.');
4045
+ for(const g of doc.groups){
4046
+ const mem=doc.nodes.filter(n=>n.group===g.id).map(n=>String(n.id));
4047
+ s.push('Group '+a11yNamed(g)+(mem.length?' contains: '+mem.join(', ')+'.':' has no declared members.'));
4048
+ }
4049
+ // A class's MEANING is `label` on the engine object (the canonical
4050
+ // projection renames it `meaning`). `ACCESSIBLE-DESCRIPTION-SOURCES` says "each declared class and its
4051
+ // stated meaning (which the legend already prints)", so the legend's own
4052
+ // filter is inherited rather than re-invented: `class x ""` is an attribute
4053
+ // grouping that states no meaning, it prints no legend entry, and it makes
4054
+ // no sentence here either.
4055
+ for(const c of (doc.classes||[])) if(c.label!=='')
4056
+ s.push('Class '+String(c.id)+' means '+a11yQ(c.label)+'.');
4057
+ return s.join(' ');
4058
+ }
4059
+ function a11yDeriveBitfield(doc){
4060
+ const s=[];
4061
+ s.push('Bitfield figure'+(a11yTitleOf(doc)!==null?' '+a11yQ(doc.title):'')+'.');
4062
+ const blocks=(doc.blocks||[]).filter(b=>b.type==='bitfield');
4063
+ if(!blocks.length) return null;
4064
+ for(const b of blocks){
4065
+ s.push('Bitfield '+a11yNamed(b)+': word '+a11yCount(b.word,'bit')
4066
+ +', numbering '+String(b.numbering)+'.');
4067
+ // `wrap` entries are ROW BREAKS (projected as `break`), a division of the
4068
+ // drawing and not a field; `ACCESSIBLE-DESCRIPTION-SOURCES`'s rule is "fields in order with widths".
4069
+ // A field's width is `w` on the engine object: a bit count, or the string
4070
+ // `*` for the remainder of the row.
4071
+ const fields=(b.fields||[]).filter(f=>!f.wrap);
4072
+ if(!fields.length){ s.push('No fields declared.'); continue; }
4073
+ s.push('Fields in order: '+fields.map(f=>
4074
+ a11yQ(f.name)+' '+(String(f.w)==='*'?'width *':a11yCount(f.w,'bit'))).join(', ')+'.');
4075
+ }
4076
+ return s.join(' ');
4077
+ }
4078
+ function a11yDeriveSequence(doc){
4079
+ const s=[];
4080
+ s.push('Sequence figure'+(a11yTitleOf(doc)!==null?' '+a11yQ(doc.title):'')+'.');
4081
+ const lls=doc.lifelines||[], msgs=doc.messages||[], frags=doc.fragments||[];
4082
+ s.push('Lifelines in declaration order: '+(lls.length?lls.map(a11yNamed).join(', '):'none')+'.');
4083
+ // The time axis is declaration order and it is TOTAL (`SEQUENCE-GENRE-VOCABULARY`, draft §31),
4084
+ // so "in their stated total order" is `line` ascending — the model's own
4085
+ // ordering key, not a re-reading of the picture.
4086
+ const ordered=msgs.slice().sort((p,q)=>p.line-q.line);
4087
+ s.push('Messages in stated order: '+(ordered.length?ordered.map((m,i)=>
4088
+ (i+1)+'. '+String(m.a)+' '+String(m.op)+' '+String(m.b)
4089
+ +(m.label===null||m.label===undefined?'':' '+a11yQ(m.label))).join('; '):'none')+'.');
4090
+ if(frags.length) s.push('Fragments: '+frags.map(f=>
4091
+ a11yNamed(f)+' (type '+String(f.type)+')').join(', ')+'.');
4092
+ return s.join(' ');
4093
+ }
4094
+ function a11yDerivedDesc(doc){
4095
+ if(!doc||A11Y_DERIVED_GENRES.indexOf(doc.genre)<0) return null;
4096
+ if(doc.genre==='topology') return a11yDeriveTopology(doc);
4097
+ if(doc.genre==='bitfield') return a11yDeriveBitfield(doc);
4098
+ if(doc.genre==='sequence') return a11yDeriveSequence(doc);
4099
+ return null; // state `absent` — an uncovered genre, spec/figdown-a11y.md §4.4
4100
+ }
4101
+
4102
+ // Apply the profile to a FINISHED root `<svg>`: the role on the root, the
4103
+ // non-visual `<title>` as its FIRST child (SVG 1.1 §5.4 / `DESCRIPTION-KEY-SPELLING` — a `<title>`
4104
+ // names its parent and belongs first), then the `<desc>` carrying the derived
4105
+ // description with its state in a `data-*` attribute (`ACCESSIBLE-DESCRIPTION-SOURCES` item 6, way 1; the
4106
+ // manifest carries the evidence, way 3). Idempotent: a root that already
4107
+ // declares a role is returned unchanged.
4108
+ function a11yApply(svg, doc){
4109
+ const s=String(svg);
4110
+ const m=/^<svg\b[^>]*>/.exec(s);
4111
+ if(!m) return s;
4112
+ if(/\brole="/.test(m[0])) return s;
4113
+ const parts=[];
4114
+ const t=a11yTitleOf(doc);
4115
+ if(t!==null) parts.push('<title>'+esc(t)+'</title>');
4116
+ const d=a11yDerivedDesc(doc);
4117
+ if(d!==null&&d!=='') parts.push('<desc data-desc-state="derived">'+esc(d)+'</desc>');
4118
+ return m[0].replace(/>$/,' role="'+A11Y_ROLE+'">')+parts.join('')+s.slice(m[0].length);
4119
+ }
4120
+
4121
+ // The `data-render-options` value for a render (core §7). ONE spelling, in
4122
+ // ONE place, so the artifact layers cannot drift: options in declaration
4123
+ // order, space-separated — the SVG/HTML idiom for a token list. A default
4124
+ // render writes no attribute at all, which is why the empty string is
4125
+ // returned rather than an empty attribute.
4126
+ function renderOptionsAttr(opts){
4127
+ const names=[];
4128
+ if(opts&&opts.title===true) names.push('with-title');
4129
+ if(opts&&opts.a11y===true) names.push('with-a11y');
4130
+ return names.length?' data-render-options="'+names.join(' ')+'"':'';
4131
+ }
4132
+
3475
4133
  // `SEMICOLON-STATUS`: `;` is RESERVED for a future statement separator, and
3476
4134
  // RULE 1.3 says a reserved mark MUST NOT be given any other meaning. Until
3477
4135
  // this release it was an ordinary character: `node a ;` parsed and `;`
@@ -3548,7 +4206,7 @@ function findComment(s){
3548
4206
  // ============================================================
3549
4207
  const FONT=13, CH=7.2, PADX=14, NH=36, GAPX=56, GAPY=44;
3550
4208
 
3551
- // ---- cw(): script-aware advance width --------------------------------
4209
+ // ---- cw(): script-aware advance width (`TEXT-ADVANCE-MEASUREMENT`) --------------------------------
3552
4210
  // Every px-per-character constant in this file — CH here, and its siblings at
3553
4211
  // the other font sizes (8.6 title, 6.6 legend, 6.5 edge label, 6.3/6.2 bitfield
3554
4212
  // caption) — is calibrated on LATIN, where one character advances ~0.554 em.
@@ -3730,8 +4388,9 @@ function noteSvg(x,y,box,carrier){
3730
4388
  // about "a group" and no more — so the association the source states was
3731
4389
  // lost on the way to the page even when the placement was perfect. The pair
3732
4390
  // (kind, ref) is unambiguous: `ref` is the element's identity in this very
3733
- // drawing — a `data-node` id, a `data-group` id, or the `data-edge` line
3734
- // number a connector is keyed by, since a connector has no id to carry.
4391
+ // drawing — a `data-node` id, a `data-group` id, or whatever `data-edge`
4392
+ // keys a connector by, which from `CONNECTOR-IDENTITY-KEY` is the connector's
4393
+ // AUTHORED id where it has one and its source line where it does not.
3735
4394
  // `title` is the one kind with no ref and needs none: it names the FIGURE,
3736
4395
  // and there is exactly one. The attribute changes no pixel.
3737
4396
  const ref=carrier&&carrier.ref!==undefined&&carrier.ref!==null?' data-note-for="'+esc(String(carrier.ref))+'"':'';
@@ -4118,15 +4777,80 @@ function render(doc,ropts){
4118
4777
  // Retiring `color=` removes the first case; omitting the swatch removes
4119
4778
  // the second. Every channel a class can still declare (`fill`, `stroke`,
4120
4779
  // `style`) is drawn, so "declared but not shown" is now unreachable.
4780
+ // 0.5 (`LEGEND-SWATCH-SHAPE`): the swatch depicts THE CHANNEL THE CLASS ACTUALLY
4781
+ // PAINTS. `CLASS-PAINT-REQUIREMENT` ruled WHETHER a swatch is drawn and never WHAT SHAPE it
4782
+ // takes, so every painting class got a `<rect>` — and a class that
4783
+ // declares only `stroke=` and is carried only by CONNECTORS then appeared
4784
+ // as a white box with a coloured outline while the drawing showed a
4785
+ // coloured LINE. The legend's visual vocabulary did not match the
4786
+ // figure's, and with `style=dashed` the dash landed on a rectangle's
4787
+ // perimeter instead of along a run. Measured on 2026-08-26: 36 of this
4788
+ // repository's 76 legend-bearing class-uses are connector-only (33 of them
4789
+ // stroke-without-fill) and 10 figures are entirely connector-only; in the
4790
+ // production corpus behind the field reports, 160 of 1350 legend-bearing
4791
+ // class-uses are connector-only and 159 of those are stroke-without-fill.
4792
+ //
4793
+ // WHICH KINDS ARE CONNECTORS is read off the model, not guessed and not
4794
+ // stored: the four connector spellings (`edge` `flowline` `transition`
4795
+ // `message`, core §10) are exactly `doc.edges` and `doc.messages` in the
4796
+ // model — every other collection that carries `class=` is a box, a frame
4797
+ // or a cell, i.e. something with an area. This adds no model field, no
4798
+ // option key and no spelling; it is a byte-moving RENDER change.
4799
+ const clsUse={};
4800
+ { const use=(x,conn)=>{
4801
+ const ids=(x.cls===undefined||x.cls===null)?[]:(Array.isArray(x.cls)?x.cls:[x.cls]);
4802
+ for(const id of ids){ const u=clsUse[id]||(clsUse[id]={conn:false,box:false});
4803
+ if(conn) u.conn=true; else u.box=true; } };
4804
+ for(const x of doc.nodes) use(x,false);
4805
+ for(const x of doc.groups) use(x,false);
4806
+ for(const x of doc.edges) use(x,true);
4807
+ for(const x of (doc.messages||[])) use(x,true);
4808
+ for(const x of (doc.lifelines||[])) use(x,false);
4809
+ for(const x of (doc.states||[])) use(x,false);
4810
+ for(const x of (doc.fragments||[])) use(x,false);
4811
+ for(const x of (doc.operands||[])) use(x,false);
4812
+ for(const b of doc.blocks){ use(b,false);
4813
+ if(b.fields) for(const f of b.fields) use(f,false);
4814
+ if(b.marks) for(const mk of b.marks) use(mk,false); } }
4121
4815
  for(const c of legendCls){
4122
4816
  const paints=c.fill!==undefined||c.stroke!==undefined||c.style!==undefined;
4123
- const tw=cw(c.label)*6.6+(paints?30:9);
4124
- if(lx>0 && lx+tw>wrapW){ lx=0; ly+=rowH; }
4817
+ // The samples this class earns, in the ruled order: LINE then BOX.
4818
+ // · carried only by connectors → a line sample (`LEGEND-SWATCH-SHAPE` clause 1)
4819
+ // · carried only by boxes → the `<rect>` `CLASS-PAINT-REQUIREMENT` already drew (2)
4820
+ // · carried by both → both samples, side by side (3): the
4821
+ // legend exists to explain, and the extra width is cheaper than
4822
+ // making a reader guess which half of the figure an entry is about.
4823
+ // · declares `fill=` → a box sample WHATEVER it is carried
4824
+ // by (4), because a fill needs an area to be visible. A box on a
4825
+ // connector-only class is then the class declaring a channel its
4826
+ // elements cannot show — `CLASS-CHANNEL-COLLISION`'s territory, reported there, not
4827
+ // re-ruled here (and `gate:legend`'s unreachable-channel check
4828
+ // already refuses that shape in this repository's corpus).
4829
+ // · declares no paint → no sample at all (5, `CLASS-PAINT-REQUIREMENT` unchanged).
4830
+ // · declared but never carried → the shape comes from the DECLARED
4831
+ // channels alone: `fill` → box, stroke-only → line. Nothing else is
4832
+ // known about it, and its own paint is the only evidence there is.
4833
+ const u=clsUse[c.id];
4834
+ const samples=[];
4125
4835
  if(paints){
4126
- const dash=c.style==='dashed'?' stroke-dasharray="6 4"':(c.style==='dotted'?' stroke-dasharray="2 4"':'');
4127
- es.push('<rect x="'+lx+'" y="'+(ly+3)+'" width="16" height="11" fill="'+(c.fill||'#fff')+'" stroke="'+(c.stroke||'#555')+'"'+dash+'/>');
4836
+ if(u ? u.conn : c.fill===undefined) samples.push('line');
4837
+ if(u ? (u.box||c.fill!==undefined) : c.fill!==undefined) samples.push('box');
4838
+ }
4839
+ const tw=cw(c.label)*6.6+9+21*samples.length;
4840
+ if(lx>0 && lx+tw>wrapW){ lx=0; ly+=rowH; }
4841
+ const dash=c.style==='dashed'?' stroke-dasharray="6 4"':(c.style==='dotted'?' stroke-dasharray="2 4"':'');
4842
+ let sx=lx;
4843
+ for(const s of samples){
4844
+ // Both samples take the SAME 16 px advance, so a mixed entry is two
4845
+ // swatches and not a wider one. The line is drawn at the rect's own
4846
+ // vertical centre (3 + 11/2) and at 1.6 — the stroke-width every
4847
+ // router in this engine gives a drawn connector — so the sample is
4848
+ // the same ink the figure lays down, at the same weight.
4849
+ if(s==='line') es.push('<line x1="'+sx+'" y1="'+(ly+8.5)+'" x2="'+(sx+16)+'" y2="'+(ly+8.5)+'" stroke="'+(c.stroke||'#555')+'" stroke-width="1.6"'+dash+'/>');
4850
+ else es.push('<rect x="'+sx+'" y="'+(ly+3)+'" width="16" height="11" fill="'+(c.fill||'#fff')+'" stroke="'+(c.stroke||'#555')+'"'+dash+'/>');
4851
+ sx+=21;
4128
4852
  }
4129
- es.push('<text x="'+(lx+(paints?21:0))+'" y="'+(ly+12.5)+'" font-size="11" fill="#1d1d1b">'+esc(c.label)+'</text>');
4853
+ es.push('<text x="'+(lx+21*samples.length)+'" y="'+(ly+12.5)+'" font-size="11" fill="#1d1d1b">'+esc(c.label)+'</text>');
4130
4854
  lx+=tw+14; maxW=Math.max(maxW,lx);
4131
4855
  }
4132
4856
  parts.push(es.join(''));
@@ -4860,6 +5584,190 @@ function renderScene(doc,y0){
4860
5584
  }
4861
5585
  }
4862
5586
  }
5587
+ // ── THE RING GEOMETRY IS DEFINED HERE BECAUSE TWO PASSES NEED IT ───────
5588
+ // (item 69.) `ringOf` used to sit with the ring DRAWING, a
5589
+ // thousand lines below, because the ring is derived from final coordinates
5590
+ // and nothing before the draw had a use for it. The lasso-containment rule
5591
+ // gives it a second caller ABOVE: the separation pass immediately below has
5592
+ // to know where each bundle's lasso will fall so it can keep a non-member
5593
+ // out of it, exactly as it already keeps one out of a group band. The
5594
+ // definition moved; not one character of the derivation changed, and the
5595
+ // ring is still COMPUTED (`rings`, below the label pass) at the geometry it
5596
+ // is drawn from, so what the separation pass sees is a PROJECTION and what
5597
+ // the truth check sees is the ring itself.
5598
+ // ── A BUNDLE RING IS ORIENTED BY ITS MEMBERS (item 43) ───────────────────
5599
+ // Until now the ring was derived from the member links' MIDPOINTS and
5600
+ // nothing else: `rx = max(46, x-spread + 38)`, `ry = max(26, y-spread + 22)`.
5601
+ // Two facts about that formula are the defect. It is DIRECTION-BLIND — the
5602
+ // axes are the canvas's, never the links' — and its floors are large enough
5603
+ // that a bundle of two short legs draws a near-circle whatever the legs do.
5604
+ // Measured on `patterns/topology-b`: rx 46 / ry 42.7, aspect
5605
+ // 1.07, sitting on two legs that fan VERTICALLY, with the caption lying
5606
+ // across the waist and on top of the `p3` endpoint label.
5607
+ //
5608
+ // The drawing convention for a link bundle is a loop THROUGH which the
5609
+ // links run: narrow along the links, long across them. So the ring is now
5610
+ // derived from the members' own frame.
5611
+ //
5612
+ // 1. DIRECTION. The mean UNDIRECTED direction of the members, by the
5613
+ // doubled-angle circular mean — doubling is what makes `a--b` and
5614
+ // `b--a` the same direction, so a bundle does not change shape when an
5615
+ // author writes a member the other way round (conformance 394).
5616
+ // 2. THE BAND. The ring sits at the MIDPOINT OF THE MEMBERS' SHARED RUN
5617
+ // along that direction — the stretch of the corridor every member is
5618
+ // actually in. That is the placement rule because it is the only band
5619
+ // at which "the spread of the members" is a fact about all of them: a
5620
+ // fanning set (the reference topology's ECMP pair, which leaves two
5621
+ // different spines and arrives at one leaf) has a different spread at
5622
+ // every station, and the shared run is the interval over which the
5623
+ // question is even well posed. Members with no shared run at all fall
5624
+ // back to the mean of their midpoints, which is the old centre.
5625
+ // 3. THE AXES. MINOR along the mean direction, sized to clear the strokes;
5626
+ // MAJOR across it, the members' spread at the band plus padding. A ring
5627
+ // needs a long axis to read as a ring, and it takes it from the spread
5628
+ // when the members fan (`rA >= rL`) and from its own RUN when they do
5629
+ // not — a single-member bundle (`reference/topology`'s multi-home link)
5630
+ // has zero spread, and the shape that hugs one link is an ellipse lying
5631
+ // ALONG it, never a circle straddling it.
5632
+ // 4. NO NODE CONTACT. The ring is shrunk along its minor axis until it
5633
+ // clears every node box, and if it cannot it reverts to the pre-0.4
5634
+ // geometry rather than inventing a shape (no corpus figure does).
5635
+ //
5636
+ // The caption is NOT placed here — it is registered with the label pass
5637
+ // below, so it can see the endpoint labels and the edges it has to avoid.
5638
+ const rnd3=v=>Math.round(v*1000)/1000; // ring coordinates, printed short
5639
+ const RING_ALONG=15, // semi-minor: the ring's body along the links
5640
+ RING_ACROSS=20, // clearance beyond the outermost member
5641
+ RING_LONG=34, // the long axis a ring needs to read as a ring
5642
+ RING_FAN=2, // below this spread the members are one line
5643
+ RING_FLOOR=7, // how thin the minor axis may be squeezed
5644
+ RING_SOLO=4; // a ONE-member bundle: the caption's stand-off
5645
+ // ── A ONE-MEMBER BUNDLE IS ITS CAPTION (item 53) ─────────────────────────
5646
+ // `bundle mh "multi-home" l1--l2` is legal and means something real (a
5647
+ // one-link LAG, an Ethernet Segment with a single member), so the
5648
+ // declaration is not an error and is not dropped. But a ring exists to
5649
+ // UNITE lines, and around ONE line it unites nothing: it is ink that adds
5650
+ // no fact, and on the reference topology it also bought the seam a 104 px
5651
+ // corridor demand (2*RX_MIN + clearance) for a shape nobody had to see.
5652
+ //
5653
+ // So a single-member bundle DRAWS NO ELLIPSE. What it draws is its
5654
+ // CAPTION, bundle-styled (the trunk's own stroke, the ring caption's type)
5655
+ // and placed BY THE LINK — which is exactly the statement the construct
5656
+ // makes: this link is the bundle, and here is its name. The frame below is
5657
+ // therefore kept as a PLACEMENT frame and not as a drawing: the caption
5658
+ // pass already sites a caption around a ring's rim, so a ring squeezed to
5659
+ // `RING_SOLO` across gives the same pass a candidate family hugging the
5660
+ // link. Nothing else in the pass changes.
5661
+ //
5662
+ // AN EMPTY CAPTION ON A ONE-MEMBER BUNDLE THEREFORE DRAWS NOTHING AT ALL.
5663
+ // That is a real consequence and it is not hidden: the reference figure
5664
+ // keeps `bundle unlabelled "" s2--l2` — it is the form demonstrator for
5665
+ // the empty label — and states in a comment that this form is now
5666
+ // MODEL-ONLY. The model still carries the trunk, `read` still reports it,
5667
+ // and the drawing says nothing because there is nothing a ring around one
5668
+ // unnamed line could say.
5669
+ // Does the ring's disc meet an axis-aligned rect? Both are mapped into the
5670
+ // frame where the ring is the unit circle; the rect becomes a convex quad,
5671
+ // and the test is "is the quad within 1 of the origin".
5672
+ const ringHitsRect=(R,RL,RA,b)=>{
5673
+ const q=[[b.x,b.y],[b.x+b.w,b.y],[b.x+b.w,b.y+b.h],[b.x,b.y+b.h]].map(p=>{
5674
+ const dx=p[0]-R.cx, dy=p[1]-R.cy;
5675
+ return [(R.ux*dx+R.uy*dy)/RL, (R.vx*dx+R.vy*dy)/RA];
5676
+ });
5677
+ let inside=false;
5678
+ for(let i=0,j=3;i<4;j=i++)
5679
+ if((q[i][1]>0)!==(q[j][1]>0) &&
5680
+ 0 < (q[j][0]-q[i][0])*(0-q[i][1])/(q[j][1]-q[i][1])+q[i][0]) inside=!inside;
5681
+ if(inside) return true;
5682
+ for(let i=0,j=3;i<4;j=i++){
5683
+ const vx=q[j][0]-q[i][0], vy=q[j][1]-q[i][1], L2=vx*vx+vy*vy;
5684
+ const t=L2?Math.max(0,Math.min(1,-(q[i][0]*vx+q[i][1]*vy)/L2)):0;
5685
+ if(Math.hypot(q[i][0]+t*vx, q[i][1]+t*vy)<1) return true;
5686
+ }
5687
+ return false;
5688
+ };
5689
+ const ringOf=t=>{
5690
+ const segs=[];
5691
+ for(const [a,b] of t.pairs){
5692
+ const A=byId[a], B=byId[b]; if(!A||!B) continue;
5693
+ const p=borderPoint(A,B.x+B.w/2,B.y+B.h/2), q=borderPoint(B,A.x+A.w/2,A.y+A.h/2);
5694
+ if(Math.hypot(q[0]-p[0],q[1]-p[1])>1e-9) segs.push([p,q]);
5695
+ }
5696
+ if(!segs.length) return null;
5697
+ // (1) mean undirected direction
5698
+ let c2=0,s2=0;
5699
+ for(const [p,q] of segs){
5700
+ const L=Math.hypot(q[0]-p[0],q[1]-p[1]);
5701
+ const c=(q[0]-p[0])/L, s=(q[1]-p[1])/L;
5702
+ c2+=c*c-s*s; s2+=2*c*s;
5703
+ }
5704
+ const th=Math.hypot(c2,s2)<1e-9
5705
+ ? Math.atan2(segs[0][1][1]-segs[0][0][1], segs[0][1][0]-segs[0][0][0])
5706
+ : 0.5*Math.atan2(s2,c2);
5707
+ const ux=Math.cos(th), uy=Math.sin(th), vx=-uy, vy=ux;
5708
+ // (2) the band: the midpoint of the shared run
5709
+ const iv=segs.map(([p,q])=>{
5710
+ const a=ux*p[0]+uy*p[1], b=ux*q[0]+uy*q[1];
5711
+ return a<=b?[a,b,p,q]:[b,a,q,p];
5712
+ });
5713
+ const lo=Math.max(...iv.map(z=>z[0])), hi=Math.min(...iv.map(z=>z[1]));
5714
+ const s=lo<hi ? (lo+hi)/2 : iv.reduce((x,z)=>x+(z[0]+z[1])/2,0)/iv.length;
5715
+ const P=iv.map(z=>{
5716
+ const f=Math.min(1,Math.max(0,(s-z[0])/((z[1]-z[0])||1)));
5717
+ return [z[2][0]+(z[3][0]-z[2][0])*f, z[2][1]+(z[3][1]-z[2][1])*f];
5718
+ });
5719
+ const cx=P.reduce((a,p)=>a+p[0],0)/P.length, cy=P.reduce((a,p)=>a+p[1],0)/P.length;
5720
+ const spread=Math.max(...P.map(p=>Math.abs(vx*(p[0]-cx)+vy*(p[1]-cy))));
5721
+ const runHalf=Math.min(...iv.map(z=>Math.min(s-z[0],z[1]-s)));
5722
+ // (3) axes. Two shapes, and which one is drawn is decided by whether the
5723
+ // members FAN at all. A set that fans is CROSSED by the ring: thin along
5724
+ // the links, long enough across them to take the whole spread with room
5725
+ // to spare, so each member passes through a part of the rim where the
5726
+ // ring is still visibly open. A set that does not fan — one link, or
5727
+ // members lying on top of each other — has no spread to take, and the
5728
+ // shape that says "this link is the bundle" is an ellipse lying ALONG it.
5729
+ let rA, rL;
5730
+ if(spread<RING_FAN){ rL=Math.max(RING_ALONG,Math.min(RING_LONG,Math.max(0,runHalf))); rA=RING_ACROSS; }
5731
+ else { rL=RING_ALONG; rA=Math.max(spread+RING_ACROSS,RING_LONG); }
5732
+ const R={cx,cy,ux,uy,vx,vy,th};
5733
+ // (0) ONE MEMBER (item 53): no ellipse is drawn, so this is a caption
5734
+ // frame and not a shape. It is squeezed across to `RING_SOLO` so the
5735
+ // caption pass's rim stations sit beside the link rather than a ring's
5736
+ // radius away from it, and the node-clearance step below is skipped —
5737
+ // there is no ink to keep out of a box.
5738
+ if(segs.length===1){
5739
+ R.solo=true; R.rL=rL; R.rA=RING_SOLO;
5740
+ R.hw=Math.hypot(rL*ux, RING_SOLO*vx); R.hh=Math.hypot(rL*uy, RING_SOLO*vy);
5741
+ return R;
5742
+ }
5743
+ // (4) out of every node box
5744
+ const boxes=nodes.filter(n=>!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}));
5745
+ if(boxes.some(b=>ringHitsRect(R,rL,rA,b))){
5746
+ let ok=false;
5747
+ for(let k=1;k<=24&&!ok;k++){
5748
+ const RL=rL-(rL-RING_FLOOR)*k/24;
5749
+ if(!boxes.some(b=>ringHitsRect(R,RL,rA,b))){ rL=RL; ok=true; }
5750
+ }
5751
+ // No orientation of this ring clears the drawing. Rather than publish a
5752
+ // ring lying over a node, revert to the pre-0.4 derivation, which is at
5753
+ // least the shape every earlier artifact recorded. No corpus figure
5754
+ // takes this branch; it exists so that a hostile geometry degrades to
5755
+ // the old defect instead of a new one.
5756
+ if(!ok){
5757
+ const M=segs.map(([p,q])=>[(p[0]+q[0])/2,(p[1]+q[1])/2]);
5758
+ const mx=M.reduce((a,m)=>a+m[0],0)/M.length, my=M.reduce((a,m)=>a+m[1],0)/M.length;
5759
+ const lx=Math.max(46,Math.max(...M.map(m=>Math.abs(m[0]-mx)))+38);
5760
+ const ly=Math.max(26,Math.max(...M.map(m=>Math.abs(m[1]-my)))+22);
5761
+ return {cx:mx, cy:my, ux:1, uy:0, vx:0, vy:1, th:0, legacy:true,
5762
+ rL:lx, rA:ly, hw:lx, hh:ly};
5763
+ }
5764
+ }
5765
+ R.rL=rL; R.rA=rA;
5766
+ // the axis-aligned box the caption is placed beside and the canvas grows to
5767
+ R.hw=Math.hypot(rL*ux, rA*vx); R.hh=Math.hypot(rL*uy, rA*vy);
5768
+ return R;
5769
+ };
5770
+
4863
5771
  // ── GROUP BAND CONTIGUITY ────────────────────────────────────────────────
4864
5772
  // A group's band is the BOUNDING BOX of its members (see gBox below), and
4865
5773
  // until this pass nothing checked that the box contained only members. A
@@ -4914,6 +5822,44 @@ function renderScene(doc,y0){
4914
5822
  const canMove=u=>u.every(n=>!pinned(n.id))
4915
5823
  && !(u[0].group&&doc.pins[u[0].group]&&doc.pins[u[0].group].fx!==null);
4916
5824
  const said=new Set();
5825
+ // ── THE SAME REGION RULE FOR A BUNDLE'S LASSO (item 69) ────
5826
+ // A band and a lasso are the same statement drawn two ways: each is a
5827
+ // shape DERIVED from a declared membership, so whatever the shape contains
5828
+ // reads as a member. This pass already keeps a non-member out of a band;
5829
+ // the lasso is added to the same convergence rather than given a pass of
5830
+ // its own, because the two constraints interact — a node pushed out of a
5831
+ // lasso can land in a band, and one pushed out of a band can land in a
5832
+ // lasso — and only a shared loop can settle both.
5833
+ //
5834
+ // CONTAINMENT IS THE WHOLE BOX, NOT ITS CENTRE, which is the band's own
5835
+ // discipline (`inBand` tests the box's extent against the band's, never a
5836
+ // point). The two differ in STRICTNESS, and the difference is forced by
5837
+ // the shapes: a band is FILLED, so a box that merely overlaps it is
5838
+ // already sitting on painted group territory; a lasso is a RING with no
5839
+ // interior (§8.4), and `ringOf` step (4) has already shrunk it clear of
5840
+ // every node box, so a box can only be wholly inside or wholly outside.
5841
+ // Full containment is therefore not a weaker rule here, it is the only
5842
+ // reachable one — and it is the same rule the complete-cover check uses
5843
+ // one construct over (`inside`).
5844
+ //
5845
+ // A ONE-MEMBER BUNDLE DRAWS NO ELLIPSE (item 53) and therefore makes no
5846
+ // containment claim: there is no shape, so there is nothing to be inside,
5847
+ // and `lassoOf` returns null for it. Its caption is a label like any other.
5848
+ // PORT SQUARES need no separate test: a square straddles the border of the
5849
+ // node it belongs to, so a node clear of the ring carries its fittings out
5850
+ // with it. EDGES are not tested at all — a lasso exists to be crossed by
5851
+ // lines, and every member link runs through it by construction.
5852
+ const trunks=(doc.trunks||[]).filter(t=>t.pairs&&t.pairs.length);
5853
+ const lMem=t=>{ const s=new Set(); for(const [a,b] of t.pairs){ s.add(a); s.add(b); } return s; };
5854
+ const lassoOf=t=>{ const R=ringOf(t); return (R&&!R.solo)?R:null; };
5855
+ const inLasso=(n,R)=>{
5856
+ for(const p of [[n.x,n.y],[n.x+n.w,n.y],[n.x,n.y+n.h],[n.x+n.w,n.y+n.h]]){
5857
+ const dx=p[0]-R.cx, dy=p[1]-R.cy;
5858
+ if(Math.hypot((R.ux*dx+R.uy*dy)/R.rL,(R.vx*dx+R.vy*dy)/R.rA)>1) return false;
5859
+ }
5860
+ return true;
5861
+ };
5862
+ const lKey=(t,n)=>'bundle '+t.id+' '+n.id;
4917
5863
  const collect=()=>{
4918
5864
  const out=[];
4919
5865
  for(const g of groups){
@@ -4923,8 +5869,50 @@ function renderScene(doc,y0){
4923
5869
  if(inBand(n,B)) out.push({g,n});
4924
5870
  }
4925
5871
  }
5872
+ for(const t of trunks){
5873
+ const R=lassoOf(t); if(!R) continue;
5874
+ const mem=lMem(t);
5875
+ for(const n of real){
5876
+ if(mem.has(n.id)||said.has(lKey(t,n))) continue;
5877
+ if(inLasso(n,R)) out.push({t,n,mem});
5878
+ }
5879
+ }
4926
5880
  return out;
4927
5881
  };
5882
+ // The MOVE, shared by both regions. `unit` travels; `obst` is the cross-axis
5883
+ // interval it must end up clear of; `keep` is everything that must NOT be
5884
+ // dragged along with it.
5885
+ const push=(unit,obst,keep)=>{
5886
+ const uLo=Math.min(...unit.map(cLo)), uHi=Math.max(...unit.map(n=>cLo(n)+cSz(n)));
5887
+ const dNeg=(obst.lo-SEP)-uHi, dPos=(obst.hi+SEP)-uLo;
5888
+ // NEARER SIDE, BUT NEVER OFF THE CANVAS. The obvious rule — move
5889
+ // whichever way is shorter — sends the unit past the layout's own
5890
+ // starting edge often enough to matter (`reference/topology` put L1 at
5891
+ // x=-90 and the viewBox clipped it away). Growing the canvas the other
5892
+ // way is not available either: the only uniform-shift machinery this
5893
+ // renderer has moves PINNED nodes with everything else, and a pinned
5894
+ // node that drifts because an unrelated node was added is the `RENDERING-DETERMINISM`
5895
+ // stability violation this engine has already paid for once. So the
5896
+ // constraint is applied HERE, to the choice: the negative direction is
5897
+ // taken only when the unit still lands inside the envelope the layout
5898
+ // had before this pass ran. Nothing outside the mover ever moves.
5899
+ const dNegOK=uLo+dNeg>=cross0;
5900
+ const d=(Math.abs(dNeg)<=Math.abs(dPos)&&dNegOK)?dNeg:dPos;
5901
+ const ranks=new Set(unit.map(n=>n.rank));
5902
+ // Everything the mover would be pushed ONTO travels with it: same rank,
5903
+ // same side, clear of the obstacle. Relative order and spacing inside a
5904
+ // lane are preserved, so the fix cannot manufacture an overlap.
5905
+ // A node that BELONGS to a group never travels this way — a group moves
5906
+ // whole or not at all, and dragging half of one along would reshape its
5907
+ // band, which is the same defect one group further on.
5908
+ for(const m of lay){
5909
+ if(keep.has(m)||!ranks.has(m.rank)) continue;
5910
+ if(!m.virtual&&m.group) continue;
5911
+ const mLo=cLo(m), mHi=mLo+cSz(m);
5912
+ if(d<0 ? (mHi<=uHi&&mHi<=obst.lo) : (mLo>=uLo&&mLo>=obst.hi)) mv(m,d);
5913
+ }
5914
+ for(const n of unit) mv(n,d);
5915
+ };
4928
5916
  let left=[];
4929
5917
  // EVERY conflict gets attention on every pass, and the band is recomputed
4930
5918
  // immediately before each resolution. Taking only the first conflict each
@@ -4935,6 +5923,49 @@ function renderScene(doc,y0){
4935
5923
  left=collect();
4936
5924
  if(!left.length) break;
4937
5925
  for(const c of left){
5926
+ if(c.t){
5927
+ // A LASSO CONFLICT. Only the intruder ever yields. The band's second
5928
+ // option — move the GROUP instead — has no honest analogue here: a
5929
+ // bundle's members are links between devices that other links also
5930
+ // touch, so "move the members" relocates half the figure and reshapes
5931
+ // the very ring it is trying to fix. When the intruder cannot move,
5932
+ // an author coordinate is what fixed it, and the rule below (at the
5933
+ // final geometry) says so rather than the engine overriding a pin.
5934
+ const R=lassoOf(c.t);
5935
+ if(!R||!inLasso(c.n,R)) continue; // an earlier resolution cleared it
5936
+ const unit=unitOf(c.n);
5937
+ if(!canMove(unit)){ said.add(lKey(c.t,c.n)); continue; }
5938
+ // Clear of the ring's AXIS-ALIGNED extent, not of the ellipse: a box
5939
+ // outside the bounding box is outside the ring for certain, whatever
5940
+ // the ring's rotation, and the pass stays monotone (always outward).
5941
+ const obst=horiz?{lo:R.cy-R.hh, hi:R.cy+R.hh}:{lo:R.cx-R.hw, hi:R.cx+R.hw};
5942
+ // AND IT MOVES NOTHING BUT THE INTRUDER. The band's push takes what
5943
+ // it would land on along with it; a lasso's cannot, and the reason is
5944
+ // the shape. A band's extent stops at its members' boxes, so a mover
5945
+ // pushed clear of it lands in free space. A lasso's extent, when the
5946
+ // ring falls back to the pre-0.4 derivation — which is what a bundle
5947
+ // whose links pass THROUGH intermediate devices always does, since
5948
+ // the ring cannot clear their boxes — spans the whole fan, so "clear
5949
+ // of the ring" is exactly where the outermost members already are.
5950
+ // Dragging them along was measured: it walks the top member off the
5951
+ // canvas (y=-26 on the three-tap stress figure) and the ring grows
5952
+ // with them, so the intruder is enclosed again on the next pass. So
5953
+ // the move is taken ONLY into room that is already free, and when
5954
+ // there is none the pass declines and the rule below refuses the
5955
+ // figure. A separation that has to damage the drawing to succeed is
5956
+ // not a separation; not drawn beats drawn wrongly, here as well.
5957
+ const uLo=Math.min(...unit.map(cLo)), uHi=Math.max(...unit.map(n=>cLo(n)+cSz(n)));
5958
+ const ranks=new Set(unit.map(n=>n.rank)), inUnit=new Set(unit);
5959
+ const others=lay.filter(m=>!inUnit.has(m)&&ranks.has(m.rank));
5960
+ const roomFor=d=>uLo+d>=cross0 &&
5961
+ others.every(m=>{ const mLo=cLo(m), mHi=mLo+cSz(m);
5962
+ return mHi<=uLo+d-SEP||mLo>=uHi+d+SEP; });
5963
+ const go=[(obst.lo-SEP)-uHi,(obst.hi+SEP)-uLo]
5964
+ .filter(roomFor).sort((p,q)=>Math.abs(p)-Math.abs(q));
5965
+ if(!go.length){ said.add(lKey(c.t,c.n)); continue; }
5966
+ for(const n of unit) mv(n,go[0]);
5967
+ continue;
5968
+ }
4938
5969
  const B=bandOf(c.g);
4939
5970
  if(!inBand(c.n,B)) continue; // an earlier resolution cleared it
4940
5971
  const gMem=memOf(c.g.id);
@@ -4958,36 +5989,7 @@ function renderScene(doc,y0){
4958
5989
  said.add(c.g.id+' '+c.n.id); continue;
4959
5990
  }
4960
5991
  }
4961
- const uLo=Math.min(...unit.map(cLo)), uHi=Math.max(...unit.map(n=>cLo(n)+cSz(n)));
4962
- const dNeg=(obst.lo-SEP)-uHi, dPos=(obst.hi+SEP)-uLo;
4963
- // NEARER SIDE, BUT NEVER OFF THE CANVAS. The obvious rule — move
4964
- // whichever way is shorter — sends the unit past the layout's own
4965
- // starting edge often enough to matter (`reference/topology` put L1 at
4966
- // x=-90 and the viewBox clipped it away). Growing the canvas the other
4967
- // way is not available either: the only uniform-shift machinery this
4968
- // renderer has moves PINNED nodes with everything else, and a pinned
4969
- // node that drifts because an unrelated node was added is the `RENDERING-DETERMINISM`
4970
- // stability violation this engine has already paid for once. So the
4971
- // constraint is applied HERE, to the choice: the negative direction is
4972
- // taken only when the unit still lands inside the envelope the layout
4973
- // had before this pass ran. Nothing outside the mover ever moves.
4974
- const dNegOK=uLo+dNeg>=cross0;
4975
- const d=(Math.abs(dNeg)<=Math.abs(dPos)&&dNegOK)?dNeg:dPos;
4976
- const ranks=new Set(unit.map(n=>n.rank));
4977
- const keep=new Set(unit.concat(unit===gMem?[]:gMem));
4978
- // Everything the mover would be pushed ONTO travels with it: same rank,
4979
- // same side, clear of the obstacle. Relative order and spacing inside a
4980
- // lane are preserved, so the fix cannot manufacture an overlap.
4981
- // A node that BELONGS to a group never travels this way — a group moves
4982
- // whole or not at all, and dragging half of one along would reshape its
4983
- // band, which is the same defect one group further on.
4984
- for(const m of lay){
4985
- if(keep.has(m)||!ranks.has(m.rank)) continue;
4986
- if(!m.virtual&&m.group) continue;
4987
- const mLo=cLo(m), mHi=mLo+cSz(m);
4988
- if(d<0 ? (mHi<=uHi&&mHi<=obst.lo) : (mLo>=uLo&&mLo>=obst.hi)) mv(m,d);
4989
- }
4990
- for(const n of unit) mv(n,d);
5992
+ push(unit,obst,new Set(unit.concat(unit===gMem?[]:gMem)));
4991
5993
  }
4992
5994
  }
4993
5995
  left=collect();
@@ -4995,6 +5997,13 @@ function renderScene(doc,y0){
4995
5997
  // is named. A figure that reaches this line with a hit is a defect in this
4996
5998
  // pass, and saying so beats drawing the false statement quietly.
4997
5999
  for(const c of left){
6000
+ // A LASSO residue is NOT reported here. This pass sees a PROJECTION of
6001
+ // the ring, taken before the boundary anchors are seated and before the
6002
+ // uniform canvas shifts; the ring the reader sees is the one computed at
6003
+ // the final geometry, and that is where its truth is judged (item 69,
6004
+ // below the label pass). Reporting twice from two geometries is how a
6005
+ // figure gets refused for a ring that was never drawn.
6006
+ if(c.t) continue;
4998
6007
  if(said.has(c.g.id+' '+c.n.id)) continue;
4999
6008
  gErrs.push('Line '+srcLine(c.g.line)+': group "'+c.g.id+'" would enclose non-member "'
5000
6009
  +c.n.id+'" and the layout pass could not separate them; the figure is not drawn rather '
@@ -5770,7 +6779,18 @@ function renderScene(doc,y0){
5770
6779
  // node box, which is already an obstacle.
5771
6780
  //
5772
6781
  // An external's label is NOT an obstacle to its OWN edge — that edge must
5773
- // reach the anchor the label names.
6782
+ // reach the anchor the label names. A GROUP's name is the same rule one
6783
+ // construct over, and 0.4 shipped only half of it (`GROUP-BOUNDARY-OBSTACLE`): a group's
6784
+ // name strip runs the full width of the band's TOP, so it lies across every
6785
+ // approach an outside node has to a member inside. Made an obstacle to all
6786
+ // comers, it turned every boundary-crossing edge into a detour that left the
6787
+ // corridor, ran down the band's outer edge and entered the member from the
6788
+ // side — the drawing then said the line arrives at the CONTAINER when the
6789
+ // source says it arrives at the MEMBER. `EDGE-BEND-RETENTION` had already ruled the group
6790
+ // BOX is an obstacle only to an edge that "neither starts nor ends inside";
6791
+ // the name is part of the same band and takes the same exemption. A foreign
6792
+ // shaft — neither endpoint in the group — is still detoured, which is the
6793
+ // whole of what 0.4 was right about.
5774
6794
  const extLbl=n=>{
5775
6795
  const cx=n.x+n.w/2, cy=n.y+n.h/2, [bdx,bdy]=bDir(n);
5776
6796
  const bw=lblPx(n.label), bl=String(n.label).split('\n').length, bh=13*bl;
@@ -5785,197 +6805,116 @@ function renderScene(doc,y0){
5785
6805
  for(const k in gBox){ const g=doc.groups.find(z=>z.id===k);
5786
6806
  if(!g||!g.label) continue;
5787
6807
  const B=gBox[k];
5788
- nameObs.push({x:B.x0+10, y:B.yA+16-11.5*0.85, w:cwMax(g.label)*6.5*11.5/11, h:11.5*1.1});
6808
+ nameObs.push({x:B.x0+10, y:B.yA+16-11.5*0.85, w:cwMax(g.label)*6.5*11.5/11, h:11.5*1.1, id:k});
5789
6809
  }
5790
- // The obstacle list a given edge must respect: every band name, plus every
5791
- // external label except the ones this edge itself terminates at.
6810
+ // Which group an endpoint id belongs to, for the name-strip exemption below.
6811
+ const grpOfId=new Map();
6812
+ for(const n of nodes) if(n.group) grpOfId.set(n.id,n.group);
6813
+ // The obstacle list a given edge must respect: every band name except the
6814
+ // ones this edge terminates AT or INSIDE, plus every external label except
6815
+ // the ones this edge itself terminates at.
5792
6816
  // A label's ink is its glyphs plus the clearance that keeps a line from
5793
6817
  // READING as struck through it. 4 px on every side — the same number the
5794
6818
  // legibility floor uses for a label's association margin — so a shaft that
5795
6819
  // grazes a name at 3 px is detoured rather than tolerated.
5796
6820
  const LBL_PAD=4;
5797
6821
  const padded=o=>({x:o.x-LBL_PAD,y:o.y-LBL_PAD,w:o.w+2*LBL_PAD,h:o.h+2*LBL_PAD});
6822
+ const ownsName=(o,e)=>o.id===e.a||o.id===e.b||
6823
+ o.id===grpOfId.get(e.a)||o.id===grpOfId.get(e.b);
5798
6824
  const lblObs=e=>extObs.filter(o=>o.id!==e.a&&o.id!==e.b).map(padded)
5799
- .concat(nameObs.map(padded));
5800
- // ── A BUNDLE RING IS ORIENTED BY ITS MEMBERS (item 43) ───────────────────
5801
- // Until now the ring was derived from the member links' MIDPOINTS and
5802
- // nothing else: `rx = max(46, x-spread + 38)`, `ry = max(26, y-spread + 22)`.
5803
- // Two facts about that formula are the defect. It is DIRECTION-BLIND the
5804
- // axes are the canvas's, never the links' and its floors are large enough
5805
- // that a bundle of two short legs draws a near-circle whatever the legs do.
5806
- // Measured on `patterns/topology-b`: rx 46 / ry 42.7, aspect
5807
- // 1.07, sitting on two legs that fan VERTICALLY, with the caption lying
5808
- // across the waist and on top of the `p3` endpoint label.
5809
- //
5810
- // The drawing convention for a link bundle is a loop THROUGH which the
5811
- // links run: narrow along the links, long across them. So the ring is now
5812
- // derived from the members' own frame.
5813
- //
5814
- // 1. DIRECTION. The mean UNDIRECTED direction of the members, by the
5815
- // doubled-angle circular mean — doubling is what makes `a--b` and
5816
- // `b--a` the same direction, so a bundle does not change shape when an
5817
- // author writes a member the other way round (conformance 394).
5818
- // 2. THE BAND. The ring sits at the MIDPOINT OF THE MEMBERS' SHARED RUN
5819
- // along that direction — the stretch of the corridor every member is
5820
- // actually in. That is the placement rule because it is the only band
5821
- // at which "the spread of the members" is a fact about all of them: a
5822
- // fanning set (the reference topology's ECMP pair, which leaves two
5823
- // different spines and arrives at one leaf) has a different spread at
5824
- // every station, and the shared run is the interval over which the
5825
- // question is even well posed. Members with no shared run at all fall
5826
- // back to the mean of their midpoints, which is the old centre.
5827
- // 3. THE AXES. MINOR along the mean direction, sized to clear the strokes;
5828
- // MAJOR across it, the members' spread at the band plus padding. A ring
5829
- // needs a long axis to read as a ring, and it takes it from the spread
5830
- // when the members fan (`rA >= rL`) and from its own RUN when they do
5831
- // not — a single-member bundle (`reference/topology`'s multi-home link)
5832
- // has zero spread, and the shape that hugs one link is an ellipse lying
5833
- // ALONG it, never a circle straddling it.
5834
- // 4. NO NODE CONTACT. The ring is shrunk along its minor axis until it
5835
- // clears every node box, and if it cannot it reverts to the pre-0.4
5836
- // geometry rather than inventing a shape (no corpus figure does).
5837
- //
5838
- // The caption is NOT placed here — it is registered with the label pass
5839
- // below, so it can see the endpoint labels and the edges it has to avoid.
5840
- const rnd3=v=>Math.round(v*1000)/1000; // ring coordinates, printed short
5841
- const RING_ALONG=15, // semi-minor: the ring's body along the links
5842
- RING_ACROSS=20, // clearance beyond the outermost member
5843
- RING_LONG=34, // the long axis a ring needs to read as a ring
5844
- RING_FAN=2, // below this spread the members are one line
5845
- RING_FLOOR=7, // how thin the minor axis may be squeezed
5846
- RING_SOLO=4; // a ONE-member bundle: the caption's stand-off
5847
- // ── A ONE-MEMBER BUNDLE IS ITS CAPTION (item 53) ─────────────────────────
5848
- // `bundle mh "multi-home" l1--l2` is legal and means something real (a
5849
- // one-link LAG, an Ethernet Segment with a single member), so the
5850
- // declaration is not an error and is not dropped. But a ring exists to
5851
- // UNITE lines, and around ONE line it unites nothing: it is ink that adds
5852
- // no fact, and on the reference topology it also bought the seam a 104 px
5853
- // corridor demand (2*RX_MIN + clearance) for a shape nobody had to see.
6825
+ .concat(nameObs.filter(o=>!ownsName(o,e)).map(padded));
6826
+ const rings=new Map();
6827
+ for(const t of doc.trunks||[]){ const R=ringOf(t); if(R) rings.set(t,R); }
6828
+ // ── A LASSO THAT ENCLOSES A NON-MEMBER IS A FALSE DRAWING (item 69) ──────
6829
+ // The group-band rule, one construct over, and the reason it is owed is a
6830
+ // measurement: a three-link aggregation drawn from the UNH-IOL LACP test
6831
+ // suite (Test Setup 2, fig 06 of that validation exercise) put a lasso at
6832
+ // cx 592 cy 455, rx 231 ry 162 around six member links — and fully inside
6833
+ // it, at normalised radius 0.70, sat two test stations that are not in the
6834
+ // aggregation at all. The figure told the reader they were. The engine said
6835
+ // nothing and `layout-lint` scored it 0, while the IDENTICAL claim written
6836
+ // as a `group` band was refused four times by the pass above. Backlog 43 and
6837
+ // 53 were about lasso SHAPE; this is lasso TRUTH.
5854
6838
  //
5855
- // So a single-member bundle DRAWS NO ELLIPSE. What it draws is its
5856
- // CAPTION, bundle-styled (the trunk's own stroke, the ring caption's type)
5857
- // and placed BY THE LINK which is exactly the statement the construct
5858
- // makes: this link is the bundle, and here is its name. The frame below is
5859
- // therefore kept as a PLACEMENT frame and not as a drawing: the caption
5860
- // pass already sites a caption around a ring's rim, so a ring squeezed to
5861
- // `RING_SOLO` across gives the same pass a candidate family hugging the
5862
- // link. Nothing else in the pass changes.
6839
+ // WHOEVER CHOSE THE POSITION BEARS THE RESPONSIBILITY the band's principle,
6840
+ // inherited whole, and its boundary inherited with it. The complete-cover
6841
+ // check (backlog 47b) fires only when at least one of the two
6842
+ // boxes is at a coordinate the author never wrote; this rule is that same
6843
+ // question asked the other way round. Where NO author coordinate is involved
6844
+ // the engine had the freedom and MUST use it, so the separation pass above
6845
+ // has already moved the intruder out and nothing reaches here. What reaches
6846
+ // here is a figure whose geometry an author fixed: the intruder is pinned,
6847
+ // or a member endpoint is, or the separation pass ran out of room. Then the
6848
+ // engine reports and the artifact is not written, because it will not draw a
6849
+ // membership the source did not declare.
5863
6850
  //
5864
- // AN EMPTY CAPTION ON A ONE-MEMBER BUNDLE THEREFORE DRAWS NOTHING AT ALL.
5865
- // That is a real consequence and it is not hidden: the reference figure
5866
- // keeps `bundle unlabelled "" s2--l2` — it is the form demonstrator for
5867
- // the empty label — and states in a comment that this form is now
5868
- // MODEL-ONLY. The model still carries the trunk, `read` still reports it,
5869
- // and the drawing says nothing because there is nothing a ring around one
5870
- // unnamed line could say.
5871
- // Does the ring's disc meet an axis-aligned rect? Both are mapped into the
5872
- // frame where the ring is the unit circle; the rect becomes a convex quad,
5873
- // and the test is "is the quad within 1 of the origin".
5874
- const ringHitsRect=(R,RL,RA,b)=>{
5875
- const q=[[b.x,b.y],[b.x+b.w,b.y],[b.x+b.w,b.y+b.h],[b.x,b.y+b.h]].map(p=>{
5876
- const dx=p[0]-R.cx, dy=p[1]-R.cy;
5877
- return [(R.ux*dx+R.uy*dy)/RL, (R.vx*dx+R.vy*dy)/RA];
5878
- });
5879
- let inside=false;
5880
- for(let i=0,j=3;i<4;j=i++)
5881
- if((q[i][1]>0)!==(q[j][1]>0) &&
5882
- 0 < (q[j][0]-q[i][0])*(0-q[i][1])/(q[j][1]-q[i][1])+q[i][0]) inside=!inside;
5883
- if(inside) return true;
5884
- for(let i=0,j=3;i<4;j=i++){
5885
- const vx=q[j][0]-q[i][0], vy=q[j][1]-q[i][1], L2=vx*vx+vy*vy;
5886
- const t=L2?Math.max(0,Math.min(1,-(q[i][0]*vx+q[i][1]*vy)/L2)):0;
5887
- if(Math.hypot(q[i][0]+t*vx, q[i][1]+t*vy)<1) return true;
5888
- }
5889
- return false;
5890
- };
5891
- const ringOf=t=>{
5892
- const segs=[];
5893
- for(const [a,b] of t.pairs){
5894
- const A=byId[a], B=byId[b]; if(!A||!B) continue;
5895
- const p=borderPoint(A,B.x+B.w/2,B.y+B.h/2), q=borderPoint(B,A.x+A.w/2,A.y+A.h/2);
5896
- if(Math.hypot(q[0]-p[0],q[1]-p[1])>1e-9) segs.push([p,q]);
5897
- }
5898
- if(!segs.length) return null;
5899
- // (1) mean undirected direction
5900
- let c2=0,s2=0;
5901
- for(const [p,q] of segs){
5902
- const L=Math.hypot(q[0]-p[0],q[1]-p[1]);
5903
- const c=(q[0]-p[0])/L, s=(q[1]-p[1])/L;
5904
- c2+=c*c-s*s; s2+=2*c*s;
5905
- }
5906
- const th=Math.hypot(c2,s2)<1e-9
5907
- ? Math.atan2(segs[0][1][1]-segs[0][0][1], segs[0][1][0]-segs[0][0][0])
5908
- : 0.5*Math.atan2(s2,c2);
5909
- const ux=Math.cos(th), uy=Math.sin(th), vx=-uy, vy=ux;
5910
- // (2) the band: the midpoint of the shared run
5911
- const iv=segs.map(([p,q])=>{
5912
- const a=ux*p[0]+uy*p[1], b=ux*q[0]+uy*q[1];
5913
- return a<=b?[a,b,p,q]:[b,a,q,p];
5914
- });
5915
- const lo=Math.max(...iv.map(z=>z[0])), hi=Math.min(...iv.map(z=>z[1]));
5916
- const s=lo<hi ? (lo+hi)/2 : iv.reduce((x,z)=>x+(z[0]+z[1])/2,0)/iv.length;
5917
- const P=iv.map(z=>{
5918
- const f=Math.min(1,Math.max(0,(s-z[0])/((z[1]-z[0])||1)));
5919
- return [z[2][0]+(z[3][0]-z[2][0])*f, z[2][1]+(z[3][1]-z[2][1])*f];
5920
- });
5921
- const cx=P.reduce((a,p)=>a+p[0],0)/P.length, cy=P.reduce((a,p)=>a+p[1],0)/P.length;
5922
- const spread=Math.max(...P.map(p=>Math.abs(vx*(p[0]-cx)+vy*(p[1]-cy))));
5923
- const runHalf=Math.min(...iv.map(z=>Math.min(s-z[0],z[1]-s)));
5924
- // (3) axes. Two shapes, and which one is drawn is decided by whether the
5925
- // members FAN at all. A set that fans is CROSSED by the ring: thin along
5926
- // the links, long enough across them to take the whole spread with room
5927
- // to spare, so each member passes through a part of the rim where the
5928
- // ring is still visibly open. A set that does not fan — one link, or
5929
- // members lying on top of each other — has no spread to take, and the
5930
- // shape that says "this link is the bundle" is an ellipse lying ALONG it.
5931
- let rA, rL;
5932
- if(spread<RING_FAN){ rL=Math.max(RING_ALONG,Math.min(RING_LONG,Math.max(0,runHalf))); rA=RING_ACROSS; }
5933
- else { rL=RING_ALONG; rA=Math.max(spread+RING_ACROSS,RING_LONG); }
5934
- const R={cx,cy,ux,uy,vx,vy,th};
5935
- // (0) ONE MEMBER (item 53): no ellipse is drawn, so this is a caption
5936
- // frame and not a shape. It is squeezed across to `RING_SOLO` so the
5937
- // caption pass's rim stations sit beside the link rather than a ring's
5938
- // radius away from it, and the node-clearance step below is skipped —
5939
- // there is no ink to keep out of a box.
5940
- if(segs.length===1){
5941
- R.solo=true; R.rL=rL; R.rA=RING_SOLO;
5942
- R.hw=Math.hypot(rL*ux, RING_SOLO*vx); R.hh=Math.hypot(rL*uy, RING_SOLO*vy);
5943
- return R;
5944
- }
5945
- // (4) out of every node box
5946
- const boxes=nodes.filter(n=>!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}));
5947
- if(boxes.some(b=>ringHitsRect(R,rL,rA,b))){
5948
- let ok=false;
5949
- for(let k=1;k<=24&&!ok;k++){
5950
- const RL=rL-(rL-RING_FLOOR)*k/24;
5951
- if(!boxes.some(b=>ringHitsRect(R,RL,rA,b))){ rL=RL; ok=true; }
5952
- }
5953
- // No orientation of this ring clears the drawing. Rather than publish a
5954
- // ring lying over a node, revert to the pre-0.4 derivation, which is at
5955
- // least the shape every earlier artifact recorded. No corpus figure
5956
- // takes this branch; it exists so that a hostile geometry degrades to
5957
- // the old defect instead of a new one.
5958
- if(!ok){
5959
- const M=segs.map(([p,q])=>[(p[0]+q[0])/2,(p[1]+q[1])/2]);
5960
- const mx=M.reduce((a,m)=>a+m[0],0)/M.length, my=M.reduce((a,m)=>a+m[1],0)/M.length;
5961
- const lx=Math.max(46,Math.max(...M.map(m=>Math.abs(m[0]-mx)))+38);
5962
- const ly=Math.max(26,Math.max(...M.map(m=>Math.abs(m[1]-my)))+22);
5963
- return {cx:mx, cy:my, ux:1, uy:0, vx:0, vy:1, th:0, legacy:true,
5964
- rL:lx, rA:ly, hw:lx, hh:ly};
6851
+ // The test is at the FINAL geometry the same ring, from the same `ringOf`,
6852
+ // that is drawn a thousand lines below because the ring the reader reads is
6853
+ // the only one whose truth is at stake.
6854
+ {
6855
+ const real=nodes.filter(n=>!n.boundary&&n.w>0&&n.h>0);
6856
+ for(const t of doc.trunks||[]){
6857
+ const R=rings.get(t);
6858
+ if(!R||R.solo) continue; // one member draws no ellipse (item 53)
6859
+ const mem=new Set(); for(const [a,b] of t.pairs){ mem.add(a); mem.add(b); }
6860
+ const memPinned=[...mem].some(id=>pinned(id));
6861
+ for(const n of real){
6862
+ if(mem.has(n.id)) continue;
6863
+ let inside=true;
6864
+ for(const p of [[n.x,n.y],[n.x+n.w,n.y],[n.x,n.y+n.h],[n.x+n.w,n.y+n.h]]){
6865
+ const dx=p[0]-R.cx, dy=p[1]-R.cy;
6866
+ if(Math.hypot((R.ux*dx+R.uy*dy)/R.rL,(R.vx*dx+R.vy*dy)/R.rA)>1){ inside=false; break; }
6867
+ }
6868
+ if(!inside) continue;
6869
+ const ln=srcLine(t.line);
6870
+ // Two voices, chosen by who chose the coordinate — the band's own
6871
+ // split. A pin is named as a pin so the author knows which line to
6872
+ // edit; a figure the engine could not separate says so, and admits it.
6873
+ gErrs.push(pinned(n.id)||memPinned
6874
+ ? 'Line '+ln+': the lasso for bundle "'+t.id+'" would enclose non-member "'+n.id
6875
+ +'" — a lasso is the drawn extent of the bundle\'s members, so this draws "'+n.id
6876
+ +'" as one of them. Move the pin clear of the bundle\'s extent, or change what "'
6877
+ +t.id+'" collects so the lasso is its members\' own. The figure is not drawn '
6878
+ +'rather than drawn wrongly.'
6879
+ : 'Line '+ln+': the lasso for bundle "'+t.id+'" would enclose non-member "'+n.id
6880
+ +'" and the layout pass could not separate them; the figure is not drawn rather '
6881
+ +'than drawn wrongly. Give "'+n.id+'" a pin outside the bundle\'s extent, or add '
6882
+ +'its link to "'+t.id+'".');
5965
6883
  }
5966
6884
  }
5967
- R.rL=rL; R.rA=rA;
5968
- // the axis-aligned box the caption is placed beside and the canvas grows to
5969
- R.hw=Math.hypot(rL*ux, rA*vx); R.hh=Math.hypot(rL*uy, rA*vy);
5970
- return R;
5971
- };
5972
- const rings=new Map();
5973
- for(const t of doc.trunks||[]){ const R=ringOf(t); if(R) rings.set(t,R); }
6885
+ }
6886
+ // WHOSE EXTENT THE LASSO IS, WRITTEN INTO THE DRAWING (item 69).
6887
+ // `data-lasso` follows the `data-port-sq` pattern exactly: ONE
6888
+ // attribute name, carried by the INK and by the thing the ink is about, with
6889
+ // the same string on both, so a reader — `tools/layout-lint.js` first — reads
6890
+ // the association instead of inferring it. The ellipse carries the bundle's
6891
+ // id; every node the ellipse was derived from carries the ids of the lassos
6892
+ // it is a member of (a node may be in more than one, space-separated). The
6893
+ // geometry itself is NOT duplicated into the attribute: `cx/cy/rx/ry` and the
6894
+ // `rotate()` are already the drawn truth, and a second copy could disagree
6895
+ // with the first. Only bundles that DRAW an ellipse appear — a one-member
6896
+ // bundle draws none (item 53) and claims no extent, so it stamps nothing.
6897
+ const lassoMem=new Map();
6898
+ for(const [t,R] of rings){
6899
+ if(R.solo) continue;
6900
+ for(const [a,b] of t.pairs) for(const id of [a,b]){
6901
+ if(!lassoMem.has(id)) lassoMem.set(id,[]);
6902
+ if(!lassoMem.get(id).includes(t.id)) lassoMem.get(id).push(t.id);
6903
+ }
6904
+ }
5974
6905
  // filled by the label pass below, consumed by the ring drawing further down
5975
6906
  const ringLbl=new Map(), ringLeads=[];
5976
6907
  // edges (sorted by plane z, then doc order)
5977
6908
  const edges=zsort(doc.edges);
5978
6909
  const esvg=[], lblsvg=[]; // labels paint last = closest to the viewer
6910
+ // THE PORT SQUARES GET THEIR OWN LAYER (item 57b), between the
6911
+ // node bodies and the labels. Over the nodes, because half of each square is
6912
+ // meant to be seen INSIDE the box and the whole point of the notation is
6913
+ // that the fitting sits ON the boundary; over the edges, because the
6914
+ // connector ATTACHES to the square rather than passing through it; under
6915
+ // everything in `lblsvg`, because a port name — and an arrowhead, which
6916
+ // lives in that layer too — must never be occluded by the fitting it names.
6917
+ const sqsvg=[];
5979
6918
  // ── deferred edge-label placement ───────────────────────────────────────
5980
6919
  // An edge label is not written where it is emitted. Each emission reserves
5981
6920
  // its slot in lblsvg (so the paint order is unchanged) and registers the
@@ -6032,8 +6971,9 @@ function renderScene(doc,y0){
6032
6971
  // box and repeating one label three times is what this removes.
6033
6972
  //
6034
6973
  // Each member still emits its OWN full path from its source outline to the
6035
- // target outline — shape-check asserts exactly that, and `data-edge` carries
6036
- // one source line so the shared trunk is stroked once per member. That
6974
+ // target outline — shape-check asserts exactly that, and `data-edge` keys
6975
+ // each one (its id, or its source line when anonymous; `CONNECTOR-IDENTITY-KEY`) so the shared
6976
+ // trunk is stroked once per member. That
6037
6977
  // coincidence is the convention and not a defect, and the members say so:
6038
6978
  // every bus path carries `data-bus="<target>"`, which is what lets a reader
6039
6979
  // (and layout-lint) tell a deliberate trunk from two edges hidden under each
@@ -6393,7 +7333,7 @@ function renderScene(doc,y0){
6393
7333
  // pattern to a period boundary at the path's end.
6394
7334
  const per=e.style==='dashed'?10:(e.style==='dotted'?6:0);
6395
7335
  const doff=per?' stroke-dashoffset="'+(((per-(runLen(pts)%per))%per).toFixed(2))+'"':'';
6396
- esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+doff+' data-bus="'+esc(bus.bus)+'"/>');
7336
+ esvg.push('<path data-edge="'+edgeRef(e)+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+doff+' data-bus="'+esc(bus.bus)+'"/>');
6397
7337
  noteSegs(e,pts);
6398
7338
  for(const p of pts){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+4-y0-20); }
6399
7339
  if(bus.dots) for(const d of bus.dots)
@@ -6426,7 +7366,26 @@ function renderScene(doc,y0){
6426
7366
  if(bus.arrow&&wantsEnd) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
6427
7367
  continue;
6428
7368
  }
6429
- if(chBack(e)&&!pinned(e.a)&&!pinned(e.b)){
7369
+ // A SELF-TRANSITION IS EXEMPT FROM THE PINNED-ENDPOINT EXCLUSION
7370
+ // (item 64). The `!pinned` guard belongs to the CHANNEL
7371
+ // route below it: a channel back edge is planned in `chPlan`, which is
7372
+ // built over auto-layout lanes and has nothing to say about a box the
7373
+ // author placed, so a pinned back edge correctly falls through to the
7374
+ // straight route. A SELF-loop has no such dependency — its geometry is a
7375
+ // function of ONE box and a free side, both of which a pinned box has —
7376
+ // and sweeping it into the same exclusion made `pin` silently delete the
7377
+ // drawing: `borderPoint(A, A's own centre)` twice is the same point, so
7378
+ // the straight route emitted a zero-length `<line>` at the state's centre
7379
+ // with the trigger label printed across the state's name. Measured on
7380
+ // 0.4 over a pinned three-state triangle and a pinned two-state
7381
+ // pair: every self-transition length 0.0. The rule the exemption keeps is
7382
+ // one line — a self-transition draws the same way whether its state's
7383
+ // coordinate came from a pin or from the layout pass — so the side
7384
+ // selection below is REUSED as-is rather than duplicated for pins; a
7385
+ // second selection rule would be a second convention, and `DOMAIN-CONVENTION-DIRECTIVES` gives the
7386
+ // engine one. (`chBack` is always true here: `isBack` takes every
7387
+ // self-edge, and the `recip` pair test skips `e.a===e.b`.)
7388
+ if(chBack(e)&&(A===B||(!pinned(e.a)&&!pinned(e.b)))){
6430
7389
  // ── ROUTING-CHANGE ARCHITECTURE NOTE (`SELF-EDGE-DRAWING`/`EDGE-BEND-RETENTION`) ──────────
6431
7390
  // Edge labels are DEFERRED: every label is registered against its
6432
7391
  // FINAL segment geometry (reqLabel/lblReq above) and placed by ONE
@@ -6483,7 +7442,7 @@ function renderScene(doc,y0){
6483
7442
  for(const sd of ['r','l','b','t']){ const pp=mkLoop(sd); if(!loopHit(pp)){ sp=pp; break; } }
6484
7443
  if(!sp) sp=mkLoop('r');
6485
7444
  for(const p of sp){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+16-y0-20); }
6486
- esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(sp)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
7445
+ esvg.push('<path data-edge="'+edgeRef(e)+'" d="'+roundPath(sp)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
6487
7446
  noteSegs(e,sp);
6488
7447
  // A self-loop's outer run is 16 px long, so sliding the label ALONG it
6489
7448
  // buys ~15 px and no escape at all from a line crossing it — and a
@@ -6623,7 +7582,7 @@ function renderScene(doc,y0){
6623
7582
  // it is what lets a reader (and `layout-lint`'s coincident term) tell a
6624
7583
  // deliberate shared trunk from two lines hidden under each other. Written
6625
7584
  // LAST, after stroke-width, so no existing reader breaks.
6626
- esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash
7585
+ esvg.push('<path data-edge="'+edgeRef(e)+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash
6627
7586
  +(P.bus?' data-bus="'+esc(P.bus)+'"':'')+'/>');
6628
7587
  noteSegs(e,pts);
6629
7588
  if(P.busTail){
@@ -6662,7 +7621,7 @@ function renderScene(doc,y0){
6662
7621
  const lrPts=longRoute(e);
6663
7622
  if(lrPts){
6664
7623
  for(const p of lrPts){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+16-y0-20); }
6665
- esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(lrPts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
7624
+ esvg.push('<path data-edge="'+edgeRef(e)+'" d="'+roundPath(lrPts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
6666
7625
  noteSegs(e,lrPts);
6667
7626
  if(e.mid){
6668
7627
  // WHICH LEG CARRIES THE LABEL, and it is the OPPOSITE of the channel
@@ -6791,7 +7750,7 @@ function renderScene(doc,y0){
6791
7750
  }
6792
7751
  }
6793
7752
  }
6794
- esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
7753
+ esvg.push('<path data-edge="'+edgeRef(e)+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
6795
7754
  noteSegs(e,pts);
6796
7755
  if(midSeg) reqLabel({p:midSeg[0],q:midSeg[1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
6797
7756
  if(e.tail) seg(p0,pts[1],e.tail,10,p1,runLen(pts));
@@ -6825,7 +7784,7 @@ function renderScene(doc,y0){
6825
7784
  }
6826
7785
  }
6827
7786
  if(route){
6828
- esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(route)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
7787
+ esvg.push('<path data-edge="'+edgeRef(e)+'" d="'+roundPath(route)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
6829
7788
  noteSegs(e,route);
6830
7789
  if(e.mid){ // the longest segment carries the mid label
6831
7790
  let bi=0,bl=-1;
@@ -6842,7 +7801,7 @@ function renderScene(doc,y0){
6842
7801
  for(const pP of route){ W=Math.max(W,pP[0]+4); Hh=Math.max(Hh,pP[1]+4-y0-20); }
6843
7802
  continue;
6844
7803
  }
6845
- esvg.push('<line data-edge="'+e.line+'" x1="'+x1+'" y1="'+yy1+'" x2="'+x2+'" y2="'+yy2+'" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
7804
+ esvg.push('<line data-edge="'+edgeRef(e)+'" x1="'+x1+'" y1="'+yy1+'" x2="'+x2+'" y2="'+yy2+'" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
6846
7805
  noteSegs(e,[[x1,yy1],[x2,yy2]]);
6847
7806
  if(e.mid)
6848
7807
  reqLabel({p:[x1,yy1],q:[x2,yy2],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:true});
@@ -7010,6 +7969,68 @@ function renderScene(doc,y0){
7010
7969
  // how near a corner a marker may be written; the offsets are one marker
7011
7970
  // width apart, which is exactly the pitch two ports need to be two.
7012
7971
  const PORT_IN=2, PORT_END=4, PORT_HUG=25, PORT_OUT=60;
7972
+ // ── THE STRADDLING PORT SQUARE (item 57b) ─────────────────
7973
+ // OMG UML 2.5.1 §11.4's composite-structure port notation: a small square
7974
+ // drawn ON the classifier's boundary, half inside and half outside, with
7975
+ // the connector attaching to it and the port name labelling it. Here the
7976
+ // classifier is the device box, the connector is the link, and the name is
7977
+ // the interface. Item 57 put the NAME at the crossing; this puts a MARK
7978
+ // there, so the crossing is stated by the drawing rather than inferred
7979
+ // from where a word happens to sit.
7980
+ //
7981
+ // `PORT_SQ` is 7 px — the CAP-HEIGHT OF THE PORT NAME ITSELF (the endpoint
7982
+ // font is 10-11 px, so its capitals are about 7), which is what makes the
7983
+ // fitting read as the name's own mark rather than a second little box: it
7984
+ // is a fifth of an ordinary 36 px topology node's height and it never
7985
+ // competes with the device outline. The size was MEASURED, not chosen:
7986
+ // swept 7/8/9/10 px against layout-lint over this corpus, and 7 is the
7987
+ // only value that regresses no figure — `reference/topology` scores 6 at
7988
+ // 9 px, 4 at 8 px and 2 at 7 px against the pre-square 4, because every
7989
+ // extra pixel of fitting is a pixel of along-border room the name no
7990
+ // longer has on a small device.
7991
+ //
7992
+ // It is stroked in the OWNING NODE's own stroke at the node's own 1 px
7993
+ // weight and filled with the node's own fill, so it belongs to the device
7994
+ // that owns the port and to nothing else. `PORT_SQ_GAP` is the engine's
7995
+ // own 3 px label standoff, reused rather than reinvented; it is also the
7996
+ // width of the FORBIDDEN BAND around a foreign square (see `portCands`).
7997
+ const PORT_SQ=7, PORT_SQ_GAP=3;
7998
+ // Where the square goes: the crossing point snapped onto the nearest side
7999
+ // of the owner's box, clamped so a square can never hang off a corner.
8000
+ // The side comes back with it — the placement family needs it.
8001
+ const portSq=(N,p)=>{
8002
+ const dl=Math.abs(p[0]-N.x), dr=Math.abs(p[0]-(N.x+N.w)),
8003
+ dt=Math.abs(p[1]-N.y), db=Math.abs(p[1]-(N.y+N.h));
8004
+ const m=Math.min(dl,dr,dt,db);
8005
+ const side=m===dt?'top':m===db?'bottom':m===dl?'left':'right';
8006
+ const vert=(side==='top'||side==='bottom');
8007
+ const h2=PORT_SQ/2;
8008
+ let cx,cy;
8009
+ // A CURVED OR ANGLED OUTLINE IS STILL THE OUTLINE. Snapping to the
8010
+ // bounding box is right for the rectangle family and WRONG for
8011
+ // everything else: `showcase/srl-evpn-irb`'s EVPN-VXLAN cloud is an
8012
+ // ellipse 620 x 110, and its `irb0.24` port snapped to the box bottom
8013
+ // came out 8 px BELOW the drawn curve — a fitting floating in space
8014
+ // beside the thing it is fitted to. The edge already ends on the true
8015
+ // outline (`borderPoint` put it there), so for a non-rectangular shape
8016
+ // the crossing point IS the answer and the square is simply centred on
8017
+ // it, axis-aligned. It straddles a slanted or curved border at a slight
8018
+ // angle to it, which is how every hand-drawn figure does it too.
8019
+ const boxy=!N.shape||N.shape==='box'||N.shape==='rounded'||N.shape==='cylinder';
8020
+ if(!boxy){ cx=Math.max(N.x+h2,Math.min(N.x+N.w-h2,p[0]));
8021
+ cy=Math.max(N.y+h2,Math.min(N.y+N.h-h2,p[1])); }
8022
+ else if(vert){ cx=Math.max(N.x+h2,Math.min(N.x+N.w-h2,p[0]));
8023
+ cy=side==='top'?N.y:N.y+N.h; }
8024
+ else { cy=Math.max(N.y+h2,Math.min(N.y+N.h-h2,p[1]));
8025
+ cx=side==='left'?N.x:N.x+N.w; }
8026
+ return {cx,cy,side,vert,key:cx.toFixed(2)+','+cy.toFixed(2),
8027
+ box:{x:cx-h2,y:cy-h2,w:PORT_SQ,h:PORT_SQ}};
8028
+ };
8029
+ // gap between two boxes, 0 when they meet — the same quantity `segBoxGap`
8030
+ // measures between a segment and a box, and it is what the standoff rule
8031
+ // below is written in.
8032
+ const boxSep=(a,b)=>Math.hypot(Math.max(b.x-(a.x+a.w),0,a.x-(b.x+b.w)),
8033
+ Math.max(b.y-(a.y+a.h),0,a.y-(b.y+b.h)));
7013
8034
  // The node's own drawn label, computed by the SAME formulas the node pass
7014
8035
  // emits it with (shrink-to-fit included), so the obstacle and the drawing
7015
8036
  // cannot disagree about where a name is.
@@ -7026,12 +8047,19 @@ function renderScene(doc,y0){
7026
8047
  // the glyph band inside that, and the two questions are different (see the
7027
8048
  // own-name term in the scorer).
7028
8049
  const inkBox=(b,fs)=>({x:b.x, y:b.y+fs*0.14, w:b.w, h:Math.max(1,b.h-fs*0.28)});
8050
+ // A label request's own text box, in the one place both the slot pass and
8051
+ // the candidate family can read it — they must agree about how wide a name
8052
+ // is or the fan below spaces names by a width nobody draws.
8053
+ const lblDims=r=>{
8054
+ const lines=String(r.text).split('\n'), nL=lines.length;
8055
+ const w=Math.max(...lines.map(cw))*6.5*r.fs/11;
8056
+ const lh=r.fs*1.3;
8057
+ return {w, h:(nL-1)*lh+r.fs*1.1, up:(nL-1)*lh/2+r.fs*0.85};
8058
+ };
7029
8059
  const portCands=r=>{
7030
8060
  const N=r.port, P=r.p, out=[];
7031
8061
  if(!N) return out;
7032
- const lines=String(r.text).split('\n'), nL=lines.length;
7033
- const w=Math.max(...lines.map(cw))*6.5*r.fs/11;
7034
- const lh=r.fs*1.3, h=(nL-1)*lh+r.fs*1.1, up=(nL-1)*lh/2+r.fs*0.85;
8062
+ const D=lblDims(r), w=D.w, h=D.h, up=D.up;
7035
8063
  // Which border did the link cross? The nearest side of the box.
7036
8064
  const dl=Math.abs(P[0]-N.x), dr=Math.abs(P[0]-(N.x+N.w)),
7037
8065
  dt=Math.abs(P[1]-N.y), db=Math.abs(P[1]-(N.y+N.h));
@@ -7039,13 +8067,28 @@ function renderScene(doc,y0){
7039
8067
  const side=m===dt?'top':m===db?'bottom':m===dl?'left':'right';
7040
8068
  const vert=(side==='top'||side==='bottom');
7041
8069
  // ROOM. A marker that cannot be written between the two corners of the
7042
- // side it crosses has no inside form at all; the ordinary outside
7043
- // family is then the only one offered, which is the stated fallback.
8070
+ // side it crosses has no INSIDE form at all but it still has an
8071
+ // outside one, hugging the border it could not get into, and that is a
8072
+ // far better answer than the along-the-shaft family it used to fall
8073
+ // through to (an unclamped `roomIn` return sent every name on a narrow
8074
+ // device out into the seam between its links, which is the defect the
8075
+ // port convention exists to end). So the shortage is now a filter on
8076
+ // the INSIDE ring only.
7044
8077
  const span=vert?N.w:N.h, need=(vert?w:h)+2*PORT_END;
7045
- if(span<need) return out;
8078
+ const roomIn=span>=need;
7046
8079
  const lo=(vert?N.x:N.y)+PORT_END, hi=(vert?N.x+N.w:N.y+N.h)-PORT_END;
7047
8080
  const pitch=(vert?w:h)+4;
7048
- const u0=vert?P[0]:P[1];
8081
+ // THE NAME IS ANCHORED TO ITS OWN SQUARE, not to the raw
8082
+ // crossing: `u0` is the square's centreline along the border, so the
8083
+ // corner clamp inside `portSq` can never leave a name off the mark it
8084
+ // names. `base` is the smallest along-border offset at which the two
8085
+ // marks are two — half the square, the 3 px standoff, half the text —
8086
+ // and `deep` is the same clearance taken PERPENDICULARLY, past the
8087
+ // square's far face.
8088
+ const SQ=portSq(N,P);
8089
+ const u0=vert?SQ.cx:SQ.cy;
8090
+ const base=PORT_SQ/2+PORT_SQ_GAP+(vert?w:h)/2;
8091
+ const deep=PORT_SQ/2+PORT_SQ_GAP;
7049
8092
  // TWO RINGS OF CANDIDATES, AND BOTH ARE AT THE CROSSING.
7050
8093
  // in = inside the border, the convention proper;
7051
8094
  // out = the mirror image, just OUTSIDE the same border.
@@ -7058,19 +8101,162 @@ function renderScene(doc,y0){
7058
8101
  // could not get inside keeps it on its own port, which is the fact it
7059
8102
  // exists to state. It is surcharged, so it is taken only after inside
7060
8103
  // has failed.
7061
- for(const inside of [true,false]) for(const k of [0,1,-1,2,-2]){
7062
- const u=Math.max(lo+(vert?w:h)/2, Math.min(hi-(vert?w:h)/2, u0+k*pitch));
8104
+ //
8105
+ // WITH A SQUARE AT THE CROSSING THE ANCHOR CHANGES, which is the
8106
+ // placement half of the maintainer's ruling ("A/B 皆有埠名文字定位不夠
8107
+ // 理想" — in both styles the port name is not sited well enough):
8108
+ //
8109
+ // 1. THE NAME IS ANCHORED TO ITS OWN SQUARE, on the square's
8110
+ // centreline along the border. When the port is alone on that side
8111
+ // the anchor IS `u0` and the name sits squarely on its fitting's
8112
+ // line — which is the whole reading the notation buys.
8113
+ // 2. WHEN THE SIDE CARRIES SEVERAL PORTS the anchor is the SLOT the
8114
+ // side pass assigned (`r.portU`): the same ideal, projected so that
8115
+ // consecutive names clear each other IN SQUARE ORDER. Order is the
8116
+ // property that matters and it is why the slot is computed for the
8117
+ // whole side at once instead of being negotiated one label at a
8118
+ // time by a greedy scorer — a greedy pass produced `p1 p3 p2` over
8119
+ // squares in the order p1 p2 p3 on `patterns/topology-a`, and every
8120
+ // one of those three labels is legible while two of them are wrong.
8121
+ //
8122
+ // The perpendicular offset follows from the anchor: a name still over
8123
+ // its own square's along-border extent must clear the square's face
8124
+ // (`deep`), and one that has stepped clear of it along the border only
8125
+ // needs the ordinary 2 px (`PORT_IN`).
8126
+ //
8127
+ // The ladder (±one text pitch at a time, from the slot) stays behind
8128
+ // that as relief for a name whose slot is occupied by something else —
8129
+ // the node's own name, a neighbour's label. Each of these exists inside
8130
+ // the border and outside it. INSIDE IS THE CONVENTION PROPER; the
8131
+ // outside ring is the mirror image, hugging the same border, and it is
8132
+ // surcharged (`hug`, PORT_HUG) so it is taken only after inside has
8133
+ // failed — but it is far cheaper than the ordinary along-the-shaft
8134
+ // family (PORT_OUT), which is the ruling's second clause: a crowded
8135
+ // interior sends the name to the OUTSIDE FLANK of its own port before it
8136
+ // ever sends it out onto the shaft.
8137
+ const uS=(r.portU===undefined?u0:r.portU);
8138
+ const dpFor=u=>(Math.abs(u-u0)<base-1e-9?deep:PORT_IN);
8139
+ const fam=[];
8140
+ for(const inside of [true,false]) fam.push({inside,u:uS,dp:dpFor(uS),ex:0});
8141
+ // OUTSIDE, THE NAME MAY ALSO STAND OFF THE BORDER. Inside, the far wall
8142
+ // of the box ends the argument; outside there is open paper, and one
8143
+ // step of it is often the whole difference. `05-lacp`'s DUT is 49.6 px
8144
+ // wide, carries three ports on its left face and their names are 41.4 px
8145
+ // — every name has to hug the outside, and every shaft converging on
8146
+ // those three fittings runs through the hug ring on its way in. Two
8147
+ // 8 px steps outward (priced like any other displacement, and past the
8148
+ // second one the standoff rule below draws the association as a leader)
8149
+ // let the fan open out to where the shafts have separated.
8150
+ for(const d of [8,16]) fam.push({inside:false,u:uS,dp:dpFor(uS)+d,ex:d});
8151
+ for(const inside of [true,false]) for(const sg of [1,-1]) for(const k of [0,1,2])
8152
+ fam.push({inside,u:uS+sg*(base+k*pitch),dp:PORT_IN,ex:base+k*pitch});
8153
+ for(const F of fam){
8154
+ const inside=F.inside, IN=F.dp;
8155
+ if(inside&&!roomIn) continue;
8156
+ // THE CORNERS BIND INSIDE AND DO NOT BIND OUTSIDE. Inside, a name is
8157
+ // written between the two corners of the face it belongs to or it is
8158
+ // written on another face; outside there is no face to run out of, so
8159
+ // clamping the outside ring to the node's own extent was simply
8160
+ // collapsing a fanned side back into a stack. A node 36 px tall with
8161
+ // three links arriving 8 px apart has no inside answer at all, and
8162
+ // its outside answer is the fan — which needs to reach past the
8163
+ // corner to exist.
8164
+ const half=(vert?w:h)/2;
8165
+ const u=inside?Math.max(lo+half, Math.min(hi-half, F.u)):F.u;
7063
8166
  let bx,by;
7064
- if(side==='bottom'){ bx=u-w/2; by=inside?N.y+N.h-PORT_IN-h:N.y+N.h+PORT_IN; }
7065
- else if(side==='top'){ bx=u-w/2; by=inside?N.y+PORT_IN:N.y-PORT_IN-h; }
7066
- else if(side==='left'){ bx=inside?N.x+PORT_IN:N.x-PORT_IN-w; by=u-h/2; }
7067
- else { bx=inside?N.x+N.w-PORT_IN-w:N.x+N.w+PORT_IN; by=u-h/2; }
8167
+ if(side==='bottom'){ bx=u-w/2; by=inside?N.y+N.h-IN-h:N.y+N.h+IN; }
8168
+ else if(side==='top'){ bx=u-w/2; by=inside?N.y+IN:N.y-IN-h; }
8169
+ else if(side==='left'){ bx=inside?N.x+IN:N.x-IN-w; by=u-h/2; }
8170
+ else { bx=inside?N.x+N.w-IN-w:N.x+N.w+IN; by=u-h/2; }
7068
8171
  out.push({x:bx+w/2, y:by+up, anchor:'middle', t:undefined, side,
7069
- ex:Math.abs(k)*pitch, inPort:inside, hug:!inside,
8172
+ sq:SQ.box, sqk:SQ.key, atBorder:true,
8173
+ ex:F.ex, inPort:inside, hug:!inside,
7070
8174
  box:{x:bx,y:by,w,h}});
7071
8175
  }
7072
8176
  return out;
7073
8177
  };
8178
+ // ── DRAW EVERY PORT SQUARE BEFORE ANY NAME IS PLACED ──────────────────
8179
+ // The squares are a function of geometry alone (owner box + crossing
8180
+ // point), so they are all known before the greedy placement pass starts.
8181
+ // Computing them here rather than inside that loop buys the property that
8182
+ // matters: EVERY name is scored against EVERY square, including squares
8183
+ // belonging to names that have not been placed yet. A name written across
8184
+ // a later port's fitting would say the wrong thing about which link it
8185
+ // belongs to, and a one-pass greedy loop that emitted squares as it went
8186
+ // could only ever avoid the ones already drawn.
8187
+ //
8188
+ // Two crossings can coincide (co-located links on one border, a bundle's
8189
+ // members leaving through one face): the square is keyed on its snapped
8190
+ // centre so one fitting is drawn once, not stroked n times. That key is
8191
+ // also the square's IDENTITY in the drawing — it is written on the rect
8192
+ // as `data-port-sq` and on the name that belongs to it, so a reader (and
8193
+ // `tools/layout-lint.js`) can tell a name's own fitting from a foreign one
8194
+ // by reading rather than by guessing at distances.
8195
+ const sqBox=[], sqSeen=new Set();
8196
+ for(const r of lblReq){
8197
+ if(!r.port) continue;
8198
+ const S=portSq(r.port,r.p);
8199
+ if(sqSeen.has(S.key)) continue;
8200
+ sqSeen.add(S.key);
8201
+ sqBox.push(Object.assign({key:S.key},S.box));
8202
+ const N=r.port;
8203
+ sqsvg.push('<rect x="'+S.box.x.toFixed(2)+'" y="'+S.box.y.toFixed(2)+
8204
+ '" width="'+PORT_SQ+'" height="'+PORT_SQ+'" fill="'+(N.fill||'#fff')+
8205
+ '" stroke="'+(N.stroke||'#8a8880')+'" data-port-sq="'+S.key+'"/>');
8206
+ W=Math.max(W, S.box.x+S.box.w+4);
8207
+ Hh=Math.max(Hh, S.box.y+S.box.h+4-y0-20);
8208
+ }
8209
+ // ── ONE SIDE'S NAMES ARE ORDERED TOGETHER, NOT ONE AT A TIME ──────────
8210
+ // "Same side, several ports: the names serialize along the border in
8211
+ // SQUARE ORDER" is a property of the SIDE, and a greedy scorer cannot hold
8212
+ // it — it places one label at a time against what is already on the paper,
8213
+ // so the second name takes the cheapest gap rather than its own place in
8214
+ // the row. Measured: `patterns/topology-a`'s aggregation node came out
8215
+ // `p1 p3 p2` over squares in the order p1 p2 p3. Every one of those labels
8216
+ // is legible and two of them are wrong, which is worse than a collision:
8217
+ // a collision announces itself and a swapped pair does not.
8218
+ //
8219
+ // So each (node, side) group is projected ONCE, here, before any placement
8220
+ // is scored. The projection is the smallest order-preserving spreading of
8221
+ // the ideal positions: walk the group in square order, push each name just
8222
+ // far enough to clear the previous one, then slide the whole run back so
8223
+ // it stays centred on where the squares actually are. A side with one port
8224
+ // is unmoved by construction — its slot IS its square's centreline — so
8225
+ // this pass costs the ordinary figure nothing.
8226
+ //
8227
+ // Separation is measured in TEXT, not in squares: half of each of the two
8228
+ // names plus the 3 px standoff. Two fittings 8 px apart on a 36 px device
8229
+ // (three links into one small node, which is `05-lacp`'s DUT) cannot give
8230
+ // their names 8 px and be read as two names; the names take the room the
8231
+ // TEXT needs and the reader maps name to square by ORDER, which is exactly
8232
+ // what the order-preserving projection guarantees.
8233
+ {
8234
+ const sides=new Map();
8235
+ for(const r of lblReq){
8236
+ if(!r.port) continue;
8237
+ const S=portSq(r.port,r.p);
8238
+ const k=(r.port.id===undefined?'?':r.port.id)+'|'+S.side;
8239
+ if(!sides.has(k)) sides.set(k,[]);
8240
+ const D=lblDims(r);
8241
+ sides.get(k).push({r, u0:S.vert?S.cx:S.cy, ext:S.vert?D.w:D.h});
8242
+ }
8243
+ for(const g of sides.values()){
8244
+ if(g.length<2) continue;
8245
+ // stable: equal crossings keep registration (edge) order
8246
+ g.forEach((e,i)=>{e.i=i;});
8247
+ g.sort((a,b)=>a.u0-b.u0||a.i-b.i);
8248
+ let u=g[0].u0, sum=0;
8249
+ g[0].u=u;
8250
+ for(let i=1;i<g.length;i++){
8251
+ const sep=(g[i-1].ext+g[i].ext)/2+PORT_SQ_GAP;
8252
+ u=Math.max(g[i].u0, u+sep);
8253
+ g[i].u=u;
8254
+ }
8255
+ for(const e of g) sum+=e.u-e.u0;
8256
+ const shift=sum/g.length; // re-centre on the squares
8257
+ for(const e of g) e.r.portU=e.u-shift;
8258
+ }
8259
+ }
7074
8260
  const placed=[];
7075
8261
  // A request may name a SECOND carrying segment (`alt`). Back edges do: the
7076
8262
  // stub leaving the source is the preferred carrier because it says which
@@ -7167,8 +8353,19 @@ function renderScene(doc,y0){
7167
8353
  // already there) and never merely because outside happens to be tidy.
7168
8354
  const cs=[];
7169
8355
  if(r.port) for(const c of portCands(r)) cs.push({c,si:0});
7170
- for(let si=0;si<sides.length;si++) for(const t of ts) for(const cl of [0,1]) for(const ep of eps)
7171
- cs.push({c:cand(r,t,sides[si],cl,ep),si,out:!!r.port});
8356
+ // THE OWN SQUARE TRAVELS WITH THE REQUEST, NOT WITH THE FAMILY
8357
+ // A port name driven all the way out to the along-shaft
8358
+ // family still names its fitting and must still be told apart from the
8359
+ // next one, so those candidates carry the same square the border family
8360
+ // does — the forbidden band, the affinity filter and the standoff rule
8361
+ // below then apply to every position this label can take, instead of
8362
+ // stopping at the border.
8363
+ const RSQ=r.port?portSq(r.port,r.p):null;
8364
+ for(let si=0;si<sides.length;si++) for(const t of ts) for(const cl of [0,1]) for(const ep of eps){
8365
+ const c=cand(r,t,sides[si],cl,ep);
8366
+ if(RSQ){ c.sq=RSQ.box; c.sqk=RSQ.key; }
8367
+ cs.push({c,si,out:!!r.port});
8368
+ }
7172
8369
  for(const CS of cs){
7173
8370
  const c=CS.c, si=CS.si, t=(c.t===undefined?tPref:c.t);
7174
8371
  c.car=[r.p,r.q]; // the carrier this candidate rides (item 59)
@@ -7177,13 +8374,82 @@ function renderScene(doc,y0){
7177
8374
  // one fact it exists to state, and no amount of collision relief buys
7178
8375
  // that back. Candidates that fail are dropped; `bestAny` keeps the
7179
8376
  // least-bad one so a figure with no legal position still draws.
8377
+ // A DRAWN ASSOCIATION REPLACES AN INFERRED ONE. Every
8378
+ // filter in this pass is a proxy for one question — can the reader
8379
+ // tell what this word belongs to — and each answers it by PROXIMITY,
8380
+ // because proximity is all an unadorned label has. A port name past
8381
+ // the standoff cap is not unadorned: it takes a leader (below), and a
8382
+ // line from the word to the fitting states the association outright.
8383
+ // So `lead` is computed first and the proximity proxies stand down
8384
+ // for it. The BAND does not: a name written across a foreign fitting
8385
+ // is a wrong statement no leader can correct.
8386
+ const lead=!!(c.sq&&boxSep(c.box,c.sq)>STANDOFF_CAP);
7180
8387
  let owns=true;
7181
- if(r.kind==='end'&&r.other){
8388
+ if(r.kind==='end'&&r.other&&!lead){
7182
8389
  const ccx=c.box.x+c.box.w/2, ccy=c.box.y+c.box.h/2;
7183
8390
  owns=Math.hypot(ccx-r.p[0],ccy-r.p[1])<Math.hypot(ccx-r.other[0],ccy-r.other[1]);
7184
8391
  }
8392
+ // A FOREIGN SQUARE'S BAND IS FORBIDDEN SPACE, NOT A PRICE
8393
+ // Once a fitting is drawn, a port name no longer names
8394
+ // "this end of this link" — it names THAT SQUARE, and the only thing
8395
+ // that says which square is proximity. On the first squared render of
8396
+ // `patterns/topology-a` the aggregation node's three names came out
8397
+ // `p1 p3 p2` over squares in the order p1 p2 p3, because the
8398
+ // free-space terms priced the second name into the gap past its own
8399
+ // fitting: every one of those labels is legible and two of the three
8400
+ // are wrong. No collision relief buys that back, so this is a filter,
8401
+ // exactly as endpoint affinity above is, and it has two halves that
8402
+ // apply to DIFFERENT candidates:
8403
+ // • the BAND — the foreign square grown by the 3 px standoff — is
8404
+ // space NO port name may enter, wherever it was going to be
8405
+ // written. A name across a neighbour's fitting is the wrong
8406
+ // statement whether it got there from the border ring or from the
8407
+ // shaft, so this half is unconditional.
8408
+ // • the AFFINITY — nearer a foreign square's centre than its own —
8409
+ // applies only AT THE BORDER, where proximity is the only thing
8410
+ // saying which square a name belongs to. Out on the shaft the
8411
+ // name is associated by the LINE it rides (and, past the standoff
8412
+ // cap, by a drawn leader), and item 42's own-endpoint filter
8413
+ // already governs that ring. Applying it there was built and
8414
+ // measured: on `05-lacp`, three names whose squares sit 14.5 px
8415
+ // apart have their own shaft running through the middle of the
8416
+ // 7 px window the affinity test leaves them, so every clean
8417
+ // position was disqualified and the figure took the strikes
8418
+ // instead (lblcol 3 -> 5).
8419
+ if(c.sq){
8420
+ const ccx=c.box.x+c.box.w/2, ccy=c.box.y+c.box.h/2;
8421
+ const d0=Math.hypot(ccx-(c.sq.x+c.sq.w/2), ccy-(c.sq.y+c.sq.h/2));
8422
+ const G=PORT_SQ_GAP;
8423
+ for(const b of sqBox){
8424
+ if(b.key===c.sqk) continue;
8425
+ if(c.box.x<b.x+b.w+G&&b.x-G<c.box.x+c.box.w&&
8426
+ c.box.y<b.y+b.h+G&&b.y-G<c.box.y+c.box.h){ owns=false; break; }
8427
+ if(c.atBorder&&
8428
+ Math.hypot(ccx-(b.x+b.w/2),ccy-(b.y+b.h/2))<d0-1e-9){ owns=false; break; }
8429
+ }
8430
+ }
7185
8431
  let s=0;
7186
8432
  for(const b of placed) s+=3*ovl(c.box,b);
8433
+ // A NAME WRITTEN ACROSS ITS OWN FITTING is the one arrangement the
8434
+ // notation cannot survive, and unlike a foreign square this one is
8435
+ // PRICED rather than forbidden: the offset family already starts the
8436
+ // name clear of its own square, so the only way back onto it is the
8437
+ // corner clamp on a device too small to hold the name anywhere else —
8438
+ // and on that device the least-bad answer still has to exist. Charged
8439
+ // at the `placed` weight, because a drawn square is drawn ink of the
8440
+ // same order, and measured on the LINE box, because a 7 px square and
8441
+ // a 12 px line box brushing at all is already too close to read as
8442
+ // two marks.
8443
+ if(c.sq) s+=3*ovl(c.box,c.sq);
8444
+ // THE LEADER'S OWN PRICE, AND THE BAND IT HAS TO SIT IN. A leader is
8445
+ // extra ink and a second thing to follow, so it must be dearer than
8446
+ // every adjacency that needs none — and it must be CHEAPER THAN A
8447
+ // STRIKETHROUGH, which costs 26 here, or the pass would keep choosing
8448
+ // a name lying across a line over the same name standing clear with
8449
+ // its association drawn. 15 is the middle of that band and it is the
8450
+ // whole of the tuning: below 26 by construction, above the 0-12 px
8451
+ // displacement prices that separate one clean position from another.
8452
+ if(lead) s+=15;
7187
8453
  // A PORT MARKER IS SUPPOSED TO BE INSIDE ITS OWN BOX (item 57), so
7188
8454
  // that one node is not charged for it. Every other box still is, at
7189
8455
  // the usual weights — a marker that has slid out of its own device
@@ -7325,31 +8591,64 @@ function renderScene(doc,y0){
7325
8591
  // the 16 px anchor and the affinity filter, both of item 42's.
7326
8592
  if(r.kind!=='end'&&segBoxGap(c.car[0],c.car[1],c.box)>STANDOFF_CAP){
7327
8593
  if(s<farS-1e-9){ farS=s; farBest=c; } continue; }
8594
+ // A PORT NAME OWES ITS SQUARE THE SAME STANDOFF, AND IT
8595
+ // PAYS FOR IT WITH A LEADER RATHER THAN WITH A FILTER. Item 59 makes
8596
+ // the cap a filter for mid-labels because their displacement is
8597
+ // unbounded — a mid-label can wander anywhere along a 900 px channel.
8598
+ // A port name's family is bounded by construction (the border rings
8599
+ // are at the border; the shaft ring is 16-24 px along its own edge),
8600
+ // and the tier prices above — PORT_HUG then PORT_OUT — already order
8601
+ // it. Making the cap a filter HERE was built and measured, and the
8602
+ // corpus refused it: on `05-lacp` it disqualified the whole shaft ring
8603
+ // for six names whose only near positions lie across their own shaft,
8604
+ // and the figure took six strikethroughs rather than six clean names
8605
+ // 20 px out (lblcol 5 -> 7). So the cap does not decide WHERE the name
8606
+ // goes; it decides whether the association is drawn, below.
7328
8607
  if(s<anyS-1e-9){ anyS=s; anyBest=c; }
7329
8608
  if(owns&&s<bestS-1e-9){ bestS=s; best=c; }
7330
8609
  }
7331
8610
  }
7332
8611
  if(!best) best=anyBest||farBest||offBest; // no legal position — least bad
7333
8612
  r0.win=best; // kept for item 55's recolour pass
7334
- lblsvg[r0.idx]=textEl(best.x,best.y,r0.fs,best.anchor,r0.col,r0.text,r0.halo);
7335
- // THE LEADER (item 59). A mid-label that had to be placed past the cap
7336
- // states its line explicitly: a hairline from the nearest point of its
7337
- // box to the nearest point of its own carrying segment, in the label's
7338
- // own colour. It is drawn UNDER nothing and over nothing it is 1 px,
7339
- // it starts at the box and it stops at the shaft so it adds no ink
7340
- // anywhere the reader is not already looking for the association.
7341
- if(r0.kind!=='end'&&best.car&&segBoxGap(best.car[0],best.car[1],best.box)>STANDOFF_CAP){
8613
+ // A PORT NAME CARRIES ITS SQUARE'S KEY. The name and the
8614
+ // fitting it names are one statement drawn as two marks, and nothing in
8615
+ // the finished SVG said they were a pair a reader (and the layout
8616
+ // gate, which now scores a foreign name written across a fitting) had
8617
+ // only proximity to go on, which is the very thing the notation exists
8618
+ // to stop relying on. The key is the square's snapped centre, so the two
8619
+ // elements carry the same string and the association is READ.
8620
+ lblsvg[r0.idx]=textEl(best.x,best.y,r0.fs,best.anchor,r0.col,r0.text,
8621
+ r0.halo+(best.sqk?' data-port-sq="'+best.sqk+'"':''));
8622
+ // THE LEADER (item 59), AND ITS SECOND CALLER. A label that
8623
+ // had to be placed past the cap states its referent explicitly: a
8624
+ // hairline from the nearest point of its box to that referent, in the
8625
+ // label's own colour. It is drawn UNDER nothing and over nothing — it is
8626
+ // 1 px, it starts at the box and it stops at the thing — so it adds no
8627
+ // ink anywhere the reader is not already looking for the association.
8628
+ //
8629
+ // TWO REFERENTS, ONE LEADER. A mid-label's is the nearest point of its
8630
+ // own carrying segment; a port name's is the CENTRE OF ITS OWN SQUARE,
8631
+ // because that is the mark it names. The drawing is otherwise identical
8632
+ // and is therefore drawn by one closure rather than copied — a second
8633
+ // leader mechanism is a second set of rules about when a leader appears,
8634
+ // and this figure only ever needs one.
8635
+ const leadTo=(fx,fy)=>{
7342
8636
  const b=best.box, cx=b.x+b.w/2, cy=b.y+b.h/2;
7343
- const P0=best.car[0], Q0=best.car[1];
7344
- const vx=Q0[0]-P0[0], vy=Q0[1]-P0[1], L2=vx*vx+vy*vy;
7345
- const t=L2?Math.max(0,Math.min(1,((cx-P0[0])*vx+(cy-P0[1])*vy)/L2)):0;
7346
- const fx=P0[0]+t*vx, fy=P0[1]+t*vy;
7347
- // the box's own boundary point on the ray toward that foot
8637
+ // the box's own boundary point on the ray toward the referent
7348
8638
  const dx=fx-cx, dy=fy-cy, ax=Math.abs(dx)||1e-9, ay=Math.abs(dy)||1e-9;
7349
8639
  const k=Math.min((b.w/2+2)/ax,(b.h/2+2)/ay);
7350
8640
  r0.leadIdx=lblsvg.length;
7351
8641
  lblsvg.push('<line x1="'+(cx+dx*k)+'" y1="'+(cy+dy*k)+'" x2="'+fx+'" y2="'+fy+
7352
8642
  '" stroke="'+r0.col+'" stroke-width="1" opacity="0.6"/>');
8643
+ };
8644
+ if(r0.kind!=='end'&&best.car&&segBoxGap(best.car[0],best.car[1],best.box)>STANDOFF_CAP){
8645
+ const b=best.box, cx=b.x+b.w/2, cy=b.y+b.h/2;
8646
+ const P0=best.car[0], Q0=best.car[1];
8647
+ const vx=Q0[0]-P0[0], vy=Q0[1]-P0[1], L2=vx*vx+vy*vy;
8648
+ const t=L2?Math.max(0,Math.min(1,((cx-P0[0])*vx+(cy-P0[1])*vy)/L2)):0;
8649
+ leadTo(P0[0]+t*vx, P0[1]+t*vy);
8650
+ } else if(best.sq&&boxSep(best.box,best.sq)>STANDOFF_CAP){
8651
+ leadTo(best.sq.x+best.sq.w/2, best.sq.y+best.sq.h/2);
7353
8652
  }
7354
8653
  placed.push(Object.assign({text:r0.text},best.box));
7355
8654
  W=Math.max(W, best.box.x+best.box.w+4);
@@ -7866,7 +9165,7 @@ function renderScene(doc,y0){
7866
9165
  used[pick]++;
7867
9166
  }
7868
9167
  if(tone.size>1) for(const [e2,col2] of tone){
7869
- const key='data-edge="'+e2.line+'"';
9168
+ const key='data-edge="'+edgeRef(e2)+'"';
7870
9169
  for(let i=0;i<esvg.length;i++)
7871
9170
  if(esvg[i].indexOf(key)>=0) esvg[i]=esvg[i].replace('stroke="#555"','stroke="'+col2+'"');
7872
9171
  for(const i of (arrowIdx.get(e2)||[]))
@@ -7907,7 +9206,8 @@ function renderScene(doc,y0){
7907
9206
  } else lblsvg.push(textEl(cx,cy-10,10,'middle',bcol,n.label,bhalo));
7908
9207
  continue;
7909
9208
  }
7910
- nsvg.push('<g data-node="'+n.id+'" data-x="'+n.x+'" data-y="'+n.y+'" style="cursor:move">');
9209
+ nsvg.push('<g data-node="'+n.id+'" data-x="'+n.x+'" data-y="'+n.y+'"'
9210
+ +(lassoMem.has(n.id)?' data-lasso="'+lassoMem.get(n.id).join(' ')+'"':'')+' style="cursor:move">');
7911
9211
  const fill=n.fill||'#fff', stroke=n.stroke||'#8a8880', txt=labelInk(fill,'#1d1d1b');
7912
9212
  const ndash=n.style==='dashed'?' stroke-dasharray="6 4"':(n.style==='dotted'?' stroke-dasharray="2 4"':'');
7913
9213
  if(n.shape==='diamond'){
@@ -7969,7 +9269,7 @@ function renderScene(doc,y0){
7969
9269
  // ONE MEMBER = NO ELLIPSE (item 53). The caption below is the whole
7970
9270
  // drawing, and an unlabelled one-member bundle draws nothing.
7971
9271
  if(!R.solo){
7972
- tsvg.push('<ellipse cx="'+rnd3(R.cx)+'" cy="'+rnd3(R.cy)+'" rx="'+rnd3(R.rL)+'" ry="'+rnd3(R.rA)+'" fill="transparent" stroke="'+(t.stroke||col)+'"'+dashOf(t.style,'6 4')+' stroke-width="1.6"'+rot+'/>');
9272
+ tsvg.push('<ellipse cx="'+rnd3(R.cx)+'" cy="'+rnd3(R.cy)+'" rx="'+rnd3(R.rL)+'" ry="'+rnd3(R.rA)+'" data-lasso="'+t.id+'" fill="transparent" stroke="'+(t.stroke||col)+'"'+dashOf(t.style,'6 4')+' stroke-width="1.6"'+rot+'/>');
7973
9273
  W=Math.max(W,R.cx+R.hw); Hh=Math.max(Hh,R.cy+R.hh-y0-20);
7974
9274
  }
7975
9275
  const L=ringLbl.get(t);
@@ -8079,7 +9379,7 @@ function renderScene(doc,y0){
8079
9379
  ' paint-order="stroke" stroke="#fff" stroke-width="3"'));
8080
9380
  }
8081
9381
  const yEnd=y0+20+Hh+10;
8082
- return {svg:gsvg.join('')+esvg.join('')+nsvg.join('')+tsvg.join('')+lblsvg.join(''), y:yEnd, w:W+2,
9382
+ return {svg:gsvg.join('')+esvg.join('')+nsvg.join('')+tsvg.join('')+sqsvg.join('')+lblsvg.join(''), y:yEnd, w:W+2,
8083
9383
  meta:{W:W, top:y0+20+chShift+tShift, Hh:Hh, left:bShift+lShift}, errs:gErrs};
8084
9384
  }
8085
9385
  // borderPoint: where the ray from n's centre toward (tx,ty) leaves the shape.
@@ -8631,6 +9931,26 @@ function renderSequence(doc,y0){
8631
9931
  // would put a 17-digit tail in the artifact for no reader's benefit.
8632
9932
  function r2(v){ return Math.round(v*100)/100; }
8633
9933
 
9934
+ // `TYPED-BLOCK-TITLE-CANVAS` (backlog 66): A TYPED BLOCK'S TITLE JOINS ITS OWN CANVAS.
9935
+ //
9936
+ // Each of the four typed-block renderers (bitfield/table/timing/chart) sizes
9937
+ // its section from its DATA — cells, ruler, lanes, floor — and draws its own
9938
+ // caption over that canvas afterwards, at x=0, without ever measuring the
9939
+ // caption into the width it returns. A caption wider than the data therefore
9940
+ // ran past the right edge of the section's own `<svg>` box, and a section
9941
+ // canvas grows right and down only, so text at a negative coordinate is
9942
+ // CLIPPED, never merely misplaced (`LABEL-PLACEMENT-METRIC`, which fixed exactly this
9943
+ // for a `table` caption and a `chart` row/column label gutter but not for a
9944
+ // bitfield/timing title or a chart's own top caption — reported from
9945
+ // downstream production authoring).
9946
+ //
9947
+ // ONE MEASUREMENT, used by all four renderers below, so the calibration lives
9948
+ // in one place rather than four. Bold text at this size is measured ~8%
9949
+ // wider than the plain per-character estimate `cwMax(...)*CH` gives at
9950
+ // regular weight — verified against the raster, not assumed, the same
9951
+ // allowance `renderTable`'s caption fix already established.
9952
+ function typedBlockTitleW(label){ return cwMax(label)*CH*1.08+2; }
9953
+
8634
9954
  // ---- bitfield ----
8635
9955
  function renderBitfield(b,y0){
8636
9956
  const cell=Math.max(18,Math.min(28,Math.floor(760/b.word))), rh=30, ruler=16;
@@ -9061,7 +10381,12 @@ function renderBitfield(b,y0){
9061
10381
  }
9062
10382
  yb+=2;
9063
10383
  }
9064
- return {svg:svg.join(''), y:yb, w:wb,
10384
+ // THE SECTION IS AS WIDE AS ITS WIDEST INK, AND THE CAPTION IS INK (`TYPED-BLOCK-TITLE-CANVAS`).
10385
+ // `wb` is the DATA extent alone (cells, ruler, `present=` captions) — the
10386
+ // BOX below stays measured against it, because a region-scope
10387
+ // `threshold`/`band` is a statement about the data, not about the title
10388
+ // that names it. Only the returned canvas width grows for the caption.
10389
+ return {svg:svg.join(''), y:yb, w:Math.max(wb, typedBlockTitleW(b.label)),
9065
10390
  box:{x0:0, x1:wb, yA:y0+18, yB:yb}};
9066
10391
  }
9067
10392
 
@@ -9124,9 +10449,14 @@ function renderTable(t,y0){
9124
10449
  const mk=markOf(r,c);
9125
10450
  // block fill= is the default DATA-cell fill (header tint is structural)
9126
10451
  const fill=(mk&&mk.fill)||(cell.hdr?'#eeede6':(hlRow(r)?'#fef3c7':(t.fill||'#fff')));
9127
- // addressable cells carry table-id:row:col (row 0 = bottom header tier)
9128
- const addrR = r>=H ? (r-H+1) : (r===H-1 ? 0 : null);
9129
- const addr = addrR===null ? '' : ' data-cell="'+t.id+':'+addrR+':'+(c+1)+'" style="cursor:pointer"';
10452
+ // addressable cells carry table-id:row:col for EVERY tier (backlog 71):
10453
+ // data rows are 1..n, the bottom header tier keeps its historical `0`
10454
+ // spelling so existing consumers of that channel see no change, and
10455
+ // every tier above it is `hN` (1-indexed from the top) — the same
10456
+ // spelling the editor's own internal row-token model already used, so
10457
+ // the editor can read this channel with no translation table.
10458
+ const addrR = r>=H ? String(r-H+1) : (r===H-1 ? '0' : 'h'+(r+1));
10459
+ const addr = ' data-cell="'+t.id+':'+addrR+':'+(c+1)+'" style="cursor:pointer"';
9130
10460
  // A merged cell owns every grid square it spans, so its internal
9131
10461
  // boundaries have the same owner on both sides and are never drawn.
9132
10462
  const rec={id:'c'+r+'_'+c, c:(mk&&mk.stroke)||null, d:false};
@@ -9178,9 +10508,10 @@ function renderTable(t,y0){
9178
10508
  // grid is not the figure; the caption is not decoration.
9179
10509
  // Bold at 13 px is wider than `CH` (a regular-weight advance), so the caption
9180
10510
  // is measured with the same 8% allowance the raster needed — verified by
9181
- // rendering, not assumed.
9182
- const capW=cwMax(t.label)*CH*1.08+2;
9183
- return {svg:svg.join(''), y:yEnd+6, w:Math.max(totalW+2,capW),
10511
+ // rendering, not assumed. `typedBlockTitleW` (`TYPED-BLOCK-TITLE-CANVAS`) is that
10512
+ // same measurement, now shared by all four typed-block renderers rather
10513
+ // than kept as this one's own private copy.
10514
+ return {svg:svg.join(''), y:yEnd+6, w:Math.max(totalW+2,typedBlockTitleW(t.label)),
9184
10515
  box:{x0:0, x1:totalW, yA:yTop+yAt[H], yB:yEnd}};
9185
10516
  }
9186
10517
 
@@ -9339,7 +10670,14 @@ function renderChart(b,y0,doc){
9339
10670
  svg.push(textEl(zr[0]+21, zr[1]-z*ZS+3.5, 9.5, 'start', '#6f6e69', String(z)));
9340
10671
  }
9341
10672
  const near=[PR(R,C,0)[0]+ox, PR(R,C,0)[1]];
9342
- const w=Math.max(near[0]+70, farRight+ox+4);
10673
+ // THE TOP CAPTION IS THE SECTION'S OWN INK TOO (`TYPED-BLOCK-TITLE-CANVAS`). The
10674
+ // row/column axis-label gutter (`ox`, above) was widened for exactly this
10675
+ // reason, but that pass measured the LEANED-OUT axis labels
10676
+ // and never the caption drawn at the section's fixed x=0 top-left corner —
10677
+ // a `chart` whose table name is long enough still ran the caption off the
10678
+ // right edge under its own data floor. The caption is never shifted (it is
10679
+ // already the section's leftmost, topmost ink); only the canvas grows.
10680
+ const w=Math.max(near[0]+70, farRight+ox+4, typedBlockTitleW(t.label+' — bar3d'));
9343
10681
  const hgt=Math.max(near[1]+24, farDown+6)-y0;
9344
10682
  return {svg:svg.join('')+lsvg.join(''), y:y0+hgt, w:w};
9345
10683
  }
@@ -9396,12 +10734,18 @@ function renderTiming(w,y0){
9396
10734
  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"/>');
9397
10735
  }
9398
10736
  const H=y+8+w.signals.length*(laneH+laneGap);
9399
- return {svg:svg.join(''), y:H, w:nameW+cycles*cycleW+2,
10737
+ // THE SECTION IS AS WIDE AS ITS WIDEST INK, AND THE TITLE IS INK (`TYPED-BLOCK-TITLE-CANVAS`,
10738
+ // 0.5). `nameW+cycles*cycleW+2` is the DATA extent alone (the
10739
+ // signal-name gutter plus the lane grid); the BOX below stays measured
10740
+ // against it, unchanged, for the same reason a table's region box stays
10741
+ // measured against the grid and not the caption.
10742
+ return {svg:svg.join(''), y:H, w:Math.max(nameW+cycles*cycleW+2, typedBlockTitleW(w.label)),
9400
10743
  box:{x0:nameW, x1:nameW+cycles*cycleW, yA:y0+18, yB:H}};
9401
10744
  }
9402
10745
 
9403
10746
  // ============================================================
9404
- return { parse: parse, render: render, stackSectionSvgs: stackSectionSvgs };
10747
+ return { parse: parse, render: render, stackSectionSvgs: stackSectionSvgs,
10748
+ a11yApply: a11yApply, renderOptionsAttr: renderOptionsAttr };
9405
10749
  })();
9406
10750
 
9407
10751
  // ---- minimal synchronous SHA-256 (FIPS 180-4), hex output ----
@@ -9472,32 +10816,62 @@ function __stackSectionSvgs(results) {
9472
10816
  // (determinism over convenience: no partial renders of invalid input).
9473
10817
  // opts (presentation, renderer tier): { title: true } draws the title;
9474
10818
  // the default does NOT (embedded figures almost always sit under the
9475
- // host document's caption — the majority case).
10819
+ // host document's caption — the majority case). { a11y: true } adds the
10820
+ // accessibility profile's emission (spec/figdown-a11y.md; ACCESSIBILITY-PROFILE):
10821
+ // role="graphics-document" on the root, the non-visual <title> as its first
10822
+ // child, and a state-flagged derived <desc>. The two are ORTHOGONAL — one
10823
+ // decides ink, the other the accessible name — and both default to off.
9476
10824
  // Multi-section sources are stacked vertically into a single SVG (MULTI-FIGURE-DOCUMENTS).
10825
+ //
10826
+ // TWO ERROR CHANNELS REACH ONE errors ARRAY. parse cannot see a
10827
+ // coordinate, so a document whose SOURCE is impeccable can still draw a false
10828
+ // statement — a group band enclosing a non-member, a pin covering a node
10829
+ // completely. The engine reports those from render (as .errs on its render
10830
+ // result), and core §8 requires a caller to treat a non-empty render
10831
+ // diagnostic list EXACTLY as it treats a parse error list. Until 0.4
10832
+ // this wrapper discarded that channel and returned errors: [] with an SVG of
10833
+ // the picture the engine had just said was wrong — the one copy of the engine
10834
+ // a require('figdown') user actually gets. Both channels now land here, and
10835
+ // either withholds the SVG.
9477
10836
  function render(text, opts) {
9478
10837
  var p = parse(text);
9479
10838
  if (p.errors.length) return { svg: null, errors: p.errors };
9480
- if (p.docs.length > 1) {
9481
- var rs = p.docs.map(function (d) { return __engine.render(d, opts); });
9482
- return { svg: __engine.stackSectionSvgs(rs), errors: [] };
9483
- }
9484
- return { svg: __engine.render(p.doc, opts).svg, errors: [] };
10839
+ if (!p.docs.length) return { svg: null, errors: [] };
10840
+ var rs = p.docs.map(function (d) { return __engine.render(d, opts); });
10841
+ var errs = [];
10842
+ for (var i = 0; i < rs.length; i++) errs = errs.concat(rs[i].errs || []);
10843
+ if (errs.length) return { svg: null, errors: errs };
10844
+ var svg = rs.length > 1 ? __engine.stackSectionSvgs(rs) : rs[0].svg;
10845
+ return { svg: __a11y(svg, p.docs[0], opts), errors: [] };
10846
+ }
10847
+ // The accessibility profile's emission (spec/figdown-a11y.md; ACCESSIBILITY-PROFILE).
10848
+ // Applied to the FINISHED root, never inside a per-section render: ACCESSIBILITY-PROFILE gives
10849
+ // an artifact ONE root, ONE name, and the name is the FIRST section's title.
10850
+ // A caller that does not ask gets the byte-identical default (core §7 / RENDERING-DETERMINISM).
10851
+ function __a11y(svg, doc, opts) {
10852
+ if (!svg || !(opts && opts.a11y === true)) return svg;
10853
+ if (typeof __engine.a11yApply !== 'function') return svg;
10854
+ return __engine.a11yApply(svg, doc);
9485
10855
  }
9486
10856
  // renderDoc(doc, opts) -> svg string, for an already-validated doc from parse().
9487
10857
  // For multi-section, pass parse().docs to renderDocs instead.
9488
10858
  function renderDoc(doc, opts) {
9489
- return __engine.render(doc, opts).svg;
10859
+ return __a11y(__engine.render(doc, opts).svg, doc, opts);
9490
10860
  }
9491
10861
  function renderDocs(docs, opts) {
9492
10862
  if (!docs || !docs.length) return '';
9493
- if (docs.length === 1) return __engine.render(docs[0], opts).svg;
9494
- return __engine.stackSectionSvgs(docs.map(function (d) { return __engine.render(d, opts); }));
10863
+ if (docs.length === 1) return __a11y(__engine.render(docs[0], opts).svg, docs[0], opts);
10864
+ return __a11y(__engine.stackSectionSvgs(docs.map(function (d) { return __engine.render(d, opts); })),
10865
+ docs[0], opts);
9495
10866
  }
9496
10867
  // artifact(text) -> { svg, errors } svg is the full self-carrying SVG:
9497
- // the render plus a <metadata id="figdown-source"> block embedding the
9498
- // source text, the SHA-256 OF THAT SOURCE, and the engine version that
9499
- // rendered it (same convention as tools/build-svg.js; spec core §7).
9500
- // svg is null when there are errors.
10868
+ // the render plus a <metadata id="figdown-source"> block
10869
+ // embedding the source text, the SHA-256 OF THAT SOURCE, and the engine
10870
+ // version that rendered it (same convention as tools/build-svg.js; spec §7).
10871
+ // svg is null when there are errors — parse-time OR geometry-time, on
10872
+ // tools/build-svg.js's contract (core §8): a non-empty render diagnostic list
10873
+ // refuses the artifact exactly as a parse error does, because writing it
10874
+ // anyway publishes the picture the engine has just said is wrong.
9501
10875
  function artifact(text, opts) {
9502
10876
  var src = String(text);
9503
10877
  var p = render(src, opts);
@@ -9505,7 +10879,7 @@ function artifact(text, opts) {
9505
10879
  // The artifact records the SHA-256 OF THE SOURCE, the ENGINE VERSION that
9506
10880
  // rendered it, and any non-default render option (core §7) — together they
9507
10881
  // keep third-party rebuilds bit-identical and give a diff somewhere to point
9508
- var optAttr = (opts && opts.title === true) ? ' data-render-options="with-title"' : '';
10882
+ var optAttr = __engine.renderOptionsAttr(opts);
9509
10883
  var meta = '<metadata id="figdown-source" data-sha256="' + __sha256hex(src) + '"'
9510
10884
  + ' data-engine-version="' + VERSION + '"' + optAttr + '><![CDATA[\n'
9511
10885
  + src.replace(/]]>/g, ']]]]><![CDATA[>') + '\n]]></metadata>';