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.mjs CHANGED
@@ -1,8 +1,8 @@
1
- // figdown.mjs — FigDown embeddable library (0.3.1)
1
+ // figdown.mjs — 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
  'use strict';
5
- var VERSION = "0.3.1";
5
+ var VERSION = "0.4.0";
6
6
 
7
7
  // ---- engine (extracted verbatim from editor/figdown.html) ----
8
8
  var __engine = (function () {
@@ -16,7 +16,7 @@ const SHAPES = ['box','rounded','circle','ellipse','diamond','cylinder'];
16
16
  // input to that promise, and under core §13 a 0.x renderer may differ from
17
17
  // the next — which makes the recorded version the only thing that can
18
18
  // explain a diff between two renderings of one source.
19
- const FIGDOWN_VERSION = '0.3.1';
19
+ const FIGDOWN_VERSION = '0.4.0';
20
20
  // `STATECHART-GENRE-SCOPE`: the language number moved for the first time. The dev
21
21
  // counter does NOT reset (core §13.0.4 — `N` counts source states of the
22
22
  // engine and only ever increases), so 0.1 is followed by
@@ -31,14 +31,34 @@ const FIGDOWN_VERSION = '0.3.1';
31
31
  // fixes only. No new features. The language does not move." Shipping `note=`
32
32
  // under `v0.2.z` would make `figdown 0.2` name two different languages: the one
33
33
  // `v0.2.0` published and the one with `note=`. So the language number moves.
34
- const LANG_VERSIONS = ['0.1', '0.2', '0.3'];
34
+ // `SEQUENCE-SOURCE-STANDARD`-R182: `figdown 0.4` joins the set, and it joins it for the
35
+ // same reason `0.2` did — a GENRE token is language surface, and core §13.0
36
+ // makes added surface a `Y` and never a `Z`. `sequence` is that token. It adds
37
+ // no keyword yet (see GENRES_BY_VERSION below), which is exactly `STATECHART-GENRE-SCOPE`'s shape:
38
+ // the dispatch point lands first and the vocabulary follows it.
39
+ const LANG_VERSIONS = ['0.1', '0.2', '0.3', '0.4'];
35
40
  // Genres per declared language version. `Y` never removes (core §13.0), so
36
41
  // each row is a superset of the one above it, and `figdown 0.1 <anything>`
37
42
  // resolves against exactly the list it resolved against before `STATECHART-GENRE-SCOPE`.
38
43
  const GENRES_BY_VERSION = {
39
44
  '0.1': ['block','topology','flowchart','bitfield','table','timing'],
40
45
  '0.2': ['block','topology','flowchart','bitfield','table','timing','statechart'],
41
- '0.3': ['block','topology','flowchart','bitfield','table','timing','statechart']
46
+ '0.3': ['block','topology','flowchart','bitfield','table','timing','statechart'],
47
+ // `SEQUENCE-SOURCE-STANDARD`-R182: `sequence` is dispatchable from here on. Like
48
+ // `statechart` at `STATECHART-GENRE-SCOPE` it arrived with NO vocabulary of its own, and the
49
+ // consequence was stated here rather than left to be discovered: there was no
50
+ // `GENRE_KW.sequence` row, and the allowlist guard is written
51
+ // `GENRE_KW[doc.genre] && !GENRE_KW[doc.genre].has(kw)`, so a genre with no
52
+ // row is NOT narrowed — a `figdown 0.4 sequence` document could write any
53
+ // registered keyword and it parsed. The document that increment meant to
54
+ // land was the header ALONE.
55
+ // `SEQUENCE-SOURCE-STANDARD`-R182: CLOSED. `GENRE_KW.sequence` exists below, so the
56
+ // genre now constrains what it names — five keywords of its own, `class`,
57
+ // and the genre-free core — and `flow`/`rank`/`group` are line errors under
58
+ // it. The genre still has NO RENDERER: a valid `sequence` document parses to
59
+ // a model and draws an empty canvas, which is the state this increment means
60
+ // to land and is pinned by a fixture rather than left to be noticed.
61
+ '0.4': ['block','topology','flowchart','bitfield','table','timing','statechart','sequence']
42
62
  };
43
63
  // The version an OPTION KEY first becomes legal in — the `CONNECTOR_MIN_VERSION`
44
64
  // device, applied to the option namespace. `DRAWN-ANNOTATION-FORM`: `note=` is gated on the
@@ -346,6 +366,26 @@ const DIRECTIVE_OPTS={
346
366
  // keyed by the surface word an author actually wrote.
347
367
  flowline:['style','class','fill','stroke','label','taillabel','headlabel','note'],
348
368
  transition:['style','class','fill','stroke','label','taillabel','headlabel','note'],
369
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's four own rows. `message` is
370
+ // the fourth connector spelling and takes the connector set — `fill=` and
371
+ // the three retired label keys stay listed for the same reason they are
372
+ // listed on the other three, so their dedicated diagnostics fire instead of
373
+ // a bare `does not take` — plus `in=` (sense 1: the fragment or operand this
374
+ // message occurs inside) and `description=`. It does NOT gain a key of its
375
+ // own: `lost=` was proposed and refused (`UNDELIVERED-MESSAGE-MARKING`), and `OPT_KEYS` is unchanged
376
+ // by this whole increment.
377
+ message:['style','class','fill','stroke','label','taillabel','headlabel','note','in','description'],
378
+ // A lifeline is drawn as a head box over a dashed line, so it has an
379
+ // interior and takes `fill=`. `in=` is sense 1.
380
+ lifeline:['class','fill','stroke','style','in','note','description'],
381
+ // `type=` is MANDATORY on `fragment` and is checked in `parseSeqDirective`,
382
+ // not here: a missing key is not an inapplicable key. No `fill=` — a
383
+ // combined fragment is a FRAME drawn over the messages it contains, and
384
+ // painting its interior would hide them.
385
+ fragment:['type','class','stroke','style','in','note','description'],
386
+ // `in=` is MANDATORY on `operand` (an operand is a compartment OF a
387
+ // fragment) and is likewise checked in `parseSeqDirective`.
388
+ operand:['in','class','stroke','style','note','description'],
349
389
  // `PAINT-ORDER-CONSTRUCT`: the `plane` row is GONE, not emptied — the keyword is
350
390
  // withdrawn from the language, so it has no acceptor row at all, the shape
351
391
  // `path`/`routing` left behind. `z-index=` goes with it: it
@@ -392,6 +432,31 @@ const DIRECTIVE_OPTS={
392
432
  cell:['fill','stroke','class'], width:[],
393
433
  signal:['data','fill','stroke'], gap:[]
394
434
  };
435
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the FIRST genre-conditional option row, and it
436
+ // exists because `DIRECTIVE_OPTS` is keyed by the SURFACE WORD an author
437
+ // wrote, while `GENRE-VOCABULARY-OBLIGATION` makes a surface word a per-genre declaration. Every other
438
+ // shared spelling in the language names the same construct in every genre
439
+ // that has it — `state` under `statechart` IS `node` renamed (`GENRE-NODE-SPELLING`), so it
440
+ // takes `node`'s row exactly — but `state` under `sequence` is a DIFFERENT
441
+ // construct under the same spelling: a StateInvariant (UML 2.5.1 §17.12.25)
442
+ // that REFERENCES a lifeline rather than declaring an id. It has no shape and
443
+ // no extent of its own, so `shape=`, `width=` and `height=` name nothing on
444
+ // it; it can sit inside a fragment, so it takes `in=` (`SEQUENCE-CONTAINMENT-SCOPE`); and it takes
445
+ // `description=` like the rest of this genre's directives.
446
+ //
447
+ // The lookup is one table indexed genre-first, so a genre with no entry falls
448
+ // through to `DIRECTIVE_OPTS` untouched and the whole 0.1/0.2/0.3 surface is
449
+ // byte-identical. It is NOT a second registry: every key named here is
450
+ // already an `OPT_KEYS` member accepted by some directive, so nothing about
451
+ // the closed option namespace changes.
452
+ const GENRE_DIRECTIVE_OPTS={
453
+ sequence:{
454
+ state:['class','fill','stroke','style','in','note','description']
455
+ }
456
+ };
457
+ const directiveOpts=(kw,genre)=>
458
+ (genre && GENRE_DIRECTIVE_OPTS[genre] && GENRE_DIRECTIVE_OPTS[genre][kw])
459
+ || DIRECTIVE_OPTS[kw];
395
460
  const STYLES=['solid','dashed','dotted'];
396
461
  // `RULE-POSITION-ENUMERATION`: every LIVE option key whose value grammar is an enum,
397
462
  // read off spec/vocabulary-sources.tsv (`shape` column = `enum`, `status`
@@ -490,7 +555,7 @@ const RETIRED_OPT_KEYS={
490
555
  // appears.
491
556
  'z-index':'z-index= has been WITHDRAWN with the `plane` keyword (`PAINT-ORDER-CONSTRUCT`): it was legal on `plane` and on nothing else, so it left with its only acceptor. There is no replacement spelling and no other directive to move it to. Delete the key: paint order is document order, and a later line paints on top (MIGRATIONS 0.3)',
492
557
  z:'z= has been WITHDRAWN: it was renamed z-index=, and z-index= was withdrawn with the `plane` keyword (`PAINT-ORDER-CONSTRUCT`) — it was legal on `plane` and on nothing else. There is no replacement spelling. Delete the key: paint order is document order, a later line paints on top (MIGRATIONS 0.3)',
493
- // `note` was HERE (`DESCRIPTION-KEY-SPELLING`) until this release (`DRAWN-ANNOTATION-FORM`), and its
558
+ // `note` was HERE (`DESCRIPTION-KEY-SPELLING`) until 0.3 (`DRAWN-ANNOTATION-FORM`), and its
494
559
  // row is gone because the key is LIVE again — SYNTAX-STYLE RULE 4.9
495
560
  // obligation 3 forbids leaving the retirement message standing past the
496
561
  // revival, on the ground that a message telling an author to write
@@ -558,7 +623,7 @@ const RETIRED_LAYER='layer has been WITHDRAWN: it was renamed plane, and plane w
558
623
  // `THRESHOLD-KEYWORD-SPELLING`: the scene keyword `guide` became `threshold`.
559
624
  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)';
560
625
  // `EXTERNAL-ENDPOINT-NAMING`: the scene keyword `boundary` became `external`.
561
- 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)';
626
+ 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)';
562
627
  // `ROW-BREAK-NAMING`: the `bitfield` child keyword `wrap` became `break`.
563
628
  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)';
564
629
  // `PRESENCE-FLAG-SPELLING`: the 0.1 rename `optional` -> `conditional` (`PRESENCE-FLAG-SPELLING`)
@@ -950,6 +1015,25 @@ const FLOWCHART_SUBJECT_KW=['external'];
950
1015
  // none of the six; `external` is additionally UML 2.5.1 §14's own
951
1016
  // `TransitionKind` literal and is reserved for it (`RESERVED-SPELLINGS`).
952
1017
  const STATECHART_SUBJECT_KW=[];
1018
+ // `sequence` (EXPERIMENTAL, 0.4, `SEQUENCE-SOURCE-STANDARD`-R182): THREE, and the array is
1019
+ // this genre's whole declaration of what a sequence figure is OF. `state` and
1020
+ // `fragment` and `operand` describe referents UML clause 17 defines —
1021
+ // `StateInvariant` (§17.12.25), `CombinedFragment` (§17.12.3) and
1022
+ // `InteractionOperand` (§17.12.14) — so they are subject vocabulary in exactly
1023
+ // `SUBJECT-VOCABULARY-SCOPE`'s sense, while `lifeline` and `message` are this genre's NODE and
1024
+ // CONNECTOR spellings and live in `GENRE_NODE_KW`/`GENRE_CONNECTOR_KW` with
1025
+ // the other genres' (`GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`). None of the six words the four scene genres
1026
+ // declare is here: `group` is REFUSED (`SEQUENCE-PARTICIPANT-GROUPING`, below), `external` `threshold`
1027
+ // `band` `bundle` have no measured need and no clause-17 referent, and
1028
+ // `flow`/`rank` are refused as a CONSEQUENCE of the genre's two axes being
1029
+ // declaration-ordered (draft §7) — they are simply absent from `GENRE_KW`.
1030
+ // `state` is SHARED with `statechart` as a spelling and is a SEPARATE
1031
+ // declaration with a different grammar: there slot 1 declares an id, here it
1032
+ // REFERENCES a lifeline (draft §29 Q5). Two genres agreeing on a spelling is
1033
+ // two declarations that agree, never one inherited — which is the whole of
1034
+ // `SUBJECT-VOCABULARY-SCOPE`, and is why this genre's `state` also takes its own option row
1035
+ // (GENRE_DIRECTIVE_OPTS).
1036
+ const SEQUENCE_SUBJECT_KW=['state','fragment','operand'];
953
1037
  // `FLOWCHART-ROLE-KEYWORDS`: the flowchart ROLE vocabulary — the FIRST exercise of
954
1038
  // `GENRE-NAMESPACE` `GENRE-VOCABULARY-OBLIGATION` ("a genre owns its words"). These three are legal ONLY under
955
1039
  // `figdown 0.1 flowchart`; `GENRE-NAMESPACE`'s allowlist is what makes `decision x` a line
@@ -985,10 +1069,50 @@ const ROLE_SHAPE={process:'box',decision:'diamond',terminator:'rounded'};
985
1069
  // symbol this genre cannot spell is a COVERAGE GAP in FigDown, not a state
986
1070
  // of the figure, and `node` is not its spelling — see
987
1071
  // the project’s working record for the coverage ledger.
