figdown 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/figdown.js CHANGED
@@ -1,4 +1,4 @@
1
- // figdown.js — FigDown embeddable library (0.3.1)
1
+ // figdown.js — FigDown embeddable library (0.4.0)
2
2
  // GENERATED FILE, DO NOT EDIT. Built from editor/figdown.html.
3
3
  // Regenerate with: node tools/make-lib.js
4
4
  (function (root, factory) {
@@ -10,7 +10,7 @@
10
10
  }
11
11
  }(typeof globalThis !== 'undefined' ? globalThis : this, function () {
12
12
  'use strict';
13
- var VERSION = "0.3.1";
13
+ var VERSION = "0.4.0";
14
14
 
15
15
  // ---- engine (extracted verbatim from editor/figdown.html) ----
16
16
  var __engine = (function () {
@@ -24,7 +24,7 @@ const SHAPES = ['box','rounded','circle','ellipse','diamond','cylinder'];
24
24
  // input to that promise, and under core §13 a 0.x renderer may differ from
25
25
  // the next — which makes the recorded version the only thing that can
26
26
  // explain a diff between two renderings of one source.
27
- const FIGDOWN_VERSION = '0.3.1';
27
+ const FIGDOWN_VERSION = '0.4.0';
28
28
  // `STATECHART-GENRE-SCOPE`: the language number moved for the first time. The dev
29
29
  // counter does NOT reset (core §13.0.4 — `N` counts source states of the
30
30
  // engine and only ever increases), so 0.1 is followed by
@@ -39,14 +39,34 @@ const FIGDOWN_VERSION = '0.3.1';
39
39
  // fixes only. No new features. The language does not move." Shipping `note=`
40
40
  // under `v0.2.z` would make `figdown 0.2` name two different languages: the one
41
41
  // `v0.2.0` published and the one with `note=`. So the language number moves.
42
- const LANG_VERSIONS = ['0.1', '0.2', '0.3'];
42
+ // `SEQUENCE-SOURCE-STANDARD`-R182: `figdown 0.4` joins the set, and it joins it for the
43
+ // same reason `0.2` did — a GENRE token is language surface, and core §13.0
44
+ // makes added surface a `Y` and never a `Z`. `sequence` is that token. It adds
45
+ // no keyword yet (see GENRES_BY_VERSION below), which is exactly `STATECHART-GENRE-SCOPE`'s shape:
46
+ // the dispatch point lands first and the vocabulary follows it.
47
+ const LANG_VERSIONS = ['0.1', '0.2', '0.3', '0.4'];
43
48
  // Genres per declared language version. `Y` never removes (core §13.0), so
44
49
  // each row is a superset of the one above it, and `figdown 0.1 <anything>`
45
50
  // resolves against exactly the list it resolved against before `STATECHART-GENRE-SCOPE`.
46
51
  const GENRES_BY_VERSION = {
47
52
  '0.1': ['block','topology','flowchart','bitfield','table','timing'],
48
53
  '0.2': ['block','topology','flowchart','bitfield','table','timing','statechart'],
49
- '0.3': ['block','topology','flowchart','bitfield','table','timing','statechart']
54
+ '0.3': ['block','topology','flowchart','bitfield','table','timing','statechart'],
55
+ // `SEQUENCE-SOURCE-STANDARD`-R182: `sequence` is dispatchable from here on. Like
56
+ // `statechart` at `STATECHART-GENRE-SCOPE` it arrived with NO vocabulary of its own, and the
57
+ // consequence was stated here rather than left to be discovered: there was no
58
+ // `GENRE_KW.sequence` row, and the allowlist guard is written
59
+ // `GENRE_KW[doc.genre] && !GENRE_KW[doc.genre].has(kw)`, so a genre with no
60
+ // row is NOT narrowed — a `figdown 0.4 sequence` document could write any
61
+ // registered keyword and it parsed. The document that increment meant to
62
+ // land was the header ALONE.
63
+ // `SEQUENCE-SOURCE-STANDARD`-R182: CLOSED. `GENRE_KW.sequence` exists below, so the
64
+ // genre now constrains what it names — five keywords of its own, `class`,
65
+ // and the genre-free core — and `flow`/`rank`/`group` are line errors under
66
+ // it. The genre still has NO RENDERER: a valid `sequence` document parses to
67
+ // a model and draws an empty canvas, which is the state this increment means
68
+ // to land and is pinned by a fixture rather than left to be noticed.
69
+ '0.4': ['block','topology','flowchart','bitfield','table','timing','statechart','sequence']
50
70
  };
51
71
  // The version an OPTION KEY first becomes legal in — the `CONNECTOR_MIN_VERSION`
52
72
  // device, applied to the option namespace. `DRAWN-ANNOTATION-FORM`: `note=` is gated on the
@@ -354,6 +374,26 @@ const DIRECTIVE_OPTS={
354
374
  // keyed by the surface word an author actually wrote.
355
375
  flowline:['style','class','fill','stroke','label','taillabel','headlabel','note'],
356
376
  transition:['style','class','fill','stroke','label','taillabel','headlabel','note'],
377
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's four own rows. `message` is
378
+ // the fourth connector spelling and takes the connector set — `fill=` and
379
+ // the three retired label keys stay listed for the same reason they are
380
+ // listed on the other three, so their dedicated diagnostics fire instead of
381
+ // a bare `does not take` — plus `in=` (sense 1: the fragment or operand this
382
+ // message occurs inside) and `description=`. It does NOT gain a key of its
383
+ // own: `lost=` was proposed and refused (`UNDELIVERED-MESSAGE-MARKING`), and `OPT_KEYS` is unchanged
384
+ // by this whole increment.
385
+ message:['style','class','fill','stroke','label','taillabel','headlabel','note','in','description'],
386
+ // A lifeline is drawn as a head box over a dashed line, so it has an
387
+ // interior and takes `fill=`. `in=` is sense 1.
388
+ lifeline:['class','fill','stroke','style','in','note','description'],
389
+ // `type=` is MANDATORY on `fragment` and is checked in `parseSeqDirective`,
390
+ // not here: a missing key is not an inapplicable key. No `fill=` — a
391
+ // combined fragment is a FRAME drawn over the messages it contains, and
392
+ // painting its interior would hide them.
393
+ fragment:['type','class','stroke','style','in','note','description'],
394
+ // `in=` is MANDATORY on `operand` (an operand is a compartment OF a
395
+ // fragment) and is likewise checked in `parseSeqDirective`.
396
+ operand:['in','class','stroke','style','note','description'],
357
397
  // `PAINT-ORDER-CONSTRUCT`: the `plane` row is GONE, not emptied — the keyword is
358
398
  // withdrawn from the language, so it has no acceptor row at all, the shape
359
399
  // `path`/`routing` left behind. `z-index=` goes with it: it
@@ -400,6 +440,31 @@ const DIRECTIVE_OPTS={
400
440
  cell:['fill','stroke','class'], width:[],
401
441
  signal:['data','fill','stroke'], gap:[]
402
442
  };
443
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the FIRST genre-conditional option row, and it
444
+ // exists because `DIRECTIVE_OPTS` is keyed by the SURFACE WORD an author
445
+ // wrote, while `GENRE-VOCABULARY-OBLIGATION` makes a surface word a per-genre declaration. Every other
446
+ // shared spelling in the language names the same construct in every genre
447
+ // that has it — `state` under `statechart` IS `node` renamed (`GENRE-NODE-SPELLING`), so it
448
+ // takes `node`'s row exactly — but `state` under `sequence` is a DIFFERENT
449
+ // construct under the same spelling: a StateInvariant (UML 2.5.1 §17.12.25)
450
+ // that REFERENCES a lifeline rather than declaring an id. It has no shape and
451
+ // no extent of its own, so `shape=`, `width=` and `height=` name nothing on
452
+ // it; it can sit inside a fragment, so it takes `in=` (`SEQUENCE-CONTAINMENT-SCOPE`); and it takes
453
+ // `description=` like the rest of this genre's directives.
454
+ //
455
+ // The lookup is one table indexed genre-first, so a genre with no entry falls
456
+ // through to `DIRECTIVE_OPTS` untouched and the whole 0.1/0.2/0.3 surface is
457
+ // byte-identical. It is NOT a second registry: every key named here is
458
+ // already an `OPT_KEYS` member accepted by some directive, so nothing about
459
+ // the closed option namespace changes.
460
+ const GENRE_DIRECTIVE_OPTS={
461
+ sequence:{
462
+ state:['class','fill','stroke','style','in','note','description']
463
+ }
464
+ };
465
+ const directiveOpts=(kw,genre)=>
466
+ (genre && GENRE_DIRECTIVE_OPTS[genre] && GENRE_DIRECTIVE_OPTS[genre][kw])
467
+ || DIRECTIVE_OPTS[kw];
403
468
  const STYLES=['solid','dashed','dotted'];
404
469
  // `RULE-POSITION-ENUMERATION`: every LIVE option key whose value grammar is an enum,
405
470
  // read off spec/vocabulary-sources.tsv (`shape` column = `enum`, `status`
@@ -498,7 +563,7 @@ const RETIRED_OPT_KEYS={
498
563
  // appears.
499
564
  'z-index':'z-index= has been WITHDRAWN with the `plane` keyword (`PAINT-ORDER-CONSTRUCT`): it was legal on `plane` and on nothing else, so it left with its only acceptor. There is no replacement spelling and no other directive to move it to. Delete the key: paint order is document order, and a later line paints on top (MIGRATIONS 0.3)',
500
565
  z:'z= has been WITHDRAWN: it was renamed z-index=, and z-index= was withdrawn with the `plane` keyword (`PAINT-ORDER-CONSTRUCT`) — it was legal on `plane` and on nothing else. There is no replacement spelling. Delete the key: paint order is document order, a later line paints on top (MIGRATIONS 0.3)',
501
- // `note` was HERE (`DESCRIPTION-KEY-SPELLING`) until this release (`DRAWN-ANNOTATION-FORM`), and its
566
+ // `note` was HERE (`DESCRIPTION-KEY-SPELLING`) until 0.3 (`DRAWN-ANNOTATION-FORM`), and its
502
567
  // row is gone because the key is LIVE again — SYNTAX-STYLE RULE 4.9
503
568
  // obligation 3 forbids leaving the retirement message standing past the
504
569
  // revival, on the ground that a message telling an author to write
@@ -566,7 +631,7 @@ const RETIRED_LAYER='layer has been WITHDRAWN: it was renamed plane, and plane w
566
631
  // `THRESHOLD-KEYWORD-SPELLING`: the scene keyword `guide` became `threshold`.
567
632
  const RETIRED_GUIDE='guide has been renamed: use threshold (in Illustrator, Inkscape, Figma and draw.io a "guide" is an author-only construction line that is NEVER rendered, while FigDown\'s is drawn output — an INVERTED name, which `UNSAFE-DEFAULT-ELIMINATION` rates worse than an unfamiliar one, and no counter-example was found where "guide" names rendered output. `guide` was also a FigDown coinage, and `SIZE-AND-DIRECTION-KEY-NAMING` makes coining a last resort; `threshold` comes whole from Grafana, whose "Show thresholds" render option offers "As lines", "As filled regions" and "As filled regions and lines" — FigDown\'s marker + region pair, split the same way — with IETF RED/AQM as the secondary source (RFC 2309: "Two RED parameters, minth (minimum threshold) and maxth (maximum threshold)"; RFC 7567: "an AQM algorithm configured with a threshold"). 78% of the measured corpus marks are thresholds; target/mean/reference marks: 0) (MIGRATIONS 0.1)';
568
633
  // `EXTERNAL-ENDPOINT-NAMING`: the scene keyword `boundary` became `external`.
569
- const RETIRED_BOUNDARY='boundary has been renamed: use external (it declares an external I/O endpoint — the spec\'s own words — while UML\'s «boundary» is an INTERNAL interface object, C4\'s System_Boundary is a dashed grouping container FigDown already spells `group`, and BPMN\'s Boundary Event is a third meaning) (MIGRATIONS 0.1)';
634
+ const RETIRED_BOUNDARY='boundary has been renamed: use external (it declares an external I/O endpoint — the spec\'s own words — while the Entity-Control-Boundary analysis pattern\'s «boundary» is an INTERNAL interface object, C4\'s System_Boundary is a dashed grouping container FigDown already spells `group`, and BPMN\'s Boundary Event is a third meaning) (MIGRATIONS 0.1)';
570
635
  // `ROW-BREAK-NAMING`: the `bitfield` child keyword `wrap` became `break`.
571
636
  const RETIRED_WRAP='wrap has been renamed: use break (in CSS and typography `wrap` is AUTOMATIC reflow — a mode — while this directive is an EXPLICIT row break, an event; CSS Fragmentation calls it "a forced break … explicitly indicated by the … author", HTML spells it `br`) (MIGRATIONS 0.1)';
572
637
  // `PRESENCE-FLAG-SPELLING`: the 0.1 rename `optional` -> `conditional` (`PRESENCE-FLAG-SPELLING`)
@@ -958,6 +1023,25 @@ const FLOWCHART_SUBJECT_KW=['external'];
958
1023
  // none of the six; `external` is additionally UML 2.5.1 §14's own
959
1024
  // `TransitionKind` literal and is reserved for it (`RESERVED-SPELLINGS`).
960
1025
  const STATECHART_SUBJECT_KW=[];
1026
+ // `sequence` (EXPERIMENTAL, 0.4, `SEQUENCE-SOURCE-STANDARD`-R182): THREE, and the array is
1027
+ // this genre's whole declaration of what a sequence figure is OF. `state` and
1028
+ // `fragment` and `operand` describe referents UML clause 17 defines —
1029
+ // `StateInvariant` (§17.12.25), `CombinedFragment` (§17.12.3) and
1030
+ // `InteractionOperand` (§17.12.14) — so they are subject vocabulary in exactly
1031
+ // `SUBJECT-VOCABULARY-SCOPE`'s sense, while `lifeline` and `message` are this genre's NODE and
1032
+ // CONNECTOR spellings and live in `GENRE_NODE_KW`/`GENRE_CONNECTOR_KW` with
1033
+ // the other genres' (`GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`). None of the six words the four scene genres
1034
+ // declare is here: `group` is REFUSED (`SEQUENCE-PARTICIPANT-GROUPING`, below), `external` `threshold`
1035
+ // `band` `bundle` have no measured need and no clause-17 referent, and
1036
+ // `flow`/`rank` are refused as a CONSEQUENCE of the genre's two axes being
1037
+ // declaration-ordered (draft §7) — they are simply absent from `GENRE_KW`.
1038
+ // `state` is SHARED with `statechart` as a spelling and is a SEPARATE
1039
+ // declaration with a different grammar: there slot 1 declares an id, here it
1040
+ // REFERENCES a lifeline (draft §29 Q5). Two genres agreeing on a spelling is
1041
+ // two declarations that agree, never one inherited — which is the whole of
1042
+ // `SUBJECT-VOCABULARY-SCOPE`, and is why this genre's `state` also takes its own option row
1043
+ // (GENRE_DIRECTIVE_OPTS).
1044
+ const SEQUENCE_SUBJECT_KW=['state','fragment','operand'];
961
1045
  // `FLOWCHART-ROLE-KEYWORDS`: the flowchart ROLE vocabulary — the FIRST exercise of
962
1046
  // `GENRE-NAMESPACE` `GENRE-VOCABULARY-OBLIGATION` ("a genre owns its words"). These three are legal ONLY under
963
1047
  // `figdown 0.1 flowchart`; `GENRE-NAMESPACE`'s allowlist is what makes `decision x` a line
@@ -993,10 +1077,50 @@ const ROLE_SHAPE={process:'box',decision:'diamond',terminator:'rounded'};
993
1077
  // symbol this genre cannot spell is a COVERAGE GAP in FigDown, not a state
994
1078
  // of the figure, and `node` is not its spelling — see
995
1079
  // the project’s working record for the coverage ledger.
996
- const GENRE_NODE_KW={block:'node',topology:'node',flowchart:'node',statechart:'state'};
997
- const GENRE_CONNECTOR_KW={block:'edge',topology:'edge',flowchart:'flowline',statechart:'transition'};
998
- const NODE_SPELLINGS=new Set(['node','state']);
999
- const CONNECTOR_SPELLINGS=new Set(['edge','flowline','transition']);
1080
+ // sequence lifeline message (OMG UML 2.5.1 §17)
1081
+ const GENRE_NODE_KW={block:'node',topology:'node',flowchart:'node',statechart:'state',sequence:'lifeline'};
1082
+ const GENRE_CONNECTOR_KW={block:'edge',topology:'edge',flowchart:'flowline',statechart:'transition',sequence:'message'};
1083
+ const NODE_SPELLINGS=new Set(['node','state','lifeline']);
1084
+ const CONNECTOR_SPELLINGS=new Set(['edge','flowline','transition','message']);
1085
+ // `SEQUENCE-SOURCE-STANDARD`-R182: WHERE EACH GENRE'S NODE ROWS LIVE IN THE PARSED DOC.
1086
+ // `GENRE_NODE_KW` above says what the WORD is; this says which `doc`
1087
+ // collection the parser puts that word's rows in. The two are separate facts
1088
+ // and only one of them is `nodes`: `statechart` renames the word and keeps the
1089
+ // collection (a `state` is a scene node), while `sequence` renames BOTH — a
1090
+ // `lifeline` is not a scene node and lands in `doc.lifelines`. Any GUI test of
1091
+ // the form "is this id a thing this genre declares" has to ask through here.
1092
+ // Hand-writing `doc.nodes` is the defect it closes: the Fill/Delete/Raise/
1093
+ // Lower enablement asked `lastDoc.nodes` and so was permanently false under
1094
+ // `sequence`, greying out four buttons whose edits (`SEQUENCE-SOURCE-STANDARD`-R182) already worked.
1095
+ const GENRE_NODE_COLL={block:'nodes',topology:'nodes',flowchart:'nodes',
1096
+ statechart:'nodes',sequence:'lifelines'};
1097
+ const docNodes=(doc)=>(doc&&doc[GENRE_NODE_COLL[doc.genre]||'nodes'])||[];
1098
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the four `sequence` directives that have their own
1099
+ // parser (`message` rides the connector scanner). The set is what dispatches
1100
+ // to `parseSeqDirective`, and it is scoped by `doc.genre` at the call site so
1101
+ // `state` still reaches `statechart`'s node parser under `statechart`.
1102
+ const SEQ_KW=new Set(['lifeline','state','fragment','operand']);
1103
+ // UML 2.5.1 `InteractionOperatorKind` (§17.12.15.3), taken WHOLE — twelve
1104
+ // values, every one the standard's own single lowercase spelling, so RULE 4.2
1105
+ // admits the abbreviations `alt` `opt` `par` `neg` `seq` unchanged. The clause
1106
+ // number matters: §17.6.2 was cited for this enum in an earlier draft and is
1107
+ // registered FALSE in spec/standards-claims.tsv (S024). FigDown makes the key
1108
+ // MANDATORY where UML gives the attribute a default of `seq`
1109
+ // (`interactionOperator : InteractionOperatorKind [1..1] = seq`, §17.12.3.5) —
1110
+ // a DECLARED divergence: a default would let a fragment assert nothing while
1111
+ // looking like it asserts something, which is the `numbering=` precedent.
1112
+ const SEQ_OPERATORS_FRAG=['alt','opt','loop','par','strict','seq','critical',
1113
+ 'neg','assert','ignore','consider','break'];
1114
+ const SEQ_FRAG_CLAUSE='UML 2.5.1 §17.12.15.3';
1115
+ // Draft §8.2/§15.4: every UML Message has a sendEvent AND a receiveEvent, so
1116
+ // a direction-less message is not a thing this domain has. `--` is a LINE
1117
+ // ERROR under `sequence` and legal in every other genre — the operator set is
1118
+ // per genre for the same reason the keywords are.
1119
+ const SEQ_OPERATORS=new Set(['->','<-','<->']);
1120
+ // Every connector spelling in the language reaches the dedicated scanner —
1121
+ // the WRONG one for the genre must get the named diagnostic, not
1122
+ // `unrecognized line` (`GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`), so the dispatch is over the whole set.
1123
+ const CONN_LINE_RE=new RegExp('^('+[...CONNECTOR_SPELLINGS].join('|')+')(\\s|$)');
1000
1124
  // `KEYWORD-RENAME-SCOPE`: the flowchart rename is GATED BY THE DECLARED LANGUAGE
1001
1125
  // VERSION, because `GENRE-CONNECTOR-SPELLING` applied it to `figdown 0.1` and that BROKE documents
1002
1126
  // legal at v0.1.8 — `figdown 0.1 flowchart` + `edge` stopped parsing, with
@@ -1010,7 +1134,12 @@ const CONNECTOR_SPELLINGS=new Set(['edge','flowline','transition']);
1010
1134
  // forbids; two spellings across VERSIONS is ordinary language evolution, and
1011
1135
  // each version accepts exactly one. `statechart` needs no gate of its own —
1012
1136
  // the GENRE requires 0.2 (GENRES_BY_VERSION), so `state`/`transition` cannot
1013
- // be reached from a 0.1 document at all.
1137
+ // be reached from a 0.1 document at all. `sequence` inherits that argument
1138
+ // unchanged: its genre token requires `figdown 0.4`, so
1139
+ // `lifeline`/`message` are unreachable from any earlier document and there is
1140
+ // no earlier spelling for them to have replaced. A `CONNECTOR_MIN_VERSION`
1141
+ // row for `message` would therefore gate nothing and would make the engine
1142
+ // claim a rename that never happened.
1014
1143
  const GENRE_CONNECTOR_KW_AT={
1015
1144
  '0.1':{block:'edge',topology:'edge',flowchart:'edge'},
1016
1145
  '0.2':GENRE_CONNECTOR_KW
@@ -1033,17 +1162,22 @@ const WORD_WHY={
1033
1162
  flowline:'the connecting line in a flowchart is a FLOWLINE — the term the flowchart domain commonly uses for the symbol ISO 5807 §9.3.1 names "Line"',
1034
1163
  transition:'the connecting line in a statechart is a TRANSITION — the term UML 2.5.1 §14 uses for it',
1035
1164
  node:'this genre has more kinds of thing than it has words for, so `node` is the general one',
1036
- state:'a statechart has exactly ONE kind of node and it is a STATE (UML 2.5.1 §14)'
1165
+ state:'a statechart has exactly ONE kind of node and it is a STATE (UML 2.5.1 §14)',
1166
+ // `SEQUENCE-SOURCE-STANDARD`-R182: both are WHOLE borrows from the genre's source
1167
+ // standard, verified against the clause text rather than against a
1168
+ // secondary description (spec/standards-claims.tsv).
1169
+ lifeline:'a sequence figure has exactly ONE kind of participant column and it is a LIFELINE — the term UML 2.5.1 §17.3.3.1 defines and §17.3.4.1 draws',
1170
+ message:'the line between two participants in a sequence figure is a MESSAGE — the term UML 2.5.1 §17.4.4.1 uses for its notation'
1037
1171
  };
1038
1172
  // The named diagnostic `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING` owe: it says WHICH word this genre uses and
1039
1173
  // WHY, and it names the migration, because every connector line in a
1040
1174
  // reclassified document has to be rewritten (the cost `GENRE-CONNECTOR-SPELLING` accepted).
1041
1175
  const WRONG_WORD=(surf,want,genre)=>
1042
1176
  '"'+surf+'" is not the word genre '+genre+' uses for this — write "'+want+'": '+WORD_WHY[want]+
1043
- '. Each scene genre takes the term its own domain uses (block/topology `node` `edge`, flowchart `node` `flowline`, statechart `state` `transition`) — run tools/migrate-figdown.js to rewrite it (MIGRATIONS 0.2)';
1177
+ '. 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)';
1044
1178
  // `SCENE-KEYWORD-MEMBERSHIP`: a word WITHDRAWN FROM ONE GENRE is not an unknown word,
1045
1179
  // and `"threshold" is not allowed in genre topology` would send an author
1046
- // looking for a typo. Each cell below was legal until this release and states
1180
+ // looking for a typo. Each cell below was legal until 0.3 and states
1047
1181
  // WHY that genre no longer declares it — the ruling's own ground, per cell,
1048
1182
  // because the grounds differ and a single sentence could not carry them.
1049
1183
  // Every one of these withdrawals was FREE: `topology`, `flowchart` and
@@ -1078,6 +1212,33 @@ const WITHDRAWN_FROM_GENRE=(kw,genre)=>
1078
1212
  GENRE_WITHDRAWN[genre][kw]+
1079
1213
  ' Subject vocabulary is per genre (core §3, `GENRE-VOCABULARY-OBLIGATION`): a spelling accepted by several genres is several '+
1080
1214
  'independent declarations, and this genre\'s was withdrawn without touching any other\'s.'+WITHDREW_AT;
1215
+ // `SEQUENCE-TIME-GAP`/`SEQUENCE-PARTICIPANT-GROUPING`: THE OTHER HALF OF `SCENE-KEYWORD-MEMBERSHIP` — a spelling a genre REFUSED
1216
+ // AT BIRTH. It is a SEPARATE table from `GENRE_WITHDRAWN` and not a sixth row
1217
+ // of it, because the two state different facts and only one of them is a
1218
+ // migration: a WITHDRAWAL took a word away from documents that legally used
1219
+ // it (so the message names the release and the rewrite tool), while a REFUSAL
1220
+ // means the genre never declared the word and no document can have been
1221
+ // written against it. Folding them would make the engine tell a `sequence`
1222
+ // author "it was WITHDRAWN from this genre" about a keyword this genre has
1223
+ // never had, and would date every refusal to a migration that did not happen.
1224
+ // What the two SHARE is the thing `SCENE-KEYWORD-MEMBERSHIP` was for: the author gets the GROUND,
1225
+ // and what to write instead, rather than a spellcheck.
1226
+ //
1227
+ // The refusal is FREE in the `EDGE-GEOMETRY-CONSTRUCTS` sense — `sequence` is EXPERIMENTAL and this
1228
+ // is the release that gives it a vocabulary at all, so no document loses a
1229
+ // line. Each cell states its own ruling's ground, because the grounds differ.
1230
+ const REFUSED_IN={
1231
+ sequence:{
1232
+ gap:'`sequence` does not declare `gap`, and the refusal is about the AXIS, not the spelling (`SEQUENCE-TIME-GAP`). UML 2.5.1 §17.3.3.1 makes the vertical axis NON-PROPORTIONAL — "The distance between two events on a time-line does not represent any literal measurement of time, only that non-zero time has passed" — so non-zero time has ALREADY passed between every adjacent pair of events in every sequence figure, by the source\'s own semantics. A `gap` line would therefore state a fact the reading rule gives everywhere, and what the author actually wants — draw more vertical space here — is a RENDERING request, which `PRESENTATION-AS-MEANING-CARRIER` keeps off the language\'s side of the line. `timing`\'s `gap` is a different construct: there the horizontal axis IS a tick count, so a break in it removes ticks that would otherwise be asserted. WRITE INSTEAD: put the elapsed time in the following message\'s label — `message c -> s "DHCPREQUEST (T1, 0.5x lease)"` — or in its `description=`. Reopens if this genre ever lands a construct that makes vertical position denote a quantity, because a discontinuity then has something to interrupt.',
1233
+ group:'`sequence` does not declare `group` (`SEQUENCE-PARTICIPANT-GROUPING`), on three grounds. ZERO measured need: the row was admitted as Mermaid `box` parity and the genre was then re-scoped to real-figure coverage without the row being re-tested. NOTHING TO BORROW: UML clause 17 has no lifeline-grouping construct — `PartDecomposition` (§17.7.3.2) decomposes ONE lifeline into a sub-interaction, and gates and the frame bound an interaction rather than a subset of its lifelines. And the GEOMETRY IS ALREADY TAKEN: `band` = membership is locked into the scene genres (the band contains every member and nothing else, core §2.6), while a `group` here would span min..max COLUMNS with no contiguity rule, so a band over two non-adjacent lifelines silently encloses a third that is not a member — one word carrying two different geometric promises, which `GENRE-VOCABULARY-OBLIGATION` forbids opening without evidence. WRITE INSTEAD: `class` naming what the participants have in common, plus `class=` on each `lifeline` — it earns a legend entry and asserts membership without asserting adjacency. Reopens on a count of real figures whose lifelines are drawn in labelled bands, and the re-proposal owes a contiguity check either way.'
1234
+ }
1235
+ };
1236
+ const REFUSED_AT=' (refused; MIGRATIONS 0.4)';
1237
+ const REFUSED_IN_GENRE=(kw,genre)=>
1238
+ '"'+kw+'" is not allowed in genre '+genre+' — this genre REFUSED it, it is not a typo and not a withdrawal: '+
1239
+ REFUSED_IN[genre][kw]+
1240
+ ' Subject vocabulary is per genre (core §3, `GENRE-VOCABULARY-OBLIGATION`), so a spelling another genre declares is that genre\'s '+
1241
+ 'declaration and never this one\'s.'+REFUSED_AT;
1081
1242
  // `MEMBERSHIP-KEY-ACCEPTANCE`: THE OPTION-KEY HALF OF `SCENE-KEYWORD-MEMBERSHIP`. A per-genre withdrawal can
1082
1243
  // strand an option KEY as easily as it strands a keyword: `in=` states
1083
1244
  // membership and its ONLY value domain is the id of a containing `group`, so
@@ -1113,6 +1274,25 @@ const WITHDRAWN_OPT_FROM_GENRE=(key,genre)=>
1113
1274
  ' An option key is per genre for the same reason a keyword is (core §3, `GENRE-VOCABULARY-OBLIGATION`): the key is accepted '+
1114
1275
  'by the directive AND by the genre, and this genre\'s acceptance was withdrawn without touching any other\'s.'+
1115
1276
  WITHDREW_OPT_AT;
1277
+ // `UNDELIVERED-MESSAGE-MARKING`: the OPTION-KEY half of the refusal table, and the one
1278
+ // place in this engine where a diagnostic fires for a key that is NOT in
1279
+ // `OPT_KEYS`. That is deliberate and is the ruling's headline: `lost=` was
1280
+ // proposed and REFUSED, so registering the spelling to get a named message
1281
+ // would put the key in the language's closed option registry — the exact
1282
+ // thing the ruling declines — and a reader counting `OPT_KEYS` would find a
1283
+ // key no directive accepts. So the refusal rides the UNKNOWN-OPTION path
1284
+ // instead: `badOpts` and the connector scanner both consult this table before
1285
+ // they say `unknown option`, which costs one lookup and adds no surface.
1286
+ const REFUSED_OPT_IN={
1287
+ sequence:{
1288
+ lost:'`sequence` does not accept `lost=`; it was proposed and REFUSED (`UNDELIVERED-MESSAGE-MARKING`), and NO option key was added to the language for this genre. The model it wanted is not UML\'s: UML 2.5.1 §17.4.3.1\'s lost Message is one whose "destination ... is outside the scope of the description" — the recipient is NOT MODELLED — while the proposed key meant the recipient is modelled, named and drawn AND DELIVERY FAILED, which is ITU-T Z.120 §4.3\'s model ("a message is sent but not consumed") under UML\'s spelling. Carrying one standard\'s word with another standard\'s meaning is the cross-source mix RULE 4.1 calls a last resort, and it was undeclared. WRITE INSTEAD: declare the meaning once and reference it — `class dropped "Sent, never delivered"` then `message c -> s "DHCPREQUEST" class=dropped description="renewal unicast never reaches the issuing server"`. The class carries the fact and earns a legend entry; `description=` carries the per-message reason. The NEED stays on the record: reopens on a measured rate of readers taking a dropped message for a delivered one, at 22%, and any re-proposal must be grounded on Z.120 §4.3 rather than on UML §17.4.3.1.'
1289
+ }
1290
+ };
1291
+ const REFUSED_OPT_IN_GENRE=(key,genre)=>
1292
+ key+'= is not allowed in genre '+genre+' — this genre REFUSED the key, it is not a typo and not a withdrawal: '+
1293
+ REFUSED_OPT_IN[genre][key]+
1294
+ ' The spelling is not in the language\'s option registry at all, so no other genre accepts it either.'+
1295
+ REFUSED_AT;
1116
1296
  const GENRE_KW={
1117
1297
  block:new Set(SCENE_HOST_KW.concat(BLOCK_SUBJECT_KW, ['node','edge'])),
1118
1298
  topology:new Set(SCENE_HOST_KW.concat(TOPOLOGY_SUBJECT_KW, ['node','edge'])),
@@ -1131,7 +1311,28 @@ const GENRE_KW={
1131
1311
  bitfield:new Set(GENRE_FREE_KW.concat(['class','bitfield'])),
1132
1312
  // chart is experimental and attaches to a table id in the same document
1133
1313
  table:new Set(GENRE_FREE_KW.concat(['class','table','chart'])),
1134
- timing:new Set(GENRE_FREE_KW.concat(['class','timing']))
1314
+ timing:new Set(GENRE_FREE_KW.concat(['class','timing'])),
1315
+ // `SEQUENCE-SOURCE-STANDARD`-R182: `sequence` gets its row, and the row is what
1316
+ // closes the Batch-1 finding that the genre "states a reading and constrains
1317
+ // nothing". It is NOT built on `SCENE_HOST_KW`, and the three absences are
1318
+ // each a decision rather than an oversight:
1319
+ // - `flow` and `rank` are REFUSED. A sequence figure's two axes are BOTH
1320
+ // declaration-ordered — lifelines left-to-right in declaration order,
1321
+ // occurrences top-to-bottom in declaration order (draft §7) — so a key
1322
+ // that reverses or re-ranks a drawing would make the picture disagree
1323
+ // with the source, which `DECLARATION-ORDER-SEMANTICS` forbids. There is nothing for them to set.
1324
+ // - `group` is REFUSED (`SEQUENCE-PARTICIPANT-GROUPING`, `REFUSED_IN` above).
1325
+ // - the REGION openers `bitfield`/`table`/`timing`/`chart` are absent
1326
+ // because this genre has no scene to compose them into: `GENRE-COMPOSITION` composition
1327
+ // stacks a region OUTSIDE the scene, and a ladder has no outside yet.
1328
+ // They are absent, not refused — a `sequence` document that wanted a
1329
+ // register layout beside its exchange is a real want with no ruling, and
1330
+ // it stays an open question rather than a silent no.
1331
+ // What is left is the genre-free core, `class`, this genre's two spellings
1332
+ // and its three subject words — nine top-level keywords, and that is the
1333
+ // whole of what a `figdown 0.4 sequence` document may write.
1334
+ sequence:new Set(GENRE_FREE_KW.concat(['class'], SEQUENCE_SUBJECT_KW,
1335
+ ['lifeline','message']))
1135
1336
  };
1136
1337
  const CHILD_KW=new Set(['field','break','cell','width','signal','gap']);
1137
1338
 
@@ -1155,6 +1356,59 @@ function splitFigdownSections(text){
1155
1356
  return secs;
1156
1357
  }
1157
1358
 
1359
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's DERIVED reading, in one
1360
+ // function so the parser's checks and (from a later increment) the renderer
1361
+ // cannot disagree about what a document says.
1362
+ //
1363
+ // Two things are derived and nothing else is:
1364
+ // ROWS — the figure's total order. `messages` and `states` are the two
1365
+ // collections that carry an occurrence; each element records the
1366
+ // source `line` it was written on, and sorting the union of the two
1367
+ // on that number IS the order (draft §31: declaration order, and
1368
+ // the order is TOTAL — a declared divergence from UML's partial
1369
+ // order, taken so a reader never has to compute one).
1370
+ // CHAIN — the containment tree from `in=`. A fragment or an operand may
1371
+ // name a fragment or an operand, so the chain alternates in
1372
+ // practice but the walk does not assume it.
1373
+ // EXTENT is the span of row slots a container owns, transitively: an occurrence
1374
+ // inside an operand is inside that operand's fragment too. `cycles` names any
1375
+ // container that reaches itself, so a caller can refuse to read a tree that is
1376
+ // not one instead of walking it to a guard.
1377
+ function seqModel(doc){
1378
+ const rows=[];
1379
+ for(const m of doc.messages||[]) rows.push({kind:'message',el:m,line:m.line});
1380
+ for(const s of doc.states||[]) rows.push({kind:'state', el:s,line:s.line});
1381
+ rows.sort((a,b)=>a.line-b.line);
1382
+ rows.forEach((r,i)=>{ r.slot=i; });
1383
+ const cont={};
1384
+ for(const f of doc.fragments||[]) cont[f.id]={kind:'fragment',el:f,parent:f['in']||null};
1385
+ for(const o of doc.operands||[]) cont[o.id]={kind:'operand', el:o,parent:o['in']||null};
1386
+ const cycles=[];
1387
+ const chain=(id)=>{
1388
+ const out=[], seen=new Set();
1389
+ let c=id;
1390
+ while(c && cont[c]){
1391
+ if(seen.has(c)) break;
1392
+ seen.add(c); out.push(c); c=cont[c].parent;
1393
+ }
1394
+ return out;
1395
+ };
1396
+ for(const id in cont){
1397
+ const seen=new Set(); let c=cont[id].parent;
1398
+ while(c && cont[c] && !seen.has(c)){ if(c===id){ cycles.push(id); break; } seen.add(c); c=cont[c].parent; }
1399
+ }
1400
+ const owned={}; for(const id in cont) owned[id]=[];
1401
+ for(const r of rows){
1402
+ const inId=r.el['in']||null;
1403
+ r.chain=inId?chain(inId):[];
1404
+ for(const id of r.chain) if(owned[id]) owned[id].push(r.slot);
1405
+ }
1406
+ const extent={};
1407
+ for(const id in cont)
1408
+ extent[id]=owned[id].length?{lo:Math.min(...owned[id]),hi:Math.max(...owned[id])}:null;
1409
+ return {rows,cont,owned,extent,chain,cycles};
1410
+ }
1411
+
1158
1412
  // parse(text) -> {doc, errs, docs}
1159
1413
  // Single-section: docs=[doc] (backward-compatible doc/errs).
1160
1414
  // Multi-section: one doc per figdown header; errs use full-file line numbers;
@@ -1175,6 +1429,11 @@ function parse(text){
1175
1429
  const docs=[]; const errs=[];
1176
1430
  for(const sec of secs){
1177
1431
  const r=parseOne(sec.text);
1432
+ // A section's element `.line` values are section-local, and a GEOMETRY-time
1433
+ // error (one only `render` can raise) is built long after this loop has
1434
+ // finished re-basing the parse messages. Record the offset on the doc so
1435
+ // that error can quote the same full-file line the author is looking at.
1436
+ r.doc.lineOffset=sec.startLine-1;
1178
1437
  for(const e of r.errs){
1179
1438
  const m=/^Line (\d+): (.*)$/.exec(e);
1180
1439
  if(m) errs.push('Line '+(+m[1]+sec.startLine-1)+': '+m[2]);
@@ -1195,7 +1454,27 @@ function parseOne(text){
1195
1454
  // a distinction the model must keep.
1196
1455
  const doc={title:null,note:null,nodes:[],groups:[],edges:[],planes:[{id:'base',label:null,z:0}],
1197
1456
  flow:'right',ranks:[],pins:{},blocks:[],trunks:[],thresholds:[],bands:[],
1198
- classes:[],boundaries:[]};
1457
+ classes:[],boundaries:[],
1458
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` model. FIVE collections,
1459
+ // one per keyword, and they are separate arrays rather than
1460
+ // aliases of `nodes`/`edges` because a message is not an edge: an
1461
+ // edge is a relation between two nodes and has no position, while
1462
+ // a message is an OCCURRENCE with a place in a total order (draft
1463
+ // §31 — order is declaration order and it is TOTAL). Folding them
1464
+ // into `nodes`/`edges` would let the scene renderer reach them
1465
+ // and would put a time-ordered thing in a collection whose model
1466
+ // says order is not meaning.
1467
+ //
1468
+ // The ORDER of `messages`, `states` and the containment they
1469
+ // declare is recovered from the `line` each element carries: the
1470
+ // parser appends in source order, so each array is already
1471
+ // ordered and the union of `messages` and `states` sorted on
1472
+ // `line` is the figure's trace. Nothing else records time.
1473
+ //
1474
+ // These stay EMPTY in every non-`sequence` document, and the
1475
+ // canonical JSON binding omits an empty one (the `externals`
1476
+ // rule), so no existing golden moves a byte.
1477
+ lifelines:[],messages:[],states:[],fragments:[],operands:[]};
1199
1478
  const nodeIds=new Set(), groupIds=new Set(), planeIds=new Set(['base']), classIds=new Set(),
1200
1479
  bundleIds=new Set(), boundaryIds=new Set(), blockIds=new Set();
1201
1480
  // §1: "IDs are ... unique per document" — nodes, groups, boundaries AND the
@@ -1318,17 +1597,40 @@ function parseOne(text){
1318
1597
  if(idHere()){ err(n,ID_RULE); return; }
1319
1598
  const tk2=tokenize(s.slice(i).trim());
1320
1599
  if(tk2.error){ err(n,tk2.error); return; }
1321
- const {pos:p2,opts:o2,optT:oT2,unk:u2,dup:d2}=splitOpts(tk2.toks);
1600
+ const {pos:p2,posq:pq2,opts:o2,optT:oT2,unk:u2,dup:d2}=splitOpts(tk2.toks);
1322
1601
  if(d2){ err(n,'duplicate option "'+d2+'=" on one line'); return; }
1323
- if(u2.length){ err(n,'unknown option "'+u2[0]+'="'); return; }
1324
- if(p2.length){ err(n,'unexpected argument "'+p2[0]+'"'); return; }
1602
+ // `UNDELIVERED-MESSAGE-MARKING`: the connector's copy of the genre REFUSAL check for
1603
+ // an option key the language does not register. It sits on the
1604
+ // unknown-option path because `lost=` is not in `OPT_KEYS` — see
1605
+ // `REFUSED_OPT_IN` — and it must be here as well as in `badOpts` because
1606
+ // `message` is scanned by this function and never reaches that one.
1607
+ if(u2.length){
1608
+ const ro=(doc.genre&&REFUSED_OPT_IN[doc.genre])||null;
1609
+ if(ro && ro[u2[0]]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u2[0],doc.genre));
1610
+ else err(n,'unknown option "'+u2[0]+'="');
1611
+ return; }
1612
+ // `SEQUENCE-SOURCE-STANDARD`-R182: `message` adds ONE trailing positional — the
1613
+ // quoted message label — to the connector grammar, because that is where
1614
+ // every sequence source in the corpus writes it and the `[mid]` form
1615
+ // reads as an annotation rather than as the message itself. Every other
1616
+ // connector spelling keeps the grammar unchanged, so the surplus-argument
1617
+ // error is still the right answer for them.
1618
+ let seqLabel=null;
1619
+ if(kw==='message'){
1620
+ if(p2.length>1){ err(n,'unexpected argument "'+p2[1]+'"'); return; }
1621
+ if(p2.length===1){
1622
+ if(!pq2[0]){ err(n,'message label must be quoted: message '+a+' '+op+' '+b+' "'+p2[0]+'" — '+Q_WHY); return; }
1623
+ if(mid!==null){ err(n,'message has two labels — the inline -['+mid+']-> mid-label and the trailing "'+p2[0]+'". Write one'); return; }
1624
+ seqLabel=p2[0];
1625
+ }
1626
+ } else if(p2.length){ err(n,'unexpected argument "'+p2[0]+'"'); return; }
1325
1627
  // 0.1: `edge` has its own scanner, so the language-wide retired
1326
1628
  // keys need their own check here or `edge` would be the one directive
1327
1629
  // that reports the generic message for a retired spelling.
1328
1630
  for(const rk in RETIRED_OPT_KEYS)
1329
1631
  if(o2[rk]!==undefined){ err(n,RETIRED_OPT_KEYS[rk]); return; }
1330
1632
  for(const k in o2)
1331
- if(!DIRECTIVE_OPTS[kw].includes(k)){ err(n,kw+' does not take '+k+'='); return; }
1633
+ if(!directiveOpts(kw,doc.genre).includes(k)){ err(n,kw+' does not take '+k+'='); return; }
1332
1634
  for(const k of ['label','taillabel','headlabel'])
1333
1635
  if(o2[k]!==undefined){ err(n,k+'= is retired — write the label inline: '+kw+' A [tail] -[mid]-> [head] B (MIGRATIONS 0.1)'); return; }
1334
1636
  if(o2.fill!==undefined){ err(n,FILL_NO_INTERIOR(kw)); return; }
@@ -1366,6 +1668,38 @@ function parseOne(text){
1366
1668
  if(!pc.ok){ err(n,pc.err); return; }
1367
1669
  ecls=pc.ids;
1368
1670
  }
1671
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` fork. It is the SAME scanner with
1672
+ // a different reading, and the model it writes is a different collection:
1673
+ // a message is an OCCURRENCE with a place in the figure's total order
1674
+ // (draft §31), an edge is a relation with no position at all.
1675
+ if(kw==='message'){
1676
+ // Draft §8.2/§15.4. `--` parses everywhere else and is a line error
1677
+ // here, so the check is at the fork rather than in the operator scanner.
1678
+ if(!SEQ_OPERATORS.has(op)){
1679
+ err(n,'message needs a direction: -> <- <-> — a Message has a sending event occurrence AND a receiving event occurrence (UML 2.5.1 §17.4.3.1 defines the LOST case as the one where the receiving occurrence is absent), so a message with unstated direction is not a thing this genre has. "--" is a line error under sequence; for a sustained two-way exchange whose individual messages are not enumerated write <->');
1680
+ return; }
1681
+ if(o2['in']!==undefined){
1682
+ const e=idErr(o2['in'], optHasQ(oT2,'in'), null);
1683
+ if(e){ err(n,e); return; }
1684
+ }
1685
+ if(o2.description!==undefined && !optQ(oT2,'description')){
1686
+ err(n,'description= must be quoted: description="'+o2.description+'" — '+Q_WHY); return; }
1687
+ // ONE label field, fed by either spelling. The shared scanner also
1688
+ // accepts the inline `-[mid]->` form, and the check above makes writing
1689
+ // both a line error, so the model can never hold two — but the model
1690
+ // must not hold the same text under two keys either, so `mid` is NOT
1691
+ // projected beside `label` here the way it is on an edge. That the two
1692
+ // spellings both reach this field at all is an ALIAS in `IDENTITY-ASSERTION`'s sense
1693
+ // and is a FINDING for the genre document (Batch 5) rather than a
1694
+ // ruling taken here: the draft settles the trailing form and says
1695
+ // nothing about the brackets. `[tail]` and `[head]` are kept — they are
1696
+ // different positions, not a second spelling of the same one.
1697
+ doc.messages.push({a,b,op,tail,head,
1698
+ label:seqLabel!==null?seqLabel:mid,
1699
+ style:o2.style,cls:ecls,stroke:o2.stroke,note:o2.note,
1700
+ desc:o2.description,in:o2['in']||null,line:n});
1701
+ return;
1702
+ }
1369
1703
  // §5 on an edge: the line IS a stroke and has no interior, so `stroke=`
1370
1704
  // and `fill=` name the same channel (`stroke=` wins when both are
1371
1705
  // written); `text=` colours the [tail]/[mid]/[head] labels.
@@ -1374,6 +1708,94 @@ function parseOne(text){
1374
1708
  plane:o2.plane||'base',line:n});
1375
1709
  }
1376
1710
 
1711
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the four `sequence` directives that are not the
1712
+ // connector. Reached only from the `doc.genre==='sequence'` dispatch, so
1713
+ // `state` under `statechart` never arrives here. Every option-VALUE check
1714
+ // (colours, `style=` enum, `class=` list, `in=` id spelling, `note=` and
1715
+ // `description=` quoting, `type=`'s bare-value rule) has already run in
1716
+ // `badOpts`; what is left is arity, mandatory arguments, and this genre's
1717
+ // own enum.
1718
+ const seqCls=(opts,optT)=>opts['class']!==undefined
1719
+ ? parseClassList(opts['class'],optList(optT,'class')).ids : undefined;
1720
+ function parseSeqDirective(kw,n,pos,posq,opts,optT){
1721
+ if(kw==='lifeline'){
1722
+ // `lifeline <id> ["label"]` — the participant column. It DECLARES an id
1723
+ // and joins the document-wide id namespace, so a lifeline cannot share
1724
+ // a spelling with a fragment or an operand.
1725
+ const id=pos[1];
1726
+ const e=idErr(id,!!posq[1],'lifeline needs <id> ["label"]');
1727
+ if(e){ err(n,e); return; }
1728
+ if(dupId(id)||doc.lifelines.some(l=>l.id===id)){ err(n,'duplicate id "'+id+'"'); return; }
1729
+ if(BLK_LBL(n,'lifeline',id,pos,posq)) return;
1730
+ if(pos[3]!==undefined){ err(n,'unexpected argument "'+pos[3]+'"'); return; }
1731
+ nodeIds.add(id);
1732
+ doc.lifelines.push({id,label:pos[2]!==undefined?pos[2]:null,in:opts['in']||null,
1733
+ cls:seqCls(opts,optT),fill:opts.fill,stroke:opts.stroke,
1734
+ style:opts.style,note:opts.note,desc:opts.description,line:n});
1735
+ return;
1736
+ }
1737
+ if(kw==='state'){
1738
+ // `state <lifeline-id> "<state name>"` — a StateInvariant (UML 2.5.1
1739
+ // §17.12.25). Slot 1 REFERENCES a lifeline; it does NOT declare an id,
1740
+ // because nothing in this genre refers to a state occurrence. That is
1741
+ // the asymmetry with `statechart`'s `state`, where slot 1 declares
1742
+ // (draft §29, Q5), and it is why the two share a spelling and nothing
1743
+ // else. Slot 2 is MANDATORY: a state occurrence with no name asserts
1744
+ // nothing at all.
1745
+ const ref=pos[1];
1746
+ const e=idErr(ref,!!posq[1],'state needs <lifeline-id> "<state name>"');
1747
+ if(e){ err(n,e); return; }
1748
+ if(pos[2]===undefined){ err(n,'state needs a quoted state name: state '+ref+' "LISTEN" — under sequence a state occurrence names the lifeline it is ON and the state it is IN, and a state with no name asserts nothing'); return; }
1749
+ if(!posq[2]){ err(n,'state name must be quoted: state '+ref+' "'+pos[2]+'" — '+Q_WHY); return; }
1750
+ if(pos[3]!==undefined){ err(n,'unexpected argument "'+pos[3]+'"'); return; }
1751
+ doc.states.push({ref,name:pos[2],in:opts['in']||null,
1752
+ cls:seqCls(opts,optT),fill:opts.fill,stroke:opts.stroke,
1753
+ style:opts.style,note:opts.note,desc:opts.description,line:n});
1754
+ return;
1755
+ }
1756
+ if(kw==='fragment'){
1757
+ // `fragment <id> ["label"] type=<operator>` — a CombinedFragment
1758
+ // (§17.12.3). `type=` is MANDATORY where UML defaults it to `seq`: a
1759
+ // fragment with no interaction operator draws a box that asserts
1760
+ // nothing, and a default would make the box look like an assertion.
1761
+ const id=pos[1];
1762
+ const e=idErr(id,!!posq[1],'fragment needs <id> ["label"] type=<operator>');
1763
+ if(e){ err(n,e); return; }
1764
+ if(dupId(id)||doc.fragments.some(f=>f.id===id)||doc.operands.some(o=>o.id===id)){
1765
+ err(n,'duplicate id "'+id+'"'); return; }
1766
+ if(BLK_LBL(n,'fragment',id,pos,posq)) return;
1767
+ if(pos[3]!==undefined){ err(n,'unexpected argument "'+pos[3]+'"'); return; }
1768
+ if(opts.type===undefined){
1769
+ err(n,'fragment needs type=<operator> — a fragment with no interaction operator asserts nothing ('+
1770
+ SEQ_OPERATORS_FRAG.join('|')+', '+SEQ_FRAG_CLAUSE+'). UML defaults the attribute to seq and FigDown does not: a default would draw a frame that looks like an assertion and is not one'); return; }
1771
+ if(!SEQ_OPERATORS_FRAG.includes(opts.type)){
1772
+ err(n,'unknown interaction operator "'+opts.type+'" — write one of '+
1773
+ SEQ_OPERATORS_FRAG.join('|')+' ('+SEQ_FRAG_CLAUSE+' InteractionOperatorKind, taken whole)'); return; }
1774
+ doc.fragments.push({id,label:pos[2]!==undefined?pos[2]:null,type:opts.type,
1775
+ in:opts['in']||null,cls:seqCls(opts,optT),stroke:opts.stroke,
1776
+ style:opts.style,note:opts.note,desc:opts.description,line:n});
1777
+ return;
1778
+ }
1779
+ if(kw==='operand'){
1780
+ // `operand <id> ["guard"] in=<fragment-id>` — an InteractionOperand
1781
+ // (§17.12.14). `in=` is MANDATORY: an operand is a COMPARTMENT OF a
1782
+ // fragment and has no meaning apart from one.
1783
+ const id=pos[1];
1784
+ const e=idErr(id,!!posq[1],'operand needs <id> ["guard"] in=<fragment-id>');
1785
+ if(e){ err(n,e); return; }
1786
+ if(dupId(id)||doc.fragments.some(f=>f.id===id)||doc.operands.some(o=>o.id===id)){
1787
+ err(n,'duplicate id "'+id+'"'); return; }
1788
+ if(BLK_LBL(n,'operand',id,pos,posq)) return;
1789
+ if(pos[3]!==undefined){ err(n,'unexpected argument "'+pos[3]+'"'); return; }
1790
+ if(opts['in']===undefined){
1791
+ err(n,'operand needs in=<fragment-id> — an operand is a compartment OF a fragment and has no meaning apart from one (UML 2.5.1 §17.12.14)'); return; }
1792
+ doc.operands.push({id,label:pos[2]!==undefined?pos[2]:null,in:opts['in'],
1793
+ cls:seqCls(opts,optT),stroke:opts.stroke,style:opts.style,
1794
+ note:opts.note,desc:opts.description,line:n});
1795
+ return;
1796
+ }
1797
+ }
1798
+
1377
1799
  for(let li=0; li<lines.length; li++){
1378
1800
  const n=li+1;
1379
1801
  let raw=lines[li];
@@ -1448,7 +1870,11 @@ function parseOne(text){
1448
1870
  // the dispatch cannot be narrowed to the genre's own word — a `flowline`
1449
1871
  // under `block` would then fall through to `unrecognized line`, which is
1450
1872
  // exactly the answer these rulings owe an author better than.
1451
- const mConn=/^(edge|flowline|transition)(\s|$)/.exec(raw.trim());
1873
+ // `SEQUENCE-SOURCE-STANDARD`-R182: `message` is the FOURTH spelling scanned here,
1874
+ // and it is derived from `CONNECTOR_SPELLINGS` rather than spelled again,
1875
+ // so a fifth genre's connector reaches the named diagnostic by joining
1876
+ // that set and not by remembering to edit a regex.
1877
+ const mConn=CONN_LINE_RE.exec(raw.trim());
1452
1878
  if(mConn){
1453
1879
  const ckw=mConn[1];
1454
1880
  if(firstContent){ firstContent=false; err(n,'first line must be "figdown 0.1 <genre>"'); }
@@ -1490,12 +1916,25 @@ function parseOne(text){
1490
1916
  // Directives not in DIRECTIVE_OPTS (title's single quoted string, unknown
1491
1917
  // keywords) are handled by their own paths.
1492
1918
  const badOpts=(k)=>{
1493
- const allowed=DIRECTIVE_OPTS[k];
1919
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the row is read GENRE-FIRST. `state` is one
1920
+ // spelling with two declarations and two option sets (`GENRE-VOCABULARY-OBLIGATION`), and
1921
+ // `GENRE_DIRECTIVE_OPTS` is the only place that fact is recorded.
1922
+ const allowed=directiveOpts(k,doc.genre);
1494
1923
  if(!allowed) return false;
1495
1924
  let bad=false;
1496
1925
  // same-line repeated option key (last-wins was silent data loss)
1497
1926
  if(dup){ err(n,'duplicate option "'+dup+'=" on one line'); bad=true; }
1498
- for(const u of unk){ err(n,'unknown option "'+u+'="'); bad=true; }
1927
+ // `UNDELIVERED-MESSAGE-MARKING`: a genre REFUSAL of a key the language never
1928
+ // registered is answered here, on the unknown-option path, because that
1929
+ // is the only path it can reach: `lost=` is deliberately absent from
1930
+ // `OPT_KEYS` (the ruling adds no option key), so `splitOpts` reports it
1931
+ // as unknown. Checked BEFORE the generic message so the author gets the
1932
+ // ground and the replacement spelling instead of a spellcheck.
1933
+ const roOpt=(doc.genre&&REFUSED_OPT_IN[doc.genre])||null;
1934
+ for(const u of unk){
1935
+ if(roOpt && roOpt[u]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u,doc.genre));
1936
+ else err(n,'unknown option "'+u+'="');
1937
+ bad=true; }
1499
1938
  // `MEMBERSHIP-KEY-ACCEPTANCE`: the PER-GENRE option-key withdrawal, checked here —
1500
1939
  // after `unknown option`, so a key the LANGUAGE does not have keeps its
1501
1940
  // own answer, and before every value check, so a withdrawn key is never
@@ -1990,6 +2429,19 @@ function parseOne(text){
1990
2429
  // Dynamic-profile reserved words keep their dedicated message (before `GENRE-KEYWORD-ALLOWLIST`).
1991
2430
  if(kw==='page'||kw==='set'||kw==='pulse'){
1992
2431
  err(n,'"'+kw+'" is reserved for the dynamic profile (not in v0.1)'); continue; }
2432
+ // `SEQUENCE-TIME-GAP`: a genre REFUSAL is consulted BEFORE the typed-block
2433
+ // child exemption below, because `gap` is BOTH — `timing`'s child keyword
2434
+ // and the one spelling `sequence` refused that is also a child word. Left
2435
+ // in the old order, `gap "T1 fires"` in a sequence document answered
2436
+ // `"gap" is a typed-block child — it needs a bitfield/table/timing block
2437
+ // above it`, which is TRUE OF THE LANGUAGE and says nothing about the
2438
+ // ruling the author has actually run into. This clause fires only for a
2439
+ // spelling that is a child keyword AND refused by this genre, so no other
2440
+ // genre's answer moves; every other refused or withdrawn word reaches its
2441
+ // own message through the ordinary chain below.
2442
+ if(sawHeader && doc.genre && CHILD_KW.has(kw) &&
2443
+ REFUSED_IN[doc.genre] && REFUSED_IN[doc.genre][kw]){
2444
+ err(n, REFUSED_IN_GENRE(kw, doc.genre)); continue; }
1993
2445
  // `GENRE-KEYWORD-ALLOWLIST`: after closing a typed region, top-level keywords
1994
2446
  // must be in the header genre allowlist. Child keywords still use the
1995
2447
  // "needs a bitfield/table/timing above" path when they appear with no cur.
@@ -2007,6 +2459,11 @@ function parseOne(text){
2007
2459
  // needs the ground, not a spellcheck.
2008
2460
  else if(GENRE_WITHDRAWN[doc.genre] && GENRE_WITHDRAWN[doc.genre][kw])
2009
2461
  err(n, WITHDRAWN_FROM_GENRE(kw, doc.genre));
2462
+ // `SEQUENCE-TIME-GAP`/`SEQUENCE-PARTICIPANT-GROUPING`: and one step further again. A word this genre
2463
+ // REFUSED is not an unknown word either, and it is not a withdrawal —
2464
+ // the author needs the ruling's ground and the spelling that works.
2465
+ else if(REFUSED_IN[doc.genre] && REFUSED_IN[doc.genre][kw])
2466
+ err(n, REFUSED_IN_GENRE(kw, doc.genre));
2010
2467
  else
2011
2468
  err(n,'"'+kw+'" is not allowed in genre '+doc.genre);
2012
2469
  continue;
@@ -2019,6 +2476,13 @@ function parseOne(text){
2019
2476
  continue;
2020
2477
  }
2021
2478
 
2479
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's four own directives.
2480
+ // Dispatched BEFORE the switch and scoped by `doc.genre`, which is what
2481
+ // keeps `state` reaching `statechart`'s node parser under `statechart` —
2482
+ // `GENRE-VOCABULARY-OBLIGATION` in the dispatcher, not only in the allowlist.
2483
+ if(doc.genre==='sequence' && SEQ_KW.has(kw)){
2484
+ parseSeqDirective(kw,n,pos,posq,opts,optT); continue; }
2485
+
2022
2486
  switch(kw){
2023
2487
  case 'title': {
2024
2488
  if(sawTitle){ err(n,'duplicate title line'); break; }
@@ -2520,6 +2984,117 @@ function parseOne(text){
2520
2984
  else if(groupIds.has(e.b)) errs.push('Line '+e.line+': edge endpoint "'+e.b+'" is a group — connect to a member node (group edges are not in v0.1)');
2521
2985
  if(e.plane && !planeIds.has(e.plane)) errs.push('Line '+e.line+': unknown plane "'+e.plane+'"');
2522
2986
  }
2987
+ // ── `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's semantic checks ────────
2988
+ // Everything here needs the WHOLE document, so none of it can live in
2989
+ // `parseSeqDirective`: forward references are legal (fixture 021 pins that
2990
+ // for the scene genres and the rule is language-wide), so an id can only be
2991
+ // resolved once every declaration has been read.
2992
+ if(doc.genre==='sequence'){
2993
+ const llIds=new Set(doc.lifelines.map(l=>l.id));
2994
+ const fragIds=new Set(doc.fragments.map(f=>f.id));
2995
+ const opIds=new Set(doc.operands.map(o=>o.id));
2996
+ // The `in=` OBJECT rule (`SEQUENCE-CONTAINMENT-SCOPE`), in one place because it is one rule: on
2997
+ // all five acceptors `in=` is sense 1 — *the element this one lives
2998
+ // inside* — and its value domain is a `fragment` or an `operand` id and
2999
+ // nothing else. The message names the domain AND the acceptor list,
3000
+ // because an author who wrote a lifeline id there has the relation right
3001
+ // and the object wrong, and needs to be told which.
3002
+ const IN_ACCEPTORS='message, operand, lifeline, state and fragment';
3003
+ const inErr=(line,val,what)=>{
3004
+ if(fragIds.has(val)||opIds.has(val)) return;
3005
+ errs.push('Line '+line+': unknown fragment or operand "'+val+'" — in= on a '+what+
3006
+ ' names the fragment or operand this '+what+' occurs inside'+
3007
+ (llIds.has(val)?', and "'+val+'" is a LIFELINE. A message already names its lifelines through its two endpoints; a state names its lifeline in slot 1. in= is containment, not participation':'')+
3008
+ '. Under sequence in= is accepted on '+IN_ACCEPTORS+' — five acceptors, all sense 1 (UML 2.5.1 §17.12.13: a StateInvariant and a CombinedFragment are both InteractionFragments, so an InteractionOperand contains them both) — and its value is always a fragment or an operand id (draft §33.7)');
3009
+ };
3010
+ for(const l of doc.lifelines) if(l['in']) inErr(l.line,l['in'],'lifeline');
3011
+ for(const m of doc.messages){
3012
+ if(!llIds.has(m.a)) errs.push('Line '+m.line+': unknown lifeline "'+m.a+'"');
3013
+ if(!llIds.has(m.b)) errs.push('Line '+m.line+': unknown lifeline "'+m.b+'"');
3014
+ if(m['in']) inErr(m.line,m['in'],'message');
3015
+ }
3016
+ for(const st of doc.states){
3017
+ if(!llIds.has(st.ref)) errs.push('Line '+st.line+': unknown lifeline "'+st.ref+'" — state slot 1 REFERENCES a lifeline (it does not declare one: nothing in this genre refers to a state occurrence, so it takes no id of its own)');
3018
+ if(st['in']) inErr(st.line,st['in'],'state');
3019
+ }
3020
+ for(const f of doc.fragments) if(f['in']) inErr(f.line,f['in'],'fragment');
3021
+ // An operand's `in=` is MANDATORY and its object is narrower than the
3022
+ // general rule: a compartment belongs to a FRAGMENT, never to another
3023
+ // compartment, so an operand id there is a specific mistake with a
3024
+ // specific answer.
3025
+ for(const o of doc.operands)
3026
+ if(!fragIds.has(o['in']))
3027
+ errs.push('Line '+o.line+': unknown fragment "'+o['in']+'" — operand in= names a FRAGMENT'+
3028
+ (opIds.has(o['in'])?', not another operand: an operand is a compartment of a fragment, and a compartment has no compartments of its own (UML 2.5.1 §17.12.3: a CombinedFragment owns its operands)'
3029
+ :llIds.has(o['in'])?', not a lifeline: an operand is a compartment of a fragment, and a fragment spans lifelines rather than belonging to one'
3030
+ :''));
3031
+ // Two CONSECUTIVE `state` lines naming the same lifeline and the same
3032
+ // state name are a line error (draft §23.2). A state that has not changed
3033
+ // is never restated, so a genuine duplicate is always a mistake — and
3034
+ // because a transition is DERIVED from an adjacent pair, a reader
3035
+ // "tidying duplicates" could otherwise silently delete a fact.
3036
+ const lastState={};
3037
+ for(const st of doc.states.slice().sort((a,b)=>a.line-b.line)){
3038
+ if(lastState[st.ref]===st.name)
3039
+ errs.push('Line '+st.line+': lifeline "'+st.ref+'" is already in state "'+st.name+
3040
+ '" — a state that has not changed is never restated, and a transition is derived from the adjacent pair, so a restatement would assert a transition that did not happen (draft §23.2)');
3041
+ lastState[st.ref]=st.name;
3042
+ }
3043
+ const SM=seqModel(doc);
3044
+ // A containment CYCLE is checked before anything that walks the chain, or
3045
+ // the walk terminates on a guard and every downstream answer is arbitrary.
3046
+ for(const id of SM.cycles)
3047
+ errs.push('Line '+SM.cont[id].el.line+': '+SM.cont[id].kind+' "'+id+
3048
+ '" is inside itself — in= containment is a tree, and a cycle denotes nothing at all');
3049
+ if(!SM.cycles.length){
3050
+ // THE ONE-LEVEL NESTING CAP (`SEQUENCE-CONTAINMENT-SCOPE`). A fragment may sit inside an
3051
+ // operand of ONE enclosing fragment and no deeper. The cap is taken on
3052
+ // the v0.1 `group` precedent — "one level is the whole of v0.1's
3053
+ // containment" (core §2.2, and the diagnostic `group does not take
3054
+ // in=`) — and it is a SCOPE decision, not a principle: a second level
3055
+ // lands on measured need, the same evidence any other cell needs.
3056
+ for(const f of doc.fragments){
3057
+ const anc=SM.chain(f['in']).filter(id=>SM.cont[id].kind==='fragment');
3058
+ if(anc.length>1)
3059
+ errs.push('Line '+f.line+': fragment "'+f.id+'" nests '+anc.length+
3060
+ ' levels deep (inside "'+anc[0]+'", inside "'+anc[anc.length-1]+'") — fragment nesting is capped at ONE level in v1: a fragment may sit in an operand of one enclosing fragment and no deeper. This is the v0.1 `group` precedent, where one level is the whole of the language\'s containment, and it is a scope decision rather than a principle — a second level lands on measured need (draft §33.7). Write the inner interaction as a sibling fragment, or state it in description=');
3061
+ }
3062
+ // CONTIGUITY (draft §28.1). A fragment's or operand's members must be a
3063
+ // CONTIGUOUS run in declaration order, and the reason is the MODEL and
3064
+ // not the drawing: an operand denotes the ORDERED RUN of the
3065
+ // occurrences it contains, so an occurrence that is not in it cannot
3066
+ // happen between two that are. Non-contiguous membership denotes
3067
+ // nothing (UML 2.5.1 §17.6).
3068
+ // One offending row is reported ONCE, against the DEEPEST container it
3069
+ // splits. A row inside an operand's span is inside that operand's
3070
+ // fragment too, so an un-deduplicated pass reports the same line twice
3071
+ // and the outer report's advice is wrong: writing `in=<fragment>` would
3072
+ // repair the fragment and leave the operand split. The deepest
3073
+ // container is the one whose `in=` actually fixes the document.
3074
+ const split=new Map(); // row slot -> container id
3075
+ for(const id in SM.cont){
3076
+ const e=SM.extent[id];
3077
+ const kind=SM.cont[id].kind, aKind=(kind==='operand'?'an ':'a ')+kind;
3078
+ if(!e){
3079
+ errs.push('Line '+SM.cont[id].el.line+': '+kind+' "'+id+
3080
+ '" has no members — '+aKind+"'s extent is the span of the lines carrying in="+id+
3081
+ ', and a container with no extent asserts nothing');
3082
+ continue; }
3083
+ const own=new Set(SM.owned[id]);
3084
+ for(let s=e.lo;s<=e.hi;s++){
3085
+ if(own.has(s)) continue;
3086
+ const prev=split.get(s);
3087
+ if(prev===undefined || SM.chain(id).length>SM.chain(prev).length) split.set(s,id);
3088
+ break;
3089
+ }
3090
+ }
3091
+ for(const [s,id] of [...split.entries()].sort((a,b)=>a[0]-b[0])){
3092
+ const r=SM.rows[s], e=SM.extent[id];
3093
+ errs.push('Line '+r.line+': this '+r.kind+' line splits '+SM.cont[id].kind+' "'+id+
3094
+ '" (lines '+SM.rows[e.lo].line+'–'+SM.rows[e.hi].line+') — members must be CONTIGUOUS in declaration order. An operand denotes the ordered run of the occurrences it contains, so an occurrence that is not in it cannot happen between two that are (UML 2.5.1 §17.6; draft §28.1). Write in='+id+' on it, or move it outside the run');
3095
+ }
3096
+ }
3097
+ }
2523
3098
  for(const r of doc.ranks) for(const id of r.ids)
2524
3099
  if(!nodeIds.has(id)) errs.push('Line '+r.line+': unknown node "'+id+'" in rank');
2525
3100
  // `MARKER-TARGET-KINDS`: `in=` on `threshold`/`band` also resolves a REGION id —
@@ -2605,32 +3180,102 @@ function parseOne(text){
2605
3180
  // `fill=` and no `stroke=`, used by an edge. Ignoring it would drop the
2606
3181
  // edge's colour with nothing to warn on; honouring it would make `fill`
2607
3182
  // mean "stroke" for that member. So it is a line error that names the
2608
- // key to add. `edge` is the only interior-less construct taking `class=`.
3183
+ // key to add. `edge` was the only interior-less construct taking `class=`
3184
+ // until the `sequence` genre added three more (`message`, `fragment`,
3185
+ // `operand`), which is what 0.4 below is about.
2609
3186
  //
2610
- // 0.1 (`CLASS-PAINT-REQUIREMENT`): the SAME hole sat one key over and was left open.
2611
- // 0.1 rejected `fill=`-only and said nothing about a class that
2612
- // paints NEITHER channel `class p "Path" color=#dc2626` plus `edge a ->
2613
- // b class=p` was accepted, drew a #555 line, and rendered a legend swatch
2614
- // that showed nothing, so the class's meaning was invisible in its own
2615
- // derived legend. With `color=` retired (`COLOUR-KEY-STATUS`) the remaining shape of the
2616
- // hole is a class carrying only `style=`, or nothing at
2617
- // all: the edge silently takes the default colour and the author who
2618
- // declared a class to CLASSIFY the edge gets no colour and no warning.
2619
- // Both halves are the same rule — a class an edge joins must declare at
2620
- // least one channel an edge HAS so they share one diagnostic shape.
2621
- // An edge has exactly two: `stroke` (its colour) and `style` (its dash).
2622
- // `style=`-only is therefore FINE and must stay fine: the dash reaches the
2623
- // edge, nothing is lost, and it is how a multi-class cascade splits one
2624
- // meaning across two declarations (`class=hot,deprecated`). The test is
2625
- // per class, per channel — the same shape `INTERIOR-LESS-ELEMENT-PAINT` chose, for the same reason.
2626
- for(const e of doc.edges) for(const cid of (e.cls||[])){
2627
- const c=doc.classes.find(x=>x.id===cid);
2628
- if(!c||c.stroke!==undefined) continue;
2629
- if(c.fill===undefined&&c.style!==undefined) continue;
2630
- if(c.fill!==undefined)
2631
- errs.push('Line '+e.line+': class "'+cid+'" sets fill= but no stroke=, and an edge has no interior add stroke= to the class (it paints the edge; fill= keeps painting members that have an interior) (MIGRATIONS 0.1)');
2632
- else
2633
- errs.push('Line '+e.line+': class "'+cid+'" declares no channel an edge has — add stroke= (an edge has only stroke= and style=: no interior, and v0.1 has no label-colour key). Without one the edge takes the default colour and the class shows nothing in the legend (MIGRATIONS 0.1)');
3187
+ // 0.1 (`CLASS-PAINT-REQUIREMENT`) added a SECOND half a class that paints NEITHER
3188
+ // channel, joined by an edge, was a line error too — and 0.4 (`CLASS-CHANNEL-REACH`)
3189
+ // RETIRES that half. It is not deleted quietly: `CLASS-PAINT-REQUIREMENT`'s own release fixed
3190
+ // the harm it named. The stated defect was that such a class "shows
3191
+ // nothing in the legend", and the same release made the derived legend
3192
+ // draw the meaning with NO swatch (see the legend strip in `render`), so
3193
+ // the meaning does reach the reader. What survived was only "the member
3194
+ // takes its default paint" which is exactly what 14 shipped `field`
3195
+ // members already get, legally, from meaning-only classes in
3196
+ // examples/gre.fd, quic.fd, srh.fd and showcase/tcp-header.fd. A rule that
3197
+ // cannot generalise past one collection was not a rule about channels. A
3198
+ // class that claims a meaning and declares no paint is therefore legal on
3199
+ // EVERY member (`CLASS-CHANNEL-REACH`, MIGRATIONS 0.4), which is also the form the
3200
+ // `sequence` genre is built on: `class` there carries what `group` (`SEQUENCE-PARTICIPANT-GROUPING`)
3201
+ // and `lost=` (`UNDELIVERED-MESSAGE-MARKING`) were refused in favour of, so a meaning with no paint
3202
+ // is that genre's designed idiom, not an oversight.
3203
+ //
3204
+ // `INTERIOR-LESS-ELEMENT-PAINT`'s half stands and now reaches EVERY collection that accepts
3205
+ // `class=` (`CLASS-CHANNEL-REACH`). Until this release the loop below ran over `doc.edges`
3206
+ // alone, so `class k "K" fill=#eee` plus `message c -> s "m" class=k` was
3207
+ // accepted, painted nothing, and put the class in the legend — a message
3208
+ // has its own collection because it has a position in time (`SEQUENCE-ORDER-MODEL`), and
3209
+ // the check never looked there.
3210
+ //
3211
+ // THE CHANNEL SETS ARE DERIVED FROM WHAT EACH RENDERER READS, not from
3212
+ // what the directive tables accept — a key the drawing never consults is
3213
+ // not a channel the member HAS. Read off the `chan()` call sites in
3214
+ // `renderSequence` and the `rsAll`/`dashOf` sites in `render`:
3215
+ // node, group, lifeline, state fill, stroke, style (box/pill: all three)
3216
+ // edge, message stroke, style (no interior)
3217
+ // fragment, operand stroke, style (frame/rule; a
3218
+ // fragment's interior would hide its own
3219
+ // members, so it has no `fill=` to set)
3220
+ // field, cell fill, stroke (`style=` left both
3221
+ // directives at `STYLE-KEY-SCOPE` and no `dashOf` reads
3222
+ // `f.style`/`mk.style`)
3223
+ // A member with all three channels can never fail this test; the rows are
3224
+ // listed anyway, because the table is the rule and a missing row would
3225
+ // read as "not considered".
3226
+ //
3227
+ // TWO CASES FIRE, and both are declared paint that cannot arrive:
3228
+ // (a) `fill=` with no `stroke=` on a class an INTERIOR-LESS member joins.
3229
+ // Not caught by (b), because `fill=` plus `style=` would pass it: on
3230
+ // a line `fill=` and `stroke=` NAME THE SAME CHANNEL (the same reason
3231
+ // `fill=` on an `edge` LINE is refused), so an author who wrote
3232
+ // `fill=` meant the line's colour and `style=` does not answer that.
3233
+ // (b) a class whose channels are ALL channels the member lacks — the
3234
+ // general shape, which reaches `style=`-only on a `field` or a `cell`.
3235
+ // Guarded on the class declaring at least one channel, so a
3236
+ // meaning-only class falls through it (`CLASS-CHANNEL-REACH`).
3237
+ // Both are per class, per channel — `INTERIOR-LESS-ELEMENT-PAINT`'s shape, for `INTERIOR-LESS-ELEMENT-PAINT`'s reason. A class
3238
+ // that also declares a channel the member HAS is fine and must stay fine:
3239
+ // `class hot "…" fill=#fee2e2 stroke=#dc2626` paints a node's box and an
3240
+ // edge's line from one meaning, and `class=hot,deprecated` splits one
3241
+ // meaning across two declarations (conformance case 308).
3242
+ const CLASS_CHANNELS={
3243
+ node: {has:['fill','stroke','style'], a:'a node'},
3244
+ group: {has:['fill','stroke','style'], a:'a group'},
3245
+ lifeline: {has:['fill','stroke','style'], a:'a lifeline'},
3246
+ state: {has:['fill','stroke','style'], a:'a state'},
3247
+ edge: {has:['stroke','style'], a:'an edge'},
3248
+ message: {has:['stroke','style'], a:'a message'},
3249
+ fragment: {has:['stroke','style'], a:'a fragment'},
3250
+ operand: {has:['stroke','style'], a:'an operand'},
3251
+ field: {has:['fill','stroke'], a:'a field'},
3252
+ cell: {has:['fill','stroke'], a:'a cell'},
3253
+ };
3254
+ const clsChan=(x,kind)=>{
3255
+ const K=CLASS_CHANNELS[kind];
3256
+ for(const cid of (x.cls===undefined||x.cls===null?[]:(Array.isArray(x.cls)?x.cls:[x.cls]))){
3257
+ const c=doc.classes.find(y=>y.id===cid);
3258
+ if(!c) continue; // unknown id: its own error
3259
+ const decl=['fill','stroke','style'].filter(k=>c[k]!==undefined);
3260
+ if(!decl.length) continue; // meaning only — legal (`CLASS-CHANNEL-REACH`)
3261
+ if(!K.has.includes('fill')&&c.fill!==undefined&&c.stroke===undefined){
3262
+ 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)');
3263
+ continue; }
3264
+ if(!decl.some(k=>K.has.includes(k)))
3265
+ 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)');
3266
+ }
3267
+ };
3268
+ for(const x of doc.nodes) clsChan(x,'node');
3269
+ for(const x of doc.groups) clsChan(x,'group');
3270
+ for(const x of doc.edges) clsChan(x,'edge');
3271
+ for(const x of (doc.messages||[])) clsChan(x,'message');
3272
+ for(const x of (doc.lifelines||[])) clsChan(x,'lifeline');
3273
+ for(const x of (doc.states||[])) clsChan(x,'state');
3274
+ for(const x of (doc.fragments||[])) clsChan(x,'fragment');
3275
+ for(const x of (doc.operands||[])) clsChan(x,'operand');
3276
+ for(const b of doc.blocks){
3277
+ if(b.fields) for(const f of b.fields) clsChan(f,'field');
3278
+ if(b.marks) for(const mk of b.marks) clsChan(mk,'cell');
2634
3279
  }
2635
3280
  }
2636
3281
  { // class references must resolve (closed grammar)
@@ -3226,13 +3871,37 @@ function render(doc,ropts){
3226
3871
  if(b.fields) for(const f of b.fields) rsAll(f);
3227
3872
  if(b.marks) for(const mk of b.marks) rsAll(mk);
3228
3873
  }
3874
+ // GEOMETRY-TIME DIAGNOSTICS. `parse` cannot see a coordinate, so a defect
3875
+ // that is only visible in the DRAWING (a group band enclosing a non-member
3876
+ // the author pinned there) has no channel to report through today. The scene
3877
+ // hands its diagnostics back here and `render` returns them beside the SVG;
3878
+ // a caller that writes an artifact must treat a non-empty list exactly as it
3879
+ // treats a parse error, because the alternative is writing a picture that
3880
+ // states something the source does not.
3881
+ let sceneErrs=[];
3229
3882
  const parts=[]; let y=0, maxW=0;
3230
3883
  if(doc.title && RO.title===true){ parts.push('<text x="0" y="16" font-size="15" font-weight="600">'+esc(doc.title)+'</text>'); y=30;
3231
3884
  maxW=Math.max(maxW, cw(doc.title)*8.6); } // canvas must fit the title
3232
3885
  let sceneMeta=null;
3233
- if(doc.nodes.length||doc.edges.length||(doc.boundaries||[]).length){
3886
+ // THE LADDER. A sequence figure has NO SCENE: both of its axes
3887
+ // are declaration-ordered, so there is nothing for the scene layout to
3888
+ // place, and its elements live in their own five collections rather than in
3889
+ // `nodes`/`edges` — which is why the scene branch could never have drawn
3890
+ // one. The branch is an `else if` and not a second pass because the two
3891
+ // renderers are alternatives, never neighbours: `lifeline` and `node` cannot
3892
+ // both appear in one document (one genre, one node spelling).
3893
+ //
3894
+ // Everything AFTER this point is shared and unchanged — the region stack,
3895
+ // the derived `class` legend, the figure-level note, the canvas padding and
3896
+ // the title. A `class` on a `message` earns its legend entry from the same
3897
+ // code that derives a topology figure's.
3898
+ if(doc.genre==='sequence'&&(doc.lifelines||[]).length){
3899
+ const s=renderSequence(doc,y); parts.push(s.svg); y=s.y; maxW=Math.max(maxW,s.w);
3900
+ }
3901
+ else if(doc.nodes.length||doc.edges.length||(doc.boundaries||[]).length){
3234
3902
  const s=renderScene(doc,y); parts.push(s.svg); y=s.y; maxW=Math.max(maxW,s.w);
3235
3903
  sceneMeta=s.meta;
3904
+ if(s.errs&&s.errs.length) sceneErrs=sceneErrs.concat(s.errs);
3236
3905
  }
3237
3906
  // `MARKER-TARGET-KINDS`: a region-scope `threshold`/`band` is drawn HERE and not
3238
3907
  // in `renderScene`, because a region is not in the scene. Typed blocks stack
@@ -3351,12 +4020,23 @@ function render(doc,ropts){
3351
4020
  +'<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">'
3352
4021
  +'<line x1="0" y1="0" x2="0" y2="6" stroke="#bbb" stroke-width="2"/></pattern></defs>'
3353
4022
  +'<g transform="translate('+PADL+','+PADT+')">'+parts.join('')+'</g></svg>', w:W, h:H,
3354
- sceneMeta:sceneMeta, pad:{x:PADL,y:PADT}};
4023
+ sceneMeta:sceneMeta, pad:{x:PADL,y:PADT}, errs:sceneErrs};
3355
4024
  }
3356
4025
 
3357
4026
  // ---- scene ----
3358
4027
  function renderScene(doc,y0){
3359
4028
  const nodes=doc.nodes.map(n=>({...n}));
4029
+ // Band padding around a group's members — the ONE place it is written. The
4030
+ // contiguity pass and the drawn rect must agree to the pixel: a pass that
4031
+ // separates against a slightly different rectangle from the one painted is a
4032
+ // pass that reports clean on a picture that is not.
4033
+ const BAND={l:14,r:14,t:26,b:12};
4034
+ // A geometry-time diagnostic carries a SOURCE line like every other error in
4035
+ // this engine, and in a multi-section document the line the author reads is
4036
+ // the FULL-FILE one. `parse` re-bases its own messages; a render error is
4037
+ // built here, after that pass, so the doc carries the offset itself (0 for a
4038
+ // single-section document, where the two numberings coincide).
4039
+ const srcLine=n=>(n===null||n===undefined)?null:n+(doc.lineOffset||0);
3360
4040
  // §2.4 plane z = paint order, applied by every pass that stacks annotations
3361
4041
  // (edges, bundle rings, threshold lines, zone bands). The sort is stable, so
3362
4042
  // same-plane items keep document order — "a later line paints on top".
@@ -3576,7 +4256,45 @@ function renderScene(doc,y0){
3576
4256
  for(let i=r0;i<r1;i++) mainGap[i]=Math.max(mainGap[i],need);
3577
4257
  }
3578
4258
  const center=n=>n.cross+cs(n)/2;
4259
+ // GROUP CONTIGUITY, ORDERING HALF. A group's band is the bounding box of its
4260
+ // members, so a non-member that the lane order happens to place BETWEEN two
4261
+ // of them is drawn inside the band — the picture then states a membership the
4262
+ // source never declared. The geometry pass further down can always evict the
4263
+ // intruder, but eviction leaves the members where they were and the band
4264
+ // keeps a hole where the intruder used to be; ordering them adjacent HERE,
4265
+ // before any coordinate exists, costs nothing and packs the group properly.
4266
+ //
4267
+ // Sorting by a key that is CONSTANT ACROSS A GROUP is what makes members
4268
+ // contiguous — equal keys land together — and the key is the group's MEAN
4269
+ // desired position, so the cluster still sits where the incoming order put
4270
+ // it. The sort is stable, so members keep the order they arrived in and a
4271
+ // lane with nothing interleaved is not reordered at all.
4272
+ //
4273
+ // A group with a PINNED member is left alone: the pin is the author's
4274
+ // coordinate, this pass cannot move it, and clustering the rest around a slot
4275
+ // the pin will overwrite only displaces free nodes for nothing. That figure
4276
+ // is the AUTHOR's half of the ruling and is reported, not redrawn.
4277
+ const clusterGroups=arr=>{ // arr of {n, d}; reordered in place
4278
+ if(!arr.some(x=>x.n.group)) return arr;
4279
+ const mean=new Map();
4280
+ for(const x of arr){ const g=x.n.group; if(!g) continue;
4281
+ if(!mean.has(g)) mean.set(g,{s:0,k:0,pin:false});
4282
+ const t=mean.get(g); t.s+=x.d; t.k++; if(pinned(x.n.id)) t.pin=true; }
4283
+ if(![...mean.values()].some(t=>!t.pin&&t.k>1)) return arr;
4284
+ const live=g=>g&&mean.has(g)&&!mean.get(g).pin;
4285
+ const key=x=>live(x.n.group)?mean.get(x.n.group).s/mean.get(x.n.group).k:x.d;
4286
+ const tag=x=>live(x.n.group)?x.n.group:'';
4287
+ arr.sort((p,q)=>key(p)-key(q)||(tag(p)<tag(q)?-1:tag(p)>tag(q)?1:0));
4288
+ return arr;
4289
+ };
3579
4290
  ranksArr.forEach(lane=>{ if(!lane) return; let c=0; // seed: doc order
4291
+ // The seed is the ONLY ordering a single-rank figure ever gets: `sweep`
4292
+ // walks rank boundaries, so a scene with no edges never reaches `place`.
4293
+ // The six-line reproduction (`group g` + three nodes, the middle one not a
4294
+ // member) is exactly that figure, which is why the clustering runs here too
4295
+ // and not only in the sweep.
4296
+ const ord=clusterGroups(lane.map((n,k)=>({n,d:k}))).map(x=>x.n);
4297
+ lane.length=0; ord.forEach(n=>lane.push(n));
3580
4298
  lane.forEach((n,k)=>{ n.cross=c; c+=cs(n)+(k<lane.length-1?gapOf(n,lane[k+1]):0); }); });
3581
4299
  // WHERE THE HOLD YIELDS, WHICH IS MOST OF THE RULE. Holding a chain node on
3582
4300
  // its chain neighbour puts every OTHER neighbour of that node on one side of
@@ -3782,6 +4500,148 @@ function renderScene(doc,y0){
3782
4500
  if(o){ n.x=o.x+p.fx; n.y=o.y+p.fy; }
3783
4501
  else { n.x=p.fx; n.y=y0+20+p.fy; }
3784
4502
  }
4503
+ // ── GROUP BAND CONTIGUITY ────────────────────────────────────────────────
4504
+ // A group's band is the BOUNDING BOX of its members (see gBox below), and
4505
+ // until this pass nothing checked that the box contained only members. A
4506
+ // non-member the layout happened to place between two members was therefore
4507
+ // drawn INSIDE the band, with no error and no warning: six legal lines
4508
+ // (`group g`, three nodes of which the middle one is not `in=g`, `layout`)
4509
+ // produced a picture that says the middle node is in the group. That is the
4510
+ // worst failure class this project has — a legal document that reads
4511
+ // confidently and wrongly — and it contradicts this genre's own rule that
4512
+ // membership is DECLARED and never inferred from rendered geometry.
4513
+ //
4514
+ // WHOEVER CHOSE THE POSITION BEARS THE RESPONSIBILITY. That single principle
4515
+ // splits the fix in two:
4516
+ //
4517
+ // the ENGINE chose it — auto-layout had freedom, so the engine MUST place
4518
+ // the members contiguously and the situation cannot arise. It is fixed
4519
+ // here, silently, at no cost to the author.
4520
+ // the AUTHOR chose it — a `pin` fixed the intruder (or fixed the members
4521
+ // whose extent IS the band) and the engine has no freedom left. It then
4522
+ // reports, naming the pin line, and the artifact is not written. The
4523
+ // engine never overrides the author's coordinate, and never draws a
4524
+ // statement the source did not make.
4525
+ //
4526
+ // It runs HERE — after pins are applied and before the group origins are
4527
+ // taken — because the defect exists in the final geometry and nowhere else.
4528
+ // The source looks fine; that is the whole point of the defect.
4529
+ const gErrs=[];
4530
+ {
4531
+ const SEP=16; // clearance left between a band and what is pushed out
4532
+ const MAXPASS=60; // resolution is monotone (always outward); this bounds
4533
+ // pathological alternation rather than expected work
4534
+ const real=nodes.filter(n=>!n.boundary);
4535
+ const memOf=id=>real.filter(n=>n.group===id);
4536
+ const cLo=n=>horiz?n.y:n.x, cSz=n=>horiz?n.h:n.w;
4537
+ const mv=(n,d)=>{ if(horiz) n.y+=d; else n.x+=d; };
4538
+ const groups=doc.groups.filter(g=>memOf(g.id).length);
4539
+ const minCross=()=>lay.length?Math.min(...lay.map(cLo)):0;
4540
+ const cross0=minCross(); // the envelope the layout had before this pass
4541
+ const bandOf=g=>{
4542
+ const m=memOf(g.id);
4543
+ const B={x0:Math.min(...m.map(n=>n.x))-BAND.l, x1:Math.max(...m.map(n=>n.x+n.w))+BAND.r,
4544
+ yA:Math.min(...m.map(n=>n.y))-BAND.t, yB:Math.max(...m.map(n=>n.y+n.h))+BAND.b};
4545
+ B.lo=horiz?B.yA:B.x0; B.hi=horiz?B.yB:B.x1;
4546
+ return B;
4547
+ };
4548
+ const inBand=(n,B)=>n.x<B.x1&&n.x+n.w>B.x0&&n.y<B.yB&&n.y+n.h>B.yA;
4549
+ const pinLine=id=>doc.pins[id]?doc.pins[id].line:null;
4550
+ // The MOVER is a whole group or a single free node — never half a group,
4551
+ // because moving one member of a group reshapes THAT group's band and the
4552
+ // next pass would only find the same class of defect one group along.
4553
+ const unitOf=n=>n.group?memOf(n.group):[n];
4554
+ const canMove=u=>u.every(n=>!pinned(n.id))
4555
+ && !(u[0].group&&doc.pins[u[0].group]&&doc.pins[u[0].group].fx!==null);
4556
+ const said=new Set();
4557
+ const collect=()=>{
4558
+ const out=[];
4559
+ for(const g of groups){
4560
+ const B=bandOf(g);
4561
+ for(const n of real){
4562
+ if(n.group===g.id||said.has(g.id+' '+n.id)) continue;
4563
+ if(inBand(n,B)) out.push({g,n});
4564
+ }
4565
+ }
4566
+ return out;
4567
+ };
4568
+ let left=[];
4569
+ // EVERY conflict gets attention on every pass, and the band is recomputed
4570
+ // immediately before each resolution. Taking only the first conflict each
4571
+ // pass was tried and is wrong: one pair that alternates starves every other
4572
+ // pair for the whole iteration budget, and the run then reports as
4573
+ // "unresolvable" figures the pass had never once looked at.
4574
+ for(let pass=0;pass<MAXPASS;pass++){
4575
+ left=collect();
4576
+ if(!left.length) break;
4577
+ for(const c of left){
4578
+ const B=bandOf(c.g);
4579
+ if(!inBand(c.n,B)) continue; // an earlier resolution cleared it
4580
+ const gMem=memOf(c.g.id);
4581
+ // Who yields: the intruder if the engine placed it, otherwise the group
4582
+ // if the engine placed THAT, otherwise nobody and the author is told.
4583
+ let unit=unitOf(c.n), obst=B;
4584
+ if(!canMove(unit)){
4585
+ if(canMove(gMem)){ unit=gMem;
4586
+ obst={lo:cLo(c.n), hi:cLo(c.n)+cSz(c.n)}; }
4587
+ else {
4588
+ // No freedom anywhere: report, name the line that took it away, and
4589
+ // stop considering this pair so the loop still terminates.
4590
+ const who=pinned(c.n.id)?c.n.id
4591
+ :(c.n.group&&doc.pins[c.n.group]&&doc.pins[c.n.group].fx!==null?c.n.group
4592
+ :(gMem.find(m=>pinned(m.id))||{id:c.g.id}).id);
4593
+ const ln=srcLine(pinLine(who));
4594
+ gErrs.push('Line '+(ln!==null?ln:srcLine(c.g.line))+': pin puts "'+c.n.id
4595
+ +'" inside the band of group "'+c.g.id+'" — a band is the bounding box of the '
4596
+ +'group\'s members, so this draws "'+c.n.id+'" as one of them. Move the pin clear '
4597
+ +'of the group\'s extent, or say what the drawing says with in='+c.g.id+'.');
4598
+ said.add(c.g.id+' '+c.n.id); continue;
4599
+ }
4600
+ }
4601
+ const uLo=Math.min(...unit.map(cLo)), uHi=Math.max(...unit.map(n=>cLo(n)+cSz(n)));
4602
+ const dNeg=(obst.lo-SEP)-uHi, dPos=(obst.hi+SEP)-uLo;
4603
+ // NEARER SIDE, BUT NEVER OFF THE CANVAS. The obvious rule — move
4604
+ // whichever way is shorter — sends the unit past the layout's own
4605
+ // starting edge often enough to matter (`reference/topology` put L1 at
4606
+ // x=-90 and the viewBox clipped it away). Growing the canvas the other
4607
+ // way is not available either: the only uniform-shift machinery this
4608
+ // renderer has moves PINNED nodes with everything else, and a pinned
4609
+ // node that drifts because an unrelated node was added is the `RENDERING-DETERMINISM`
4610
+ // stability violation this engine has already paid for once. So the
4611
+ // constraint is applied HERE, to the choice: the negative direction is
4612
+ // taken only when the unit still lands inside the envelope the layout
4613
+ // had before this pass ran. Nothing outside the mover ever moves.
4614
+ const dNegOK=uLo+dNeg>=cross0;
4615
+ const d=(Math.abs(dNeg)<=Math.abs(dPos)&&dNegOK)?dNeg:dPos;
4616
+ const ranks=new Set(unit.map(n=>n.rank));
4617
+ const keep=new Set(unit.concat(unit===gMem?[]:gMem));
4618
+ // Everything the mover would be pushed ONTO travels with it: same rank,
4619
+ // same side, clear of the obstacle. Relative order and spacing inside a
4620
+ // lane are preserved, so the fix cannot manufacture an overlap.
4621
+ // A node that BELONGS to a group never travels this way — a group moves
4622
+ // whole or not at all, and dragging half of one along would reshape its
4623
+ // band, which is the same defect one group further on.
4624
+ for(const m of lay){
4625
+ if(keep.has(m)||!ranks.has(m.rank)) continue;
4626
+ if(!m.virtual&&m.group) continue;
4627
+ const mLo=cLo(m), mHi=mLo+cSz(m);
4628
+ if(d<0 ? (mHi<=uHi&&mHi<=obst.lo) : (mLo>=uLo&&mLo>=obst.hi)) mv(m,d);
4629
+ }
4630
+ for(const n of unit) mv(n,d);
4631
+ }
4632
+ }
4633
+ left=collect();
4634
+ // The invariant is CHECKED, not assumed: anything the pass could not place
4635
+ // is named. A figure that reaches this line with a hit is a defect in this
4636
+ // pass, and saying so beats drawing the false statement quietly.
4637
+ for(const c of left){
4638
+ if(said.has(c.g.id+' '+c.n.id)) continue;
4639
+ gErrs.push('Line '+srcLine(c.g.line)+': group "'+c.g.id+'" would enclose non-member "'
4640
+ +c.n.id+'" and the layout pass could not separate them; the figure is not drawn rather '
4641
+ +'than drawn wrongly. Give "'+c.n.id+'" a pin outside the group, or add it with in='
4642
+ +c.g.id+'.');
4643
+ }
4644
+ }
3785
4645
  // Pass 3: an unpinned group has no anchor of its own; its display origin
3786
4646
  // (drag anchor / data-gx,gy) is the top-left of its members' FINAL positions,
3787
4647
  // so it reflects any pinned members and matches the group box drawn below.
@@ -3958,8 +4818,8 @@ function renderScene(doc,y0){
3958
4818
  const mem=nodes.filter(n=>n.group===g.id);
3959
4819
  if(!mem.length) continue;
3960
4820
  const o=gOrigin[g.id];
3961
- const x0=Math.min(...mem.map(n=>n.x))-14, x1=Math.max(...mem.map(n=>n.x+n.w))+14;
3962
- const yA=Math.min(...mem.map(n=>n.y))-26, yB=Math.max(...mem.map(n=>n.y+n.h))+12;
4821
+ const x0=Math.min(...mem.map(n=>n.x))-BAND.l, x1=Math.max(...mem.map(n=>n.x+n.w))+BAND.r;
4822
+ const yA=Math.min(...mem.map(n=>n.y))-BAND.t, yB=Math.max(...mem.map(n=>n.y+n.h))+BAND.b;
3963
4823
  gBox[g.id]={x0,x1,yA,yB};
3964
4824
  const gdash=g.style==='dashed'?' stroke-dasharray="6 4"':(g.style==='dotted'?' stroke-dasharray="2 4"':'');
3965
4825
  gsvg.push('<g data-group="'+g.id+'" data-gx="'+o.x+'" data-gy="'+o.y+'" style="cursor:move">'
@@ -4989,7 +5849,7 @@ function renderScene(doc,y0){
4989
5849
  }
4990
5850
  const yEnd=y0+20+Hh+10;
4991
5851
  return {svg:gsvg.join('')+esvg.join('')+nsvg.join('')+tsvg.join('')+lblsvg.join(''), y:yEnd, w:W+2,
4992
- meta:{W:W, top:y0+20+chShift, Hh:Hh, left:bShift}};
5852
+ meta:{W:W, top:y0+20+chShift, Hh:Hh, left:bShift}, errs:gErrs};
4993
5853
  }
4994
5854
  // borderPoint: where the ray from n's centre toward (tx,ty) leaves the shape.
4995
5855
  // It must leave the DRAWN outline: a rectangle clip on a diamond or an ellipse
@@ -5153,6 +6013,393 @@ function edgeRuns(v, p, n, span, ownAt){
5153
6013
  return out;
5154
6014
  }
5155
6015
 
6016
+ // ---- ladder (the `sequence` genre) ----
6017
+ //
6018
+ // THE ORDERING RULE, stated once, because everything below depends on it:
6019
+ //
6020
+ // The TIME axis is the declaration order of the `message` and `state` lines
6021
+ // taken JOINTLY. Line m above line n asserts that m occurs before n.
6022
+ // `lifeline` declaration order is the COLUMN axis, left to right. Both axes
6023
+ // are declaration-ordered, and that is why this genre has no `flow` and no
6024
+ // `rank`: a key that reordered the drawing would make the picture disagree
6025
+ // with the text (`SEQUENCE-SOURCE-STANDARD`-R182). `fragment` and `operand` lines are
6026
+ // DECLARATIONS and carry no time position of their own; a container's drawn
6027
+ // extent is the span of its members' positions. Implementation: every
6028
+ // element carries its source line number, so the row order is recovered by
6029
+ // ONE sort on that number in `seqModel` — the model never stores an ordinal.
6030
+ //
6031
+ // Everything else in this function is a DRAWING CONVENTION the engine owns
6032
+ // under `DOMAIN-CONVENTION-DIRECTIVES` and is marked CHOSEN where it is not obvious. The author names
6033
+ // MEANING (who talks to whom, in what order, inside which fragment); the
6034
+ // engine decides every coordinate, and there is no key that moves one.
6035
+ //
6036
+ // The layout is SIX DETERMINISTIC PASSES and no fixed-point iteration:
6037
+ // 1. container column spans (which columns each fragment/operand covers)
6038
+ // 2. the column axis (centre-to-centre distances, widened to fit)
6039
+ // 3. the time axis (one slot per row, plus container headroom)
6040
+ // 4. container box geometry (from the tops/bottoms the cursor recorded)
6041
+ // 5. paint (background, mid, ink — three ordered layers)
6042
+ // 6. the canvas extent (widest of columns, boxes and overhanging ink)
6043
+ //
6044
+ // NOT DRAWN: activation bars. UML's ExecutionSpecification is a separate
6045
+ // referent with a separate spelling, and the genre has no keyword for it — so
6046
+ // the renderer must not invent one out of message adjacency, which would put
6047
+ // an assertion in the picture that the source does not make.
6048
+ function renderSequence(doc,y0){
6049
+ const M=seqModel(doc);
6050
+ const lls=doc.lifelines;
6051
+ if(!lls.length) return {svg:'',y:y0,w:0};
6052
+ const col={}; lls.forEach((l,i)=>{ col[l.id]=i; });
6053
+ // `OMITTED-LABEL-RECORDING`/`EMPTY-LABEL-STATE` display fallback, applied here rather than in `render`: the model
6054
+ // records an omitted label as absent (null) and the RENDERER substitutes the
6055
+ // id, so `lifeline c` draws "c". An explicitly empty label draws nothing.
6056
+ const lblOf=(x)=>(x.label===null||x.label===undefined)?x.id:x.label;
6057
+ // class cascade — the same rule `render` applies to nodes and edges, applied
6058
+ // to this genre's elements (a `class` on a `message` is what `lost=` was
6059
+ // refused in favour of, `UNDELIVERED-MESSAGE-MARKING`). Read-only: the element is never patched.
6060
+ const C={}; for(const c of doc.classes||[]) C[c.id]=c;
6061
+ const clsIds=(x)=>x.cls===undefined||x.cls===null?[]:(Array.isArray(x.cls)?x.cls:[x.cls]);
6062
+ const chan=(x,k)=>{ if(x[k]!==undefined) return x[k];
6063
+ let v; for(const id of clsIds(x)) if(C[id]&&C[id][k]!==undefined) v=C[id][k]; return v; };
6064
+ // `seqModel` reports the containment chain; DEPTH is a view of it and lives
6065
+ // here because only the drawing needs it (nesting inset, paint order).
6066
+ const depth=(id)=>M.chain(id).length-1;
6067
+
6068
+ // ── geometry constants (CHOSEN, `DOMAIN-CONVENTION-DIRECTIVES`) ────────────────────────────────────
6069
+ const HEAD_H=32, HEAD_PADX=13, HEAD_MINW=76;
6070
+ const ROW_H=34; // base row pitch. See the F5 note below.
6071
+ const SELF_W=40, SELF_EXTRA=26, STATE_H=22;
6072
+ const LBL_FS=11, LBL_LIFT=8; // label sits LBL_LIFT px above its own arrow
6073
+ const FRAG_TOP=26, FRAG_BOT=12, OPERAND_TOP=20, FRAG_PADX=22, FRAG_INSET=9;
6074
+ const ENC_PAD=6; // clearance a container's frame keeps off its members
6075
+ // F5 (spec/core.md §14.3) is a CONSTRAINT ON ROW_H, not an afterthought.
6076
+ // A message label's centre sits LBL_LIFT + fs*0.55 ≈ 14 px above its own
6077
+ // arrow, so its margin against the arrow one row away is ROW_H - 2*14.
6078
+ // F5 requires that to exceed M = 4 px, i.e. ROW_H > 32. ROW_H = 34 gives a
6079
+ // computed margin of 6 px at the worst case (two consecutive messages over
6080
+ // the same span) and much more in practice. This is the whole reason a
6081
+ // ladder is F5-cheap: the geometry separates labels by CONSTRUCTION, so the
6082
+ // margin is a property of the row pitch and not of any per-figure search.
6083
+
6084
+ const headW=lls.map(l=>Math.max(HEAD_MINW, cwMax(lblOf(l))*CH+2*HEAD_PADX));
6085
+ const lblPx=(s)=>s?cwMax(s)*(6.5*LBL_FS/11)+8:0;
6086
+ // A state pill's width is needed in TWO passes — the container-enclosure
6087
+ // pass and the paint pass — so it is written once. A pill wider than its
6088
+ // container's padding is exactly the case that made the enclosure pass
6089
+ // necessary (see PASS 4).
6090
+ const statePillW=(el)=>Math.max(46, cwMax(el.name)*6.6+18);
6091
+
6092
+ // ── PASS 1 — container column spans (needed BEFORE the column axis,
6093
+ // because a fragment's operator tab and label have to fit inside its
6094
+ // own box) ────────────────────────────────────────────────────────────
6095
+ const cspan={};
6096
+ for(const id in M.cont){
6097
+ const slots=M.owned[id];
6098
+ let cmin=Infinity,cmax=-Infinity;
6099
+ for(const sl of slots){ const r=M.rows[sl];
6100
+ if(r.kind==='message'){ const a=col[r.el.a],b=col[r.el.b];
6101
+ if(a!==undefined){cmin=Math.min(cmin,a);cmax=Math.max(cmax,a);}
6102
+ if(b!==undefined){cmin=Math.min(cmin,b);cmax=Math.max(cmax,b);} }
6103
+ else { const a=col[r.el.ref];
6104
+ if(a!==undefined){cmin=Math.min(cmin,a);cmax=Math.max(cmax,a);} } }
6105
+ cspan[id]=isFinite(cmin)?{cmin,cmax}:null;
6106
+ }
6107
+ // a fragment must be at least as wide as the operands it holds
6108
+ for(const id in M.cont){
6109
+ if(M.cont[id].kind!=='operand') continue;
6110
+ const p=M.cont[id].parent;
6111
+ if(p&&cspan[p]&&cspan[id]){ cspan[p].cmin=Math.min(cspan[p].cmin,cspan[id].cmin);
6112
+ cspan[p].cmax=Math.max(cspan[p].cmax,cspan[id].cmax); }
6113
+ }
6114
+ const tabW=(id)=>cwMax(M.cont[id].el.type||'')*6.6+16;
6115
+ const capW=(id)=>{ const c=M.cont[id];
6116
+ const lab=(c.el.label===null||c.el.label===undefined)?'':('['+c.el.label+']');
6117
+ return (c.kind==='fragment'?tabW(id)+14:8)+cwMax(lab)*6.6+10; };
6118
+
6119
+ // ── PASS 2 — the column axis: centre-to-centre distances ────────────────
6120
+ const nc=lls.length, cd=[];
6121
+ for(let k=0;k+1<nc;k++) cd.push(headW[k]/2+headW[k+1]/2+26);
6122
+ let rightPad=0;
6123
+ const widen=(i,j,need)=>{ // need = required span i..j
6124
+ if(j<=i) return;
6125
+ let have=0; for(let k=i;k<j;k++) have+=cd[k];
6126
+ if(have>=need) return;
6127
+ const add=(need-have)/(j-i); for(let k=i;k<j;k++) cd[k]+=add;
6128
+ };
6129
+ for(const r of M.rows){
6130
+ if(r.kind!=='message') continue;
6131
+ const ci=col[r.el.a], cj=col[r.el.b];
6132
+ if(ci===undefined||cj===undefined) continue;
6133
+ const w=lblPx(r.el.label)+26;
6134
+ if(ci===cj){ // self-message
6135
+ const need=SELF_W+lblPx(r.el.label)+16;
6136
+ if(ci+1<nc) widen(ci,ci+1,need); else rightPad=Math.max(rightPad,need);
6137
+ } else widen(Math.min(ci,cj),Math.max(ci,cj),w);
6138
+ }
6139
+ // the fragment caption is INSIDE the box, so it constrains the columns the
6140
+ // box spans — a caption that overflows its own box names nothing.
6141
+ for(const id in M.cont){
6142
+ const cs=cspan[id]; if(!cs) continue;
6143
+ const d=depth(id), pad=Math.max(6,FRAG_PADX-d*FRAG_INSET);
6144
+ if(cs.cmin===cs.cmax) rightPad=Math.max(rightPad,capW(id)-pad-headW[cs.cmax]/2);
6145
+ else widen(cs.cmin,cs.cmax,capW(id)-2*pad);
6146
+ }
6147
+ // The LEFT margin is structural too. Column 0's head box normally sets it,
6148
+ // but two things drawn on that column are wider than it: a `state` pill (as
6149
+ // wide as its state name) and a container frame whose left edge sits a
6150
+ // padding outside the column. Whichever overhangs furthest pushes the whole
6151
+ // axis right, so nothing is ever drawn at a negative x — the canvas has no
6152
+ // room there and the ink would simply be clipped away.
6153
+ let leftPad=0;
6154
+ for(const r of M.rows)
6155
+ if(r.kind==='state'&&col[r.el.ref]===0)
6156
+ leftPad=Math.max(leftPad, statePillW(r.el)/2+ENC_PAD-headW[0]/2);
6157
+ for(const id in M.cont){
6158
+ const cs=cspan[id]; if(!cs||cs.cmin!==0) continue;
6159
+ const d=depth(id), pad=Math.max(6,FRAG_PADX-d*FRAG_INSET);
6160
+ leftPad=Math.max(leftPad, pad+ENC_PAD+6-headW[0]/2);
6161
+ }
6162
+ const x=[]; x[0]=headW[0]/2+Math.max(0,leftPad);
6163
+ for(let k=1;k<nc;k++) x[k]=x[k-1]+cd[k-1];
6164
+
6165
+ // ── PASS 3 — the time axis: one slot per row, plus the space containers
6166
+ // need for their frames ────────────────────────────────────────────────
6167
+ const opensAt={}, closesAt={};
6168
+ for(const id in M.cont){ const e=M.extent[id]; if(!e) continue;
6169
+ (opensAt[e.lo]=opensAt[e.lo]||[]).push(id);
6170
+ (closesAt[e.hi]=closesAt[e.hi]||[]).push(id); }
6171
+ const sortDeep=(a)=>a.slice().sort((p,q)=>depth(p)-depth(q));
6172
+ const yTop=y0;
6173
+ let y=yTop+HEAD_H+22;
6174
+ // Box tops and bottoms are recorded AS THE CURSOR PASSES THEM, so an outer
6175
+ // fragment and the operand that opens with it get DIFFERENT tops and their
6176
+ // captions cannot land on each other. (Deriving both from the member row
6177
+ // overlapped them — visible in the rendered pixels, not in any metric.)
6178
+ const boxTop={}, boxBot={};
6179
+ for(const r of M.rows){
6180
+ for(const id of sortDeep(opensAt[r.slot]||[])){
6181
+ // a top-level fragment gets clear air above it, or two consecutive
6182
+ // fragments share a border and read as one box.
6183
+ if(depth(id)===0&&M.cont[id].kind==='fragment') y+=8;
6184
+ boxTop[id]=y; y+=(M.cont[id].kind==='fragment'?FRAG_TOP:OPERAND_TOP); }
6185
+ r.y0=y;
6186
+ let h=ROW_H;
6187
+ if(r.kind==='message'&&r.el.a===r.el.b) h+=SELF_EXTRA;
6188
+ // A label is drawn ABOVE its own arrow, so every extra line of it is
6189
+ // extra row pitch — otherwise line 2 lands ON the arrow.
6190
+ if(r.kind==='message') h+=Math.max(0,String(r.el.label||'').split('\n').length-1)*LBL_FS*1.3;
6191
+ // NOTE what is NOT here: `description=` reserves no row height, because it
6192
+ // puts NO INK on the page (core §10, §12.7 — "description= addresses the
6193
+ // machine and draws nothing, note= addresses the human and always draws").
6194
+ // The ladder honours that division: a description becomes an SVG <title>
6195
+ // on the element it names and nothing else. (The prototype this was ported
6196
+ // from drew it as grey prose under the arrow, which is the one thing the
6197
+ // key is defined not to do.)
6198
+ r.yMid=y+h/2;
6199
+ y+=h;
6200
+ r.y1=y;
6201
+ for(const id of sortDeep(closesAt[r.slot]||[]).reverse()){
6202
+ if(M.cont[id].kind==='fragment') y+=FRAG_BOT;
6203
+ boxBot[id]=y; }
6204
+ // The NEXT row's label is drawn ABOVE its own arrow, so a row that follows
6205
+ // a closing frame starts its label ~3 px under that frame's border and the
6206
+ // two read as one mark. A frame that closes therefore buys clear air below
6207
+ // it, on the same ground as the clear air a top-level fragment buys above.
6208
+ if((closesAt[r.slot]||[]).length && r.slot+1<M.rows.length) y+=10;
6209
+ }
6210
+ const bottom=y+10;
6211
+
6212
+ // ── PASS 4 — containers: box geometry ───────────────────────────────────
6213
+ const fbox={};
6214
+ for(const id in M.cont){
6215
+ const e=M.extent[id]; if(!e) continue;
6216
+ const cs=cspan[id]||{cmin:0,cmax:nc-1};
6217
+ const d=depth(id), pad=Math.max(6,FRAG_PADX-d*FRAG_INSET);
6218
+ fbox[id]={x0:x[cs.cmin]-pad, x1:Math.max(x[cs.cmax]+pad, x[cs.cmin]-pad+capW(id)),
6219
+ y0:boxTop[id], y1:boxBot[id],
6220
+ cmin:cs.cmin,cmax:cs.cmax,d,kind:M.cont[id].kind};
6221
+ }
6222
+ // A container's frame must ENCLOSE THE INK OF ITS MEMBERS, and the column
6223
+ // span alone does not guarantee that: a member's drawing can be wider than
6224
+ // the column it sits on. A `state` pill is centred on its lifeline and is as
6225
+ // wide as its state name, so a long name overhangs the fixed padding and the
6226
+ // pill pokes out through the frame that is supposed to contain it — visible
6227
+ // in the rendered pixels of the reference figure ("RENEWING" inside `loop`)
6228
+ // and invisible to every metric. The frame is therefore grown to the drawn
6229
+ // extent of what it owns. `owned` is TRANSITIVE, so an inner operand's
6230
+ // members widen the enclosing fragment too.
6231
+ for(const id in fbox){
6232
+ for(const sl of M.owned[id]){
6233
+ const r=M.rows[sl];
6234
+ let lo,hi;
6235
+ if(r.kind==='state'){ const ci=col[r.el.ref]; if(ci===undefined) continue;
6236
+ const w=statePillW(r.el); lo=x[ci]-w/2; hi=x[ci]+w/2; }
6237
+ else { const a=col[r.el.a], b=col[r.el.b]; if(a===undefined||b===undefined) continue;
6238
+ lo=Math.min(x[a],x[b]);
6239
+ hi=(a===b)?x[a]+SELF_W+8+lblPx(r.el.label):Math.max(x[a],x[b]); }
6240
+ fbox[id].x0=Math.min(fbox[id].x0,lo-ENC_PAD);
6241
+ fbox[id].x1=Math.max(fbox[id].x1,hi+ENC_PAD);
6242
+ }
6243
+ }
6244
+ // a fragment must contain its operands' boxes
6245
+ for(const id in fbox){
6246
+ if(fbox[id].kind!=='operand') continue;
6247
+ const p=M.cont[id].parent;
6248
+ if(p&&fbox[p]){ fbox[p].y1=Math.max(fbox[p].y1,fbox[id].y1);
6249
+ fbox[p].x0=Math.min(fbox[p].x0,fbox[id].x0-6);
6250
+ fbox[p].x1=Math.max(fbox[p].x1,fbox[id].x1+6); }
6251
+ }
6252
+ // An operand is a COMPARTMENT OF its fragment, so it is exactly as wide as
6253
+ // the fragment: its separator rule DIVIDES the frame and must reach both
6254
+ // borders, and its guard is read against the frame's left edge. Derived from
6255
+ // the parent LAST, after the parent has finished growing, so the divider can
6256
+ // never be shorter than the box it divides (UML 2.5.1 §17.12.3/§17.12.14).
6257
+ for(const id in fbox){
6258
+ if(fbox[id].kind!=='operand') continue;
6259
+ const p=M.cont[id].parent;
6260
+ if(p&&fbox[p]){ fbox[id].x0=fbox[p].x0; fbox[id].x1=fbox[p].x1; }
6261
+ }
6262
+
6263
+ // ── PASS 5 — paint ──────────────────────────────────────────────────────
6264
+ const bg=[], mid=[], ink=[];
6265
+ const HALO=' paint-order="stroke" stroke="#fff" stroke-width="3"';
6266
+ const arrowTri=(tip,from,c)=>{
6267
+ const dx=tip[0]-from[0], dy=tip[1]-from[1], L=Math.hypot(dx,dy)||1;
6268
+ const ux=dx/L, uy=dy/L, arm=10.08, hw=5.6;
6269
+ const bx=tip[0]-ux*arm, by=tip[1]-uy*arm;
6270
+ ink.push('<path d="M'+r2(tip[0])+' '+r2(tip[1])+' L'+r2(bx-uy*hw)+' '+r2(by+ux*hw)
6271
+ +' L'+r2(bx+uy*hw)+' '+r2(by-ux*hw)+' z" fill="'+c+'" stroke="none"/>');
6272
+ };
6273
+ const inkExtent=[];
6274
+ // `description=` → an SVG <title> and nothing else (core §10). `DESCRIPTION-KEY-SPELLING`'s rule
6275
+ // applies: a <title> names its PARENT, so it is never a loose sibling in the
6276
+ // figure's single <g> — where every description in the figure would name the
6277
+ // same element and a conforming UA would show one arbitrary tooltip for the
6278
+ // whole picture. Here each one wraps its own shape in a one-element <g>,
6279
+ // which keeps the shape SELF-CLOSING so the reference linter's edge and node
6280
+ // readers still find it.
6281
+ const titleEl=(s)=>(s===undefined||s===null)?'':'<title>'+esc(s)+'</title>';
6282
+ const withTitle=(s,shape)=>s===undefined||s===null?shape:'<g>'+titleEl(s)+shape+'</g>';
6283
+
6284
+ // lifelines: head box + descending dashed line.
6285
+ // The head is emitted as a `data-node` group — it IS the participant, and
6286
+ // the reference linter's node reader finds it there.
6287
+ lls.forEach((l,i)=>{
6288
+ const w=headW[i], hx=x[i]-w/2, lab=lblOf(l);
6289
+ const f=chan(l,'fill')||'#eef2ff', st=chan(l,'stroke')||'#4f46e5';
6290
+ bg.push('<line x1="'+r2(x[i])+'" y1="'+r2(yTop+HEAD_H+8)+'" x2="'+r2(x[i])+'" y2="'+r2(bottom)
6291
+ +'" stroke="#94a3b8" stroke-width="1" stroke-dasharray="4 4"/>');
6292
+ bg.push('<g data-node="'+esc(l.id)+'" data-x="'+r2(hx)+'" data-y="'+r2(yTop+8)+'">'
6293
+ +titleEl(l.desc)
6294
+ +'<rect x="'+r2(hx)+'" y="'+r2(yTop+8)+'" width="'+r2(w)+'" height="'+HEAD_H
6295
+ +'" rx="4" fill="'+f+'" stroke="'+st+'"'+dashOf(chan(l,'style'),'')+'/>'
6296
+ +textEl(x[i], yTop+8+HEAD_H/2+4.5, 13, 'middle', labelInk(f,'#1d1d1b'), lab, '')
6297
+ +'</g>');
6298
+ });
6299
+
6300
+ // fragment / operand boxes, outermost first so nesting paints correctly
6301
+ const boxIds=Object.keys(fbox).sort((a,b)=>fbox[a].d-fbox[b].d);
6302
+ for(const id of boxIds){
6303
+ const B=fbox[id], c=M.cont[id];
6304
+ // both containers take `stroke=` and `class=`; the DEFAULT differs,
6305
+ // because a fragment's frame is a border and an operand's rule is a
6306
+ // divider inside one (CHOSEN, `DOMAIN-CONVENTION-DIRECTIVES`).
6307
+ const st=chan(c.el,'stroke')||(c.kind==='fragment'?'#64748b':'#94a3b8');
6308
+ if(c.kind==='fragment'){
6309
+ mid.push(withTitle(c.el.desc,
6310
+ '<rect x="'+r2(B.x0)+'" y="'+r2(B.y0)+'" width="'+r2(B.x1-B.x0)+'" height="'+r2(B.y1-B.y0)
6311
+ +'" fill="none" stroke="'+st+'" stroke-width="1"'+dashOf(chan(c.el,'style'),'')+'/>'));
6312
+ // the operator tab — UML's pentagon in the top-left corner (§17.12.3;
6313
+ // the operator vocabulary itself is §17.12.15.3's InteractionOperatorKind)
6314
+ const tw0=cwMax(c.el.type)*6.6+16, th=15;
6315
+ mid.push('<path d="M'+r2(B.x0)+' '+r2(B.y0)+' h'+r2(tw0)+' l6,'+r2(th-6)+' v'+r2(6)
6316
+ +' h'+r2(-tw0-6)+' z" fill="#f8fafc" stroke="'+st+'" stroke-width="1"/>');
6317
+ mid.push(textEl(B.x0+7, B.y0+11, 10.5, 'start', '#334155', c.el.type, ''));
6318
+ if(c.el.label!==null&&c.el.label!==undefined)
6319
+ mid.push(textEl(B.x0+tw0+14, B.y0+11, 10.5, 'start', '#475569', '['+c.el.label+']', HALO));
6320
+ } else {
6321
+ // an operand compartment: a dashed rule above it (except the first) and
6322
+ // its guard at the left. UML draws the guard in square brackets.
6323
+ const p=M.cont[id].parent, sibs=doc.operands.filter(o=>o['in']===p);
6324
+ const first=sibs.length&&sibs[0].id===id;
6325
+ const rule=first?''
6326
+ :'<line x1="'+r2(B.x0)+'" y1="'+r2(B.y0+4)+'" x2="'+r2(B.x1)+'" y2="'+r2(B.y0+4)
6327
+ +'" stroke="'+st+'" stroke-width="1" stroke-dasharray="5 4"/>';
6328
+ const guard=(c.el.label!==null&&c.el.label!==undefined)
6329
+ ? textEl(B.x0+8, B.y0+13, 10.5, 'start', '#475569', '['+c.el.label+']', HALO) : '';
6330
+ // An operand has no box of its own, so its <title> names the group
6331
+ // holding the two marks it DOES draw — the separator rule and the guard.
6332
+ if(rule||guard) mid.push(withTitle(c.el.desc, rule+guard));
6333
+ }
6334
+ }
6335
+
6336
+ // rows
6337
+ for(const r of M.rows){
6338
+ if(r.kind==='state'){
6339
+ // a state occurrence is a pill CENTRED ON ITS OWN COLUMN — the lifeline
6340
+ // it names in slot 1 (UML 2.5.1 §17.12.25's StateInvariant).
6341
+ const ci=col[r.el.ref]; if(ci===undefined) continue;
6342
+ const f=chan(r.el,'fill')||'#fff7ed', st=chan(r.el,'stroke')||'#c2410c';
6343
+ const w=statePillW(r.el);
6344
+ mid.push(withTitle(r.el.desc,
6345
+ '<rect x="'+r2(x[ci]-w/2)+'" y="'+r2(r.yMid-STATE_H/2)+'" width="'+r2(w)+'" height="'+STATE_H
6346
+ +'" rx="9" fill="'+f+'" stroke="'+st+'" stroke-width="1"'+dashOf(chan(r.el,'style'),'')+'/>'));
6347
+ ink.push(textEl(x[ci], r.yMid+4, LBL_FS, 'middle', labelInk(f,'#7c2d12'), r.el.name, ''));
6348
+ inkExtent.push(x[ci]+w/2);
6349
+ continue;
6350
+ }
6351
+ // message
6352
+ const e=r.el, ci=col[e.a], cj=col[e.b];
6353
+ if(ci===undefined||cj===undefined) continue;
6354
+ const st=chan(e,'stroke')||'#334155';
6355
+ const dash=dashOf(chan(e,'style'),'');
6356
+ if(ci===cj){ // self-message
6357
+ // a rectangular loop off the column and back to it. The shaft is one
6358
+ // `path` at the same stroke-width as a straight message, so the axis
6359
+ // readers see one edge and not three.
6360
+ const sx=x[ci], top=r.yMid-11, bot=r.yMid+11, ex=sx+SELF_W;
6361
+ mid.push(withTitle(e.desc,
6362
+ '<path d="M'+r2(sx)+' '+r2(top)+' L'+r2(ex)+' '+r2(top)+' L'+r2(ex)+' '+r2(bot)
6363
+ +' L'+r2(sx+11)+' '+r2(bot)+'" fill="none" stroke="'+st+'" stroke-width="1.6"'+dash+'/>'));
6364
+ arrowTri([sx+2,bot],[sx+12,bot],st);
6365
+ if(e.label){ ink.push(textEl(ex+8, r.yMid+4, LBL_FS, 'start', '#1d1d1b', e.label, HALO));
6366
+ inkExtent.push(ex+8+lblPx(e.label)); }
6367
+ continue;
6368
+ }
6369
+ // A message between NON-ADJACENT columns crosses the lifelines between
6370
+ // them: the shaft is drawn straight from source centre to target centre
6371
+ // and the dashed columns it passes are left intact. This is UML's drawing
6372
+ // and it is also the honest one — a jog around an intervening lifeline
6373
+ // would suggest the message went somewhere it did not.
6374
+ const fwd=(e.op==='<-')?false:true; // '->' and '<->' read a→b
6375
+ let sx=fwd?x[ci]:x[cj], tx0=fwd?x[cj]:x[ci];
6376
+ const dir=Math.sign(tx0-sx)||1;
6377
+ sx+=dir*1.5;
6378
+ const ex=tx0-dir*1.5;
6379
+ mid.push(withTitle(e.desc,
6380
+ '<line x1="'+r2(sx)+'" y1="'+r2(r.yMid)+'" x2="'+r2(ex)+'" y2="'+r2(r.yMid)
6381
+ +'" stroke="'+st+'" stroke-width="1.6"'+dash+'/>'));
6382
+ arrowTri([ex,r.yMid],[ex-dir*10,r.yMid],st);
6383
+ // `<->` is ONE shaft with TWO heads: the model says one occurrence, so
6384
+ // the drawing must not show two lines and invite a reader to count two.
6385
+ if(e.op==='<->') arrowTri([sx,r.yMid],[sx+dir*10,r.yMid],st);
6386
+ if(e.label){
6387
+ const nl=String(e.label).split('\n').length;
6388
+ ink.push(textEl((sx+ex)/2, r.yMid-LBL_LIFT-(nl-1)*LBL_FS*1.3/2, LBL_FS, 'middle', '#1d1d1b', e.label, HALO));
6389
+ }
6390
+ }
6391
+
6392
+ // ── PASS 6 — the canvas extent ──────────────────────────────────────────
6393
+ const W=Math.max(x[nc-1]+headW[nc-1]/2, ...Object.keys(fbox).map(k=>fbox[k].x1),
6394
+ ...inkExtent)+rightPad+4;
6395
+ return {svg:bg.join('')+mid.join('')+ink.join(''), y:bottom, w:W,
6396
+ box:{x0:0,x1:W,yA:yTop,yB:bottom}};
6397
+ }
6398
+ // coordinates are emitted at 2 decimal places: the ladder's arithmetic divides
6399
+ // (`widen` spreads a shortfall over a run of columns), and an unrounded double
6400
+ // would put a 17-digit tail in the artifact for no reader's benefit.
6401
+ function r2(v){ return Math.round(v*100)/100; }
6402
+
5156
6403
  // ---- bitfield ----
5157
6404
  function renderBitfield(b,y0){
5158
6405
  const cell=Math.max(18,Math.min(28,Math.floor(760/b.word))), rh=30, ruler=16;