988
- const GENRE_NODE_KW={block:'node',topology:'node',flowchart:'node',statechart:'state'};
989
- const GENRE_CONNECTOR_KW={block:'edge',topology:'edge',flowchart:'flowline',statechart:'transition'};
990
- const NODE_SPELLINGS=new Set(['node','state']);
991
- const CONNECTOR_SPELLINGS=new Set(['edge','flowline','transition']);
1072
+ // sequence lifeline message (OMG UML 2.5.1 §17)
1073
+ const GENRE_NODE_KW={block:'node',topology:'node',flowchart:'node',statechart:'state',sequence:'lifeline'};
1074
+ const GENRE_CONNECTOR_KW={block:'edge',topology:'edge',flowchart:'flowline',statechart:'transition',sequence:'message'};
1075
+ const NODE_SPELLINGS=new Set(['node','state','lifeline']);
1076
+ const CONNECTOR_SPELLINGS=new Set(['edge','flowline','transition','message']);
1077
+ // `SEQUENCE-SOURCE-STANDARD`-R182: WHERE EACH GENRE'S NODE ROWS LIVE IN THE PARSED DOC.
1078
+ // `GENRE_NODE_KW` above says what the WORD is; this says which `doc`
1079
+ // collection the parser puts that word's rows in. The two are separate facts
1080
+ // and only one of them is `nodes`: `statechart` renames the word and keeps the
1081
+ // collection (a `state` is a scene node), while `sequence` renames BOTH — a
1082
+ // `lifeline` is not a scene node and lands in `doc.lifelines`. Any GUI test of
1083
+ // the form "is this id a thing this genre declares" has to ask through here.
1084
+ // Hand-writing `doc.nodes` is the defect it closes: the Fill/Delete/Raise/
1085
+ // Lower enablement asked `lastDoc.nodes` and so was permanently false under
1086
+ // `sequence`, greying out four buttons whose edits (`SEQUENCE-SOURCE-STANDARD`-R182) already worked.
1087
+ const GENRE_NODE_COLL={block:'nodes',topology:'nodes',flowchart:'nodes',
1088
+ statechart:'nodes',sequence:'lifelines'};
1089
+ const docNodes=(doc)=>(doc&&doc[GENRE_NODE_COLL[doc.genre]||'nodes'])||[];
1090
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the four `sequence` directives that have their own
1091
+ // parser (`message` rides the connector scanner). The set is what dispatches
1092
+ // to `parseSeqDirective`, and it is scoped by `doc.genre` at the call site so
1093
+ // `state` still reaches `statechart`'s node parser under `statechart`.
1094
+ const SEQ_KW=new Set(['lifeline','state','fragment','operand']);
1095
+ // UML 2.5.1 `InteractionOperatorKind` (§17.12.15.3), taken WHOLE — twelve
1096
+ // values, every one the standard's own single lowercase spelling, so RULE 4.2
1097
+ // admits the abbreviations `alt` `opt` `par` `neg` `seq` unchanged. The clause
1098
+ // number matters: §17.6.2 was cited for this enum in an earlier draft and is
1099
+ // registered FALSE in spec/standards-claims.tsv (S024). FigDown makes the key
1100
+ // MANDATORY where UML gives the attribute a default of `seq`
1101
+ // (`interactionOperator : InteractionOperatorKind [1..1] = seq`, §17.12.3.5) —
1102
+ // a DECLARED divergence: a default would let a fragment assert nothing while
1103
+ // looking like it asserts something, which is the `numbering=` precedent.
1104
+ const SEQ_OPERATORS_FRAG=['alt','opt','loop','par','strict','seq','critical',
1105
+ 'neg','assert','ignore','consider','break'];
1106
+ const SEQ_FRAG_CLAUSE='UML 2.5.1 §17.12.15.3';
1107
+ // Draft §8.2/§15.4: every UML Message has a sendEvent AND a receiveEvent, so
1108
+ // a direction-less message is not a thing this domain has. `--` is a LINE
1109
+ // ERROR under `sequence` and legal in every other genre — the operator set is
1110
+ // per genre for the same reason the keywords are.
1111
+ const SEQ_OPERATORS=new Set(['->','<-','<->']);
1112
+ // Every connector spelling in the language reaches the dedicated scanner —
1113
+ // the WRONG one for the genre must get the named diagnostic, not
1114
+ // `unrecognized line` (`GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`), so the dispatch is over the whole set.
1115
+ const CONN_LINE_RE=new RegExp('^('+[...CONNECTOR_SPELLINGS].join('|')+')(\\s|$)');
992
1116
  // `KEYWORD-RENAME-SCOPE`: the flowchart rename is GATED BY THE DECLARED LANGUAGE
993
1117
  // VERSION, because `GENRE-CONNECTOR-SPELLING` applied it to `figdown 0.1` and that BROKE documents
994
1118
  // legal at v0.1.8 — `figdown 0.1 flowchart` + `edge` stopped parsing, with
@@ -1002,7 +1126,12 @@ const CONNECTOR_SPELLINGS=new Set(['edge','flowline','transition']);
1002
1126
  // forbids; two spellings across VERSIONS is ordinary language evolution, and
1003
1127
  // each version accepts exactly one. `statechart` needs no gate of its own —
1004
1128
  // the GENRE requires 0.2 (GENRES_BY_VERSION), so `state`/`transition` cannot
1005
- // be reached from a 0.1 document at all.
1129
+ // be reached from a 0.1 document at all. `sequence` inherits that argument
1130
+ // unchanged: its genre token requires `figdown 0.4`, so
1131
+ // `lifeline`/`message` are unreachable from any earlier document and there is
1132
+ // no earlier spelling for them to have replaced. A `CONNECTOR_MIN_VERSION`
1133
+ // row for `message` would therefore gate nothing and would make the engine
1134
+ // claim a rename that never happened.
1006
1135
  const GENRE_CONNECTOR_KW_AT={
1007
1136
  '0.1':{block:'edge',topology:'edge',flowchart:'edge'},
1008
1137
  '0.2':GENRE_CONNECTOR_KW
@@ -1025,17 +1154,22 @@ const WORD_WHY={
1025
1154
  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"',
1026
1155
  transition:'the connecting line in a statechart is a TRANSITION — the term UML 2.5.1 §14 uses for it',
1027
1156
  node:'this genre has more kinds of thing than it has words for, so `node` is the general one',
1028
- state:'a statechart has exactly ONE kind of node and it is a STATE (UML 2.5.1 §14)'
1157
+ state:'a statechart has exactly ONE kind of node and it is a STATE (UML 2.5.1 §14)',
1158
+ // `SEQUENCE-SOURCE-STANDARD`-R182: both are WHOLE borrows from the genre's source
1159
+ // standard, verified against the clause text rather than against a
1160
+ // secondary description (spec/standards-claims.tsv).
1161
+ 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',
1162
+ 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'
1029
1163
  };
1030
1164
  // The named diagnostic `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING` owe: it says WHICH word this genre uses and
1031
1165
  // WHY, and it names the migration, because every connector line in a
1032
1166
  // reclassified document has to be rewritten (the cost `GENRE-CONNECTOR-SPELLING` accepted).
1033
1167
  const WRONG_WORD=(surf,want,genre)=>
1034
1168
  '"'+surf+'" is not the word genre '+genre+' uses for this — write "'+want+'": '+WORD_WHY[want]+
1035
- '. Each scene genre takes the term its own domain uses (block/topology `node` `edge`, flowchart `node` `flowline`, statechart `state` `transition`) — run tools/migrate-figdown.js to rewrite it (MIGRATIONS 0.2)';
1169
+ '. 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)';
1036
1170
  // `SCENE-KEYWORD-MEMBERSHIP`: a word WITHDRAWN FROM ONE GENRE is not an unknown word,
1037
1171
  // and `"threshold" is not allowed in genre topology` would send an author
1038
- // looking for a typo. Each cell below was legal until this release and states
1172
+ // looking for a typo. Each cell below was legal until 0.3 and states
1039
1173
  // WHY that genre no longer declares it — the ruling's own ground, per cell,
1040
1174
  // because the grounds differ and a single sentence could not carry them.
1041
1175
  // Every one of these withdrawals was FREE: `topology`, `flowchart` and
@@ -1070,6 +1204,33 @@ const WITHDRAWN_FROM_GENRE=(kw,genre)=>
1070
1204
  GENRE_WITHDRAWN[genre][kw]+
1071
1205
  ' Subject vocabulary is per genre (core §3, `GENRE-VOCABULARY-OBLIGATION`): a spelling accepted by several genres is several '+
1072
1206
  'independent declarations, and this genre\'s was withdrawn without touching any other\'s.'+WITHDREW_AT;
1207
+ // `SEQUENCE-TIME-GAP`/`SEQUENCE-PARTICIPANT-GROUPING`: THE OTHER HALF OF `SCENE-KEYWORD-MEMBERSHIP` — a spelling a genre REFUSED
1208
+ // AT BIRTH. It is a SEPARATE table from `GENRE_WITHDRAWN` and not a sixth row
1209
+ // of it, because the two state different facts and only one of them is a
1210
+ // migration: a WITHDRAWAL took a word away from documents that legally used
1211
+ // it (so the message names the release and the rewrite tool), while a REFUSAL
1212
+ // means the genre never declared the word and no document can have been
1213
+ // written against it. Folding them would make the engine tell a `sequence`
1214
+ // author "it was WITHDRAWN from this genre" about a keyword this genre has
1215
+ // never had, and would date every refusal to a migration that did not happen.
1216
+ // What the two SHARE is the thing `SCENE-KEYWORD-MEMBERSHIP` was for: the author gets the GROUND,
1217
+ // and what to write instead, rather than a spellcheck.
1218
+ //
1219
+ // The refusal is FREE in the `EDGE-GEOMETRY-CONSTRUCTS` sense — `sequence` is EXPERIMENTAL and this
1220
+ // is the release that gives it a vocabulary at all, so no document loses a
1221
+ // line. Each cell states its own ruling's ground, because the grounds differ.
1222
+ const REFUSED_IN={
1223
+ sequence:{
1224
+ 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.',
1225
+ 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.'
1226
+ }
1227
+ };
1228
+ const REFUSED_AT=' (refused; MIGRATIONS 0.4)';
1229
+ const REFUSED_IN_GENRE=(kw,genre)=>
1230
+ '"'+kw+'" is not allowed in genre '+genre+' — this genre REFUSED it, it is not a typo and not a withdrawal: '+
1231
+ REFUSED_IN[genre][kw]+
1232
+ ' Subject vocabulary is per genre (core §3, `GENRE-VOCABULARY-OBLIGATION`), so a spelling another genre declares is that genre\'s '+
1233
+ 'declaration and never this one\'s.'+REFUSED_AT;
1073
1234
  // `MEMBERSHIP-KEY-ACCEPTANCE`: THE OPTION-KEY HALF OF `SCENE-KEYWORD-MEMBERSHIP`. A per-genre withdrawal can
1074
1235
  // strand an option KEY as easily as it strands a keyword: `in=` states
1075
1236
  // membership and its ONLY value domain is the id of a containing `group`, so
@@ -1105,6 +1266,25 @@ const WITHDRAWN_OPT_FROM_GENRE=(key,genre)=>
1105
1266
  ' An option key is per genre for the same reason a keyword is (core §3, `GENRE-VOCABULARY-OBLIGATION`): the key is accepted '+
1106
1267
  'by the directive AND by the genre, and this genre\'s acceptance was withdrawn without touching any other\'s.'+
1107
1268
  WITHDREW_OPT_AT;
1269
+ // `UNDELIVERED-MESSAGE-MARKING`: the OPTION-KEY half of the refusal table, and the one
1270
+ // place in this engine where a diagnostic fires for a key that is NOT in
1271
+ // `OPT_KEYS`. That is deliberate and is the ruling's headline: `lost=` was
1272
+ // proposed and REFUSED, so registering the spelling to get a named message
1273
+ // would put the key in the language's closed option registry — the exact
1274
+ // thing the ruling declines — and a reader counting `OPT_KEYS` would find a
1275
+ // key no directive accepts. So the refusal rides the UNKNOWN-OPTION path
1276
+ // instead: `badOpts` and the connector scanner both consult this table before
1277
+ // they say `unknown option`, which costs one lookup and adds no surface.
1278
+ const REFUSED_OPT_IN={
1279
+ sequence:{
1280
+ 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.'
1281
+ }
1282
+ };
1283
+ const REFUSED_OPT_IN_GENRE=(key,genre)=>
1284
+ key+'= is not allowed in genre '+genre+' — this genre REFUSED the key, it is not a typo and not a withdrawal: '+
1285
+ REFUSED_OPT_IN[genre][key]+
1286
+ ' The spelling is not in the language\'s option registry at all, so no other genre accepts it either.'+
1287
+ REFUSED_AT;
1108
1288
  const GENRE_KW={
1109
1289
  block:new Set(SCENE_HOST_KW.concat(BLOCK_SUBJECT_KW, ['node','edge'])),
1110
1290
  topology:new Set(SCENE_HOST_KW.concat(TOPOLOGY_SUBJECT_KW, ['node','edge'])),
@@ -1123,7 +1303,28 @@ const GENRE_KW={
1123
1303
  bitfield:new Set(GENRE_FREE_KW.concat(['class','bitfield'])),
1124
1304
  // chart is experimental and attaches to a table id in the same document
1125
1305
  table:new Set(GENRE_FREE_KW.concat(['class','table','chart'])),
1126
- timing:new Set(GENRE_FREE_KW.concat(['class','timing']))
1306
+ timing:new Set(GENRE_FREE_KW.concat(['class','timing'])),
1307
+ // `SEQUENCE-SOURCE-STANDARD`-R182: `sequence` gets its row, and the row is what
1308
+ // closes the Batch-1 finding that the genre "states a reading and constrains
1309
+ // nothing". It is NOT built on `SCENE_HOST_KW`, and the three absences are
1310
+ // each a decision rather than an oversight:
1311
+ // - `flow` and `rank` are REFUSED. A sequence figure's two axes are BOTH
1312
+ // declaration-ordered — lifelines left-to-right in declaration order,
1313
+ // occurrences top-to-bottom in declaration order (draft §7) — so a key
1314
+ // that reverses or re-ranks a drawing would make the picture disagree
1315
+ // with the source, which `DECLARATION-ORDER-SEMANTICS` forbids. There is nothing for them to set.
1316
+ // - `group` is REFUSED (`SEQUENCE-PARTICIPANT-GROUPING`, `REFUSED_IN` above).
1317
+ // - the REGION openers `bitfield`/`table`/`timing`/`chart` are absent
1318
+ // because this genre has no scene to compose them into: `GENRE-COMPOSITION` composition
1319
+ // stacks a region OUTSIDE the scene, and a ladder has no outside yet.
1320
+ // They are absent, not refused — a `sequence` document that wanted a
1321
+ // register layout beside its exchange is a real want with no ruling, and
1322
+ // it stays an open question rather than a silent no.
1323
+ // What is left is the genre-free core, `class`, this genre's two spellings
1324
+ // and its three subject words — nine top-level keywords, and that is the
1325
+ // whole of what a `figdown 0.4 sequence` document may write.
1326
+ sequence:new Set(GENRE_FREE_KW.concat(['class'], SEQUENCE_SUBJECT_KW,
1327
+ ['lifeline','message']))
1127
1328
  };
1128
1329
  const CHILD_KW=new Set(['field','break','cell','width','signal','gap']);
1129
1330
 
@@ -1147,6 +1348,59 @@ function splitFigdownSections(text){
1147
1348
  return secs;
1148
1349
  }
1149
1350
 
1351
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's DERIVED reading, in one
1352
+ // function so the parser's checks and (from a later increment) the renderer
1353
+ // cannot disagree about what a document says.
1354
+ //
1355
+ // Two things are derived and nothing else is:
1356
+ // ROWS — the figure's total order. `messages` and `states` are the two
1357
+ // collections that carry an occurrence; each element records the
1358
+ // source `line` it was written on, and sorting the union of the two
1359
+ // on that number IS the order (draft §31: declaration order, and
1360
+ // the order is TOTAL — a declared divergence from UML's partial
1361
+ // order, taken so a reader never has to compute one).
1362
+ // CHAIN — the containment tree from `in=`. A fragment or an operand may
1363
+ // name a fragment or an operand, so the chain alternates in
1364
+ // practice but the walk does not assume it.
1365
+ // EXTENT is the span of row slots a container owns, transitively: an occurrence
1366
+ // inside an operand is inside that operand's fragment too. `cycles` names any
1367
+ // container that reaches itself, so a caller can refuse to read a tree that is
1368
+ // not one instead of walking it to a guard.
1369
+ function seqModel(doc){
1370
+ const rows=[];
1371
+ for(const m of doc.messages||[]) rows.push({kind:'message',el:m,line:m.line});
1372
+ for(const s of doc.states||[]) rows.push({kind:'state', el:s,line:s.line});
1373
+ rows.sort((a,b)=>a.line-b.line);
1374
+ rows.forEach((r,i)=>{ r.slot=i; });
1375
+ const cont={};
1376
+ for(const f of doc.fragments||[]) cont[f.id]={kind:'fragment',el:f,parent:f['in']||null};
1377
+ for(const o of doc.operands||[]) cont[o.id]={kind:'operand', el:o,parent:o['in']||null};
1378
+ const cycles=[];
1379
+ const chain=(id)=>{
1380
+ const out=[], seen=new Set();
1381
+ let c=id;
1382
+ while(c && cont[c]){
1383
+ if(seen.has(c)) break;
1384
+ seen.add(c); out.push(c); c=cont[c].parent;
1385
+ }
1386
+ return out;
1387
+ };
1388
+ for(const id in cont){
1389
+ const seen=new Set(); let c=cont[id].parent;
1390
+ while(c && cont[c] && !seen.has(c)){ if(c===id){ cycles.push(id); break; } seen.add(c); c=cont[c].parent; }
1391
+ }
1392
+ const owned={}; for(const id in cont) owned[id]=[];
1393
+ for(const r of rows){
1394
+ const inId=r.el['in']||null;
1395
+ r.chain=inId?chain(inId):[];
1396
+ for(const id of r.chain) if(owned[id]) owned[id].push(r.slot);
1397
+ }
1398
+ const extent={};
1399
+ for(const id in cont)
1400
+ extent[id]=owned[id].length?{lo:Math.min(...owned[id]),hi:Math.max(...owned[id])}:null;
1401
+ return {rows,cont,owned,extent,chain,cycles};
1402
+ }
1403
+
1150
1404
  // parse(text) -> {doc, errs, docs}
1151
1405
  // Single-section: docs=[doc] (backward-compatible doc/errs).
1152
1406
  // Multi-section: one doc per figdown header; errs use full-file line numbers;
@@ -1167,6 +1421,11 @@ function parse(text){
1167
1421
  const docs=[]; const errs=[];
1168
1422
  for(const sec of secs){
1169
1423
  const r=parseOne(sec.text);
1424
+ // A section's element `.line` values are section-local, and a GEOMETRY-time
1425
+ // error (one only `render` can raise) is built long after this loop has
1426
+ // finished re-basing the parse messages. Record the offset on the doc so
1427
+ // that error can quote the same full-file line the author is looking at.
1428
+ r.doc.lineOffset=sec.startLine-1;
1170
1429
  for(const e of r.errs){
1171
1430
  const m=/^Line (\d+): (.*)$/.exec(e);
1172
1431
  if(m) errs.push('Line '+(+m[1]+sec.startLine-1)+': '+m[2]);
@@ -1187,7 +1446,27 @@ function parseOne(text){
1187
1446
  // a distinction the model must keep.
1188
1447
  const doc={title:null,note:null,nodes:[],groups:[],edges:[],planes:[{id:'base',label:null,z:0}],
1189
1448
  flow:'right',ranks:[],pins:{},blocks:[],trunks:[],thresholds:[],bands:[],
1190
- classes:[],boundaries:[]};
1449
+ classes:[],boundaries:[],
1450
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` model. FIVE collections,
1451
+ // one per keyword, and they are separate arrays rather than
1452
+ // aliases of `nodes`/`edges` because a message is not an edge: an
1453
+ // edge is a relation between two nodes and has no position, while
1454
+ // a message is an OCCURRENCE with a place in a total order (draft
1455
+ // §31 — order is declaration order and it is TOTAL). Folding them
1456
+ // into `nodes`/`edges` would let the scene renderer reach them
1457
+ // and would put a time-ordered thing in a collection whose model
1458
+ // says order is not meaning.
1459
+ //
1460
+ // The ORDER of `messages`, `states` and the containment they
1461
+ // declare is recovered from the `line` each element carries: the
1462
+ // parser appends in source order, so each array is already
1463
+ // ordered and the union of `messages` and `states` sorted on
1464
+ // `line` is the figure's trace. Nothing else records time.
1465
+ //
1466
+ // These stay EMPTY in every non-`sequence` document, and the
1467
+ // canonical JSON binding omits an empty one (the `externals`
1468
+ // rule), so no existing golden moves a byte.
1469
+ lifelines:[],messages:[],states:[],fragments:[],operands:[]};
1191
1470
  const nodeIds=new Set(), groupIds=new Set(), planeIds=new Set(['base']), classIds=new Set(),
1192
1471
  bundleIds=new Set(), boundaryIds=new Set(), blockIds=new Set();
1193
1472
  // §1: "IDs are ... unique per document" — nodes, groups, boundaries AND the
@@ -1310,17 +1589,40 @@ function parseOne(text){
1310
1589
  if(idHere()){ err(n,ID_RULE); return; }
1311
1590
  const tk2=tokenize(s.slice(i).trim());
1312
1591
  if(tk2.error){ err(n,tk2.error); return; }
1313
- const {pos:p2,opts:o2,optT:oT2,unk:u2,dup:d2}=splitOpts(tk2.toks);
1592
+ const {pos:p2,posq:pq2,opts:o2,optT:oT2,unk:u2,dup:d2}=splitOpts(tk2.toks);
1314
1593
  if(d2){ err(n,'duplicate option "'+d2+'=" on one line'); return; }
1315
- if(u2.length){ err(n,'unknown option "'+u2[0]+'="'); return; }
1316
- if(p2.length){ err(n,'unexpected argument "'+p2[0]+'"'); return; }
1594
+ // `UNDELIVERED-MESSAGE-MARKING`: the connector's copy of the genre REFUSAL check for
1595
+ // an option key the language does not register. It sits on the
1596
+ // unknown-option path because `lost=` is not in `OPT_KEYS` — see
1597
+ // `REFUSED_OPT_IN` — and it must be here as well as in `badOpts` because
1598
+ // `message` is scanned by this function and never reaches that one.
1599
+ if(u2.length){
1600
+ const ro=(doc.genre&&REFUSED_OPT_IN[doc.genre])||null;
1601
+ if(ro && ro[u2[0]]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u2[0],doc.genre));
1602
+ else err(n,'unknown option "'+u2[0]+'="');
1603
+ return; }
1604
+ // `SEQUENCE-SOURCE-STANDARD`-R182: `message` adds ONE trailing positional — the
1605
+ // quoted message label — to the connector grammar, because that is where
1606
+ // every sequence source in the corpus writes it and the `[mid]` form
1607
+ // reads as an annotation rather than as the message itself. Every other
1608
+ // connector spelling keeps the grammar unchanged, so the surplus-argument
1609
+ // error is still the right answer for them.
1610
+ let seqLabel=null;
1611
+ if(kw==='message'){
1612
+ if(p2.length>1){ err(n,'unexpected argument "'+p2[1]+'"'); return; }
1613
+ if(p2.length===1){
1614
+ if(!pq2[0]){ err(n,'message label must be quoted: message '+a+' '+op+' '+b+' "'+p2[0]+'" — '+Q_WHY); return; }
1615
+ if(mid!==null){ err(n,'message has two labels — the inline -['+mid+']-> mid-label and the trailing "'+p2[0]+'". Write one'); return; }
1616
+ seqLabel=p2[0];
1617
+ }
1618
+ } else if(p2.length){ err(n,'unexpected argument "'+p2[0]+'"'); return; }
1317
1619
  // 0.1: `edge` has its own scanner, so the language-wide retired
1318
1620
  // keys need their own check here or `edge` would be the one directive
1319
1621
  // that reports the generic message for a retired spelling.
1320
1622
  for(const rk in RETIRED_OPT_KEYS)
1321
1623
  if(o2[rk]!==undefined){ err(n,RETIRED_OPT_KEYS[rk]); return; }
1322
1624
  for(const k in o2)
1323
- if(!DIRECTIVE_OPTS[kw].includes(k)){ err(n,kw+' does not take '+k+'='); return; }
1625
+ if(!directiveOpts(kw,doc.genre).includes(k)){ err(n,kw+' does not take '+k+'='); return; }
1324
1626
  for(const k of ['label','taillabel','headlabel'])
1325
1627
  if(o2[k]!==undefined){ err(n,k+'= is retired — write the label inline: '+kw+' A [tail] -[mid]-> [head] B (MIGRATIONS 0.1)'); return; }
1326
1628
  if(o2.fill!==undefined){ err(n,FILL_NO_INTERIOR(kw)); return; }
@@ -1358,6 +1660,38 @@ function parseOne(text){
1358
1660
  if(!pc.ok){ err(n,pc.err); return; }
1359
1661
  ecls=pc.ids;
1360
1662
  }
1663
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` fork. It is the SAME scanner with
1664
+ // a different reading, and the model it writes is a different collection:
1665
+ // a message is an OCCURRENCE with a place in the figure's total order
1666
+ // (draft §31), an edge is a relation with no position at all.
1667
+ if(kw==='message'){
1668
+ // Draft §8.2/§15.4. `--` parses everywhere else and is a line error
1669
+ // here, so the check is at the fork rather than in the operator scanner.
1670
+ if(!SEQ_OPERATORS.has(op)){
1671
+ 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 <->');
1672
+ return; }
1673
+ if(o2['in']!==undefined){
1674
+ const e=idErr(o2['in'], optHasQ(oT2,'in'), null);
1675
+ if(e){ err(n,e); return; }
1676
+ }
1677
+ if(o2.description!==undefined && !optQ(oT2,'description')){
1678
+ err(n,'description= must be quoted: description="'+o2.description+'" — '+Q_WHY); return; }
1679
+ // ONE label field, fed by either spelling. The shared scanner also
1680
+ // accepts the inline `-[mid]->` form, and the check above makes writing
1681
+ // both a line error, so the model can never hold two — but the model
1682
+ // must not hold the same text under two keys either, so `mid` is NOT
1683
+ // projected beside `label` here the way it is on an edge. That the two
1684
+ // spellings both reach this field at all is an ALIAS in `IDENTITY-ASSERTION`'s sense
1685
+ // and is a FINDING for the genre document (Batch 5) rather than a
1686
+ // ruling taken here: the draft settles the trailing form and says
1687
+ // nothing about the brackets. `[tail]` and `[head]` are kept — they are
1688
+ // different positions, not a second spelling of the same one.
1689
+ doc.messages.push({a,b,op,tail,head,
1690
+ label:seqLabel!==null?seqLabel:mid,
1691
+ style:o2.style,cls:ecls,stroke:o2.stroke,note:o2.note,
1692
+ desc:o2.description,in:o2['in']||null,line:n});
1693
+ return;
1694
+ }
1361
1695
  // §5 on an edge: the line IS a stroke and has no interior, so `stroke=`
1362
1696
  // and `fill=` name the same channel (`stroke=` wins when both are
1363
1697
  // written); `text=` colours the [tail]/[mid]/[head] labels.
@@ -1366,6 +1700,94 @@ function parseOne(text){
1366
1700
  plane:o2.plane||'base',line:n});
1367
1701
  }
1368
1702
 
1703
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the four `sequence` directives that are not the
1704
+ // connector. Reached only from the `doc.genre==='sequence'` dispatch, so
1705
+ // `state` under `statechart` never arrives here. Every option-VALUE check
1706
+ // (colours, `style=` enum, `class=` list, `in=` id spelling, `note=` and
1707
+ // `description=` quoting, `type=`'s bare-value rule) has already run in
1708
+ // `badOpts`; what is left is arity, mandatory arguments, and this genre's
1709
+ // own enum.
1710
+ const seqCls=(opts,optT)=>opts['class']!==undefined
1711
+ ? parseClassList(opts['class'],optList(optT,'class')).ids : undefined;
1712
+ function parseSeqDirective(kw,n,pos,posq,opts,optT){
1713
+ if(kw==='lifeline'){
1714
+ // `lifeline <id> ["label"]` — the participant column. It DECLARES an id
1715
+ // and joins the document-wide id namespace, so a lifeline cannot share
1716
+ // a spelling with a fragment or an operand.
1717
+ const id=pos[1];
1718
+ const e=idErr(id,!!posq[1],'lifeline needs <id> ["label"]');
1719
+ if(e){ err(n,e); return; }
1720
+ if(dupId(id)||doc.lifelines.some(l=>l.id===id)){ err(n,'duplicate id "'+id+'"'); return; }
1721
+ if(BLK_LBL(n,'lifeline',id,pos,posq)) return;
1722
+ if(pos[3]!==undefined){ err(n,'unexpected argument "'+pos[3]+'"'); return; }
1723
+ nodeIds.add(id);
1724
+ doc.lifelines.push({id,label:pos[2]!==undefined?pos[2]:null,in:opts['in']||null,
1725
+ cls:seqCls(opts,optT),fill:opts.fill,stroke:opts.stroke,
1726
+ style:opts.style,note:opts.note,desc:opts.description,line:n});
1727
+ return;
1728
+ }
1729
+ if(kw==='state'){
1730
+ // `state <lifeline-id> "<state name>"` — a StateInvariant (UML 2.5.1
1731
+ // §17.12.25). Slot 1 REFERENCES a lifeline; it does NOT declare an id,
1732
+ // because nothing in this genre refers to a state occurrence. That is
1733
+ // the asymmetry with `statechart`'s `state`, where slot 1 declares
1734
+ // (draft §29, Q5), and it is why the two share a spelling and nothing
1735
+ // else. Slot 2 is MANDATORY: a state occurrence with no name asserts
1736
+ // nothing at all.
1737
+ const ref=pos[1];
1738
+ const e=idErr(ref,!!posq[1],'state needs <lifeline-id> "<state name>"');
1739
+ if(e){ err(n,e); return; }
1740
+ 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; }
1741
+ if(!posq[2]){ err(n,'state name must be quoted: state '+ref+' "'+pos[2]+'" — '+Q_WHY); return; }
1742
+ if(pos[3]!==undefined){ err(n,'unexpected argument "'+pos[3]+'"'); return; }
1743
+ doc.states.push({ref,name:pos[2],in:opts['in']||null,
1744
+ cls:seqCls(opts,optT),fill:opts.fill,stroke:opts.stroke,
1745
+ style:opts.style,note:opts.note,desc:opts.description,line:n});
1746
+ return;
1747
+ }
1748
+ if(kw==='fragment'){
1749
+ // `fragment <id> ["label"] type=<operator>` — a CombinedFragment
1750
+ // (§17.12.3). `type=` is MANDATORY where UML defaults it to `seq`: a
1751
+ // fragment with no interaction operator draws a box that asserts
1752
+ // nothing, and a default would make the box look like an assertion.
1753
+ const id=pos[1];
1754
+ const e=idErr(id,!!posq[1],'fragment needs <id> ["label"] type=<operator>');
1755
+ if(e){ err(n,e); return; }
1756
+ if(dupId(id)||doc.fragments.some(f=>f.id===id)||doc.operands.some(o=>o.id===id)){
1757
+ err(n,'duplicate id "'+id+'"'); return; }
1758
+ if(BLK_LBL(n,'fragment',id,pos,posq)) return;
1759
+ if(pos[3]!==undefined){ err(n,'unexpected argument "'+pos[3]+'"'); return; }
1760
+ if(opts.type===undefined){
1761
+ err(n,'fragment needs type=<operator> — a fragment with no interaction operator asserts nothing ('+
1762
+ 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; }
1763
+ if(!SEQ_OPERATORS_FRAG.includes(opts.type)){
1764
+ err(n,'unknown interaction operator "'+opts.type+'" — write one of '+
1765
+ SEQ_OPERATORS_FRAG.join('|')+' ('+SEQ_FRAG_CLAUSE+' InteractionOperatorKind, taken whole)'); return; }
1766
+ doc.fragments.push({id,label:pos[2]!==undefined?pos[2]:null,type:opts.type,
1767
+ in:opts['in']||null,cls:seqCls(opts,optT),stroke:opts.stroke,
1768
+ style:opts.style,note:opts.note,desc:opts.description,line:n});
1769
+ return;
1770
+ }
1771
+ if(kw==='operand'){
1772
+ // `operand <id> ["guard"] in=<fragment-id>` — an InteractionOperand
1773
+ // (§17.12.14). `in=` is MANDATORY: an operand is a COMPARTMENT OF a
1774
+ // fragment and has no meaning apart from one.
1775
+ const id=pos[1];
1776
+ const e=idErr(id,!!posq[1],'operand needs <id> ["guard"] in=<fragment-id>');
1777
+ if(e){ err(n,e); return; }
1778
+ if(dupId(id)||doc.fragments.some(f=>f.id===id)||doc.operands.some(o=>o.id===id)){
1779
+ err(n,'duplicate id "'+id+'"'); return; }
1780
+ if(BLK_LBL(n,'operand',id,pos,posq)) return;
1781
+ if(pos[3]!==undefined){ err(n,'unexpected argument "'+pos[3]+'"'); return; }
1782
+ if(opts['in']===undefined){
1783
+ 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; }
1784
+ doc.operands.push({id,label:pos[2]!==undefined?pos[2]:null,in:opts['in'],
1785
+ cls:seqCls(opts,optT),stroke:opts.stroke,style:opts.style,
1786
+ note:opts.note,desc:opts.description,line:n});
1787
+ return;
1788
+ }
1789
+ }
1790
+
1369
1791
  for(let li=0; li<lines.length; li++){
1370
1792
  const n=li+1;
1371
1793
  let raw=lines[li];
@@ -1440,7 +1862,11 @@ function parseOne(text){
1440
1862
  // the dispatch cannot be narrowed to the genre's own word — a `flowline`
1441
1863
  // under `block` would then fall through to `unrecognized line`, which is
1442
1864
  // exactly the answer these rulings owe an author better than.
1443
- const mConn=/^(edge|flowline|transition)(\s|$)/.exec(raw.trim());
1865
+ // `SEQUENCE-SOURCE-STANDARD`-R182: `message` is the FOURTH spelling scanned here,
1866
+ // and it is derived from `CONNECTOR_SPELLINGS` rather than spelled again,
1867
+ // so a fifth genre's connector reaches the named diagnostic by joining
1868
+ // that set and not by remembering to edit a regex.
1869
+ const mConn=CONN_LINE_RE.exec(raw.trim());
1444
1870
  if(mConn){
1445
1871
  const ckw=mConn[1];
1446
1872
  if(firstContent){ firstContent=false; err(n,'first line must be "figdown 0.1 <genre>"'); }
@@ -1482,12 +1908,25 @@ function parseOne(text){
1482
1908
  // Directives not in DIRECTIVE_OPTS (title's single quoted string, unknown
1483
1909
  // keywords) are handled by their own paths.
1484
1910
  const badOpts=(k)=>{
1485
- const allowed=DIRECTIVE_OPTS[k];
1911
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the row is read GENRE-FIRST. `state` is one
1912
+ // spelling with two declarations and two option sets (`GENRE-VOCABULARY-OBLIGATION`), and
1913
+ // `GENRE_DIRECTIVE_OPTS` is the only place that fact is recorded.
1914
+ const allowed=directiveOpts(k,doc.genre);
1486
1915
  if(!allowed) return false;
1487
1916
  let bad=false;
1488
1917
  // same-line repeated option key (last-wins was silent data loss)
1489
1918
  if(dup){ err(n,'duplicate option "'+dup+'=" on one line'); bad=true; }
1490
- for(const u of unk){ err(n,'unknown option "'+u+'="'); bad=true; }
1919
+ // `UNDELIVERED-MESSAGE-MARKING`: a genre REFUSAL of a key the language never
1920
+ // registered is answered here, on the unknown-option path, because that
1921
+ // is the only path it can reach: `lost=` is deliberately absent from
1922
+ // `OPT_KEYS` (the ruling adds no option key), so `splitOpts` reports it
1923
+ // as unknown. Checked BEFORE the generic message so the author gets the
1924
+ // ground and the replacement spelling instead of a spellcheck.
1925
+ const roOpt=(doc.genre&&REFUSED_OPT_IN[doc.genre])||null;
1926
+ for(const u of unk){
1927
+ if(roOpt && roOpt[u]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u,doc.genre));
1928
+ else err(n,'unknown option "'+u+'="');
1929
+ bad=true; }
1491
1930
  // `MEMBERSHIP-KEY-ACCEPTANCE`: the PER-GENRE option-key withdrawal, checked here —
1492
1931
  // after `unknown option`, so a key the LANGUAGE does not have keeps its
1493
1932
  // own answer, and before every value check, so a withdrawn key is never
@@ -1982,6 +2421,19 @@ function parseOne(text){
1982
2421
  // Dynamic-profile reserved words keep their dedicated message (before `GENRE-KEYWORD-ALLOWLIST`).
1983
2422
  if(kw==='page'||kw==='set'||kw==='pulse'){
1984
2423
  err(n,'"'+kw+'" is reserved for the dynamic profile (not in v0.1)'); continue; }
2424
+ // `SEQUENCE-TIME-GAP`: a genre REFUSAL is consulted BEFORE the typed-block
2425
+ // child exemption below, because `gap` is BOTH — `timing`'s child keyword
2426
+ // and the one spelling `sequence` refused that is also a child word. Left
2427
+ // in the old order, `gap "T1 fires"` in a sequence document answered
2428
+ // `"gap" is a typed-block child — it needs a bitfield/table/timing block
2429
+ // above it`, which is TRUE OF THE LANGUAGE and says nothing about the
2430
+ // ruling the author has actually run into. This clause fires only for a
2431
+ // spelling that is a child keyword AND refused by this genre, so no other
2432
+ // genre's answer moves; every other refused or withdrawn word reaches its
2433
+ // own message through the ordinary chain below.
2434
+ if(sawHeader && doc.genre && CHILD_KW.has(kw) &&
2435
+ REFUSED_IN[doc.genre] && REFUSED_IN[doc.genre][kw]){
2436
+ err(n, REFUSED_IN_GENRE(kw, doc.genre)); continue; }
1985
2437
  // `GENRE-KEYWORD-ALLOWLIST`: after closing a typed region, top-level keywords
1986
2438
  // must be in the header genre allowlist. Child keywords still use the
1987
2439
  // "needs a bitfield/table/timing above" path when they appear with no cur.
@@ -1999,6 +2451,11 @@ function parseOne(text){
1999
2451
  // needs the ground, not a spellcheck.
2000
2452
  else if(GENRE_WITHDRAWN[doc.genre] && GENRE_WITHDRAWN[doc.genre][kw])
2001
2453
  err(n, WITHDRAWN_FROM_GENRE(kw, doc.genre));
2454
+ // `SEQUENCE-TIME-GAP`/`SEQUENCE-PARTICIPANT-GROUPING`: and one step further again. A word this genre
2455
+ // REFUSED is not an unknown word either, and it is not a withdrawal —
2456
+ // the author needs the ruling's ground and the spelling that works.
2457
+ else if(REFUSED_IN[doc.genre] && REFUSED_IN[doc.genre][kw])
2458
+ err(n, REFUSED_IN_GENRE(kw, doc.genre));
2002
2459
  else
2003
2460
  err(n,'"'+kw+'" is not allowed in genre '+doc.genre);
2004
2461
  continue;
@@ -2011,6 +2468,13 @@ function parseOne(text){
2011
2468
  continue;
2012
2469
  }
2013
2470
 
2471
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's four own directives.
2472
+ // Dispatched BEFORE the switch and scoped by `doc.genre`, which is what
2473
+ // keeps `state` reaching `statechart`'s node parser under `statechart` —
2474
+ // `GENRE-VOCABULARY-OBLIGATION` in the dispatcher, not only in the allowlist.
2475
+ if(doc.genre==='sequence' && SEQ_KW.has(kw)){
2476
+ parseSeqDirective(kw,n,pos,posq,opts,optT); continue; }
2477
+
2014
2478
  switch(kw){
2015
2479
  case 'title': {
2016
2480
  if(sawTitle){ err(n,'duplicate title line'); break; }
@@ -2512,6 +2976,117 @@ function parseOne(text){
2512
2976
  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)');
2513
2977
  if(e.plane && !planeIds.has(e.plane)) errs.push('Line '+e.line+': unknown plane "'+e.plane+'"');
2514
2978
  }
2979
+ // ── `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's semantic checks ────────
2980
+ // Everything here needs the WHOLE document, so none of it can live in
2981
+ // `parseSeqDirective`: forward references are legal (fixture 021 pins that
2982
+ // for the scene genres and the rule is language-wide), so an id can only be
2983
+ // resolved once every declaration has been read.
2984
+ if(doc.genre==='sequence'){
2985
+ const llIds=new Set(doc.lifelines.map(l=>l.id));
2986
+ const fragIds=new Set(doc.fragments.map(f=>f.id));
2987
+ const opIds=new Set(doc.operands.map(o=>o.id));
2988
+ // The `in=` OBJECT rule (`SEQUENCE-CONTAINMENT-SCOPE`), in one place because it is one rule: on
2989
+ // all five acceptors `in=` is sense 1 — *the element this one lives
2990
+ // inside* — and its value domain is a `fragment` or an `operand` id and
2991
+ // nothing else. The message names the domain AND the acceptor list,
2992
+ // because an author who wrote a lifeline id there has the relation right
2993
+ // and the object wrong, and needs to be told which.
2994
+ const IN_ACCEPTORS='message, operand, lifeline, state and fragment';
2995
+ const inErr=(line,val,what)=>{
2996
+ if(fragIds.has(val)||opIds.has(val)) return;
2997
+ errs.push('Line '+line+': unknown fragment or operand "'+val+'" — in= on a '+what+
2998
+ ' names the fragment or operand this '+what+' occurs inside'+
2999
+ (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':'')+
3000
+ '. 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)');
3001
+ };
3002
+ for(const l of doc.lifelines) if(l['in']) inErr(l.line,l['in'],'lifeline');
3003
+ for(const m of doc.messages){
3004
+ if(!llIds.has(m.a)) errs.push('Line '+m.line+': unknown lifeline "'+m.a+'"');
3005
+ if(!llIds.has(m.b)) errs.push('Line '+m.line+': unknown lifeline "'+m.b+'"');
3006
+ if(m['in']) inErr(m.line,m['in'],'message');
3007
+ }
3008
+ for(const st of doc.states){
3009
+ 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)');
3010
+ if(st['in']) inErr(st.line,st['in'],'state');
3011
+ }
3012
+ for(const f of doc.fragments) if(f['in']) inErr(f.line,f['in'],'fragment');
3013
+ // An operand's `in=` is MANDATORY and its object is narrower than the
3014
+ // general rule: a compartment belongs to a FRAGMENT, never to another
3015
+ // compartment, so an operand id there is a specific mistake with a
3016
+ // specific answer.
3017
+ for(const o of doc.operands)
3018
+ if(!fragIds.has(o['in']))
3019
+ errs.push('Line '+o.line+': unknown fragment "'+o['in']+'" — operand in= names a FRAGMENT'+
3020
+ (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)'
3021
+ :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'
3022
+ :''));
3023
+ // Two CONSECUTIVE `state` lines naming the same lifeline and the same
3024
+ // state name are a line error (draft §23.2). A state that has not changed
3025
+ // is never restated, so a genuine duplicate is always a mistake — and
3026
+ // because a transition is DERIVED from an adjacent pair, a reader
3027
+ // "tidying duplicates" could otherwise silently delete a fact.
3028
+ const lastState={};
3029
+ for(const st of doc.states.slice().sort((a,b)=>a.line-b.line)){
3030
+ if(lastState[st.ref]===st.name)
3031
+ errs.push('Line '+st.line+': lifeline "'+st.ref+'" is already in state "'+st.name+
3032
+ '" — 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)');
3033
+ lastState[st.ref]=st.name;
3034
+ }
3035
+ const SM=seqModel(doc);
3036
+ // A containment CYCLE is checked before anything that walks the chain, or
3037
+ // the walk terminates on a guard and every downstream answer is arbitrary.
3038
+ for(const id of SM.cycles)
3039
+ errs.push('Line '+SM.cont[id].el.line+': '+SM.cont[id].kind+' "'+id+
3040
+ '" is inside itself — in= containment is a tree, and a cycle denotes nothing at all');
3041
+ if(!SM.cycles.length){
3042
+ // THE ONE-LEVEL NESTING CAP (`SEQUENCE-CONTAINMENT-SCOPE`). A fragment may sit inside an
3043
+ // operand of ONE enclosing fragment and no deeper. The cap is taken on
3044
+ // the v0.1 `group` precedent — "one level is the whole of v0.1's
3045
+ // containment" (core §2.2, and the diagnostic `group does not take
3046
+ // in=`) — and it is a SCOPE decision, not a principle: a second level
3047
+ // lands on measured need, the same evidence any other cell needs.
3048
+ for(const f of doc.fragments){
3049
+ const anc=SM.chain(f['in']).filter(id=>SM.cont[id].kind==='fragment');
3050
+ if(anc.length>1)
3051
+ errs.push('Line '+f.line+': fragment "'+f.id+'" nests '+anc.length+
3052
+ ' 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=');
3053
+ }
3054
+ // CONTIGUITY (draft §28.1). A fragment's or operand's members must be a
3055
+ // CONTIGUOUS run in declaration order, and the reason is the MODEL and
3056
+ // not the drawing: an operand denotes the ORDERED RUN of the
3057
+ // occurrences it contains, so an occurrence that is not in it cannot
3058
+ // happen between two that are. Non-contiguous membership denotes
3059
+ // nothing (UML 2.5.1 §17.6).
3060
+ // One offending row is reported ONCE, against the DEEPEST container it
3061
+ // splits. A row inside an operand's span is inside that operand's
3062
+ // fragment too, so an un-deduplicated pass reports the same line twice
3063
+ // and the outer report's advice is wrong: writing `in=<fragment>` would
3064
+ // repair the fragment and leave the operand split. The deepest
3065
+ // container is the one whose `in=` actually fixes the document.
3066
+ const split=new Map(); // row slot -> container id
3067
+ for(const id in SM.cont){
3068
+ const e=SM.extent[id];
3069
+ const kind=SM.cont[id].kind, aKind=(kind==='operand'?'an ':'a ')+kind;
3070
+ if(!e){
3071
+ errs.push('Line '+SM.cont[id].el.line+': '+kind+' "'+id+
3072
+ '" has no members — '+aKind+"'s extent is the span of the lines carrying in="+id+
3073
+ ', and a container with no extent asserts nothing');
3074
+ continue; }
3075
+ const own=new Set(SM.owned[id]);
3076
+ for(let s=e.lo;s<=e.hi;s++){
3077
+ if(own.has(s)) continue;
3078
+ const prev=split.get(s);
3079
+ if(prev===undefined || SM.chain(id).length>SM.chain(prev).length) split.set(s,id);
3080
+ break;
3081
+ }
3082
+ }
3083
+ for(const [s,id] of [...split.entries()].sort((a,b)=>a[0]-b[0])){
3084
+ const r=SM.rows[s], e=SM.extent[id];
3085
+ errs.push('Line '+r.line+': this '+r.kind+' line splits '+SM.cont[id].kind+' "'+id+
3086
+ '" (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');
3087
+ }
3088
+ }
3089
+ }
2515
3090
  for(const r of doc.ranks) for(const id of r.ids)
2516
3091
  if(!nodeIds.has(id)) errs.push('Line '+r.line+': unknown node "'+id+'" in rank');
2517
3092
  // `MARKER-TARGET-KINDS`: `in=` on `threshold`/`band` also resolves a REGION id —
@@ -2597,32 +3172,102 @@ function parseOne(text){
2597
3172
  // `fill=` and no `stroke=`, used by an edge. Ignoring it would drop the
2598
3173
  // edge's colour with nothing to warn on; honouring it would make `fill`
2599
3174
  // mean "stroke" for that member. So it is a line error that names the
2600
- // key to add. `edge` is the only interior-less construct taking `class=`.
3175
+ // key to add. `edge` was the only interior-less construct taking `class=`
3176
+ // until the `sequence` genre added three more (`message`, `fragment`,
3177
+ // `operand`), which is what 0.4 below is about.
2601
3178
  //
2602
- // 0.1 (`CLASS-PAINT-REQUIREMENT`): the SAME hole sat one key over and was left open.
2603
- // 0.1 rejected `fill=`-only and said nothing about a class that
2604
- // paints NEITHER channel `class p "Path" color=#dc2626` plus `edge a ->
2605
- // b class=p` was accepted, drew a #555 line, and rendered a legend swatch
2606
- // that showed nothing, so the class's meaning was invisible in its own
2607
- // derived legend. With `color=` retired (`COLOUR-KEY-STATUS`) the remaining shape of the
2608
- // hole is a class carrying only `style=`, or nothing at
2609
- // all: the edge silently takes the default colour and the author who
2610
- // declared a class to CLASSIFY the edge gets no colour and no warning.
2611
- // Both halves are the same rule — a class an edge joins must declare at
2612
- // least one channel an edge HAS so they share one diagnostic shape.
2613
- // An edge has exactly two: `stroke` (its colour) and `style` (its dash).
2614
- // `style=`-only is therefore FINE and must stay fine: the dash reaches the
2615
- // edge, nothing is lost, and it is how a multi-class cascade splits one
2616
- // meaning across two declarations (`class=hot,deprecated`). The test is
2617
- // per class, per channel — the same shape `INTERIOR-LESS-ELEMENT-PAINT` chose, for the same reason.
2618
- for(const e of doc.edges) for(const cid of (e.cls||[])){
2619
- const c=doc.classes.find(x=>x.id===cid);
2620
- if(!c||c.stroke!==undefined) continue;
2621
- if(c.fill===undefined&&c.style!==undefined) continue;
2622
- if(c.fill!==undefined)
2623
- 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)');
2624
- else
2625
- 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)');
3179
+ // 0.1 (`CLASS-PAINT-REQUIREMENT`) added a SECOND half a class that paints NEITHER
3180
+ // channel, joined by an edge, was a line error too — and 0.4 (`CLASS-CHANNEL-REACH`)
3181
+ // RETIRES that half. It is not deleted quietly: `CLASS-PAINT-REQUIREMENT`'s own release fixed
3182
+ // the harm it named. The stated defect was that such a class "shows
3183
+ // nothing in the legend", and the same release made the derived legend
3184
+ // draw the meaning with NO swatch (see the legend strip in `render`), so
3185
+ // the meaning does reach the reader. What survived was only "the member
3186
+ // takes its default paint" which is exactly what 14 shipped `field`
3187
+ // members already get, legally, from meaning-only classes in
3188
+ // examples/gre.fd, quic.fd, srh.fd and showcase/tcp-header.fd. A rule that
3189
+ // cannot generalise past one collection was not a rule about channels. A
3190
+ // class that claims a meaning and declares no paint is therefore legal on
3191
+ // EVERY member (`CLASS-CHANNEL-REACH`, MIGRATIONS 0.4), which is also the form the
3192
+ // `sequence` genre is built on: `class` there carries what `group` (`SEQUENCE-PARTICIPANT-GROUPING`)
3193
+ // and `lost=` (`UNDELIVERED-MESSAGE-MARKING`) were refused in favour of, so a meaning with no paint
3194
+ // is that genre's designed idiom, not an oversight.
3195
+ //
3196
+ // `INTERIOR-LESS-ELEMENT-PAINT`'s half stands and now reaches EVERY collection that accepts
3197
+ // `class=` (`CLASS-CHANNEL-REACH`). Until this release the loop below ran over `doc.edges`
3198
+ // alone, so `class k "K" fill=#eee` plus `message c -> s "m" class=k` was
3199
+ // accepted, painted nothing, and put the class in the legend — a message
3200
+ // has its own collection because it has a position in time (`SEQUENCE-ORDER-MODEL`), and
3201
+ // the check never looked there.
3202
+ //
3203
+ // THE CHANNEL SETS ARE DERIVED FROM WHAT EACH RENDERER READS, not from
3204
+ // what the directive tables accept — a key the drawing never consults is
3205
+ // not a channel the member HAS. Read off the `chan()` call sites in
3206
+ // `renderSequence` and the `rsAll`/`dashOf` sites in `render`:
3207
+ // node, group, lifeline, state fill, stroke, style (box/pill: all three)
3208
+ // edge, message stroke, style (no interior)
3209
+ // fragment, operand stroke, style (frame/rule; a
3210
+ // fragment's interior would hide its own
3211
+ // members, so it has no `fill=` to set)
3212
+ // field, cell fill, stroke (`style=` left both
3213
+ // directives at `STYLE-KEY-SCOPE` and no `dashOf` reads
3214
+ // `f.style`/`mk.style`)
3215
+ // A member with all three channels can never fail this test; the rows are
3216
+ // listed anyway, because the table is the rule and a missing row would
3217
+ // read as "not considered".
3218
+ //
3219
+ // TWO CASES FIRE, and both are declared paint that cannot arrive:
3220
+ // (a) `fill=` with no `stroke=` on a class an INTERIOR-LESS member joins.
3221
+ // Not caught by (b), because `fill=` plus `style=` would pass it: on
3222
+ // a line `fill=` and `stroke=` NAME THE SAME CHANNEL (the same reason
3223
+ // `fill=` on an `edge` LINE is refused), so an author who wrote
3224
+ // `fill=` meant the line's colour and `style=` does not answer that.
3225
+ // (b) a class whose channels are ALL channels the member lacks — the
3226
+ // general shape, which reaches `style=`-only on a `field` or a `cell`.
3227
+ // Guarded on the class declaring at least one channel, so a
3228
+ // meaning-only class falls through it (`CLASS-CHANNEL-REACH`).
3229
+ // Both are per class, per channel — `INTERIOR-LESS-ELEMENT-PAINT`'s shape, for `INTERIOR-LESS-ELEMENT-PAINT`'s reason. A class
3230
+ // that also declares a channel the member HAS is fine and must stay fine:
3231
+ // `class hot "…" fill=#fee2e2 stroke=#dc2626` paints a node's box and an
3232
+ // edge's line from one meaning, and `class=hot,deprecated` splits one
3233
+ // meaning across two declarations (conformance case 308).
3234
+ const CLASS_CHANNELS={
3235
+ node: {has:['fill','stroke','style'], a:'a node'},
3236
+ group: {has:['fill','stroke','style'], a:'a group'},
3237
+ lifeline: {has:['fill','stroke','style'], a:'a lifeline'},
3238
+ state: {has:['fill','stroke','style'], a:'a state'},
3239
+ edge: {has:['stroke','style'], a:'an edge'},
3240
+ message: {has:['stroke','style'], a:'a message'},
3241
+ fragment: {has:['stroke','style'], a:'a fragment'},
3242
+ operand: {has:['stroke','style'], a:'an operand'},
3243
+ field: {has:['fill','stroke'], a:'a field'},
3244
+ cell: {has:['fill','stroke'], a:'a cell'},
3245
+ };
3246
+ const clsChan=(x,kind)=>{
3247
+ const K=CLASS_CHANNELS[kind];
3248
+ for(const cid of (x.cls===undefined||x.cls===null?[]:(Array.isArray(x.cls)?x.cls:[x.cls]))){
3249
+ const c=doc.classes.find(y=>y.id===cid);
3250
+ if(!c) continue; // unknown id: its own error
3251
+ const decl=['fill','stroke','style'].filter(k=>c[k]!==undefined);
3252
+ if(!decl.length) continue; // meaning only — legal (`CLASS-CHANNEL-REACH`)
3253
+ if(!K.has.includes('fill')&&c.fill!==undefined&&c.stroke===undefined){
3254
+ 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)');
3255
+ continue; }
3256
+ if(!decl.some(k=>K.has.includes(k)))
3257
+ 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)');
3258
+ }
3259
+ };
3260
+ for(const x of doc.nodes) clsChan(x,'node');
3261
+ for(const x of doc.groups) clsChan(x,'group');
3262
+ for(const x of doc.edges) clsChan(x,'edge');
3263
+ for(const x of (doc.messages||[])) clsChan(x,'message');
3264
+ for(const x of (doc.lifelines||[])) clsChan(x,'lifeline');
3265
+ for(const x of (doc.states||[])) clsChan(x,'state');
3266
+ for(const x of (doc.fragments||[])) clsChan(x,'fragment');
3267
+ for(const x of (doc.operands||[])) clsChan(x,'operand');
3268
+ for(const b of doc.blocks){
3269
+ if(b.fields) for(const f of b.fields) clsChan(f,'field');
3270
+ if(b.marks) for(const mk of b.marks) clsChan(mk,'cell');
2626
3271
  }
2627
3272
  }
2628
3273
  { // class references must resolve (closed grammar)
@@ -3218,13 +3863,37 @@ function render(doc,ropts){
3218
3863
  if(b.fields) for(const f of b.fields) rsAll(f);
3219
3864
  if(b.marks) for(const mk of b.marks) rsAll(mk);
3220
3865
  }
3866
+ // GEOMETRY-TIME DIAGNOSTICS. `parse` cannot see a coordinate, so a defect
3867
+ // that is only visible in the DRAWING (a group band enclosing a non-member
3868
+ // the author pinned there) has no channel to report through today. The scene
3869
+ // hands its diagnostics back here and `render` returns them beside the SVG;
3870
+ // a caller that writes an artifact must treat a non-empty list exactly as it
3871
+ // treats a parse error, because the alternative is writing a picture that
3872
+ // states something the source does not.
3873
+ let sceneErrs=[];
3221
3874
  const parts=[]; let y=0, maxW=0;
3222
3875
  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;
3223
3876
  maxW=Math.max(maxW, cw(doc.title)*8.6); } // canvas must fit the title
3224
3877
  let sceneMeta=null;
3225
- if(doc.nodes.length||doc.edges.length||(doc.boundaries||[]).length){
3878
+ // THE LADDER. A sequence figure has NO SCENE: both of its axes
3879
+ // are declaration-ordered, so there is nothing for the scene layout to
3880
+ // place, and its elements live in their own five collections rather than in
3881
+ // `nodes`/`edges` — which is why the scene branch could never have drawn
3882
+ // one. The branch is an `else if` and not a second pass because the two
3883
+ // renderers are alternatives, never neighbours: `lifeline` and `node` cannot
3884
+ // both appear in one document (one genre, one node spelling).
3885
+ //
3886
+ // Everything AFTER this point is shared and unchanged — the region stack,
3887
+ // the derived `class` legend, the figure-level note, the canvas padding and
3888
+ // the title. A `class` on a `message` earns its legend entry from the same
3889
+ // code that derives a topology figure's.
3890
+ if(doc.genre==='sequence'&&(doc.lifelines||[]).length){
3891
+ const s=renderSequence(doc,y); parts.push(s.svg); y=s.y; maxW=Math.max(maxW,s.w);
3892
+ }
3893
+ else if(doc.nodes.length||doc.edges.length||(doc.boundaries||[]).length){
3226
3894
  const s=renderScene(doc,y); parts.push(s.svg); y=s.y; maxW=Math.max(maxW,s.w);
3227
3895
  sceneMeta=s.meta;
3896
+ if(s.errs&&s.errs.length) sceneErrs=sceneErrs.concat(s.errs);
3228
3897
  }
3229
3898
  // `MARKER-TARGET-KINDS`: a region-scope `threshold`/`band` is drawn HERE and not
3230
3899
  // in `renderScene`, because a region is not in the scene. Typed blocks stack
@@ -3343,12 +4012,23 @@ function render(doc,ropts){
3343
4012
  +'<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">'
3344
4013
  +'<line x1="0" y1="0" x2="0" y2="6" stroke="#bbb" stroke-width="2"/></pattern></defs>'
3345
4014
  +'<g transform="translate('+PADL+','+PADT+')">'+parts.join('')+'</g></svg>', w:W, h:H,
3346
- sceneMeta:sceneMeta, pad:{x:PADL,y:PADT}};
4015
+ sceneMeta:sceneMeta, pad:{x:PADL,y:PADT}, errs:sceneErrs};
3347
4016
  }
3348
4017
 
3349
4018
  // ---- scene ----
3350
4019
  function renderScene(doc,y0){
3351
4020
  const nodes=doc.nodes.map(n=>({...n}));
4021
+ // Band padding around a group's members — the ONE place it is written. The
4022
+ // contiguity pass and the drawn rect must agree to the pixel: a pass that
4023
+ // separates against a slightly different rectangle from the one painted is a
4024
+ // pass that reports clean on a picture that is not.
4025
+ const BAND={l:14,r:14,t:26,b:12};
4026
+ // A geometry-time diagnostic carries a SOURCE line like every other error in
4027
+ // this engine, and in a multi-section document the line the author reads is
4028
+ // the FULL-FILE one. `parse` re-bases its own messages; a render error is
4029
+ // built here, after that pass, so the doc carries the offset itself (0 for a
4030
+ // single-section document, where the two numberings coincide).
4031
+ const srcLine=n=>(n===null||n===undefined)?null:n+(doc.lineOffset||0);
3352
4032
  // §2.4 plane z = paint order, applied by every pass that stacks annotations
3353
4033
  // (edges, bundle rings, threshold lines, zone bands). The sort is stable, so
3354
4034
  // same-plane items keep document order — "a later line paints on top".
@@ -3568,7 +4248,45 @@ function renderScene(doc,y0){
3568
4248
  for(let i=r0;i<r1;i++) mainGap[i]=Math.max(mainGap[i],need);
3569
4249
  }
3570
4250
  const center=n=>n.cross+cs(n)/2;
4251
+ // GROUP CONTIGUITY, ORDERING HALF. A group's band is the bounding box of its
4252
+ // members, so a non-member that the lane order happens to place BETWEEN two
4253
+ // of them is drawn inside the band — the picture then states a membership the
4254
+ // source never declared. The geometry pass further down can always evict the
4255
+ // intruder, but eviction leaves the members where they were and the band
4256
+ // keeps a hole where the intruder used to be; ordering them adjacent HERE,
4257
+ // before any coordinate exists, costs nothing and packs the group properly.
4258
+ //
4259
+ // Sorting by a key that is CONSTANT ACROSS A GROUP is what makes members
4260
+ // contiguous — equal keys land together — and the key is the group's MEAN
4261
+ // desired position, so the cluster still sits where the incoming order put
4262
+ // it. The sort is stable, so members keep the order they arrived in and a
4263
+ // lane with nothing interleaved is not reordered at all.
4264
+ //
4265
+ // A group with a PINNED member is left alone: the pin is the author's
4266
+ // coordinate, this pass cannot move it, and clustering the rest around a slot
4267
+ // the pin will overwrite only displaces free nodes for nothing. That figure
4268
+ // is the AUTHOR's half of the ruling and is reported, not redrawn.
4269
+ const clusterGroups=arr=>{ // arr of {n, d}; reordered in place
4270
+ if(!arr.some(x=>x.n.group)) return arr;
4271
+ const mean=new Map();
4272
+ for(const x of arr){ const g=x.n.group; if(!g) continue;
4273
+ if(!mean.has(g)) mean.set(g,{s:0,k:0,pin:false});
4274
+ const t=mean.get(g); t.s+=x.d; t.k++; if(pinned(x.n.id)) t.pin=true; }
4275
+ if(![...mean.values()].some(t=>!t.pin&&t.k>1)) return arr;
4276
+ const live=g=>g&&mean.has(g)&&!mean.get(g).pin;
4277
+ const key=x=>live(x.n.group)?mean.get(x.n.group).s/mean.get(x.n.group).k:x.d;
4278
+ const tag=x=>live(x.n.group)?x.n.group:'';
4279
+ arr.sort((p,q)=>key(p)-key(q)||(tag(p)<tag(q)?-1:tag(p)>tag(q)?1:0));
4280
+ return arr;
4281
+ };
3571
4282
  ranksArr.forEach(lane=>{ if(!lane) return; let c=0; // seed: doc order
4283
+ // The seed is the ONLY ordering a single-rank figure ever gets: `sweep`
4284
+ // walks rank boundaries, so a scene with no edges never reaches `place`.
4285
+ // The six-line reproduction (`group g` + three nodes, the middle one not a
4286
+ // member) is exactly that figure, which is why the clustering runs here too
4287
+ // and not only in the sweep.
4288
+ const ord=clusterGroups(lane.map((n,k)=>({n,d:k}))).map(x=>x.n);
4289
+ lane.length=0; ord.forEach(n=>lane.push(n));
3572
4290
  lane.forEach((n,k)=>{ n.cross=c; c+=cs(n)+(k<lane.length-1?gapOf(n,lane[k+1]):0); }); });
3573
4291
  // WHERE THE HOLD YIELDS, WHICH IS MOST OF THE RULE. Holding a chain node on
3574
4292
  // its chain neighbour puts every OTHER neighbour of that node on one side of
@@ -3774,6 +4492,148 @@ function renderScene(doc,y0){
3774
4492
  if(o){ n.x=o.x+p.fx; n.y=o.y+p.fy; }
3775
4493
  else { n.x=p.fx; n.y=y0+20+p.fy; }
3776
4494
  }
4495
+ // ── GROUP BAND CONTIGUITY ────────────────────────────────────────────────
4496
+ // A group's band is the BOUNDING BOX of its members (see gBox below), and
4497
+ // until this pass nothing checked that the box contained only members. A
4498
+ // non-member the layout happened to place between two members was therefore
4499
+ // drawn INSIDE the band, with no error and no warning: six legal lines
4500
+ // (`group g`, three nodes of which the middle one is not `in=g`, `layout`)
4501
+ // produced a picture that says the middle node is in the group. That is the
4502
+ // worst failure class this project has — a legal document that reads
4503
+ // confidently and wrongly — and it contradicts this genre's own rule that
4504
+ // membership is DECLARED and never inferred from rendered geometry.
4505
+ //
4506
+ // WHOEVER CHOSE THE POSITION BEARS THE RESPONSIBILITY. That single principle
4507
+ // splits the fix in two:
4508
+ //
4509
+ // the ENGINE chose it — auto-layout had freedom, so the engine MUST place
4510
+ // the members contiguously and the situation cannot arise. It is fixed
4511
+ // here, silently, at no cost to the author.
4512
+ // the AUTHOR chose it — a `pin` fixed the intruder (or fixed the members
4513
+ // whose extent IS the band) and the engine has no freedom left. It then
4514
+ // reports, naming the pin line, and the artifact is not written. The
4515
+ // engine never overrides the author's coordinate, and never draws a
4516
+ // statement the source did not make.
4517
+ //
4518
+ // It runs HERE — after pins are applied and before the group origins are
4519
+ // taken — because the defect exists in the final geometry and nowhere else.
4520
+ // The source looks fine; that is the whole point of the defect.
4521
+ const gErrs=[];
4522
+ {
4523
+ const SEP=16; // clearance left between a band and what is pushed out
4524
+ const MAXPASS=60; // resolution is monotone (always outward); this bounds
4525
+ // pathological alternation rather than expected work
4526
+ const real=nodes.filter(n=>!n.boundary);
4527
+ const memOf=id=>real.filter(n=>n.group===id);
4528
+ const cLo=n=>horiz?n.y:n.x, cSz=n=>horiz?n.h:n.w;
4529
+ const mv=(n,d)=>{ if(horiz) n.y+=d; else n.x+=d; };
4530
+ const groups=doc.groups.filter(g=>memOf(g.id).length);
4531
+ const minCross=()=>lay.length?Math.min(...lay.map(cLo)):0;
4532
+ const cross0=minCross(); // the envelope the layout had before this pass
4533
+ const bandOf=g=>{
4534
+ const m=memOf(g.id);
4535
+ const B={x0:Math.min(...m.map(n=>n.x))-BAND.l, x1:Math.max(...m.map(n=>n.x+n.w))+BAND.r,
4536
+ yA:Math.min(...m.map(n=>n.y))-BAND.t, yB:Math.max(...m.map(n=>n.y+n.h))+BAND.b};
4537
+ B.lo=horiz?B.yA:B.x0; B.hi=horiz?B.yB:B.x1;
4538
+ return B;
4539
+ };
4540
+ const inBand=(n,B)=>n.x<B.x1&&n.x+n.w>B.x0&&n.y<B.yB&&n.y+n.h>B.yA;
4541
+ const pinLine=id=>doc.pins[id]?doc.pins[id].line:null;
4542
+ // The MOVER is a whole group or a single free node — never half a group,
4543
+ // because moving one member of a group reshapes THAT group's band and the
4544
+ // next pass would only find the same class of defect one group along.
4545
+ const unitOf=n=>n.group?memOf(n.group):[n];
4546
+ const canMove=u=>u.every(n=>!pinned(n.id))
4547
+ && !(u[0].group&&doc.pins[u[0].group]&&doc.pins[u[0].group].fx!==null);
4548
+ const said=new Set();
4549
+ const collect=()=>{
4550
+ const out=[];
4551
+ for(const g of groups){
4552
+ const B=bandOf(g);
4553
+ for(const n of real){
4554
+ if(n.group===g.id||said.has(g.id+' '+n.id)) continue;
4555
+ if(inBand(n,B)) out.push({g,n});
4556
+ }
4557
+ }
4558
+ return out;
4559
+ };
4560
+ let left=[];
4561
+ // EVERY conflict gets attention on every pass, and the band is recomputed
4562
+ // immediately before each resolution. Taking only the first conflict each
4563
+ // pass was tried and is wrong: one pair that alternates starves every other
4564
+ // pair for the whole iteration budget, and the run then reports as
4565
+ // "unresolvable" figures the pass had never once looked at.
4566
+ for(let pass=0;pass<MAXPASS;pass++){
4567
+ left=collect();
4568
+ if(!left.length) break;
4569
+ for(const c of left){
4570
+ const B=bandOf(c.g);
4571
+ if(!inBand(c.n,B)) continue; // an earlier resolution cleared it
4572
+ const gMem=memOf(c.g.id);
4573
+ // Who yields: the intruder if the engine placed it, otherwise the group
4574
+ // if the engine placed THAT, otherwise nobody and the author is told.
4575
+ let unit=unitOf(c.n), obst=B;
4576
+ if(!canMove(unit)){
4577
+ if(canMove(gMem)){ unit=gMem;
4578
+ obst={lo:cLo(c.n), hi:cLo(c.n)+cSz(c.n)}; }
4579
+ else {
4580
+ // No freedom anywhere: report, name the line that took it away, and
4581
+ // stop considering this pair so the loop still terminates.
4582
+ const who=pinned(c.n.id)?c.n.id
4583
+ :(c.n.group&&doc.pins[c.n.group]&&doc.pins[c.n.group].fx!==null?c.n.group
4584
+ :(gMem.find(m=>pinned(m.id))||{id:c.g.id}).id);
4585
+ const ln=srcLine(pinLine(who));
4586
+ gErrs.push('Line '+(ln!==null?ln:srcLine(c.g.line))+': pin puts "'+c.n.id
4587
+ +'" inside the band of group "'+c.g.id+'" — a band is the bounding box of the '
4588
+ +'group\'s members, so this draws "'+c.n.id+'" as one of them. Move the pin clear '
4589
+ +'of the group\'s extent, or say what the drawing says with in='+c.g.id+'.');
4590
+ said.add(c.g.id+' '+c.n.id); continue;
4591
+ }
4592
+ }
4593
+ const uLo=Math.min(...unit.map(cLo)), uHi=Math.max(...unit.map(n=>cLo(n)+cSz(n)));
4594
+ const dNeg=(obst.lo-SEP)-uHi, dPos=(obst.hi+SEP)-uLo;
4595
+ // NEARER SIDE, BUT NEVER OFF THE CANVAS. The obvious rule — move
4596
+ // whichever way is shorter — sends the unit past the layout's own
4597
+ // starting edge often enough to matter (`reference/topology` put L1 at
4598
+ // x=-90 and the viewBox clipped it away). Growing the canvas the other
4599
+ // way is not available either: the only uniform-shift machinery this
4600
+ // renderer has moves PINNED nodes with everything else, and a pinned
4601
+ // node that drifts because an unrelated node was added is the `RENDERING-DETERMINISM`
4602
+ // stability violation this engine has already paid for once. So the
4603
+ // constraint is applied HERE, to the choice: the negative direction is
4604
+ // taken only when the unit still lands inside the envelope the layout
4605
+ // had before this pass ran. Nothing outside the mover ever moves.
4606
+ const dNegOK=uLo+dNeg>=cross0;
4607
+ const d=(Math.abs(dNeg)<=Math.abs(dPos)&&dNegOK)?dNeg:dPos;
4608
+ const ranks=new Set(unit.map(n=>n.rank));
4609
+ const keep=new Set(unit.concat(unit===gMem?[]:gMem));
4610
+ // Everything the mover would be pushed ONTO travels with it: same rank,
4611
+ // same side, clear of the obstacle. Relative order and spacing inside a
4612
+ // lane are preserved, so the fix cannot manufacture an overlap.
4613
+ // A node that BELONGS to a group never travels this way — a group moves
4614
+ // whole or not at all, and dragging half of one along would reshape its
4615
+ // band, which is the same defect one group further on.
4616
+ for(const m of lay){
4617
+ if(keep.has(m)||!ranks.has(m.rank)) continue;
4618
+ if(!m.virtual&&m.group) continue;
4619
+ const mLo=cLo(m), mHi=mLo+cSz(m);
4620
+ if(d<0 ? (mHi<=uHi&&mHi<=obst.lo) : (mLo>=uLo&&mLo>=obst.hi)) mv(m,d);
4621
+ }
4622
+ for(const n of unit) mv(n,d);
4623
+ }
4624
+ }
4625
+ left=collect();
4626
+ // The invariant is CHECKED, not assumed: anything the pass could not place
4627
+ // is named. A figure that reaches this line with a hit is a defect in this
4628
+ // pass, and saying so beats drawing the false statement quietly.
4629
+ for(const c of left){
4630
+ if(said.has(c.g.id+' '+c.n.id)) continue;
4631
+ gErrs.push('Line '+srcLine(c.g.line)+': group "'+c.g.id+'" would enclose non-member "'
4632
+ +c.n.id+'" and the layout pass could not separate them; the figure is not drawn rather '
4633
+ +'than drawn wrongly. Give "'+c.n.id+'" a pin outside the group, or add it with in='
4634
+ +c.g.id+'.');
4635
+ }
4636
+ }
3777
4637
  // Pass 3: an unpinned group has no anchor of its own; its display origin
3778
4638
  // (drag anchor / data-gx,gy) is the top-left of its members' FINAL positions,
3779
4639
  // so it reflects any pinned members and matches the group box drawn below.
@@ -3950,8 +4810,8 @@ function renderScene(doc,y0){
3950
4810
  const mem=nodes.filter(n=>n.group===g.id);
3951
4811
  if(!mem.length) continue;
3952
4812
  const o=gOrigin[g.id];
3953
- const x0=Math.min(...mem.map(n=>n.x))-14, x1=Math.max(...mem.map(n=>n.x+n.w))+14;
3954
- const yA=Math.min(...mem.map(n=>n.y))-26, yB=Math.max(...mem.map(n=>n.y+n.h))+12;
4813
+ const x0=Math.min(...mem.map(n=>n.x))-BAND.l, x1=Math.max(...mem.map(n=>n.x+n.w))+BAND.r;
4814
+ const yA=Math.min(...mem.map(n=>n.y))-BAND.t, yB=Math.max(...mem.map(n=>n.y+n.h))+BAND.b;
3955
4815
  gBox[g.id]={x0,x1,yA,yB};
3956
4816
  const gdash=g.style==='dashed'?' stroke-dasharray="6 4"':(g.style==='dotted'?' stroke-dasharray="2 4"':'');
3957
4817
  gsvg.push('<g data-group="'+g.id+'" data-gx="'+o.x+'" data-gy="'+o.y+'" style="cursor:move">'
@@ -4981,7 +5841,7 @@ function renderScene(doc,y0){
4981
5841
  }
4982
5842
  const yEnd=y0+20+Hh+10;
4983
5843
  return {svg:gsvg.join('')+esvg.join('')+nsvg.join('')+tsvg.join('')+lblsvg.join(''), y:yEnd, w:W+2,
4984
- meta:{W:W, top:y0+20+chShift, Hh:Hh, left:bShift}};
5844
+ meta:{W:W, top:y0+20+chShift, Hh:Hh, left:bShift}, errs:gErrs};
4985
5845
  }
4986
5846
  // borderPoint: where the ray from n's centre toward (tx,ty) leaves the shape.
4987
5847
  // It must leave the DRAWN outline: a rectangle clip on a diamond or an ellipse
@@ -5145,6 +6005,393 @@ function edgeRuns(v, p, n, span, ownAt){
5145
6005
  return out;
5146
6006
  }
5147
6007
 
6008
+ // ---- ladder (the `sequence` genre) ----
6009
+ //
6010
+ // THE ORDERING RULE, stated once, because everything below depends on it:
6011
+ //
6012
+ // The TIME axis is the declaration order of the `message` and `state` lines
6013
+ // taken JOINTLY. Line m above line n asserts that m occurs before n.
6014
+ // `lifeline` declaration order is the COLUMN axis, left to right. Both axes
6015
+ // are declaration-ordered, and that is why this genre has no `flow` and no
6016
+ // `rank`: a key that reordered the drawing would make the picture disagree
6017
+ // with the text (`SEQUENCE-SOURCE-STANDARD`-R182). `fragment` and `operand` lines are
6018
+ // DECLARATIONS and carry no time position of their own; a container's drawn
6019
+ // extent is the span of its members' positions. Implementation: every
6020
+ // element carries its source line number, so the row order is recovered by
6021
+ // ONE sort on that number in `seqModel` — the model never stores an ordinal.
6022
+ //
6023
+ // Everything else in this function is a DRAWING CONVENTION the engine owns
6024
+ // under `DOMAIN-CONVENTION-DIRECTIVES` and is marked CHOSEN where it is not obvious. The author names
6025
+ // MEANING (who talks to whom, in what order, inside which fragment); the
6026
+ // engine decides every coordinate, and there is no key that moves one.
6027
+ //
6028
+ // The layout is SIX DETERMINISTIC PASSES and no fixed-point iteration:
6029
+ // 1. container column spans (which columns each fragment/operand covers)
6030
+ // 2. the column axis (centre-to-centre distances, widened to fit)
6031
+ // 3. the time axis (one slot per row, plus container headroom)
6032
+ // 4. container box geometry (from the tops/bottoms the cursor recorded)
6033
+ // 5. paint (background, mid, ink — three ordered layers)
6034
+ // 6. the canvas extent (widest of columns, boxes and overhanging ink)
6035
+ //
6036
+ // NOT DRAWN: activation bars. UML's ExecutionSpecification is a separate
6037
+ // referent with a separate spelling, and the genre has no keyword for it — so
6038
+ // the renderer must not invent one out of message adjacency, which would put
6039
+ // an assertion in the picture that the source does not make.
6040
+ function renderSequence(doc,y0){
6041
+ const M=seqModel(doc);
6042
+ const lls=doc.lifelines;
6043
+ if(!lls.length) return {svg:'',y:y0,w:0};
6044
+ const col={}; lls.forEach((l,i)=>{ col[l.id]=i; });
6045
+ // `OMITTED-LABEL-RECORDING`/`EMPTY-LABEL-STATE` display fallback, applied here rather than in `render`: the model
6046
+ // records an omitted label as absent (null) and the RENDERER substitutes the
6047
+ // id, so `lifeline c` draws "c". An explicitly empty label draws nothing.
6048
+ const lblOf=(x)=>(x.label===null||x.label===undefined)?x.id:x.label;
6049
+ // class cascade — the same rule `render` applies to nodes and edges, applied
6050
+ // to this genre's elements (a `class` on a `message` is what `lost=` was
6051
+ // refused in favour of, `UNDELIVERED-MESSAGE-MARKING`). Read-only: the element is never patched.
6052
+ const C={}; for(const c of doc.classes||[]) C[c.id]=c;
6053
+ const clsIds=(x)=>x.cls===undefined||x.cls===null?[]:(Array.isArray(x.cls)?x.cls:[x.cls]);
6054
+ const chan=(x,k)=>{ if(x[k]!==undefined) return x[k];
6055
+ let v; for(const id of clsIds(x)) if(C[id]&&C[id][k]!==undefined) v=C[id][k]; return v; };
6056
+ // `seqModel` reports the containment chain; DEPTH is a view of it and lives
6057
+ // here because only the drawing needs it (nesting inset, paint order).
6058
+ const depth=(id)=>M.chain(id).length-1;
6059
+
6060
+ // ── geometry constants (CHOSEN, `DOMAIN-CONVENTION-DIRECTIVES`) ────────────────────────────────────
6061
+ const HEAD_H=32, HEAD_PADX=13, HEAD_MINW=76;
6062
+ const ROW_H=34; // base row pitch. See the F5 note below.
6063
+ const SELF_W=40, SELF_EXTRA=26, STATE_H=22;
6064
+ const LBL_FS=11, LBL_LIFT=8; // label sits LBL_LIFT px above its own arrow
6065
+ const FRAG_TOP=26, FRAG_BOT=12, OPERAND_TOP=20, FRAG_PADX=22, FRAG_INSET=9;
6066
+ const ENC_PAD=6; // clearance a container's frame keeps off its members
6067
+ // F5 (spec/core.md §14.3) is a CONSTRAINT ON ROW_H, not an afterthought.
6068
+ // A message label's centre sits LBL_LIFT + fs*0.55 ≈ 14 px above its own
6069
+ // arrow, so its margin against the arrow one row away is ROW_H - 2*14.
6070
+ // F5 requires that to exceed M = 4 px, i.e. ROW_H > 32. ROW_H = 34 gives a
6071
+ // computed margin of 6 px at the worst case (two consecutive messages over
6072
+ // the same span) and much more in practice. This is the whole reason a
6073
+ // ladder is F5-cheap: the geometry separates labels by CONSTRUCTION, so the
6074
+ // margin is a property of the row pitch and not of any per-figure search.
6075
+
6076
+ const headW=lls.map(l=>Math.max(HEAD_MINW, cwMax(lblOf(l))*CH+2*HEAD_PADX));
6077
+ const lblPx=(s)=>s?cwMax(s)*(6.5*LBL_FS/11)+8:0;
6078
+ // A state pill's width is needed in TWO passes — the container-enclosure
6079
+ // pass and the paint pass — so it is written once. A pill wider than its
6080
+ // container's padding is exactly the case that made the enclosure pass
6081
+ // necessary (see PASS 4).
6082
+ const statePillW=(el)=>Math.max(46, cwMax(el.name)*6.6+18);
6083
+
6084
+ // ── PASS 1 — container column spans (needed BEFORE the column axis,
6085
+ // because a fragment's operator tab and label have to fit inside its
6086
+ // own box) ────────────────────────────────────────────────────────────
6087
+ const cspan={};
6088
+ for(const id in M.cont){
6089
+ const slots=M.owned[id];
6090
+ let cmin=Infinity,cmax=-Infinity;
6091
+ for(const sl of slots){ const r=M.rows[sl];
6092
+ if(r.kind==='message'){ const a=col[r.el.a],b=col[r.el.b];
6093
+ if(a!==undefined){cmin=Math.min(cmin,a);cmax=Math.max(cmax,a);}
6094
+ if(b!==undefined){cmin=Math.min(cmin,b);cmax=Math.max(cmax,b);} }
6095
+ else { const a=col[r.el.ref];
6096
+ if(a!==undefined){cmin=Math.min(cmin,a);cmax=Math.max(cmax,a);} } }
6097
+ cspan[id]=isFinite(cmin)?{cmin,cmax}:null;
6098
+ }
6099
+ // a fragment must be at least as wide as the operands it holds
6100
+ for(const id in M.cont){
6101
+ if(M.cont[id].kind!=='operand') continue;
6102
+ const p=M.cont[id].parent;
6103
+ if(p&&cspan[p]&&cspan[id]){ cspan[p].cmin=Math.min(cspan[p].cmin,cspan[id].cmin);
6104
+ cspan[p].cmax=Math.max(cspan[p].cmax,cspan[id].cmax); }
6105
+ }
6106
+ const tabW=(id)=>cwMax(M.cont[id].el.type||'')*6.6+16;
6107
+ const capW=(id)=>{ const c=M.cont[id];
6108
+ const lab=(c.el.label===null||c.el.label===undefined)?'':('['+c.el.label+']');
6109
+ return (c.kind==='fragment'?tabW(id)+14:8)+cwMax(lab)*6.6+10; };
6110
+
6111
+ // ── PASS 2 — the column axis: centre-to-centre distances ────────────────
6112
+ const nc=lls.length, cd=[];
6113
+ for(let k=0;k+1<nc;k++) cd.push(headW[k]/2+headW[k+1]/2+26);
6114
+ let rightPad=0;
6115
+ const widen=(i,j,need)=>{ // need = required span i..j
6116
+ if(j<=i) return;
6117
+ let have=0; for(let k=i;k<j;k++) have+=cd[k];
6118
+ if(have>=need) return;
6119
+ const add=(need-have)/(j-i); for(let k=i;k<j;k++) cd[k]+=add;
6120
+ };
6121
+ for(const r of M.rows){
6122
+ if(r.kind!=='message') continue;
6123
+ const ci=col[r.el.a], cj=col[r.el.b];
6124
+ if(ci===undefined||cj===undefined) continue;
6125
+ const w=lblPx(r.el.label)+26;
6126
+ if(ci===cj){ // self-message
6127
+ const need=SELF_W+lblPx(r.el.label)+16;
6128
+ if(ci+1<nc) widen(ci,ci+1,need); else rightPad=Math.max(rightPad,need);
6129
+ } else widen(Math.min(ci,cj),Math.max(ci,cj),w);
6130
+ }
6131
+ // the fragment caption is INSIDE the box, so it constrains the columns the
6132
+ // box spans — a caption that overflows its own box names nothing.
6133
+ for(const id in M.cont){
6134
+ const cs=cspan[id]; if(!cs) continue;
6135
+ const d=depth(id), pad=Math.max(6,FRAG_PADX-d*FRAG_INSET);
6136
+ if(cs.cmin===cs.cmax) rightPad=Math.max(rightPad,capW(id)-pad-headW[cs.cmax]/2);
6137
+ else widen(cs.cmin,cs.cmax,capW(id)-2*pad);
6138
+ }
6139
+ // The LEFT margin is structural too. Column 0's head box normally sets it,
6140
+ // but two things drawn on that column are wider than it: a `state` pill (as
6141
+ // wide as its state name) and a container frame whose left edge sits a
6142
+ // padding outside the column. Whichever overhangs furthest pushes the whole
6143
+ // axis right, so nothing is ever drawn at a negative x — the canvas has no
6144
+ // room there and the ink would simply be clipped away.
6145
+ let leftPad=0;
6146
+ for(const r of M.rows)
6147
+ if(r.kind==='state'&&col[r.el.ref]===0)
6148
+ leftPad=Math.max(leftPad, statePillW(r.el)/2+ENC_PAD-headW[0]/2);
6149
+ for(const id in M.cont){
6150
+ const cs=cspan[id]; if(!cs||cs.cmin!==0) continue;
6151
+ const d=depth(id), pad=Math.max(6,FRAG_PADX-d*FRAG_INSET);
6152
+ leftPad=Math.max(leftPad, pad+ENC_PAD+6-headW[0]/2);
6153
+ }
6154
+ const x=[]; x[0]=headW[0]/2+Math.max(0,leftPad);
6155
+ for(let k=1;k<nc;k++) x[k]=x[k-1]+cd[k-1];
6156
+
6157
+ // ── PASS 3 — the time axis: one slot per row, plus the space containers
6158
+ // need for their frames ────────────────────────────────────────────────
6159
+ const opensAt={}, closesAt={};
6160
+ for(const id in M.cont){ const e=M.extent[id]; if(!e) continue;
6161
+ (opensAt[e.lo]=opensAt[e.lo]||[]).push(id);
6162
+ (closesAt[e.hi]=closesAt[e.hi]||[]).push(id); }
6163
+ const sortDeep=(a)=>a.slice().sort((p,q)=>depth(p)-depth(q));
6164
+ const yTop=y0;
6165
+ let y=yTop+HEAD_H+22;
6166
+ // Box tops and bottoms are recorded AS THE CURSOR PASSES THEM, so an outer
6167
+ // fragment and the operand that opens with it get DIFFERENT tops and their
6168
+ // captions cannot land on each other. (Deriving both from the member row
6169
+ // overlapped them — visible in the rendered pixels, not in any metric.)
6170
+ const boxTop={}, boxBot={};
6171
+ for(const r of M.rows){
6172
+ for(const id of sortDeep(opensAt[r.slot]||[])){
6173
+ // a top-level fragment gets clear air above it, or two consecutive
6174
+ // fragments share a border and read as one box.
6175
+ if(depth(id)===0&&M.cont[id].kind==='fragment') y+=8;
6176
+ boxTop[id]=y; y+=(M.cont[id].kind==='fragment'?FRAG_TOP:OPERAND_TOP); }
6177
+ r.y0=y;
6178
+ let h=ROW_H;
6179
+ if(r.kind==='message'&&r.el.a===r.el.b) h+=SELF_EXTRA;
6180
+ // A label is drawn ABOVE its own arrow, so every extra line of it is
6181
+ // extra row pitch — otherwise line 2 lands ON the arrow.
6182
+ if(r.kind==='message') h+=Math.max(0,String(r.el.label||'').split('\n').length-1)*LBL_FS*1.3;
6183
+ // NOTE what is NOT here: `description=` reserves no row height, because it
6184
+ // puts NO INK on the page (core §10, §12.7 — "description= addresses the
6185
+ // machine and draws nothing, note= addresses the human and always draws").
6186
+ // The ladder honours that division: a description becomes an SVG <title>
6187
+ // on the element it names and nothing else. (The prototype this was ported
6188
+ // from drew it as grey prose under the arrow, which is the one thing the
6189
+ // key is defined not to do.)
6190
+ r.yMid=y+h/2;
6191
+ y+=h;
6192
+ r.y1=y;
6193
+ for(const id of sortDeep(closesAt[r.slot]||[]).reverse()){
6194
+ if(M.cont[id].kind==='fragment') y+=FRAG_BOT;
6195
+ boxBot[id]=y; }
6196
+ // The NEXT row's label is drawn ABOVE its own arrow, so a row that follows
6197
+ // a closing frame starts its label ~3 px under that frame's border and the
6198
+ // two read as one mark. A frame that closes therefore buys clear air below
6199
+ // it, on the same ground as the clear air a top-level fragment buys above.
6200
+ if((closesAt[r.slot]||[]).length && r.slot+1<M.rows.length) y+=10;
6201
+ }
6202
+ const bottom=y+10;
6203
+
6204
+ // ── PASS 4 — containers: box geometry ───────────────────────────────────
6205
+ const fbox={};
6206
+ for(const id in M.cont){
6207
+ const e=M.extent[id]; if(!e) continue;
6208
+ const cs=cspan[id]||{cmin:0,cmax:nc-1};
6209
+ const d=depth(id), pad=Math.max(6,FRAG_PADX-d*FRAG_INSET);
6210
+ fbox[id]={x0:x[cs.cmin]-pad, x1:Math.max(x[cs.cmax]+pad, x[cs.cmin]-pad+capW(id)),
6211
+ y0:boxTop[id], y1:boxBot[id],
6212
+ cmin:cs.cmin,cmax:cs.cmax,d,kind:M.cont[id].kind};
6213
+ }
6214
+ // A container's frame must ENCLOSE THE INK OF ITS MEMBERS, and the column
6215
+ // span alone does not guarantee that: a member's drawing can be wider than
6216
+ // the column it sits on. A `state` pill is centred on its lifeline and is as
6217
+ // wide as its state name, so a long name overhangs the fixed padding and the
6218
+ // pill pokes out through the frame that is supposed to contain it — visible
6219
+ // in the rendered pixels of the reference figure ("RENEWING" inside `loop`)
6220
+ // and invisible to every metric. The frame is therefore grown to the drawn
6221
+ // extent of what it owns. `owned` is TRANSITIVE, so an inner operand's
6222
+ // members widen the enclosing fragment too.
6223
+ for(const id in fbox){
6224
+ for(const sl of M.owned[id]){
6225
+ const r=M.rows[sl];
6226
+ let lo,hi;
6227
+ if(r.kind==='state'){ const ci=col[r.el.ref]; if(ci===undefined) continue;
6228
+ const w=statePillW(r.el); lo=x[ci]-w/2; hi=x[ci]+w/2; }
6229
+ else { const a=col[r.el.a], b=col[r.el.b]; if(a===undefined||b===undefined) continue;
6230
+ lo=Math.min(x[a],x[b]);
6231
+ hi=(a===b)?x[a]+SELF_W+8+lblPx(r.el.label):Math.max(x[a],x[b]); }
6232
+ fbox[id].x0=Math.min(fbox[id].x0,lo-ENC_PAD);
6233
+ fbox[id].x1=Math.max(fbox[id].x1,hi+ENC_PAD);
6234
+ }
6235
+ }
6236
+ // a fragment must contain its operands' boxes
6237
+ for(const id in fbox){
6238
+ if(fbox[id].kind!=='operand') continue;
6239
+ const p=M.cont[id].parent;
6240
+ if(p&&fbox[p]){ fbox[p].y1=Math.max(fbox[p].y1,fbox[id].y1);
6241
+ fbox[p].x0=Math.min(fbox[p].x0,fbox[id].x0-6);
6242
+ fbox[p].x1=Math.max(fbox[p].x1,fbox[id].x1+6); }
6243
+ }
6244
+ // An operand is a COMPARTMENT OF its fragment, so it is exactly as wide as
6245
+ // the fragment: its separator rule DIVIDES the frame and must reach both
6246
+ // borders, and its guard is read against the frame's left edge. Derived from
6247
+ // the parent LAST, after the parent has finished growing, so the divider can
6248
+ // never be shorter than the box it divides (UML 2.5.1 §17.12.3/§17.12.14).
6249
+ for(const id in fbox){
6250
+ if(fbox[id].kind!=='operand') continue;
6251
+ const p=M.cont[id].parent;
6252
+ if(p&&fbox[p]){ fbox[id].x0=fbox[p].x0; fbox[id].x1=fbox[p].x1; }
6253
+ }
6254
+
6255
+ // ── PASS 5 — paint ──────────────────────────────────────────────────────
6256
+ const bg=[], mid=[], ink=[];
6257
+ const HALO=' paint-order="stroke" stroke="#fff" stroke-width="3"';
6258
+ const arrowTri=(tip,from,c)=>{
6259
+ const dx=tip[0]-from[0], dy=tip[1]-from[1], L=Math.hypot(dx,dy)||1;
6260
+ const ux=dx/L, uy=dy/L, arm=10.08, hw=5.6;
6261
+ const bx=tip[0]-ux*arm, by=tip[1]-uy*arm;
6262
+ ink.push('<path d="M'+r2(tip[0])+' '+r2(tip[1])+' L'+r2(bx-uy*hw)+' '+r2(by+ux*hw)
6263
+ +' L'+r2(bx+uy*hw)+' '+r2(by-ux*hw)+' z" fill="'+c+'" stroke="none"/>');
6264
+ };
6265
+ const inkExtent=[];
6266
+ // `description=` → an SVG <title> and nothing else (core §10). `DESCRIPTION-KEY-SPELLING`'s rule
6267
+ // applies: a <title> names its PARENT, so it is never a loose sibling in the
6268
+ // figure's single <g> — where every description in the figure would name the
6269
+ // same element and a conforming UA would show one arbitrary tooltip for the
6270
+ // whole picture. Here each one wraps its own shape in a one-element <g>,
6271
+ // which keeps the shape SELF-CLOSING so the reference linter's edge and node
6272
+ // readers still find it.
6273
+ const titleEl=(s)=>(s===undefined||s===null)?'':'<title>'+esc(s)+'</title>';
6274
+ const withTitle=(s,shape)=>s===undefined||s===null?shape:'<g>'+titleEl(s)+shape+'</g>';
6275
+
6276
+ // lifelines: head box + descending dashed line.
6277
+ // The head is emitted as a `data-node` group — it IS the participant, and
6278
+ // the reference linter's node reader finds it there.
6279
+ lls.forEach((l,i)=>{
6280
+ const w=headW[i], hx=x[i]-w/2, lab=lblOf(l);
6281
+ const f=chan(l,'fill')||'#eef2ff', st=chan(l,'stroke')||'#4f46e5';
6282
+ bg.push('<line x1="'+r2(x[i])+'" y1="'+r2(yTop+HEAD_H+8)+'" x2="'+r2(x[i])+'" y2="'+r2(bottom)
6283
+ +'" stroke="#94a3b8" stroke-width="1" stroke-dasharray="4 4"/>');
6284
+ bg.push('<g data-node="'+esc(l.id)+'" data-x="'+r2(hx)+'" data-y="'+r2(yTop+8)+'">'
6285
+ +titleEl(l.desc)
6286
+ +'<rect x="'+r2(hx)+'" y="'+r2(yTop+8)+'" width="'+r2(w)+'" height="'+HEAD_H
6287
+ +'" rx="4" fill="'+f+'" stroke="'+st+'"'+dashOf(chan(l,'style'),'')+'/>'
6288
+ +textEl(x[i], yTop+8+HEAD_H/2+4.5, 13, 'middle', labelInk(f,'#1d1d1b'), lab, '')
6289
+ +'</g>');
6290
+ });
6291
+
6292
+ // fragment / operand boxes, outermost first so nesting paints correctly
6293
+ const boxIds=Object.keys(fbox).sort((a,b)=>fbox[a].d-fbox[b].d);
6294
+ for(const id of boxIds){
6295
+ const B=fbox[id], c=M.cont[id];
6296
+ // both containers take `stroke=` and `class=`; the DEFAULT differs,
6297
+ // because a fragment's frame is a border and an operand's rule is a
6298
+ // divider inside one (CHOSEN, `DOMAIN-CONVENTION-DIRECTIVES`).
6299
+ const st=chan(c.el,'stroke')||(c.kind==='fragment'?'#64748b':'#94a3b8');
6300
+ if(c.kind==='fragment'){
6301
+ mid.push(withTitle(c.el.desc,
6302
+ '<rect x="'+r2(B.x0)+'" y="'+r2(B.y0)+'" width="'+r2(B.x1-B.x0)+'" height="'+r2(B.y1-B.y0)
6303
+ +'" fill="none" stroke="'+st+'" stroke-width="1"'+dashOf(chan(c.el,'style'),'')+'/>'));
6304
+ // the operator tab — UML's pentagon in the top-left corner (§17.12.3;
6305
+ // the operator vocabulary itself is §17.12.15.3's InteractionOperatorKind)
6306
+ const tw0=cwMax(c.el.type)*6.6+16, th=15;
6307
+ mid.push('<path d="M'+r2(B.x0)+' '+r2(B.y0)+' h'+r2(tw0)+' l6,'+r2(th-6)+' v'+r2(6)
6308
+ +' h'+r2(-tw0-6)+' z" fill="#f8fafc" stroke="'+st+'" stroke-width="1"/>');
6309
+ mid.push(textEl(B.x0+7, B.y0+11, 10.5, 'start', '#334155', c.el.type, ''));
6310
+ if(c.el.label!==null&&c.el.label!==undefined)
6311
+ mid.push(textEl(B.x0+tw0+14, B.y0+11, 10.5, 'start', '#475569', '['+c.el.label+']', HALO));
6312
+ } else {
6313
+ // an operand compartment: a dashed rule above it (except the first) and
6314
+ // its guard at the left. UML draws the guard in square brackets.
6315
+ const p=M.cont[id].parent, sibs=doc.operands.filter(o=>o['in']===p);
6316
+ const first=sibs.length&&sibs[0].id===id;
6317
+ const rule=first?''
6318
+ :'<line x1="'+r2(B.x0)+'" y1="'+r2(B.y0+4)+'" x2="'+r2(B.x1)+'" y2="'+r2(B.y0+4)
6319
+ +'" stroke="'+st+'" stroke-width="1" stroke-dasharray="5 4"/>';
6320
+ const guard=(c.el.label!==null&&c.el.label!==undefined)
6321
+ ? textEl(B.x0+8, B.y0+13, 10.5, 'start', '#475569', '['+c.el.label+']', HALO) : '';
6322
+ // An operand has no box of its own, so its <title> names the group
6323
+ // holding the two marks it DOES draw — the separator rule and the guard.
6324
+ if(rule||guard) mid.push(withTitle(c.el.desc, rule+guard));
6325
+ }
6326
+ }
6327
+
6328
+ // rows
6329
+ for(const r of M.rows){
6330
+ if(r.kind==='state'){
6331
+ // a state occurrence is a pill CENTRED ON ITS OWN COLUMN — the lifeline
6332
+ // it names in slot 1 (UML 2.5.1 §17.12.25's StateInvariant).
6333
+ const ci=col[r.el.ref]; if(ci===undefined) continue;
6334
+ const f=chan(r.el,'fill')||'#fff7ed', st=chan(r.el,'stroke')||'#c2410c';
6335
+ const w=statePillW(r.el);
6336
+ mid.push(withTitle(r.el.desc,
6337
+ '<rect x="'+r2(x[ci]-w/2)+'" y="'+r2(r.yMid-STATE_H/2)+'" width="'+r2(w)+'" height="'+STATE_H
6338
+ +'" rx="9" fill="'+f+'" stroke="'+st+'" stroke-width="1"'+dashOf(chan(r.el,'style'),'')+'/>'));
6339
+ ink.push(textEl(x[ci], r.yMid+4, LBL_FS, 'middle', labelInk(f,'#7c2d12'), r.el.name, ''));
6340
+ inkExtent.push(x[ci]+w/2);
6341
+ continue;
6342
+ }
6343
+ // message
6344
+ const e=r.el, ci=col[e.a], cj=col[e.b];
6345
+ if(ci===undefined||cj===undefined) continue;
6346
+ const st=chan(e,'stroke')||'#334155';
6347
+ const dash=dashOf(chan(e,'style'),'');
6348
+ if(ci===cj){ // self-message
6349
+ // a rectangular loop off the column and back to it. The shaft is one
6350
+ // `path` at the same stroke-width as a straight message, so the axis
6351
+ // readers see one edge and not three.
6352
+ const sx=x[ci], top=r.yMid-11, bot=r.yMid+11, ex=sx+SELF_W;
6353
+ mid.push(withTitle(e.desc,
6354
+ '<path d="M'+r2(sx)+' '+r2(top)+' L'+r2(ex)+' '+r2(top)+' L'+r2(ex)+' '+r2(bot)
6355
+ +' L'+r2(sx+11)+' '+r2(bot)+'" fill="none" stroke="'+st+'" stroke-width="1.6"'+dash+'/>'));
6356
+ arrowTri([sx+2,bot],[sx+12,bot],st);
6357
+ if(e.label){ ink.push(textEl(ex+8, r.yMid+4, LBL_FS, 'start', '#1d1d1b', e.label, HALO));
6358
+ inkExtent.push(ex+8+lblPx(e.label)); }
6359
+ continue;
6360
+ }
6361
+ // A message between NON-ADJACENT columns crosses the lifelines between
6362
+ // them: the shaft is drawn straight from source centre to target centre
6363
+ // and the dashed columns it passes are left intact. This is UML's drawing
6364
+ // and it is also the honest one — a jog around an intervening lifeline
6365
+ // would suggest the message went somewhere it did not.
6366
+ const fwd=(e.op==='<-')?false:true; // '->' and '<->' read a→b
6367
+ let sx=fwd?x[ci]:x[cj], tx0=fwd?x[cj]:x[ci];
6368
+ const dir=Math.sign(tx0-sx)||1;
6369
+ sx+=dir*1.5;
6370
+ const ex=tx0-dir*1.5;
6371
+ mid.push(withTitle(e.desc,
6372
+ '<line x1="'+r2(sx)+'" y1="'+r2(r.yMid)+'" x2="'+r2(ex)+'" y2="'+r2(r.yMid)
6373
+ +'" stroke="'+st+'" stroke-width="1.6"'+dash+'/>'));
6374
+ arrowTri([ex,r.yMid],[ex-dir*10,r.yMid],st);
6375
+ // `<->` is ONE shaft with TWO heads: the model says one occurrence, so
6376
+ // the drawing must not show two lines and invite a reader to count two.
6377
+ if(e.op==='<->') arrowTri([sx,r.yMid],[sx+dir*10,r.yMid],st);
6378
+ if(e.label){
6379
+ const nl=String(e.label).split('\n').length;
6380
+ ink.push(textEl((sx+ex)/2, r.yMid-LBL_LIFT-(nl-1)*LBL_FS*1.3/2, LBL_FS, 'middle', '#1d1d1b', e.label, HALO));
6381
+ }
6382
+ }
6383
+
6384
+ // ── PASS 6 — the canvas extent ──────────────────────────────────────────
6385
+ const W=Math.max(x[nc-1]+headW[nc-1]/2, ...Object.keys(fbox).map(k=>fbox[k].x1),
6386
+ ...inkExtent)+rightPad+4;
6387
+ return {svg:bg.join('')+mid.join('')+ink.join(''), y:bottom, w:W,
6388
+ box:{x0:0,x1:W,yA:yTop,yB:bottom}};
6389
+ }
6390
+ // coordinates are emitted at 2 decimal places: the ladder's arithmetic divides
6391
+ // (`widen` spreads a shortfall over a run of columns), and an unrounded double
6392
+ // would put a 17-digit tail in the artifact for no reader's benefit.
6393
+ function r2(v){ return Math.round(v*100)/100; }
6394
+
5148
6395
  // ---- bitfield ----
5149
6396
  function renderBitfield(b,y0){
5150
6397
  const cell=Math.max(18,Math.min(28,Math.floor(760/b.word))), rh=30, ruler=16;