figdown 0.3.2 → 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.
@@ -121,7 +121,7 @@ const SHAPES = ['box','rounded','circle','ellipse','diamond','cylinder'];
121
121
  // input to that promise, and under core §13 a 0.x renderer may differ from
122
122
  // the next — which makes the recorded version the only thing that can
123
123
  // explain a diff between two renderings of one source.
124
- const FIGDOWN_VERSION = '0.3.2';
124
+ const FIGDOWN_VERSION = '0.4.0';
125
125
  // `STATECHART-GENRE-SCOPE`: the language number moved for the first time. The dev
126
126
  // counter does NOT reset (core §13.0.4 — `N` counts source states of the
127
127
  // engine and only ever increases), so 0.1 is followed by
@@ -136,14 +136,34 @@ const FIGDOWN_VERSION = '0.3.2';
136
136
  // fixes only. No new features. The language does not move." Shipping `note=`
137
137
  // under `v0.2.z` would make `figdown 0.2` name two different languages: the one
138
138
  // `v0.2.0` published and the one with `note=`. So the language number moves.
139
- const LANG_VERSIONS = ['0.1', '0.2', '0.3'];
139
+ // `SEQUENCE-SOURCE-STANDARD`-R182: `figdown 0.4` joins the set, and it joins it for the
140
+ // same reason `0.2` did — a GENRE token is language surface, and core §13.0
141
+ // makes added surface a `Y` and never a `Z`. `sequence` is that token. It adds
142
+ // no keyword yet (see GENRES_BY_VERSION below), which is exactly `STATECHART-GENRE-SCOPE`'s shape:
143
+ // the dispatch point lands first and the vocabulary follows it.
144
+ const LANG_VERSIONS = ['0.1', '0.2', '0.3', '0.4'];
140
145
  // Genres per declared language version. `Y` never removes (core §13.0), so
141
146
  // each row is a superset of the one above it, and `figdown 0.1 <anything>`
142
147
  // resolves against exactly the list it resolved against before `STATECHART-GENRE-SCOPE`.
143
148
  const GENRES_BY_VERSION = {
144
149
  '0.1': ['block','topology','flowchart','bitfield','table','timing'],
145
150
  '0.2': ['block','topology','flowchart','bitfield','table','timing','statechart'],
146
- '0.3': ['block','topology','flowchart','bitfield','table','timing','statechart']
151
+ '0.3': ['block','topology','flowchart','bitfield','table','timing','statechart'],
152
+ // `SEQUENCE-SOURCE-STANDARD`-R182: `sequence` is dispatchable from here on. Like
153
+ // `statechart` at `STATECHART-GENRE-SCOPE` it arrived with NO vocabulary of its own, and the
154
+ // consequence was stated here rather than left to be discovered: there was no
155
+ // `GENRE_KW.sequence` row, and the allowlist guard is written
156
+ // `GENRE_KW[doc.genre] && !GENRE_KW[doc.genre].has(kw)`, so a genre with no
157
+ // row is NOT narrowed — a `figdown 0.4 sequence` document could write any
158
+ // registered keyword and it parsed. The document that increment meant to
159
+ // land was the header ALONE.
160
+ // `SEQUENCE-SOURCE-STANDARD`-R182: CLOSED. `GENRE_KW.sequence` exists below, so the
161
+ // genre now constrains what it names — five keywords of its own, `class`,
162
+ // and the genre-free core — and `flow`/`rank`/`group` are line errors under
163
+ // it. The genre still has NO RENDERER: a valid `sequence` document parses to
164
+ // a model and draws an empty canvas, which is the state this increment means
165
+ // to land and is pinned by a fixture rather than left to be noticed.
166
+ '0.4': ['block','topology','flowchart','bitfield','table','timing','statechart','sequence']
147
167
  };
148
168
  // The version an OPTION KEY first becomes legal in — the `CONNECTOR_MIN_VERSION`
149
169
  // device, applied to the option namespace. `DRAWN-ANNOTATION-FORM`: `note=` is gated on the
@@ -451,6 +471,26 @@ const DIRECTIVE_OPTS={
451
471
  // keyed by the surface word an author actually wrote.
452
472
  flowline:['style','class','fill','stroke','label','taillabel','headlabel','note'],
453
473
  transition:['style','class','fill','stroke','label','taillabel','headlabel','note'],
474
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's four own rows. `message` is
475
+ // the fourth connector spelling and takes the connector set — `fill=` and
476
+ // the three retired label keys stay listed for the same reason they are
477
+ // listed on the other three, so their dedicated diagnostics fire instead of
478
+ // a bare `does not take` — plus `in=` (sense 1: the fragment or operand this
479
+ // message occurs inside) and `description=`. It does NOT gain a key of its
480
+ // own: `lost=` was proposed and refused (`UNDELIVERED-MESSAGE-MARKING`), and `OPT_KEYS` is unchanged
481
+ // by this whole increment.
482
+ message:['style','class','fill','stroke','label','taillabel','headlabel','note','in','description'],
483
+ // A lifeline is drawn as a head box over a dashed line, so it has an
484
+ // interior and takes `fill=`. `in=` is sense 1.
485
+ lifeline:['class','fill','stroke','style','in','note','description'],
486
+ // `type=` is MANDATORY on `fragment` and is checked in `parseSeqDirective`,
487
+ // not here: a missing key is not an inapplicable key. No `fill=` — a
488
+ // combined fragment is a FRAME drawn over the messages it contains, and
489
+ // painting its interior would hide them.
490
+ fragment:['type','class','stroke','style','in','note','description'],
491
+ // `in=` is MANDATORY on `operand` (an operand is a compartment OF a
492
+ // fragment) and is likewise checked in `parseSeqDirective`.
493
+ operand:['in','class','stroke','style','note','description'],
454
494
  // `PAINT-ORDER-CONSTRUCT`: the `plane` row is GONE, not emptied — the keyword is
455
495
  // withdrawn from the language, so it has no acceptor row at all, the shape
456
496
  // `path`/`routing` left behind. `z-index=` goes with it: it
@@ -497,6 +537,31 @@ const DIRECTIVE_OPTS={
497
537
  cell:['fill','stroke','class'], width:[],
498
538
  signal:['data','fill','stroke'], gap:[]
499
539
  };
540
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the FIRST genre-conditional option row, and it
541
+ // exists because `DIRECTIVE_OPTS` is keyed by the SURFACE WORD an author
542
+ // wrote, while `GENRE-VOCABULARY-OBLIGATION` makes a surface word a per-genre declaration. Every other
543
+ // shared spelling in the language names the same construct in every genre
544
+ // that has it — `state` under `statechart` IS `node` renamed (`GENRE-NODE-SPELLING`), so it
545
+ // takes `node`'s row exactly — but `state` under `sequence` is a DIFFERENT
546
+ // construct under the same spelling: a StateInvariant (UML 2.5.1 §17.12.25)
547
+ // that REFERENCES a lifeline rather than declaring an id. It has no shape and
548
+ // no extent of its own, so `shape=`, `width=` and `height=` name nothing on
549
+ // it; it can sit inside a fragment, so it takes `in=` (`SEQUENCE-CONTAINMENT-SCOPE`); and it takes
550
+ // `description=` like the rest of this genre's directives.
551
+ //
552
+ // The lookup is one table indexed genre-first, so a genre with no entry falls
553
+ // through to `DIRECTIVE_OPTS` untouched and the whole 0.1/0.2/0.3 surface is
554
+ // byte-identical. It is NOT a second registry: every key named here is
555
+ // already an `OPT_KEYS` member accepted by some directive, so nothing about
556
+ // the closed option namespace changes.
557
+ const GENRE_DIRECTIVE_OPTS={
558
+ sequence:{
559
+ state:['class','fill','stroke','style','in','note','description']
560
+ }
561
+ };
562
+ const directiveOpts=(kw,genre)=>
563
+ (genre && GENRE_DIRECTIVE_OPTS[genre] && GENRE_DIRECTIVE_OPTS[genre][kw])
564
+ || DIRECTIVE_OPTS[kw];
500
565
  const STYLES=['solid','dashed','dotted'];
501
566
  // `RULE-POSITION-ENUMERATION`: every LIVE option key whose value grammar is an enum,
502
567
  // read off spec/vocabulary-sources.tsv (`shape` column = `enum`, `status`
@@ -595,7 +660,7 @@ const RETIRED_OPT_KEYS={
595
660
  // appears.
596
661
  '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)',
597
662
  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)',
598
- // `note` was HERE (`DESCRIPTION-KEY-SPELLING`) until this release (`DRAWN-ANNOTATION-FORM`), and its
663
+ // `note` was HERE (`DESCRIPTION-KEY-SPELLING`) until 0.3 (`DRAWN-ANNOTATION-FORM`), and its
599
664
  // row is gone because the key is LIVE again — SYNTAX-STYLE RULE 4.9
600
665
  // obligation 3 forbids leaving the retirement message standing past the
601
666
  // revival, on the ground that a message telling an author to write
@@ -663,7 +728,7 @@ const RETIRED_LAYER='layer has been WITHDRAWN: it was renamed plane, and plane w
663
728
  // `THRESHOLD-KEYWORD-SPELLING`: the scene keyword `guide` became `threshold`.
664
729
  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)';
665
730
  // `EXTERNAL-ENDPOINT-NAMING`: the scene keyword `boundary` became `external`.
666
- 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)';
731
+ 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)';
667
732
  // `ROW-BREAK-NAMING`: the `bitfield` child keyword `wrap` became `break`.
668
733
  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)';
669
734
  // `PRESENCE-FLAG-SPELLING`: the 0.1 rename `optional` -> `conditional` (`PRESENCE-FLAG-SPELLING`)
@@ -1055,6 +1120,25 @@ const FLOWCHART_SUBJECT_KW=['external'];
1055
1120
  // none of the six; `external` is additionally UML 2.5.1 §14's own
1056
1121
  // `TransitionKind` literal and is reserved for it (`RESERVED-SPELLINGS`).
1057
1122
  const STATECHART_SUBJECT_KW=[];
1123
+ // `sequence` (EXPERIMENTAL, 0.4, `SEQUENCE-SOURCE-STANDARD`-R182): THREE, and the array is
1124
+ // this genre's whole declaration of what a sequence figure is OF. `state` and
1125
+ // `fragment` and `operand` describe referents UML clause 17 defines —
1126
+ // `StateInvariant` (§17.12.25), `CombinedFragment` (§17.12.3) and
1127
+ // `InteractionOperand` (§17.12.14) — so they are subject vocabulary in exactly
1128
+ // `SUBJECT-VOCABULARY-SCOPE`'s sense, while `lifeline` and `message` are this genre's NODE and
1129
+ // CONNECTOR spellings and live in `GENRE_NODE_KW`/`GENRE_CONNECTOR_KW` with
1130
+ // the other genres' (`GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`). None of the six words the four scene genres
1131
+ // declare is here: `group` is REFUSED (`SEQUENCE-PARTICIPANT-GROUPING`, below), `external` `threshold`
1132
+ // `band` `bundle` have no measured need and no clause-17 referent, and
1133
+ // `flow`/`rank` are refused as a CONSEQUENCE of the genre's two axes being
1134
+ // declaration-ordered (draft §7) — they are simply absent from `GENRE_KW`.
1135
+ // `state` is SHARED with `statechart` as a spelling and is a SEPARATE
1136
+ // declaration with a different grammar: there slot 1 declares an id, here it
1137
+ // REFERENCES a lifeline (draft §29 Q5). Two genres agreeing on a spelling is
1138
+ // two declarations that agree, never one inherited — which is the whole of
1139
+ // `SUBJECT-VOCABULARY-SCOPE`, and is why this genre's `state` also takes its own option row
1140
+ // (GENRE_DIRECTIVE_OPTS).
1141
+ const SEQUENCE_SUBJECT_KW=['state','fragment','operand'];
1058
1142
  // `FLOWCHART-ROLE-KEYWORDS`: the flowchart ROLE vocabulary — the FIRST exercise of
1059
1143
  // `GENRE-NAMESPACE` `GENRE-VOCABULARY-OBLIGATION` ("a genre owns its words"). These three are legal ONLY under
1060
1144
  // `figdown 0.1 flowchart`; `GENRE-NAMESPACE`'s allowlist is what makes `decision x` a line
@@ -1090,10 +1174,50 @@ const ROLE_SHAPE={process:'box',decision:'diamond',terminator:'rounded'};
1090
1174
  // symbol this genre cannot spell is a COVERAGE GAP in FigDown, not a state
1091
1175
  // of the figure, and `node` is not its spelling — see
1092
1176
  // the project’s working record for the coverage ledger.
1093
- const GENRE_NODE_KW={block:'node',topology:'node',flowchart:'node',statechart:'state'};
1094
- const GENRE_CONNECTOR_KW={block:'edge',topology:'edge',flowchart:'flowline',statechart:'transition'};
1095
- const NODE_SPELLINGS=new Set(['node','state']);
1096
- const CONNECTOR_SPELLINGS=new Set(['edge','flowline','transition']);
1177
+ // sequence lifeline message (OMG UML 2.5.1 §17)
1178
+ const GENRE_NODE_KW={block:'node',topology:'node',flowchart:'node',statechart:'state',sequence:'lifeline'};
1179
+ const GENRE_CONNECTOR_KW={block:'edge',topology:'edge',flowchart:'flowline',statechart:'transition',sequence:'message'};
1180
+ const NODE_SPELLINGS=new Set(['node','state','lifeline']);
1181
+ const CONNECTOR_SPELLINGS=new Set(['edge','flowline','transition','message']);
1182
+ // `SEQUENCE-SOURCE-STANDARD`-R182: WHERE EACH GENRE'S NODE ROWS LIVE IN THE PARSED DOC.
1183
+ // `GENRE_NODE_KW` above says what the WORD is; this says which `doc`
1184
+ // collection the parser puts that word's rows in. The two are separate facts
1185
+ // and only one of them is `nodes`: `statechart` renames the word and keeps the
1186
+ // collection (a `state` is a scene node), while `sequence` renames BOTH — a
1187
+ // `lifeline` is not a scene node and lands in `doc.lifelines`. Any GUI test of
1188
+ // the form "is this id a thing this genre declares" has to ask through here.
1189
+ // Hand-writing `doc.nodes` is the defect it closes: the Fill/Delete/Raise/
1190
+ // Lower enablement asked `lastDoc.nodes` and so was permanently false under
1191
+ // `sequence`, greying out four buttons whose edits (`SEQUENCE-SOURCE-STANDARD`-R182) already worked.
1192
+ const GENRE_NODE_COLL={block:'nodes',topology:'nodes',flowchart:'nodes',
1193
+ statechart:'nodes',sequence:'lifelines'};
1194
+ const docNodes=(doc)=>(doc&&doc[GENRE_NODE_COLL[doc.genre]||'nodes'])||[];
1195
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the four `sequence` directives that have their own
1196
+ // parser (`message` rides the connector scanner). The set is what dispatches
1197
+ // to `parseSeqDirective`, and it is scoped by `doc.genre` at the call site so
1198
+ // `state` still reaches `statechart`'s node parser under `statechart`.
1199
+ const SEQ_KW=new Set(['lifeline','state','fragment','operand']);
1200
+ // UML 2.5.1 `InteractionOperatorKind` (§17.12.15.3), taken WHOLE — twelve
1201
+ // values, every one the standard's own single lowercase spelling, so RULE 4.2
1202
+ // admits the abbreviations `alt` `opt` `par` `neg` `seq` unchanged. The clause
1203
+ // number matters: §17.6.2 was cited for this enum in an earlier draft and is
1204
+ // registered FALSE in spec/standards-claims.tsv (S024). FigDown makes the key
1205
+ // MANDATORY where UML gives the attribute a default of `seq`
1206
+ // (`interactionOperator : InteractionOperatorKind [1..1] = seq`, §17.12.3.5) —
1207
+ // a DECLARED divergence: a default would let a fragment assert nothing while
1208
+ // looking like it asserts something, which is the `numbering=` precedent.
1209
+ const SEQ_OPERATORS_FRAG=['alt','opt','loop','par','strict','seq','critical',
1210
+ 'neg','assert','ignore','consider','break'];
1211
+ const SEQ_FRAG_CLAUSE='UML 2.5.1 §17.12.15.3';
1212
+ // Draft §8.2/§15.4: every UML Message has a sendEvent AND a receiveEvent, so
1213
+ // a direction-less message is not a thing this domain has. `--` is a LINE
1214
+ // ERROR under `sequence` and legal in every other genre — the operator set is
1215
+ // per genre for the same reason the keywords are.
1216
+ const SEQ_OPERATORS=new Set(['->','<-','<->']);
1217
+ // Every connector spelling in the language reaches the dedicated scanner —
1218
+ // the WRONG one for the genre must get the named diagnostic, not
1219
+ // `unrecognized line` (`GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`), so the dispatch is over the whole set.
1220
+ const CONN_LINE_RE=new RegExp('^('+[...CONNECTOR_SPELLINGS].join('|')+')(\\s|$)');
1097
1221
  // `KEYWORD-RENAME-SCOPE`: the flowchart rename is GATED BY THE DECLARED LANGUAGE
1098
1222
  // VERSION, because `GENRE-CONNECTOR-SPELLING` applied it to `figdown 0.1` and that BROKE documents
1099
1223
  // legal at v0.1.8 — `figdown 0.1 flowchart` + `edge` stopped parsing, with
@@ -1107,7 +1231,12 @@ const CONNECTOR_SPELLINGS=new Set(['edge','flowline','transition']);
1107
1231
  // forbids; two spellings across VERSIONS is ordinary language evolution, and
1108
1232
  // each version accepts exactly one. `statechart` needs no gate of its own —
1109
1233
  // the GENRE requires 0.2 (GENRES_BY_VERSION), so `state`/`transition` cannot
1110
- // be reached from a 0.1 document at all.
1234
+ // be reached from a 0.1 document at all. `sequence` inherits that argument
1235
+ // unchanged: its genre token requires `figdown 0.4`, so
1236
+ // `lifeline`/`message` are unreachable from any earlier document and there is
1237
+ // no earlier spelling for them to have replaced. A `CONNECTOR_MIN_VERSION`
1238
+ // row for `message` would therefore gate nothing and would make the engine
1239
+ // claim a rename that never happened.
1111
1240
  const GENRE_CONNECTOR_KW_AT={
1112
1241
  '0.1':{block:'edge',topology:'edge',flowchart:'edge'},
1113
1242
  '0.2':GENRE_CONNECTOR_KW
@@ -1130,17 +1259,22 @@ const WORD_WHY={
1130
1259
  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"',
1131
1260
  transition:'the connecting line in a statechart is a TRANSITION — the term UML 2.5.1 §14 uses for it',
1132
1261
  node:'this genre has more kinds of thing than it has words for, so `node` is the general one',
1133
- state:'a statechart has exactly ONE kind of node and it is a STATE (UML 2.5.1 §14)'
1262
+ state:'a statechart has exactly ONE kind of node and it is a STATE (UML 2.5.1 §14)',
1263
+ // `SEQUENCE-SOURCE-STANDARD`-R182: both are WHOLE borrows from the genre's source
1264
+ // standard, verified against the clause text rather than against a
1265
+ // secondary description (spec/standards-claims.tsv).
1266
+ 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',
1267
+ 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'
1134
1268
  };
1135
1269
  // The named diagnostic `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING` owe: it says WHICH word this genre uses and
1136
1270
  // WHY, and it names the migration, because every connector line in a
1137
1271
  // reclassified document has to be rewritten (the cost `GENRE-CONNECTOR-SPELLING` accepted).
1138
1272
  const WRONG_WORD=(surf,want,genre)=>
1139
1273
  '"'+surf+'" is not the word genre '+genre+' uses for this — write "'+want+'": '+WORD_WHY[want]+
1140
- '. 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)';
1274
+ '. 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)';
1141
1275
  // `SCENE-KEYWORD-MEMBERSHIP`: a word WITHDRAWN FROM ONE GENRE is not an unknown word,
1142
1276
  // and `"threshold" is not allowed in genre topology` would send an author
1143
- // looking for a typo. Each cell below was legal until this release and states
1277
+ // looking for a typo. Each cell below was legal until 0.3 and states
1144
1278
  // WHY that genre no longer declares it — the ruling's own ground, per cell,
1145
1279
  // because the grounds differ and a single sentence could not carry them.
1146
1280
  // Every one of these withdrawals was FREE: `topology`, `flowchart` and
@@ -1175,6 +1309,33 @@ const WITHDRAWN_FROM_GENRE=(kw,genre)=>
1175
1309
  GENRE_WITHDRAWN[genre][kw]+
1176
1310
  ' Subject vocabulary is per genre (core §3, `GENRE-VOCABULARY-OBLIGATION`): a spelling accepted by several genres is several '+
1177
1311
  'independent declarations, and this genre\'s was withdrawn without touching any other\'s.'+WITHDREW_AT;
1312
+ // `SEQUENCE-TIME-GAP`/`SEQUENCE-PARTICIPANT-GROUPING`: THE OTHER HALF OF `SCENE-KEYWORD-MEMBERSHIP` — a spelling a genre REFUSED
1313
+ // AT BIRTH. It is a SEPARATE table from `GENRE_WITHDRAWN` and not a sixth row
1314
+ // of it, because the two state different facts and only one of them is a
1315
+ // migration: a WITHDRAWAL took a word away from documents that legally used
1316
+ // it (so the message names the release and the rewrite tool), while a REFUSAL
1317
+ // means the genre never declared the word and no document can have been
1318
+ // written against it. Folding them would make the engine tell a `sequence`
1319
+ // author "it was WITHDRAWN from this genre" about a keyword this genre has
1320
+ // never had, and would date every refusal to a migration that did not happen.
1321
+ // What the two SHARE is the thing `SCENE-KEYWORD-MEMBERSHIP` was for: the author gets the GROUND,
1322
+ // and what to write instead, rather than a spellcheck.
1323
+ //
1324
+ // The refusal is FREE in the `EDGE-GEOMETRY-CONSTRUCTS` sense — `sequence` is EXPERIMENTAL and this
1325
+ // is the release that gives it a vocabulary at all, so no document loses a
1326
+ // line. Each cell states its own ruling's ground, because the grounds differ.
1327
+ const REFUSED_IN={
1328
+ sequence:{
1329
+ 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.',
1330
+ 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.'
1331
+ }
1332
+ };
1333
+ const REFUSED_AT=' (refused; MIGRATIONS 0.4)';
1334
+ const REFUSED_IN_GENRE=(kw,genre)=>
1335
+ '"'+kw+'" is not allowed in genre '+genre+' — this genre REFUSED it, it is not a typo and not a withdrawal: '+
1336
+ REFUSED_IN[genre][kw]+
1337
+ ' Subject vocabulary is per genre (core §3, `GENRE-VOCABULARY-OBLIGATION`), so a spelling another genre declares is that genre\'s '+
1338
+ 'declaration and never this one\'s.'+REFUSED_AT;
1178
1339
  // `MEMBERSHIP-KEY-ACCEPTANCE`: THE OPTION-KEY HALF OF `SCENE-KEYWORD-MEMBERSHIP`. A per-genre withdrawal can
1179
1340
  // strand an option KEY as easily as it strands a keyword: `in=` states
1180
1341
  // membership and its ONLY value domain is the id of a containing `group`, so
@@ -1210,6 +1371,25 @@ const WITHDRAWN_OPT_FROM_GENRE=(key,genre)=>
1210
1371
  ' An option key is per genre for the same reason a keyword is (core §3, `GENRE-VOCABULARY-OBLIGATION`): the key is accepted '+
1211
1372
  'by the directive AND by the genre, and this genre\'s acceptance was withdrawn without touching any other\'s.'+
1212
1373
  WITHDREW_OPT_AT;
1374
+ // `UNDELIVERED-MESSAGE-MARKING`: the OPTION-KEY half of the refusal table, and the one
1375
+ // place in this engine where a diagnostic fires for a key that is NOT in
1376
+ // `OPT_KEYS`. That is deliberate and is the ruling's headline: `lost=` was
1377
+ // proposed and REFUSED, so registering the spelling to get a named message
1378
+ // would put the key in the language's closed option registry — the exact
1379
+ // thing the ruling declines — and a reader counting `OPT_KEYS` would find a
1380
+ // key no directive accepts. So the refusal rides the UNKNOWN-OPTION path
1381
+ // instead: `badOpts` and the connector scanner both consult this table before
1382
+ // they say `unknown option`, which costs one lookup and adds no surface.
1383
+ const REFUSED_OPT_IN={
1384
+ sequence:{
1385
+ 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.'
1386
+ }
1387
+ };
1388
+ const REFUSED_OPT_IN_GENRE=(key,genre)=>
1389
+ key+'= is not allowed in genre '+genre+' — this genre REFUSED the key, it is not a typo and not a withdrawal: '+
1390
+ REFUSED_OPT_IN[genre][key]+
1391
+ ' The spelling is not in the language\'s option registry at all, so no other genre accepts it either.'+
1392
+ REFUSED_AT;
1213
1393
  const GENRE_KW={
1214
1394
  block:new Set(SCENE_HOST_KW.concat(BLOCK_SUBJECT_KW, ['node','edge'])),
1215
1395
  topology:new Set(SCENE_HOST_KW.concat(TOPOLOGY_SUBJECT_KW, ['node','edge'])),
@@ -1228,7 +1408,28 @@ const GENRE_KW={
1228
1408
  bitfield:new Set(GENRE_FREE_KW.concat(['class','bitfield'])),
1229
1409
  // chart is experimental and attaches to a table id in the same document
1230
1410
  table:new Set(GENRE_FREE_KW.concat(['class','table','chart'])),
1231
- timing:new Set(GENRE_FREE_KW.concat(['class','timing']))
1411
+ timing:new Set(GENRE_FREE_KW.concat(['class','timing'])),
1412
+ // `SEQUENCE-SOURCE-STANDARD`-R182: `sequence` gets its row, and the row is what
1413
+ // closes the Batch-1 finding that the genre "states a reading and constrains
1414
+ // nothing". It is NOT built on `SCENE_HOST_KW`, and the three absences are
1415
+ // each a decision rather than an oversight:
1416
+ // - `flow` and `rank` are REFUSED. A sequence figure's two axes are BOTH
1417
+ // declaration-ordered — lifelines left-to-right in declaration order,
1418
+ // occurrences top-to-bottom in declaration order (draft §7) — so a key
1419
+ // that reverses or re-ranks a drawing would make the picture disagree
1420
+ // with the source, which `DECLARATION-ORDER-SEMANTICS` forbids. There is nothing for them to set.
1421
+ // - `group` is REFUSED (`SEQUENCE-PARTICIPANT-GROUPING`, `REFUSED_IN` above).
1422
+ // - the REGION openers `bitfield`/`table`/`timing`/`chart` are absent
1423
+ // because this genre has no scene to compose them into: `GENRE-COMPOSITION` composition
1424
+ // stacks a region OUTSIDE the scene, and a ladder has no outside yet.
1425
+ // They are absent, not refused — a `sequence` document that wanted a
1426
+ // register layout beside its exchange is a real want with no ruling, and
1427
+ // it stays an open question rather than a silent no.
1428
+ // What is left is the genre-free core, `class`, this genre's two spellings
1429
+ // and its three subject words — nine top-level keywords, and that is the
1430
+ // whole of what a `figdown 0.4 sequence` document may write.
1431
+ sequence:new Set(GENRE_FREE_KW.concat(['class'], SEQUENCE_SUBJECT_KW,
1432
+ ['lifeline','message']))
1232
1433
  };
1233
1434
  const CHILD_KW=new Set(['field','break','cell','width','signal','gap']);
1234
1435
 
@@ -1252,6 +1453,59 @@ function splitFigdownSections(text){
1252
1453
  return secs;
1253
1454
  }
1254
1455
 
1456
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's DERIVED reading, in one
1457
+ // function so the parser's checks and (from a later increment) the renderer
1458
+ // cannot disagree about what a document says.
1459
+ //
1460
+ // Two things are derived and nothing else is:
1461
+ // ROWS — the figure's total order. `messages` and `states` are the two
1462
+ // collections that carry an occurrence; each element records the
1463
+ // source `line` it was written on, and sorting the union of the two
1464
+ // on that number IS the order (draft §31: declaration order, and
1465
+ // the order is TOTAL — a declared divergence from UML's partial
1466
+ // order, taken so a reader never has to compute one).
1467
+ // CHAIN — the containment tree from `in=`. A fragment or an operand may
1468
+ // name a fragment or an operand, so the chain alternates in
1469
+ // practice but the walk does not assume it.
1470
+ // EXTENT is the span of row slots a container owns, transitively: an occurrence
1471
+ // inside an operand is inside that operand's fragment too. `cycles` names any
1472
+ // container that reaches itself, so a caller can refuse to read a tree that is
1473
+ // not one instead of walking it to a guard.
1474
+ function seqModel(doc){
1475
+ const rows=[];
1476
+ for(const m of doc.messages||[]) rows.push({kind:'message',el:m,line:m.line});
1477
+ for(const s of doc.states||[]) rows.push({kind:'state', el:s,line:s.line});
1478
+ rows.sort((a,b)=>a.line-b.line);
1479
+ rows.forEach((r,i)=>{ r.slot=i; });
1480
+ const cont={};
1481
+ for(const f of doc.fragments||[]) cont[f.id]={kind:'fragment',el:f,parent:f['in']||null};
1482
+ for(const o of doc.operands||[]) cont[o.id]={kind:'operand', el:o,parent:o['in']||null};
1483
+ const cycles=[];
1484
+ const chain=(id)=>{
1485
+ const out=[], seen=new Set();
1486
+ let c=id;
1487
+ while(c && cont[c]){
1488
+ if(seen.has(c)) break;
1489
+ seen.add(c); out.push(c); c=cont[c].parent;
1490
+ }
1491
+ return out;
1492
+ };
1493
+ for(const id in cont){
1494
+ const seen=new Set(); let c=cont[id].parent;
1495
+ while(c && cont[c] && !seen.has(c)){ if(c===id){ cycles.push(id); break; } seen.add(c); c=cont[c].parent; }
1496
+ }
1497
+ const owned={}; for(const id in cont) owned[id]=[];
1498
+ for(const r of rows){
1499
+ const inId=r.el['in']||null;
1500
+ r.chain=inId?chain(inId):[];
1501
+ for(const id of r.chain) if(owned[id]) owned[id].push(r.slot);
1502
+ }
1503
+ const extent={};
1504
+ for(const id in cont)
1505
+ extent[id]=owned[id].length?{lo:Math.min(...owned[id]),hi:Math.max(...owned[id])}:null;
1506
+ return {rows,cont,owned,extent,chain,cycles};
1507
+ }
1508
+
1255
1509
  // parse(text) -> {doc, errs, docs}
1256
1510
  // Single-section: docs=[doc] (backward-compatible doc/errs).
1257
1511
  // Multi-section: one doc per figdown header; errs use full-file line numbers;
@@ -1272,6 +1526,11 @@ function parse(text){
1272
1526
  const docs=[]; const errs=[];
1273
1527
  for(const sec of secs){
1274
1528
  const r=parseOne(sec.text);
1529
+ // A section's element `.line` values are section-local, and a GEOMETRY-time
1530
+ // error (one only `render` can raise) is built long after this loop has
1531
+ // finished re-basing the parse messages. Record the offset on the doc so
1532
+ // that error can quote the same full-file line the author is looking at.
1533
+ r.doc.lineOffset=sec.startLine-1;
1275
1534
  for(const e of r.errs){
1276
1535
  const m=/^Line (\d+): (.*)$/.exec(e);
1277
1536
  if(m) errs.push('Line '+(+m[1]+sec.startLine-1)+': '+m[2]);
@@ -1292,7 +1551,27 @@ function parseOne(text){
1292
1551
  // a distinction the model must keep.
1293
1552
  const doc={title:null,note:null,nodes:[],groups:[],edges:[],planes:[{id:'base',label:null,z:0}],
1294
1553
  flow:'right',ranks:[],pins:{},blocks:[],trunks:[],thresholds:[],bands:[],
1295
- classes:[],boundaries:[]};
1554
+ classes:[],boundaries:[],
1555
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` model. FIVE collections,
1556
+ // one per keyword, and they are separate arrays rather than
1557
+ // aliases of `nodes`/`edges` because a message is not an edge: an
1558
+ // edge is a relation between two nodes and has no position, while
1559
+ // a message is an OCCURRENCE with a place in a total order (draft
1560
+ // §31 — order is declaration order and it is TOTAL). Folding them
1561
+ // into `nodes`/`edges` would let the scene renderer reach them
1562
+ // and would put a time-ordered thing in a collection whose model
1563
+ // says order is not meaning.
1564
+ //
1565
+ // The ORDER of `messages`, `states` and the containment they
1566
+ // declare is recovered from the `line` each element carries: the
1567
+ // parser appends in source order, so each array is already
1568
+ // ordered and the union of `messages` and `states` sorted on
1569
+ // `line` is the figure's trace. Nothing else records time.
1570
+ //
1571
+ // These stay EMPTY in every non-`sequence` document, and the
1572
+ // canonical JSON binding omits an empty one (the `externals`
1573
+ // rule), so no existing golden moves a byte.
1574
+ lifelines:[],messages:[],states:[],fragments:[],operands:[]};
1296
1575
  const nodeIds=new Set(), groupIds=new Set(), planeIds=new Set(['base']), classIds=new Set(),
1297
1576
  bundleIds=new Set(), boundaryIds=new Set(), blockIds=new Set();
1298
1577
  // §1: "IDs are ... unique per document" — nodes, groups, boundaries AND the
@@ -1415,17 +1694,40 @@ function parseOne(text){
1415
1694
  if(idHere()){ err(n,ID_RULE); return; }
1416
1695
  const tk2=tokenize(s.slice(i).trim());
1417
1696
  if(tk2.error){ err(n,tk2.error); return; }
1418
- const {pos:p2,opts:o2,optT:oT2,unk:u2,dup:d2}=splitOpts(tk2.toks);
1697
+ const {pos:p2,posq:pq2,opts:o2,optT:oT2,unk:u2,dup:d2}=splitOpts(tk2.toks);
1419
1698
  if(d2){ err(n,'duplicate option "'+d2+'=" on one line'); return; }
1420
- if(u2.length){ err(n,'unknown option "'+u2[0]+'="'); return; }
1421
- if(p2.length){ err(n,'unexpected argument "'+p2[0]+'"'); return; }
1699
+ // `UNDELIVERED-MESSAGE-MARKING`: the connector's copy of the genre REFUSAL check for
1700
+ // an option key the language does not register. It sits on the
1701
+ // unknown-option path because `lost=` is not in `OPT_KEYS` — see
1702
+ // `REFUSED_OPT_IN` — and it must be here as well as in `badOpts` because
1703
+ // `message` is scanned by this function and never reaches that one.
1704
+ if(u2.length){
1705
+ const ro=(doc.genre&&REFUSED_OPT_IN[doc.genre])||null;
1706
+ if(ro && ro[u2[0]]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u2[0],doc.genre));
1707
+ else err(n,'unknown option "'+u2[0]+'="');
1708
+ return; }
1709
+ // `SEQUENCE-SOURCE-STANDARD`-R182: `message` adds ONE trailing positional — the
1710
+ // quoted message label — to the connector grammar, because that is where
1711
+ // every sequence source in the corpus writes it and the `[mid]` form
1712
+ // reads as an annotation rather than as the message itself. Every other
1713
+ // connector spelling keeps the grammar unchanged, so the surplus-argument
1714
+ // error is still the right answer for them.
1715
+ let seqLabel=null;
1716
+ if(kw==='message'){
1717
+ if(p2.length>1){ err(n,'unexpected argument "'+p2[1]+'"'); return; }
1718
+ if(p2.length===1){
1719
+ if(!pq2[0]){ err(n,'message label must be quoted: message '+a+' '+op+' '+b+' "'+p2[0]+'" — '+Q_WHY); return; }
1720
+ if(mid!==null){ err(n,'message has two labels — the inline -['+mid+']-> mid-label and the trailing "'+p2[0]+'". Write one'); return; }
1721
+ seqLabel=p2[0];
1722
+ }
1723
+ } else if(p2.length){ err(n,'unexpected argument "'+p2[0]+'"'); return; }
1422
1724
  // 0.1: `edge` has its own scanner, so the language-wide retired
1423
1725
  // keys need their own check here or `edge` would be the one directive
1424
1726
  // that reports the generic message for a retired spelling.
1425
1727
  for(const rk in RETIRED_OPT_KEYS)
1426
1728
  if(o2[rk]!==undefined){ err(n,RETIRED_OPT_KEYS[rk]); return; }
1427
1729
  for(const k in o2)
1428
- if(!DIRECTIVE_OPTS[kw].includes(k)){ err(n,kw+' does not take '+k+'='); return; }
1730
+ if(!directiveOpts(kw,doc.genre).includes(k)){ err(n,kw+' does not take '+k+'='); return; }
1429
1731
  for(const k of ['label','taillabel','headlabel'])
1430
1732
  if(o2[k]!==undefined){ err(n,k+'= is retired — write the label inline: '+kw+' A [tail] -[mid]-> [head] B (MIGRATIONS 0.1)'); return; }
1431
1733
  if(o2.fill!==undefined){ err(n,FILL_NO_INTERIOR(kw)); return; }
@@ -1463,6 +1765,38 @@ function parseOne(text){
1463
1765
  if(!pc.ok){ err(n,pc.err); return; }
1464
1766
  ecls=pc.ids;
1465
1767
  }
1768
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` fork. It is the SAME scanner with
1769
+ // a different reading, and the model it writes is a different collection:
1770
+ // a message is an OCCURRENCE with a place in the figure's total order
1771
+ // (draft §31), an edge is a relation with no position at all.
1772
+ if(kw==='message'){
1773
+ // Draft §8.2/§15.4. `--` parses everywhere else and is a line error
1774
+ // here, so the check is at the fork rather than in the operator scanner.
1775
+ if(!SEQ_OPERATORS.has(op)){
1776
+ 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 <->');
1777
+ return; }
1778
+ if(o2['in']!==undefined){
1779
+ const e=idErr(o2['in'], optHasQ(oT2,'in'), null);
1780
+ if(e){ err(n,e); return; }
1781
+ }
1782
+ if(o2.description!==undefined && !optQ(oT2,'description')){
1783
+ err(n,'description= must be quoted: description="'+o2.description+'" — '+Q_WHY); return; }
1784
+ // ONE label field, fed by either spelling. The shared scanner also
1785
+ // accepts the inline `-[mid]->` form, and the check above makes writing
1786
+ // both a line error, so the model can never hold two — but the model
1787
+ // must not hold the same text under two keys either, so `mid` is NOT
1788
+ // projected beside `label` here the way it is on an edge. That the two
1789
+ // spellings both reach this field at all is an ALIAS in `IDENTITY-ASSERTION`'s sense
1790
+ // and is a FINDING for the genre document (Batch 5) rather than a
1791
+ // ruling taken here: the draft settles the trailing form and says
1792
+ // nothing about the brackets. `[tail]` and `[head]` are kept — they are
1793
+ // different positions, not a second spelling of the same one.
1794
+ doc.messages.push({a,b,op,tail,head,
1795
+ label:seqLabel!==null?seqLabel:mid,
1796
+ style:o2.style,cls:ecls,stroke:o2.stroke,note:o2.note,
1797
+ desc:o2.description,in:o2['in']||null,line:n});
1798
+ return;
1799
+ }
1466
1800
  // §5 on an edge: the line IS a stroke and has no interior, so `stroke=`
1467
1801
  // and `fill=` name the same channel (`stroke=` wins when both are
1468
1802
  // written); `text=` colours the [tail]/[mid]/[head] labels.
@@ -1471,6 +1805,94 @@ function parseOne(text){
1471
1805
  plane:o2.plane||'base',line:n});
1472
1806
  }
1473
1807
 
1808
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the four `sequence` directives that are not the
1809
+ // connector. Reached only from the `doc.genre==='sequence'` dispatch, so
1810
+ // `state` under `statechart` never arrives here. Every option-VALUE check
1811
+ // (colours, `style=` enum, `class=` list, `in=` id spelling, `note=` and
1812
+ // `description=` quoting, `type=`'s bare-value rule) has already run in
1813
+ // `badOpts`; what is left is arity, mandatory arguments, and this genre's
1814
+ // own enum.
1815
+ const seqCls=(opts,optT)=>opts['class']!==undefined
1816
+ ? parseClassList(opts['class'],optList(optT,'class')).ids : undefined;
1817
+ function parseSeqDirective(kw,n,pos,posq,opts,optT){
1818
+ if(kw==='lifeline'){
1819
+ // `lifeline <id> ["label"]` — the participant column. It DECLARES an id
1820
+ // and joins the document-wide id namespace, so a lifeline cannot share
1821
+ // a spelling with a fragment or an operand.
1822
+ const id=pos[1];
1823
+ const e=idErr(id,!!posq[1],'lifeline needs <id> ["label"]');
1824
+ if(e){ err(n,e); return; }
1825
+ if(dupId(id)||doc.lifelines.some(l=>l.id===id)){ err(n,'duplicate id "'+id+'"'); return; }
1826
+ if(BLK_LBL(n,'lifeline',id,pos,posq)) return;
1827
+ if(pos[3]!==undefined){ err(n,'unexpected argument "'+pos[3]+'"'); return; }
1828
+ nodeIds.add(id);
1829
+ doc.lifelines.push({id,label:pos[2]!==undefined?pos[2]:null,in:opts['in']||null,
1830
+ cls:seqCls(opts,optT),fill:opts.fill,stroke:opts.stroke,
1831
+ style:opts.style,note:opts.note,desc:opts.description,line:n});
1832
+ return;
1833
+ }
1834
+ if(kw==='state'){
1835
+ // `state <lifeline-id> "<state name>"` — a StateInvariant (UML 2.5.1
1836
+ // §17.12.25). Slot 1 REFERENCES a lifeline; it does NOT declare an id,
1837
+ // because nothing in this genre refers to a state occurrence. That is
1838
+ // the asymmetry with `statechart`'s `state`, where slot 1 declares
1839
+ // (draft §29, Q5), and it is why the two share a spelling and nothing
1840
+ // else. Slot 2 is MANDATORY: a state occurrence with no name asserts
1841
+ // nothing at all.
1842
+ const ref=pos[1];
1843
+ const e=idErr(ref,!!posq[1],'state needs <lifeline-id> "<state name>"');
1844
+ if(e){ err(n,e); return; }
1845
+ 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; }
1846
+ if(!posq[2]){ err(n,'state name must be quoted: state '+ref+' "'+pos[2]+'" — '+Q_WHY); return; }
1847
+ if(pos[3]!==undefined){ err(n,'unexpected argument "'+pos[3]+'"'); return; }
1848
+ doc.states.push({ref,name:pos[2],in:opts['in']||null,
1849
+ cls:seqCls(opts,optT),fill:opts.fill,stroke:opts.stroke,
1850
+ style:opts.style,note:opts.note,desc:opts.description,line:n});
1851
+ return;
1852
+ }
1853
+ if(kw==='fragment'){
1854
+ // `fragment <id> ["label"] type=<operator>` — a CombinedFragment
1855
+ // (§17.12.3). `type=` is MANDATORY where UML defaults it to `seq`: a
1856
+ // fragment with no interaction operator draws a box that asserts
1857
+ // nothing, and a default would make the box look like an assertion.
1858
+ const id=pos[1];
1859
+ const e=idErr(id,!!posq[1],'fragment needs <id> ["label"] type=<operator>');
1860
+ if(e){ err(n,e); return; }
1861
+ if(dupId(id)||doc.fragments.some(f=>f.id===id)||doc.operands.some(o=>o.id===id)){
1862
+ err(n,'duplicate id "'+id+'"'); return; }
1863
+ if(BLK_LBL(n,'fragment',id,pos,posq)) return;
1864
+ if(pos[3]!==undefined){ err(n,'unexpected argument "'+pos[3]+'"'); return; }
1865
+ if(opts.type===undefined){
1866
+ err(n,'fragment needs type=<operator> — a fragment with no interaction operator asserts nothing ('+
1867
+ 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; }
1868
+ if(!SEQ_OPERATORS_FRAG.includes(opts.type)){
1869
+ err(n,'unknown interaction operator "'+opts.type+'" — write one of '+
1870
+ SEQ_OPERATORS_FRAG.join('|')+' ('+SEQ_FRAG_CLAUSE+' InteractionOperatorKind, taken whole)'); return; }
1871
+ doc.fragments.push({id,label:pos[2]!==undefined?pos[2]:null,type:opts.type,
1872
+ in:opts['in']||null,cls:seqCls(opts,optT),stroke:opts.stroke,
1873
+ style:opts.style,note:opts.note,desc:opts.description,line:n});
1874
+ return;
1875
+ }
1876
+ if(kw==='operand'){
1877
+ // `operand <id> ["guard"] in=<fragment-id>` — an InteractionOperand
1878
+ // (§17.12.14). `in=` is MANDATORY: an operand is a COMPARTMENT OF a
1879
+ // fragment and has no meaning apart from one.
1880
+ const id=pos[1];
1881
+ const e=idErr(id,!!posq[1],'operand needs <id> ["guard"] in=<fragment-id>');
1882
+ if(e){ err(n,e); return; }
1883
+ if(dupId(id)||doc.fragments.some(f=>f.id===id)||doc.operands.some(o=>o.id===id)){
1884
+ err(n,'duplicate id "'+id+'"'); return; }
1885
+ if(BLK_LBL(n,'operand',id,pos,posq)) return;
1886
+ if(pos[3]!==undefined){ err(n,'unexpected argument "'+pos[3]+'"'); return; }
1887
+ if(opts['in']===undefined){
1888
+ 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; }
1889
+ doc.operands.push({id,label:pos[2]!==undefined?pos[2]:null,in:opts['in'],
1890
+ cls:seqCls(opts,optT),stroke:opts.stroke,style:opts.style,
1891
+ note:opts.note,desc:opts.description,line:n});
1892
+ return;
1893
+ }
1894
+ }
1895
+
1474
1896
  for(let li=0; li<lines.length; li++){
1475
1897
  const n=li+1;
1476
1898
  let raw=lines[li];
@@ -1545,7 +1967,11 @@ function parseOne(text){
1545
1967
  // the dispatch cannot be narrowed to the genre's own word — a `flowline`
1546
1968
  // under `block` would then fall through to `unrecognized line`, which is
1547
1969
  // exactly the answer these rulings owe an author better than.
1548
- const mConn=/^(edge|flowline|transition)(\s|$)/.exec(raw.trim());
1970
+ // `SEQUENCE-SOURCE-STANDARD`-R182: `message` is the FOURTH spelling scanned here,
1971
+ // and it is derived from `CONNECTOR_SPELLINGS` rather than spelled again,
1972
+ // so a fifth genre's connector reaches the named diagnostic by joining
1973
+ // that set and not by remembering to edit a regex.
1974
+ const mConn=CONN_LINE_RE.exec(raw.trim());
1549
1975
  if(mConn){
1550
1976
  const ckw=mConn[1];
1551
1977
  if(firstContent){ firstContent=false; err(n,'first line must be "figdown 0.1 <genre>"'); }
@@ -1587,12 +2013,25 @@ function parseOne(text){
1587
2013
  // Directives not in DIRECTIVE_OPTS (title's single quoted string, unknown
1588
2014
  // keywords) are handled by their own paths.
1589
2015
  const badOpts=(k)=>{
1590
- const allowed=DIRECTIVE_OPTS[k];
2016
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the row is read GENRE-FIRST. `state` is one
2017
+ // spelling with two declarations and two option sets (`GENRE-VOCABULARY-OBLIGATION`), and
2018
+ // `GENRE_DIRECTIVE_OPTS` is the only place that fact is recorded.
2019
+ const allowed=directiveOpts(k,doc.genre);
1591
2020
  if(!allowed) return false;
1592
2021
  let bad=false;
1593
2022
  // same-line repeated option key (last-wins was silent data loss)
1594
2023
  if(dup){ err(n,'duplicate option "'+dup+'=" on one line'); bad=true; }
1595
- for(const u of unk){ err(n,'unknown option "'+u+'="'); bad=true; }
2024
+ // `UNDELIVERED-MESSAGE-MARKING`: a genre REFUSAL of a key the language never
2025
+ // registered is answered here, on the unknown-option path, because that
2026
+ // is the only path it can reach: `lost=` is deliberately absent from
2027
+ // `OPT_KEYS` (the ruling adds no option key), so `splitOpts` reports it
2028
+ // as unknown. Checked BEFORE the generic message so the author gets the
2029
+ // ground and the replacement spelling instead of a spellcheck.
2030
+ const roOpt=(doc.genre&&REFUSED_OPT_IN[doc.genre])||null;
2031
+ for(const u of unk){
2032
+ if(roOpt && roOpt[u]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u,doc.genre));
2033
+ else err(n,'unknown option "'+u+'="');
2034
+ bad=true; }
1596
2035
  // `MEMBERSHIP-KEY-ACCEPTANCE`: the PER-GENRE option-key withdrawal, checked here —
1597
2036
  // after `unknown option`, so a key the LANGUAGE does not have keeps its
1598
2037
  // own answer, and before every value check, so a withdrawn key is never
@@ -2087,6 +2526,19 @@ function parseOne(text){
2087
2526
  // Dynamic-profile reserved words keep their dedicated message (before `GENRE-KEYWORD-ALLOWLIST`).
2088
2527
  if(kw==='page'||kw==='set'||kw==='pulse'){
2089
2528
  err(n,'"'+kw+'" is reserved for the dynamic profile (not in v0.1)'); continue; }
2529
+ // `SEQUENCE-TIME-GAP`: a genre REFUSAL is consulted BEFORE the typed-block
2530
+ // child exemption below, because `gap` is BOTH — `timing`'s child keyword
2531
+ // and the one spelling `sequence` refused that is also a child word. Left
2532
+ // in the old order, `gap "T1 fires"` in a sequence document answered
2533
+ // `"gap" is a typed-block child — it needs a bitfield/table/timing block
2534
+ // above it`, which is TRUE OF THE LANGUAGE and says nothing about the
2535
+ // ruling the author has actually run into. This clause fires only for a
2536
+ // spelling that is a child keyword AND refused by this genre, so no other
2537
+ // genre's answer moves; every other refused or withdrawn word reaches its
2538
+ // own message through the ordinary chain below.
2539
+ if(sawHeader && doc.genre && CHILD_KW.has(kw) &&
2540
+ REFUSED_IN[doc.genre] && REFUSED_IN[doc.genre][kw]){
2541
+ err(n, REFUSED_IN_GENRE(kw, doc.genre)); continue; }
2090
2542
  // `GENRE-KEYWORD-ALLOWLIST`: after closing a typed region, top-level keywords
2091
2543
  // must be in the header genre allowlist. Child keywords still use the
2092
2544
  // "needs a bitfield/table/timing above" path when they appear with no cur.
@@ -2104,6 +2556,11 @@ function parseOne(text){
2104
2556
  // needs the ground, not a spellcheck.
2105
2557
  else if(GENRE_WITHDRAWN[doc.genre] && GENRE_WITHDRAWN[doc.genre][kw])
2106
2558
  err(n, WITHDRAWN_FROM_GENRE(kw, doc.genre));
2559
+ // `SEQUENCE-TIME-GAP`/`SEQUENCE-PARTICIPANT-GROUPING`: and one step further again. A word this genre
2560
+ // REFUSED is not an unknown word either, and it is not a withdrawal —
2561
+ // the author needs the ruling's ground and the spelling that works.
2562
+ else if(REFUSED_IN[doc.genre] && REFUSED_IN[doc.genre][kw])
2563
+ err(n, REFUSED_IN_GENRE(kw, doc.genre));
2107
2564
  else
2108
2565
  err(n,'"'+kw+'" is not allowed in genre '+doc.genre);
2109
2566
  continue;
@@ -2116,6 +2573,13 @@ function parseOne(text){
2116
2573
  continue;
2117
2574
  }
2118
2575
 
2576
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's four own directives.
2577
+ // Dispatched BEFORE the switch and scoped by `doc.genre`, which is what
2578
+ // keeps `state` reaching `statechart`'s node parser under `statechart` —
2579
+ // `GENRE-VOCABULARY-OBLIGATION` in the dispatcher, not only in the allowlist.
2580
+ if(doc.genre==='sequence' && SEQ_KW.has(kw)){
2581
+ parseSeqDirective(kw,n,pos,posq,opts,optT); continue; }
2582
+
2119
2583
  switch(kw){
2120
2584
  case 'title': {
2121
2585
  if(sawTitle){ err(n,'duplicate title line'); break; }
@@ -2617,6 +3081,117 @@ function parseOne(text){
2617
3081
  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)');
2618
3082
  if(e.plane && !planeIds.has(e.plane)) errs.push('Line '+e.line+': unknown plane "'+e.plane+'"');
2619
3083
  }
3084
+ // ── `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's semantic checks ────────
3085
+ // Everything here needs the WHOLE document, so none of it can live in
3086
+ // `parseSeqDirective`: forward references are legal (fixture 021 pins that
3087
+ // for the scene genres and the rule is language-wide), so an id can only be
3088
+ // resolved once every declaration has been read.
3089
+ if(doc.genre==='sequence'){
3090
+ const llIds=new Set(doc.lifelines.map(l=>l.id));
3091
+ const fragIds=new Set(doc.fragments.map(f=>f.id));
3092
+ const opIds=new Set(doc.operands.map(o=>o.id));
3093
+ // The `in=` OBJECT rule (`SEQUENCE-CONTAINMENT-SCOPE`), in one place because it is one rule: on
3094
+ // all five acceptors `in=` is sense 1 — *the element this one lives
3095
+ // inside* — and its value domain is a `fragment` or an `operand` id and
3096
+ // nothing else. The message names the domain AND the acceptor list,
3097
+ // because an author who wrote a lifeline id there has the relation right
3098
+ // and the object wrong, and needs to be told which.
3099
+ const IN_ACCEPTORS='message, operand, lifeline, state and fragment';
3100
+ const inErr=(line,val,what)=>{
3101
+ if(fragIds.has(val)||opIds.has(val)) return;
3102
+ errs.push('Line '+line+': unknown fragment or operand "'+val+'" — in= on a '+what+
3103
+ ' names the fragment or operand this '+what+' occurs inside'+
3104
+ (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':'')+
3105
+ '. 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)');
3106
+ };
3107
+ for(const l of doc.lifelines) if(l['in']) inErr(l.line,l['in'],'lifeline');
3108
+ for(const m of doc.messages){
3109
+ if(!llIds.has(m.a)) errs.push('Line '+m.line+': unknown lifeline "'+m.a+'"');
3110
+ if(!llIds.has(m.b)) errs.push('Line '+m.line+': unknown lifeline "'+m.b+'"');
3111
+ if(m['in']) inErr(m.line,m['in'],'message');
3112
+ }
3113
+ for(const st of doc.states){
3114
+ 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)');
3115
+ if(st['in']) inErr(st.line,st['in'],'state');
3116
+ }
3117
+ for(const f of doc.fragments) if(f['in']) inErr(f.line,f['in'],'fragment');
3118
+ // An operand's `in=` is MANDATORY and its object is narrower than the
3119
+ // general rule: a compartment belongs to a FRAGMENT, never to another
3120
+ // compartment, so an operand id there is a specific mistake with a
3121
+ // specific answer.
3122
+ for(const o of doc.operands)
3123
+ if(!fragIds.has(o['in']))
3124
+ errs.push('Line '+o.line+': unknown fragment "'+o['in']+'" — operand in= names a FRAGMENT'+
3125
+ (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)'
3126
+ :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'
3127
+ :''));
3128
+ // Two CONSECUTIVE `state` lines naming the same lifeline and the same
3129
+ // state name are a line error (draft §23.2). A state that has not changed
3130
+ // is never restated, so a genuine duplicate is always a mistake — and
3131
+ // because a transition is DERIVED from an adjacent pair, a reader
3132
+ // "tidying duplicates" could otherwise silently delete a fact.
3133
+ const lastState={};
3134
+ for(const st of doc.states.slice().sort((a,b)=>a.line-b.line)){
3135
+ if(lastState[st.ref]===st.name)
3136
+ errs.push('Line '+st.line+': lifeline "'+st.ref+'" is already in state "'+st.name+
3137
+ '" — 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)');
3138
+ lastState[st.ref]=st.name;
3139
+ }
3140
+ const SM=seqModel(doc);
3141
+ // A containment CYCLE is checked before anything that walks the chain, or
3142
+ // the walk terminates on a guard and every downstream answer is arbitrary.
3143
+ for(const id of SM.cycles)
3144
+ errs.push('Line '+SM.cont[id].el.line+': '+SM.cont[id].kind+' "'+id+
3145
+ '" is inside itself — in= containment is a tree, and a cycle denotes nothing at all');
3146
+ if(!SM.cycles.length){
3147
+ // THE ONE-LEVEL NESTING CAP (`SEQUENCE-CONTAINMENT-SCOPE`). A fragment may sit inside an
3148
+ // operand of ONE enclosing fragment and no deeper. The cap is taken on
3149
+ // the v0.1 `group` precedent — "one level is the whole of v0.1's
3150
+ // containment" (core §2.2, and the diagnostic `group does not take
3151
+ // in=`) — and it is a SCOPE decision, not a principle: a second level
3152
+ // lands on measured need, the same evidence any other cell needs.
3153
+ for(const f of doc.fragments){
3154
+ const anc=SM.chain(f['in']).filter(id=>SM.cont[id].kind==='fragment');
3155
+ if(anc.length>1)
3156
+ errs.push('Line '+f.line+': fragment "'+f.id+'" nests '+anc.length+
3157
+ ' 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=');
3158
+ }
3159
+ // CONTIGUITY (draft §28.1). A fragment's or operand's members must be a
3160
+ // CONTIGUOUS run in declaration order, and the reason is the MODEL and
3161
+ // not the drawing: an operand denotes the ORDERED RUN of the
3162
+ // occurrences it contains, so an occurrence that is not in it cannot
3163
+ // happen between two that are. Non-contiguous membership denotes
3164
+ // nothing (UML 2.5.1 §17.6).
3165
+ // One offending row is reported ONCE, against the DEEPEST container it
3166
+ // splits. A row inside an operand's span is inside that operand's
3167
+ // fragment too, so an un-deduplicated pass reports the same line twice
3168
+ // and the outer report's advice is wrong: writing `in=<fragment>` would
3169
+ // repair the fragment and leave the operand split. The deepest
3170
+ // container is the one whose `in=` actually fixes the document.
3171
+ const split=new Map(); // row slot -> container id
3172
+ for(const id in SM.cont){
3173
+ const e=SM.extent[id];
3174
+ const kind=SM.cont[id].kind, aKind=(kind==='operand'?'an ':'a ')+kind;
3175
+ if(!e){
3176
+ errs.push('Line '+SM.cont[id].el.line+': '+kind+' "'+id+
3177
+ '" has no members — '+aKind+"'s extent is the span of the lines carrying in="+id+
3178
+ ', and a container with no extent asserts nothing');
3179
+ continue; }
3180
+ const own=new Set(SM.owned[id]);
3181
+ for(let s=e.lo;s<=e.hi;s++){
3182
+ if(own.has(s)) continue;
3183
+ const prev=split.get(s);
3184
+ if(prev===undefined || SM.chain(id).length>SM.chain(prev).length) split.set(s,id);
3185
+ break;
3186
+ }
3187
+ }
3188
+ for(const [s,id] of [...split.entries()].sort((a,b)=>a[0]-b[0])){
3189
+ const r=SM.rows[s], e=SM.extent[id];
3190
+ errs.push('Line '+r.line+': this '+r.kind+' line splits '+SM.cont[id].kind+' "'+id+
3191
+ '" (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');
3192
+ }
3193
+ }
3194
+ }
2620
3195
  for(const r of doc.ranks) for(const id of r.ids)
2621
3196
  if(!nodeIds.has(id)) errs.push('Line '+r.line+': unknown node "'+id+'" in rank');
2622
3197
  // `MARKER-TARGET-KINDS`: `in=` on `threshold`/`band` also resolves a REGION id —
@@ -2702,32 +3277,102 @@ function parseOne(text){
2702
3277
  // `fill=` and no `stroke=`, used by an edge. Ignoring it would drop the
2703
3278
  // edge's colour with nothing to warn on; honouring it would make `fill`
2704
3279
  // mean "stroke" for that member. So it is a line error that names the
2705
- // key to add. `edge` is the only interior-less construct taking `class=`.
3280
+ // key to add. `edge` was the only interior-less construct taking `class=`
3281
+ // until the `sequence` genre added three more (`message`, `fragment`,
3282
+ // `operand`), which is what 0.4 below is about.
2706
3283
  //
2707
- // 0.1 (`CLASS-PAINT-REQUIREMENT`): the SAME hole sat one key over and was left open.
2708
- // 0.1 rejected `fill=`-only and said nothing about a class that
2709
- // paints NEITHER channel `class p "Path" color=#dc2626` plus `edge a ->
2710
- // b class=p` was accepted, drew a #555 line, and rendered a legend swatch
2711
- // that showed nothing, so the class's meaning was invisible in its own
2712
- // derived legend. With `color=` retired (`COLOUR-KEY-STATUS`) the remaining shape of the
2713
- // hole is a class carrying only `style=`, or nothing at
2714
- // all: the edge silently takes the default colour and the author who
2715
- // declared a class to CLASSIFY the edge gets no colour and no warning.
2716
- // Both halves are the same rule — a class an edge joins must declare at
2717
- // least one channel an edge HAS so they share one diagnostic shape.
2718
- // An edge has exactly two: `stroke` (its colour) and `style` (its dash).
2719
- // `style=`-only is therefore FINE and must stay fine: the dash reaches the
2720
- // edge, nothing is lost, and it is how a multi-class cascade splits one
2721
- // meaning across two declarations (`class=hot,deprecated`). The test is
2722
- // per class, per channel — the same shape `INTERIOR-LESS-ELEMENT-PAINT` chose, for the same reason.
2723
- for(const e of doc.edges) for(const cid of (e.cls||[])){
2724
- const c=doc.classes.find(x=>x.id===cid);
2725
- if(!c||c.stroke!==undefined) continue;
2726
- if(c.fill===undefined&&c.style!==undefined) continue;
2727
- if(c.fill!==undefined)
2728
- 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)');
2729
- else
2730
- 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)');
3284
+ // 0.1 (`CLASS-PAINT-REQUIREMENT`) added a SECOND half a class that paints NEITHER
3285
+ // channel, joined by an edge, was a line error too — and 0.4 (`CLASS-CHANNEL-REACH`)
3286
+ // RETIRES that half. It is not deleted quietly: `CLASS-PAINT-REQUIREMENT`'s own release fixed
3287
+ // the harm it named. The stated defect was that such a class "shows
3288
+ // nothing in the legend", and the same release made the derived legend
3289
+ // draw the meaning with NO swatch (see the legend strip in `render`), so
3290
+ // the meaning does reach the reader. What survived was only "the member
3291
+ // takes its default paint" which is exactly what 14 shipped `field`
3292
+ // members already get, legally, from meaning-only classes in
3293
+ // examples/gre.fd, quic.fd, srh.fd and showcase/tcp-header.fd. A rule that
3294
+ // cannot generalise past one collection was not a rule about channels. A
3295
+ // class that claims a meaning and declares no paint is therefore legal on
3296
+ // EVERY member (`CLASS-CHANNEL-REACH`, MIGRATIONS 0.4), which is also the form the
3297
+ // `sequence` genre is built on: `class` there carries what `group` (`SEQUENCE-PARTICIPANT-GROUPING`)
3298
+ // and `lost=` (`UNDELIVERED-MESSAGE-MARKING`) were refused in favour of, so a meaning with no paint
3299
+ // is that genre's designed idiom, not an oversight.
3300
+ //
3301
+ // `INTERIOR-LESS-ELEMENT-PAINT`'s half stands and now reaches EVERY collection that accepts
3302
+ // `class=` (`CLASS-CHANNEL-REACH`). Until this release the loop below ran over `doc.edges`
3303
+ // alone, so `class k "K" fill=#eee` plus `message c -> s "m" class=k` was
3304
+ // accepted, painted nothing, and put the class in the legend — a message
3305
+ // has its own collection because it has a position in time (`SEQUENCE-ORDER-MODEL`), and
3306
+ // the check never looked there.
3307
+ //
3308
+ // THE CHANNEL SETS ARE DERIVED FROM WHAT EACH RENDERER READS, not from
3309
+ // what the directive tables accept — a key the drawing never consults is
3310
+ // not a channel the member HAS. Read off the `chan()` call sites in
3311
+ // `renderSequence` and the `rsAll`/`dashOf` sites in `render`:
3312
+ // node, group, lifeline, state fill, stroke, style (box/pill: all three)
3313
+ // edge, message stroke, style (no interior)
3314
+ // fragment, operand stroke, style (frame/rule; a
3315
+ // fragment's interior would hide its own
3316
+ // members, so it has no `fill=` to set)
3317
+ // field, cell fill, stroke (`style=` left both
3318
+ // directives at `STYLE-KEY-SCOPE` and no `dashOf` reads
3319
+ // `f.style`/`mk.style`)
3320
+ // A member with all three channels can never fail this test; the rows are
3321
+ // listed anyway, because the table is the rule and a missing row would
3322
+ // read as "not considered".
3323
+ //
3324
+ // TWO CASES FIRE, and both are declared paint that cannot arrive:
3325
+ // (a) `fill=` with no `stroke=` on a class an INTERIOR-LESS member joins.
3326
+ // Not caught by (b), because `fill=` plus `style=` would pass it: on
3327
+ // a line `fill=` and `stroke=` NAME THE SAME CHANNEL (the same reason
3328
+ // `fill=` on an `edge` LINE is refused), so an author who wrote
3329
+ // `fill=` meant the line's colour and `style=` does not answer that.
3330
+ // (b) a class whose channels are ALL channels the member lacks — the
3331
+ // general shape, which reaches `style=`-only on a `field` or a `cell`.
3332
+ // Guarded on the class declaring at least one channel, so a
3333
+ // meaning-only class falls through it (`CLASS-CHANNEL-REACH`).
3334
+ // Both are per class, per channel — `INTERIOR-LESS-ELEMENT-PAINT`'s shape, for `INTERIOR-LESS-ELEMENT-PAINT`'s reason. A class
3335
+ // that also declares a channel the member HAS is fine and must stay fine:
3336
+ // `class hot "…" fill=#fee2e2 stroke=#dc2626` paints a node's box and an
3337
+ // edge's line from one meaning, and `class=hot,deprecated` splits one
3338
+ // meaning across two declarations (conformance case 308).
3339
+ const CLASS_CHANNELS={
3340
+ node: {has:['fill','stroke','style'], a:'a node'},
3341
+ group: {has:['fill','stroke','style'], a:'a group'},
3342
+ lifeline: {has:['fill','stroke','style'], a:'a lifeline'},
3343
+ state: {has:['fill','stroke','style'], a:'a state'},
3344
+ edge: {has:['stroke','style'], a:'an edge'},
3345
+ message: {has:['stroke','style'], a:'a message'},
3346
+ fragment: {has:['stroke','style'], a:'a fragment'},
3347
+ operand: {has:['stroke','style'], a:'an operand'},
3348
+ field: {has:['fill','stroke'], a:'a field'},
3349
+ cell: {has:['fill','stroke'], a:'a cell'},
3350
+ };
3351
+ const clsChan=(x,kind)=>{
3352
+ const K=CLASS_CHANNELS[kind];
3353
+ for(const cid of (x.cls===undefined||x.cls===null?[]:(Array.isArray(x.cls)?x.cls:[x.cls]))){
3354
+ const c=doc.classes.find(y=>y.id===cid);
3355
+ if(!c) continue; // unknown id: its own error
3356
+ const decl=['fill','stroke','style'].filter(k=>c[k]!==undefined);
3357
+ if(!decl.length) continue; // meaning only — legal (`CLASS-CHANNEL-REACH`)
3358
+ if(!K.has.includes('fill')&&c.fill!==undefined&&c.stroke===undefined){
3359
+ 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)');
3360
+ continue; }
3361
+ if(!decl.some(k=>K.has.includes(k)))
3362
+ 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)');
3363
+ }
3364
+ };
3365
+ for(const x of doc.nodes) clsChan(x,'node');
3366
+ for(const x of doc.groups) clsChan(x,'group');
3367
+ for(const x of doc.edges) clsChan(x,'edge');
3368
+ for(const x of (doc.messages||[])) clsChan(x,'message');
3369
+ for(const x of (doc.lifelines||[])) clsChan(x,'lifeline');
3370
+ for(const x of (doc.states||[])) clsChan(x,'state');
3371
+ for(const x of (doc.fragments||[])) clsChan(x,'fragment');
3372
+ for(const x of (doc.operands||[])) clsChan(x,'operand');
3373
+ for(const b of doc.blocks){
3374
+ if(b.fields) for(const f of b.fields) clsChan(f,'field');
3375
+ if(b.marks) for(const mk of b.marks) clsChan(mk,'cell');
2731
3376
  }
2732
3377
  }
2733
3378
  { // class references must resolve (closed grammar)
@@ -3323,13 +3968,37 @@ function render(doc,ropts){
3323
3968
  if(b.fields) for(const f of b.fields) rsAll(f);
3324
3969
  if(b.marks) for(const mk of b.marks) rsAll(mk);
3325
3970
  }
3971
+ // GEOMETRY-TIME DIAGNOSTICS. `parse` cannot see a coordinate, so a defect
3972
+ // that is only visible in the DRAWING (a group band enclosing a non-member
3973
+ // the author pinned there) has no channel to report through today. The scene
3974
+ // hands its diagnostics back here and `render` returns them beside the SVG;
3975
+ // a caller that writes an artifact must treat a non-empty list exactly as it
3976
+ // treats a parse error, because the alternative is writing a picture that
3977
+ // states something the source does not.
3978
+ let sceneErrs=[];
3326
3979
  const parts=[]; let y=0, maxW=0;
3327
3980
  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;
3328
3981
  maxW=Math.max(maxW, cw(doc.title)*8.6); } // canvas must fit the title
3329
3982
  let sceneMeta=null;
3330
- if(doc.nodes.length||doc.edges.length||(doc.boundaries||[]).length){
3983
+ // THE LADDER. A sequence figure has NO SCENE: both of its axes
3984
+ // are declaration-ordered, so there is nothing for the scene layout to
3985
+ // place, and its elements live in their own five collections rather than in
3986
+ // `nodes`/`edges` — which is why the scene branch could never have drawn
3987
+ // one. The branch is an `else if` and not a second pass because the two
3988
+ // renderers are alternatives, never neighbours: `lifeline` and `node` cannot
3989
+ // both appear in one document (one genre, one node spelling).
3990
+ //
3991
+ // Everything AFTER this point is shared and unchanged — the region stack,
3992
+ // the derived `class` legend, the figure-level note, the canvas padding and
3993
+ // the title. A `class` on a `message` earns its legend entry from the same
3994
+ // code that derives a topology figure's.
3995
+ if(doc.genre==='sequence'&&(doc.lifelines||[]).length){
3996
+ const s=renderSequence(doc,y); parts.push(s.svg); y=s.y; maxW=Math.max(maxW,s.w);
3997
+ }
3998
+ else if(doc.nodes.length||doc.edges.length||(doc.boundaries||[]).length){
3331
3999
  const s=renderScene(doc,y); parts.push(s.svg); y=s.y; maxW=Math.max(maxW,s.w);
3332
4000
  sceneMeta=s.meta;
4001
+ if(s.errs&&s.errs.length) sceneErrs=sceneErrs.concat(s.errs);
3333
4002
  }
3334
4003
  // `MARKER-TARGET-KINDS`: a region-scope `threshold`/`band` is drawn HERE and not
3335
4004
  // in `renderScene`, because a region is not in the scene. Typed blocks stack
@@ -3448,12 +4117,23 @@ function render(doc,ropts){
3448
4117
  +'<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">'
3449
4118
  +'<line x1="0" y1="0" x2="0" y2="6" stroke="#bbb" stroke-width="2"/></pattern></defs>'
3450
4119
  +'<g transform="translate('+PADL+','+PADT+')">'+parts.join('')+'</g></svg>', w:W, h:H,
3451
- sceneMeta:sceneMeta, pad:{x:PADL,y:PADT}};
4120
+ sceneMeta:sceneMeta, pad:{x:PADL,y:PADT}, errs:sceneErrs};
3452
4121
  }
3453
4122
 
3454
4123
  // ---- scene ----
3455
4124
  function renderScene(doc,y0){
3456
4125
  const nodes=doc.nodes.map(n=>({...n}));
4126
+ // Band padding around a group's members — the ONE place it is written. The
4127
+ // contiguity pass and the drawn rect must agree to the pixel: a pass that
4128
+ // separates against a slightly different rectangle from the one painted is a
4129
+ // pass that reports clean on a picture that is not.
4130
+ const BAND={l:14,r:14,t:26,b:12};
4131
+ // A geometry-time diagnostic carries a SOURCE line like every other error in
4132
+ // this engine, and in a multi-section document the line the author reads is
4133
+ // the FULL-FILE one. `parse` re-bases its own messages; a render error is
4134
+ // built here, after that pass, so the doc carries the offset itself (0 for a
4135
+ // single-section document, where the two numberings coincide).
4136
+ const srcLine=n=>(n===null||n===undefined)?null:n+(doc.lineOffset||0);
3457
4137
  // §2.4 plane z = paint order, applied by every pass that stacks annotations
3458
4138
  // (edges, bundle rings, threshold lines, zone bands). The sort is stable, so
3459
4139
  // same-plane items keep document order — "a later line paints on top".
@@ -3673,7 +4353,45 @@ function renderScene(doc,y0){
3673
4353
  for(let i=r0;i<r1;i++) mainGap[i]=Math.max(mainGap[i],need);
3674
4354
  }
3675
4355
  const center=n=>n.cross+cs(n)/2;
4356
+ // GROUP CONTIGUITY, ORDERING HALF. A group's band is the bounding box of its
4357
+ // members, so a non-member that the lane order happens to place BETWEEN two
4358
+ // of them is drawn inside the band — the picture then states a membership the
4359
+ // source never declared. The geometry pass further down can always evict the
4360
+ // intruder, but eviction leaves the members where they were and the band
4361
+ // keeps a hole where the intruder used to be; ordering them adjacent HERE,
4362
+ // before any coordinate exists, costs nothing and packs the group properly.
4363
+ //
4364
+ // Sorting by a key that is CONSTANT ACROSS A GROUP is what makes members
4365
+ // contiguous — equal keys land together — and the key is the group's MEAN
4366
+ // desired position, so the cluster still sits where the incoming order put
4367
+ // it. The sort is stable, so members keep the order they arrived in and a
4368
+ // lane with nothing interleaved is not reordered at all.
4369
+ //
4370
+ // A group with a PINNED member is left alone: the pin is the author's
4371
+ // coordinate, this pass cannot move it, and clustering the rest around a slot
4372
+ // the pin will overwrite only displaces free nodes for nothing. That figure
4373
+ // is the AUTHOR's half of the ruling and is reported, not redrawn.
4374
+ const clusterGroups=arr=>{ // arr of {n, d}; reordered in place
4375
+ if(!arr.some(x=>x.n.group)) return arr;
4376
+ const mean=new Map();
4377
+ for(const x of arr){ const g=x.n.group; if(!g) continue;
4378
+ if(!mean.has(g)) mean.set(g,{s:0,k:0,pin:false});
4379
+ const t=mean.get(g); t.s+=x.d; t.k++; if(pinned(x.n.id)) t.pin=true; }
4380
+ if(![...mean.values()].some(t=>!t.pin&&t.k>1)) return arr;
4381
+ const live=g=>g&&mean.has(g)&&!mean.get(g).pin;
4382
+ const key=x=>live(x.n.group)?mean.get(x.n.group).s/mean.get(x.n.group).k:x.d;
4383
+ const tag=x=>live(x.n.group)?x.n.group:'';
4384
+ arr.sort((p,q)=>key(p)-key(q)||(tag(p)<tag(q)?-1:tag(p)>tag(q)?1:0));
4385
+ return arr;
4386
+ };
3676
4387
  ranksArr.forEach(lane=>{ if(!lane) return; let c=0; // seed: doc order
4388
+ // The seed is the ONLY ordering a single-rank figure ever gets: `sweep`
4389
+ // walks rank boundaries, so a scene with no edges never reaches `place`.
4390
+ // The six-line reproduction (`group g` + three nodes, the middle one not a
4391
+ // member) is exactly that figure, which is why the clustering runs here too
4392
+ // and not only in the sweep.
4393
+ const ord=clusterGroups(lane.map((n,k)=>({n,d:k}))).map(x=>x.n);
4394
+ lane.length=0; ord.forEach(n=>lane.push(n));
3677
4395
  lane.forEach((n,k)=>{ n.cross=c; c+=cs(n)+(k<lane.length-1?gapOf(n,lane[k+1]):0); }); });
3678
4396
  // WHERE THE HOLD YIELDS, WHICH IS MOST OF THE RULE. Holding a chain node on
3679
4397
  // its chain neighbour puts every OTHER neighbour of that node on one side of
@@ -3879,6 +4597,148 @@ function renderScene(doc,y0){
3879
4597
  if(o){ n.x=o.x+p.fx; n.y=o.y+p.fy; }
3880
4598
  else { n.x=p.fx; n.y=y0+20+p.fy; }
3881
4599
  }
4600
+ // ── GROUP BAND CONTIGUITY ────────────────────────────────────────────────
4601
+ // A group's band is the BOUNDING BOX of its members (see gBox below), and
4602
+ // until this pass nothing checked that the box contained only members. A
4603
+ // non-member the layout happened to place between two members was therefore
4604
+ // drawn INSIDE the band, with no error and no warning: six legal lines
4605
+ // (`group g`, three nodes of which the middle one is not `in=g`, `layout`)
4606
+ // produced a picture that says the middle node is in the group. That is the
4607
+ // worst failure class this project has — a legal document that reads
4608
+ // confidently and wrongly — and it contradicts this genre's own rule that
4609
+ // membership is DECLARED and never inferred from rendered geometry.
4610
+ //
4611
+ // WHOEVER CHOSE THE POSITION BEARS THE RESPONSIBILITY. That single principle
4612
+ // splits the fix in two:
4613
+ //
4614
+ // the ENGINE chose it — auto-layout had freedom, so the engine MUST place
4615
+ // the members contiguously and the situation cannot arise. It is fixed
4616
+ // here, silently, at no cost to the author.
4617
+ // the AUTHOR chose it — a `pin` fixed the intruder (or fixed the members
4618
+ // whose extent IS the band) and the engine has no freedom left. It then
4619
+ // reports, naming the pin line, and the artifact is not written. The
4620
+ // engine never overrides the author's coordinate, and never draws a
4621
+ // statement the source did not make.
4622
+ //
4623
+ // It runs HERE — after pins are applied and before the group origins are
4624
+ // taken — because the defect exists in the final geometry and nowhere else.
4625
+ // The source looks fine; that is the whole point of the defect.
4626
+ const gErrs=[];
4627
+ {
4628
+ const SEP=16; // clearance left between a band and what is pushed out
4629
+ const MAXPASS=60; // resolution is monotone (always outward); this bounds
4630
+ // pathological alternation rather than expected work
4631
+ const real=nodes.filter(n=>!n.boundary);
4632
+ const memOf=id=>real.filter(n=>n.group===id);
4633
+ const cLo=n=>horiz?n.y:n.x, cSz=n=>horiz?n.h:n.w;
4634
+ const mv=(n,d)=>{ if(horiz) n.y+=d; else n.x+=d; };
4635
+ const groups=doc.groups.filter(g=>memOf(g.id).length);
4636
+ const minCross=()=>lay.length?Math.min(...lay.map(cLo)):0;
4637
+ const cross0=minCross(); // the envelope the layout had before this pass
4638
+ const bandOf=g=>{
4639
+ const m=memOf(g.id);
4640
+ const B={x0:Math.min(...m.map(n=>n.x))-BAND.l, x1:Math.max(...m.map(n=>n.x+n.w))+BAND.r,
4641
+ yA:Math.min(...m.map(n=>n.y))-BAND.t, yB:Math.max(...m.map(n=>n.y+n.h))+BAND.b};
4642
+ B.lo=horiz?B.yA:B.x0; B.hi=horiz?B.yB:B.x1;
4643
+ return B;
4644
+ };
4645
+ const inBand=(n,B)=>n.x<B.x1&&n.x+n.w>B.x0&&n.y<B.yB&&n.y+n.h>B.yA;
4646
+ const pinLine=id=>doc.pins[id]?doc.pins[id].line:null;
4647
+ // The MOVER is a whole group or a single free node — never half a group,
4648
+ // because moving one member of a group reshapes THAT group's band and the
4649
+ // next pass would only find the same class of defect one group along.
4650
+ const unitOf=n=>n.group?memOf(n.group):[n];
4651
+ const canMove=u=>u.every(n=>!pinned(n.id))
4652
+ && !(u[0].group&&doc.pins[u[0].group]&&doc.pins[u[0].group].fx!==null);
4653
+ const said=new Set();
4654
+ const collect=()=>{
4655
+ const out=[];
4656
+ for(const g of groups){
4657
+ const B=bandOf(g);
4658
+ for(const n of real){
4659
+ if(n.group===g.id||said.has(g.id+' '+n.id)) continue;
4660
+ if(inBand(n,B)) out.push({g,n});
4661
+ }
4662
+ }
4663
+ return out;
4664
+ };
4665
+ let left=[];
4666
+ // EVERY conflict gets attention on every pass, and the band is recomputed
4667
+ // immediately before each resolution. Taking only the first conflict each
4668
+ // pass was tried and is wrong: one pair that alternates starves every other
4669
+ // pair for the whole iteration budget, and the run then reports as
4670
+ // "unresolvable" figures the pass had never once looked at.
4671
+ for(let pass=0;pass<MAXPASS;pass++){
4672
+ left=collect();
4673
+ if(!left.length) break;
4674
+ for(const c of left){
4675
+ const B=bandOf(c.g);
4676
+ if(!inBand(c.n,B)) continue; // an earlier resolution cleared it
4677
+ const gMem=memOf(c.g.id);
4678
+ // Who yields: the intruder if the engine placed it, otherwise the group
4679
+ // if the engine placed THAT, otherwise nobody and the author is told.
4680
+ let unit=unitOf(c.n), obst=B;
4681
+ if(!canMove(unit)){
4682
+ if(canMove(gMem)){ unit=gMem;
4683
+ obst={lo:cLo(c.n), hi:cLo(c.n)+cSz(c.n)}; }
4684
+ else {
4685
+ // No freedom anywhere: report, name the line that took it away, and
4686
+ // stop considering this pair so the loop still terminates.
4687
+ const who=pinned(c.n.id)?c.n.id
4688
+ :(c.n.group&&doc.pins[c.n.group]&&doc.pins[c.n.group].fx!==null?c.n.group
4689
+ :(gMem.find(m=>pinned(m.id))||{id:c.g.id}).id);
4690
+ const ln=srcLine(pinLine(who));
4691
+ gErrs.push('Line '+(ln!==null?ln:srcLine(c.g.line))+': pin puts "'+c.n.id
4692
+ +'" inside the band of group "'+c.g.id+'" — a band is the bounding box of the '
4693
+ +'group\'s members, so this draws "'+c.n.id+'" as one of them. Move the pin clear '
4694
+ +'of the group\'s extent, or say what the drawing says with in='+c.g.id+'.');
4695
+ said.add(c.g.id+' '+c.n.id); continue;
4696
+ }
4697
+ }
4698
+ const uLo=Math.min(...unit.map(cLo)), uHi=Math.max(...unit.map(n=>cLo(n)+cSz(n)));
4699
+ const dNeg=(obst.lo-SEP)-uHi, dPos=(obst.hi+SEP)-uLo;
4700
+ // NEARER SIDE, BUT NEVER OFF THE CANVAS. The obvious rule — move
4701
+ // whichever way is shorter — sends the unit past the layout's own
4702
+ // starting edge often enough to matter (`reference/topology` put L1 at
4703
+ // x=-90 and the viewBox clipped it away). Growing the canvas the other
4704
+ // way is not available either: the only uniform-shift machinery this
4705
+ // renderer has moves PINNED nodes with everything else, and a pinned
4706
+ // node that drifts because an unrelated node was added is the `RENDERING-DETERMINISM`
4707
+ // stability violation this engine has already paid for once. So the
4708
+ // constraint is applied HERE, to the choice: the negative direction is
4709
+ // taken only when the unit still lands inside the envelope the layout
4710
+ // had before this pass ran. Nothing outside the mover ever moves.
4711
+ const dNegOK=uLo+dNeg>=cross0;
4712
+ const d=(Math.abs(dNeg)<=Math.abs(dPos)&&dNegOK)?dNeg:dPos;
4713
+ const ranks=new Set(unit.map(n=>n.rank));
4714
+ const keep=new Set(unit.concat(unit===gMem?[]:gMem));
4715
+ // Everything the mover would be pushed ONTO travels with it: same rank,
4716
+ // same side, clear of the obstacle. Relative order and spacing inside a
4717
+ // lane are preserved, so the fix cannot manufacture an overlap.
4718
+ // A node that BELONGS to a group never travels this way — a group moves
4719
+ // whole or not at all, and dragging half of one along would reshape its
4720
+ // band, which is the same defect one group further on.
4721
+ for(const m of lay){
4722
+ if(keep.has(m)||!ranks.has(m.rank)) continue;
4723
+ if(!m.virtual&&m.group) continue;
4724
+ const mLo=cLo(m), mHi=mLo+cSz(m);
4725
+ if(d<0 ? (mHi<=uHi&&mHi<=obst.lo) : (mLo>=uLo&&mLo>=obst.hi)) mv(m,d);
4726
+ }
4727
+ for(const n of unit) mv(n,d);
4728
+ }
4729
+ }
4730
+ left=collect();
4731
+ // The invariant is CHECKED, not assumed: anything the pass could not place
4732
+ // is named. A figure that reaches this line with a hit is a defect in this
4733
+ // pass, and saying so beats drawing the false statement quietly.
4734
+ for(const c of left){
4735
+ if(said.has(c.g.id+' '+c.n.id)) continue;
4736
+ gErrs.push('Line '+srcLine(c.g.line)+': group "'+c.g.id+'" would enclose non-member "'
4737
+ +c.n.id+'" and the layout pass could not separate them; the figure is not drawn rather '
4738
+ +'than drawn wrongly. Give "'+c.n.id+'" a pin outside the group, or add it with in='
4739
+ +c.g.id+'.');
4740
+ }
4741
+ }
3882
4742
  // Pass 3: an unpinned group has no anchor of its own; its display origin
3883
4743
  // (drag anchor / data-gx,gy) is the top-left of its members' FINAL positions,
3884
4744
  // so it reflects any pinned members and matches the group box drawn below.
@@ -4055,8 +4915,8 @@ function renderScene(doc,y0){
4055
4915
  const mem=nodes.filter(n=>n.group===g.id);
4056
4916
  if(!mem.length) continue;
4057
4917
  const o=gOrigin[g.id];
4058
- const x0=Math.min(...mem.map(n=>n.x))-14, x1=Math.max(...mem.map(n=>n.x+n.w))+14;
4059
- const yA=Math.min(...mem.map(n=>n.y))-26, yB=Math.max(...mem.map(n=>n.y+n.h))+12;
4918
+ const x0=Math.min(...mem.map(n=>n.x))-BAND.l, x1=Math.max(...mem.map(n=>n.x+n.w))+BAND.r;
4919
+ const yA=Math.min(...mem.map(n=>n.y))-BAND.t, yB=Math.max(...mem.map(n=>n.y+n.h))+BAND.b;
4060
4920
  gBox[g.id]={x0,x1,yA,yB};
4061
4921
  const gdash=g.style==='dashed'?' stroke-dasharray="6 4"':(g.style==='dotted'?' stroke-dasharray="2 4"':'');
4062
4922
  gsvg.push('<g data-group="'+g.id+'" data-gx="'+o.x+'" data-gy="'+o.y+'" style="cursor:move">'
@@ -5086,7 +5946,7 @@ function renderScene(doc,y0){
5086
5946
  }
5087
5947
  const yEnd=y0+20+Hh+10;
5088
5948
  return {svg:gsvg.join('')+esvg.join('')+nsvg.join('')+tsvg.join('')+lblsvg.join(''), y:yEnd, w:W+2,
5089
- meta:{W:W, top:y0+20+chShift, Hh:Hh, left:bShift}};
5949
+ meta:{W:W, top:y0+20+chShift, Hh:Hh, left:bShift}, errs:gErrs};
5090
5950
  }
5091
5951
  // borderPoint: where the ray from n's centre toward (tx,ty) leaves the shape.
5092
5952
  // It must leave the DRAWN outline: a rectangle clip on a diamond or an ellipse
@@ -5250,6 +6110,393 @@ function edgeRuns(v, p, n, span, ownAt){
5250
6110
  return out;
5251
6111
  }
5252
6112
 
6113
+ // ---- ladder (the `sequence` genre) ----
6114
+ //
6115
+ // THE ORDERING RULE, stated once, because everything below depends on it:
6116
+ //
6117
+ // The TIME axis is the declaration order of the `message` and `state` lines
6118
+ // taken JOINTLY. Line m above line n asserts that m occurs before n.
6119
+ // `lifeline` declaration order is the COLUMN axis, left to right. Both axes
6120
+ // are declaration-ordered, and that is why this genre has no `flow` and no
6121
+ // `rank`: a key that reordered the drawing would make the picture disagree
6122
+ // with the text (`SEQUENCE-SOURCE-STANDARD`-R182). `fragment` and `operand` lines are
6123
+ // DECLARATIONS and carry no time position of their own; a container's drawn
6124
+ // extent is the span of its members' positions. Implementation: every
6125
+ // element carries its source line number, so the row order is recovered by
6126
+ // ONE sort on that number in `seqModel` — the model never stores an ordinal.
6127
+ //
6128
+ // Everything else in this function is a DRAWING CONVENTION the engine owns
6129
+ // under `DOMAIN-CONVENTION-DIRECTIVES` and is marked CHOSEN where it is not obvious. The author names
6130
+ // MEANING (who talks to whom, in what order, inside which fragment); the
6131
+ // engine decides every coordinate, and there is no key that moves one.
6132
+ //
6133
+ // The layout is SIX DETERMINISTIC PASSES and no fixed-point iteration:
6134
+ // 1. container column spans (which columns each fragment/operand covers)
6135
+ // 2. the column axis (centre-to-centre distances, widened to fit)
6136
+ // 3. the time axis (one slot per row, plus container headroom)
6137
+ // 4. container box geometry (from the tops/bottoms the cursor recorded)
6138
+ // 5. paint (background, mid, ink — three ordered layers)
6139
+ // 6. the canvas extent (widest of columns, boxes and overhanging ink)
6140
+ //
6141
+ // NOT DRAWN: activation bars. UML's ExecutionSpecification is a separate
6142
+ // referent with a separate spelling, and the genre has no keyword for it — so
6143
+ // the renderer must not invent one out of message adjacency, which would put
6144
+ // an assertion in the picture that the source does not make.
6145
+ function renderSequence(doc,y0){
6146
+ const M=seqModel(doc);
6147
+ const lls=doc.lifelines;
6148
+ if(!lls.length) return {svg:'',y:y0,w:0};
6149
+ const col={}; lls.forEach((l,i)=>{ col[l.id]=i; });
6150
+ // `OMITTED-LABEL-RECORDING`/`EMPTY-LABEL-STATE` display fallback, applied here rather than in `render`: the model
6151
+ // records an omitted label as absent (null) and the RENDERER substitutes the
6152
+ // id, so `lifeline c` draws "c". An explicitly empty label draws nothing.
6153
+ const lblOf=(x)=>(x.label===null||x.label===undefined)?x.id:x.label;
6154
+ // class cascade — the same rule `render` applies to nodes and edges, applied
6155
+ // to this genre's elements (a `class` on a `message` is what `lost=` was
6156
+ // refused in favour of, `UNDELIVERED-MESSAGE-MARKING`). Read-only: the element is never patched.
6157
+ const C={}; for(const c of doc.classes||[]) C[c.id]=c;
6158
+ const clsIds=(x)=>x.cls===undefined||x.cls===null?[]:(Array.isArray(x.cls)?x.cls:[x.cls]);
6159
+ const chan=(x,k)=>{ if(x[k]!==undefined) return x[k];
6160
+ let v; for(const id of clsIds(x)) if(C[id]&&C[id][k]!==undefined) v=C[id][k]; return v; };
6161
+ // `seqModel` reports the containment chain; DEPTH is a view of it and lives
6162
+ // here because only the drawing needs it (nesting inset, paint order).
6163
+ const depth=(id)=>M.chain(id).length-1;
6164
+
6165
+ // ── geometry constants (CHOSEN, `DOMAIN-CONVENTION-DIRECTIVES`) ────────────────────────────────────
6166
+ const HEAD_H=32, HEAD_PADX=13, HEAD_MINW=76;
6167
+ const ROW_H=34; // base row pitch. See the F5 note below.
6168
+ const SELF_W=40, SELF_EXTRA=26, STATE_H=22;
6169
+ const LBL_FS=11, LBL_LIFT=8; // label sits LBL_LIFT px above its own arrow
6170
+ const FRAG_TOP=26, FRAG_BOT=12, OPERAND_TOP=20, FRAG_PADX=22, FRAG_INSET=9;
6171
+ const ENC_PAD=6; // clearance a container's frame keeps off its members
6172
+ // F5 (spec/core.md §14.3) is a CONSTRAINT ON ROW_H, not an afterthought.
6173
+ // A message label's centre sits LBL_LIFT + fs*0.55 ≈ 14 px above its own
6174
+ // arrow, so its margin against the arrow one row away is ROW_H - 2*14.
6175
+ // F5 requires that to exceed M = 4 px, i.e. ROW_H > 32. ROW_H = 34 gives a
6176
+ // computed margin of 6 px at the worst case (two consecutive messages over
6177
+ // the same span) and much more in practice. This is the whole reason a
6178
+ // ladder is F5-cheap: the geometry separates labels by CONSTRUCTION, so the
6179
+ // margin is a property of the row pitch and not of any per-figure search.
6180
+
6181
+ const headW=lls.map(l=>Math.max(HEAD_MINW, cwMax(lblOf(l))*CH+2*HEAD_PADX));
6182
+ const lblPx=(s)=>s?cwMax(s)*(6.5*LBL_FS/11)+8:0;
6183
+ // A state pill's width is needed in TWO passes — the container-enclosure
6184
+ // pass and the paint pass — so it is written once. A pill wider than its
6185
+ // container's padding is exactly the case that made the enclosure pass
6186
+ // necessary (see PASS 4).
6187
+ const statePillW=(el)=>Math.max(46, cwMax(el.name)*6.6+18);
6188
+
6189
+ // ── PASS 1 — container column spans (needed BEFORE the column axis,
6190
+ // because a fragment's operator tab and label have to fit inside its
6191
+ // own box) ────────────────────────────────────────────────────────────
6192
+ const cspan={};
6193
+ for(const id in M.cont){
6194
+ const slots=M.owned[id];
6195
+ let cmin=Infinity,cmax=-Infinity;
6196
+ for(const sl of slots){ const r=M.rows[sl];
6197
+ if(r.kind==='message'){ const a=col[r.el.a],b=col[r.el.b];
6198
+ if(a!==undefined){cmin=Math.min(cmin,a);cmax=Math.max(cmax,a);}
6199
+ if(b!==undefined){cmin=Math.min(cmin,b);cmax=Math.max(cmax,b);} }
6200
+ else { const a=col[r.el.ref];
6201
+ if(a!==undefined){cmin=Math.min(cmin,a);cmax=Math.max(cmax,a);} } }
6202
+ cspan[id]=isFinite(cmin)?{cmin,cmax}:null;
6203
+ }
6204
+ // a fragment must be at least as wide as the operands it holds
6205
+ for(const id in M.cont){
6206
+ if(M.cont[id].kind!=='operand') continue;
6207
+ const p=M.cont[id].parent;
6208
+ if(p&&cspan[p]&&cspan[id]){ cspan[p].cmin=Math.min(cspan[p].cmin,cspan[id].cmin);
6209
+ cspan[p].cmax=Math.max(cspan[p].cmax,cspan[id].cmax); }
6210
+ }
6211
+ const tabW=(id)=>cwMax(M.cont[id].el.type||'')*6.6+16;
6212
+ const capW=(id)=>{ const c=M.cont[id];
6213
+ const lab=(c.el.label===null||c.el.label===undefined)?'':('['+c.el.label+']');
6214
+ return (c.kind==='fragment'?tabW(id)+14:8)+cwMax(lab)*6.6+10; };
6215
+
6216
+ // ── PASS 2 — the column axis: centre-to-centre distances ────────────────
6217
+ const nc=lls.length, cd=[];
6218
+ for(let k=0;k+1<nc;k++) cd.push(headW[k]/2+headW[k+1]/2+26);
6219
+ let rightPad=0;
6220
+ const widen=(i,j,need)=>{ // need = required span i..j
6221
+ if(j<=i) return;
6222
+ let have=0; for(let k=i;k<j;k++) have+=cd[k];
6223
+ if(have>=need) return;
6224
+ const add=(need-have)/(j-i); for(let k=i;k<j;k++) cd[k]+=add;
6225
+ };
6226
+ for(const r of M.rows){
6227
+ if(r.kind!=='message') continue;
6228
+ const ci=col[r.el.a], cj=col[r.el.b];
6229
+ if(ci===undefined||cj===undefined) continue;
6230
+ const w=lblPx(r.el.label)+26;
6231
+ if(ci===cj){ // self-message
6232
+ const need=SELF_W+lblPx(r.el.label)+16;
6233
+ if(ci+1<nc) widen(ci,ci+1,need); else rightPad=Math.max(rightPad,need);
6234
+ } else widen(Math.min(ci,cj),Math.max(ci,cj),w);
6235
+ }
6236
+ // the fragment caption is INSIDE the box, so it constrains the columns the
6237
+ // box spans — a caption that overflows its own box names nothing.
6238
+ for(const id in M.cont){
6239
+ const cs=cspan[id]; if(!cs) continue;
6240
+ const d=depth(id), pad=Math.max(6,FRAG_PADX-d*FRAG_INSET);
6241
+ if(cs.cmin===cs.cmax) rightPad=Math.max(rightPad,capW(id)-pad-headW[cs.cmax]/2);
6242
+ else widen(cs.cmin,cs.cmax,capW(id)-2*pad);
6243
+ }
6244
+ // The LEFT margin is structural too. Column 0's head box normally sets it,
6245
+ // but two things drawn on that column are wider than it: a `state` pill (as
6246
+ // wide as its state name) and a container frame whose left edge sits a
6247
+ // padding outside the column. Whichever overhangs furthest pushes the whole
6248
+ // axis right, so nothing is ever drawn at a negative x — the canvas has no
6249
+ // room there and the ink would simply be clipped away.
6250
+ let leftPad=0;
6251
+ for(const r of M.rows)
6252
+ if(r.kind==='state'&&col[r.el.ref]===0)
6253
+ leftPad=Math.max(leftPad, statePillW(r.el)/2+ENC_PAD-headW[0]/2);
6254
+ for(const id in M.cont){
6255
+ const cs=cspan[id]; if(!cs||cs.cmin!==0) continue;
6256
+ const d=depth(id), pad=Math.max(6,FRAG_PADX-d*FRAG_INSET);
6257
+ leftPad=Math.max(leftPad, pad+ENC_PAD+6-headW[0]/2);
6258
+ }
6259
+ const x=[]; x[0]=headW[0]/2+Math.max(0,leftPad);
6260
+ for(let k=1;k<nc;k++) x[k]=x[k-1]+cd[k-1];
6261
+
6262
+ // ── PASS 3 — the time axis: one slot per row, plus the space containers
6263
+ // need for their frames ────────────────────────────────────────────────
6264
+ const opensAt={}, closesAt={};
6265
+ for(const id in M.cont){ const e=M.extent[id]; if(!e) continue;
6266
+ (opensAt[e.lo]=opensAt[e.lo]||[]).push(id);
6267
+ (closesAt[e.hi]=closesAt[e.hi]||[]).push(id); }
6268
+ const sortDeep=(a)=>a.slice().sort((p,q)=>depth(p)-depth(q));
6269
+ const yTop=y0;
6270
+ let y=yTop+HEAD_H+22;
6271
+ // Box tops and bottoms are recorded AS THE CURSOR PASSES THEM, so an outer
6272
+ // fragment and the operand that opens with it get DIFFERENT tops and their
6273
+ // captions cannot land on each other. (Deriving both from the member row
6274
+ // overlapped them — visible in the rendered pixels, not in any metric.)
6275
+ const boxTop={}, boxBot={};
6276
+ for(const r of M.rows){
6277
+ for(const id of sortDeep(opensAt[r.slot]||[])){
6278
+ // a top-level fragment gets clear air above it, or two consecutive
6279
+ // fragments share a border and read as one box.
6280
+ if(depth(id)===0&&M.cont[id].kind==='fragment') y+=8;
6281
+ boxTop[id]=y; y+=(M.cont[id].kind==='fragment'?FRAG_TOP:OPERAND_TOP); }
6282
+ r.y0=y;
6283
+ let h=ROW_H;
6284
+ if(r.kind==='message'&&r.el.a===r.el.b) h+=SELF_EXTRA;
6285
+ // A label is drawn ABOVE its own arrow, so every extra line of it is
6286
+ // extra row pitch — otherwise line 2 lands ON the arrow.
6287
+ if(r.kind==='message') h+=Math.max(0,String(r.el.label||'').split('\n').length-1)*LBL_FS*1.3;
6288
+ // NOTE what is NOT here: `description=` reserves no row height, because it
6289
+ // puts NO INK on the page (core §10, §12.7 — "description= addresses the
6290
+ // machine and draws nothing, note= addresses the human and always draws").
6291
+ // The ladder honours that division: a description becomes an SVG <title>
6292
+ // on the element it names and nothing else. (The prototype this was ported
6293
+ // from drew it as grey prose under the arrow, which is the one thing the
6294
+ // key is defined not to do.)
6295
+ r.yMid=y+h/2;
6296
+ y+=h;
6297
+ r.y1=y;
6298
+ for(const id of sortDeep(closesAt[r.slot]||[]).reverse()){
6299
+ if(M.cont[id].kind==='fragment') y+=FRAG_BOT;
6300
+ boxBot[id]=y; }
6301
+ // The NEXT row's label is drawn ABOVE its own arrow, so a row that follows
6302
+ // a closing frame starts its label ~3 px under that frame's border and the
6303
+ // two read as one mark. A frame that closes therefore buys clear air below
6304
+ // it, on the same ground as the clear air a top-level fragment buys above.
6305
+ if((closesAt[r.slot]||[]).length && r.slot+1<M.rows.length) y+=10;
6306
+ }
6307
+ const bottom=y+10;
6308
+
6309
+ // ── PASS 4 — containers: box geometry ───────────────────────────────────
6310
+ const fbox={};
6311
+ for(const id in M.cont){
6312
+ const e=M.extent[id]; if(!e) continue;
6313
+ const cs=cspan[id]||{cmin:0,cmax:nc-1};
6314
+ const d=depth(id), pad=Math.max(6,FRAG_PADX-d*FRAG_INSET);
6315
+ fbox[id]={x0:x[cs.cmin]-pad, x1:Math.max(x[cs.cmax]+pad, x[cs.cmin]-pad+capW(id)),
6316
+ y0:boxTop[id], y1:boxBot[id],
6317
+ cmin:cs.cmin,cmax:cs.cmax,d,kind:M.cont[id].kind};
6318
+ }
6319
+ // A container's frame must ENCLOSE THE INK OF ITS MEMBERS, and the column
6320
+ // span alone does not guarantee that: a member's drawing can be wider than
6321
+ // the column it sits on. A `state` pill is centred on its lifeline and is as
6322
+ // wide as its state name, so a long name overhangs the fixed padding and the
6323
+ // pill pokes out through the frame that is supposed to contain it — visible
6324
+ // in the rendered pixels of the reference figure ("RENEWING" inside `loop`)
6325
+ // and invisible to every metric. The frame is therefore grown to the drawn
6326
+ // extent of what it owns. `owned` is TRANSITIVE, so an inner operand's
6327
+ // members widen the enclosing fragment too.
6328
+ for(const id in fbox){
6329
+ for(const sl of M.owned[id]){
6330
+ const r=M.rows[sl];
6331
+ let lo,hi;
6332
+ if(r.kind==='state'){ const ci=col[r.el.ref]; if(ci===undefined) continue;
6333
+ const w=statePillW(r.el); lo=x[ci]-w/2; hi=x[ci]+w/2; }
6334
+ else { const a=col[r.el.a], b=col[r.el.b]; if(a===undefined||b===undefined) continue;
6335
+ lo=Math.min(x[a],x[b]);
6336
+ hi=(a===b)?x[a]+SELF_W+8+lblPx(r.el.label):Math.max(x[a],x[b]); }
6337
+ fbox[id].x0=Math.min(fbox[id].x0,lo-ENC_PAD);
6338
+ fbox[id].x1=Math.max(fbox[id].x1,hi+ENC_PAD);
6339
+ }
6340
+ }
6341
+ // a fragment must contain its operands' boxes
6342
+ for(const id in fbox){
6343
+ if(fbox[id].kind!=='operand') continue;
6344
+ const p=M.cont[id].parent;
6345
+ if(p&&fbox[p]){ fbox[p].y1=Math.max(fbox[p].y1,fbox[id].y1);
6346
+ fbox[p].x0=Math.min(fbox[p].x0,fbox[id].x0-6);
6347
+ fbox[p].x1=Math.max(fbox[p].x1,fbox[id].x1+6); }
6348
+ }
6349
+ // An operand is a COMPARTMENT OF its fragment, so it is exactly as wide as
6350
+ // the fragment: its separator rule DIVIDES the frame and must reach both
6351
+ // borders, and its guard is read against the frame's left edge. Derived from
6352
+ // the parent LAST, after the parent has finished growing, so the divider can
6353
+ // never be shorter than the box it divides (UML 2.5.1 §17.12.3/§17.12.14).
6354
+ for(const id in fbox){
6355
+ if(fbox[id].kind!=='operand') continue;
6356
+ const p=M.cont[id].parent;
6357
+ if(p&&fbox[p]){ fbox[id].x0=fbox[p].x0; fbox[id].x1=fbox[p].x1; }
6358
+ }
6359
+
6360
+ // ── PASS 5 — paint ──────────────────────────────────────────────────────
6361
+ const bg=[], mid=[], ink=[];
6362
+ const HALO=' paint-order="stroke" stroke="#fff" stroke-width="3"';
6363
+ const arrowTri=(tip,from,c)=>{
6364
+ const dx=tip[0]-from[0], dy=tip[1]-from[1], L=Math.hypot(dx,dy)||1;
6365
+ const ux=dx/L, uy=dy/L, arm=10.08, hw=5.6;
6366
+ const bx=tip[0]-ux*arm, by=tip[1]-uy*arm;
6367
+ ink.push('<path d="M'+r2(tip[0])+' '+r2(tip[1])+' L'+r2(bx-uy*hw)+' '+r2(by+ux*hw)
6368
+ +' L'+r2(bx+uy*hw)+' '+r2(by-ux*hw)+' z" fill="'+c+'" stroke="none"/>');
6369
+ };
6370
+ const inkExtent=[];
6371
+ // `description=` → an SVG <title> and nothing else (core §10). `DESCRIPTION-KEY-SPELLING`'s rule
6372
+ // applies: a <title> names its PARENT, so it is never a loose sibling in the
6373
+ // figure's single <g> — where every description in the figure would name the
6374
+ // same element and a conforming UA would show one arbitrary tooltip for the
6375
+ // whole picture. Here each one wraps its own shape in a one-element <g>,
6376
+ // which keeps the shape SELF-CLOSING so the reference linter's edge and node
6377
+ // readers still find it.
6378
+ const titleEl=(s)=>(s===undefined||s===null)?'':'<title>'+esc(s)+'</title>';
6379
+ const withTitle=(s,shape)=>s===undefined||s===null?shape:'<g>'+titleEl(s)+shape+'</g>';
6380
+
6381
+ // lifelines: head box + descending dashed line.
6382
+ // The head is emitted as a `data-node` group — it IS the participant, and
6383
+ // the reference linter's node reader finds it there.
6384
+ lls.forEach((l,i)=>{
6385
+ const w=headW[i], hx=x[i]-w/2, lab=lblOf(l);
6386
+ const f=chan(l,'fill')||'#eef2ff', st=chan(l,'stroke')||'#4f46e5';
6387
+ bg.push('<line x1="'+r2(x[i])+'" y1="'+r2(yTop+HEAD_H+8)+'" x2="'+r2(x[i])+'" y2="'+r2(bottom)
6388
+ +'" stroke="#94a3b8" stroke-width="1" stroke-dasharray="4 4"/>');
6389
+ bg.push('<g data-node="'+esc(l.id)+'" data-x="'+r2(hx)+'" data-y="'+r2(yTop+8)+'">'
6390
+ +titleEl(l.desc)
6391
+ +'<rect x="'+r2(hx)+'" y="'+r2(yTop+8)+'" width="'+r2(w)+'" height="'+HEAD_H
6392
+ +'" rx="4" fill="'+f+'" stroke="'+st+'"'+dashOf(chan(l,'style'),'')+'/>'
6393
+ +textEl(x[i], yTop+8+HEAD_H/2+4.5, 13, 'middle', labelInk(f,'#1d1d1b'), lab, '')
6394
+ +'</g>');
6395
+ });
6396
+
6397
+ // fragment / operand boxes, outermost first so nesting paints correctly
6398
+ const boxIds=Object.keys(fbox).sort((a,b)=>fbox[a].d-fbox[b].d);
6399
+ for(const id of boxIds){
6400
+ const B=fbox[id], c=M.cont[id];
6401
+ // both containers take `stroke=` and `class=`; the DEFAULT differs,
6402
+ // because a fragment's frame is a border and an operand's rule is a
6403
+ // divider inside one (CHOSEN, `DOMAIN-CONVENTION-DIRECTIVES`).
6404
+ const st=chan(c.el,'stroke')||(c.kind==='fragment'?'#64748b':'#94a3b8');
6405
+ if(c.kind==='fragment'){
6406
+ mid.push(withTitle(c.el.desc,
6407
+ '<rect x="'+r2(B.x0)+'" y="'+r2(B.y0)+'" width="'+r2(B.x1-B.x0)+'" height="'+r2(B.y1-B.y0)
6408
+ +'" fill="none" stroke="'+st+'" stroke-width="1"'+dashOf(chan(c.el,'style'),'')+'/>'));
6409
+ // the operator tab — UML's pentagon in the top-left corner (§17.12.3;
6410
+ // the operator vocabulary itself is §17.12.15.3's InteractionOperatorKind)
6411
+ const tw0=cwMax(c.el.type)*6.6+16, th=15;
6412
+ mid.push('<path d="M'+r2(B.x0)+' '+r2(B.y0)+' h'+r2(tw0)+' l6,'+r2(th-6)+' v'+r2(6)
6413
+ +' h'+r2(-tw0-6)+' z" fill="#f8fafc" stroke="'+st+'" stroke-width="1"/>');
6414
+ mid.push(textEl(B.x0+7, B.y0+11, 10.5, 'start', '#334155', c.el.type, ''));
6415
+ if(c.el.label!==null&&c.el.label!==undefined)
6416
+ mid.push(textEl(B.x0+tw0+14, B.y0+11, 10.5, 'start', '#475569', '['+c.el.label+']', HALO));
6417
+ } else {
6418
+ // an operand compartment: a dashed rule above it (except the first) and
6419
+ // its guard at the left. UML draws the guard in square brackets.
6420
+ const p=M.cont[id].parent, sibs=doc.operands.filter(o=>o['in']===p);
6421
+ const first=sibs.length&&sibs[0].id===id;
6422
+ const rule=first?''
6423
+ :'<line x1="'+r2(B.x0)+'" y1="'+r2(B.y0+4)+'" x2="'+r2(B.x1)+'" y2="'+r2(B.y0+4)
6424
+ +'" stroke="'+st+'" stroke-width="1" stroke-dasharray="5 4"/>';
6425
+ const guard=(c.el.label!==null&&c.el.label!==undefined)
6426
+ ? textEl(B.x0+8, B.y0+13, 10.5, 'start', '#475569', '['+c.el.label+']', HALO) : '';
6427
+ // An operand has no box of its own, so its <title> names the group
6428
+ // holding the two marks it DOES draw — the separator rule and the guard.
6429
+ if(rule||guard) mid.push(withTitle(c.el.desc, rule+guard));
6430
+ }
6431
+ }
6432
+
6433
+ // rows
6434
+ for(const r of M.rows){
6435
+ if(r.kind==='state'){
6436
+ // a state occurrence is a pill CENTRED ON ITS OWN COLUMN — the lifeline
6437
+ // it names in slot 1 (UML 2.5.1 §17.12.25's StateInvariant).
6438
+ const ci=col[r.el.ref]; if(ci===undefined) continue;
6439
+ const f=chan(r.el,'fill')||'#fff7ed', st=chan(r.el,'stroke')||'#c2410c';
6440
+ const w=statePillW(r.el);
6441
+ mid.push(withTitle(r.el.desc,
6442
+ '<rect x="'+r2(x[ci]-w/2)+'" y="'+r2(r.yMid-STATE_H/2)+'" width="'+r2(w)+'" height="'+STATE_H
6443
+ +'" rx="9" fill="'+f+'" stroke="'+st+'" stroke-width="1"'+dashOf(chan(r.el,'style'),'')+'/>'));
6444
+ ink.push(textEl(x[ci], r.yMid+4, LBL_FS, 'middle', labelInk(f,'#7c2d12'), r.el.name, ''));
6445
+ inkExtent.push(x[ci]+w/2);
6446
+ continue;
6447
+ }
6448
+ // message
6449
+ const e=r.el, ci=col[e.a], cj=col[e.b];
6450
+ if(ci===undefined||cj===undefined) continue;
6451
+ const st=chan(e,'stroke')||'#334155';
6452
+ const dash=dashOf(chan(e,'style'),'');
6453
+ if(ci===cj){ // self-message
6454
+ // a rectangular loop off the column and back to it. The shaft is one
6455
+ // `path` at the same stroke-width as a straight message, so the axis
6456
+ // readers see one edge and not three.
6457
+ const sx=x[ci], top=r.yMid-11, bot=r.yMid+11, ex=sx+SELF_W;
6458
+ mid.push(withTitle(e.desc,
6459
+ '<path d="M'+r2(sx)+' '+r2(top)+' L'+r2(ex)+' '+r2(top)+' L'+r2(ex)+' '+r2(bot)
6460
+ +' L'+r2(sx+11)+' '+r2(bot)+'" fill="none" stroke="'+st+'" stroke-width="1.6"'+dash+'/>'));
6461
+ arrowTri([sx+2,bot],[sx+12,bot],st);
6462
+ if(e.label){ ink.push(textEl(ex+8, r.yMid+4, LBL_FS, 'start', '#1d1d1b', e.label, HALO));
6463
+ inkExtent.push(ex+8+lblPx(e.label)); }
6464
+ continue;
6465
+ }
6466
+ // A message between NON-ADJACENT columns crosses the lifelines between
6467
+ // them: the shaft is drawn straight from source centre to target centre
6468
+ // and the dashed columns it passes are left intact. This is UML's drawing
6469
+ // and it is also the honest one — a jog around an intervening lifeline
6470
+ // would suggest the message went somewhere it did not.
6471
+ const fwd=(e.op==='<-')?false:true; // '->' and '<->' read a→b
6472
+ let sx=fwd?x[ci]:x[cj], tx0=fwd?x[cj]:x[ci];
6473
+ const dir=Math.sign(tx0-sx)||1;
6474
+ sx+=dir*1.5;
6475
+ const ex=tx0-dir*1.5;
6476
+ mid.push(withTitle(e.desc,
6477
+ '<line x1="'+r2(sx)+'" y1="'+r2(r.yMid)+'" x2="'+r2(ex)+'" y2="'+r2(r.yMid)
6478
+ +'" stroke="'+st+'" stroke-width="1.6"'+dash+'/>'));
6479
+ arrowTri([ex,r.yMid],[ex-dir*10,r.yMid],st);
6480
+ // `<->` is ONE shaft with TWO heads: the model says one occurrence, so
6481
+ // the drawing must not show two lines and invite a reader to count two.
6482
+ if(e.op==='<->') arrowTri([sx,r.yMid],[sx+dir*10,r.yMid],st);
6483
+ if(e.label){
6484
+ const nl=String(e.label).split('\n').length;
6485
+ ink.push(textEl((sx+ex)/2, r.yMid-LBL_LIFT-(nl-1)*LBL_FS*1.3/2, LBL_FS, 'middle', '#1d1d1b', e.label, HALO));
6486
+ }
6487
+ }
6488
+
6489
+ // ── PASS 6 — the canvas extent ──────────────────────────────────────────
6490
+ const W=Math.max(x[nc-1]+headW[nc-1]/2, ...Object.keys(fbox).map(k=>fbox[k].x1),
6491
+ ...inkExtent)+rightPad+4;
6492
+ return {svg:bg.join('')+mid.join('')+ink.join(''), y:bottom, w:W,
6493
+ box:{x0:0,x1:W,yA:yTop,yB:bottom}};
6494
+ }
6495
+ // coordinates are emitted at 2 decimal places: the ladder's arithmetic divides
6496
+ // (`widen` spreads a shortfall over a run of columns), and an unrounded double
6497
+ // would put a 17-digit tail in the artifact for no reader's benefit.
6498
+ function r2(v){ return Math.round(v*100)/100; }
6499
+
5253
6500
  // ---- bitfield ----
5254
6501
  function renderBitfield(b,y0){
5255
6502
  const cell=Math.max(18,Math.min(28,Math.floor(760/b.word))), rh=30, ruler=16;
@@ -6324,12 +7571,28 @@ let lastSVG='', lastMeta=null, lastPad={x:0,y:0}, lastDoc=null;
6324
7571
  // SPELLINGS, not per-genre legality (the parser owns legality), so the
6325
7572
  // per-genre withdrawals of `SCENE-KEYWORD-MEMBERSHIP` change nothing here — `threshold`, `band` and
6326
7573
  // `bundle` are still content lines in the genres that still declare them.
6327
- const CONTENT_KW=/^(figdown|title|node|state|group|external|edge|flowline|transition|flow|rank|threshold|band|bundle|class)\b/;
7574
+ // `SEQUENCE-SOURCE-STANDARD`-R182: `lifeline` `message` `fragment` `operand` join the
7575
+ // list, because under `sequence` they ARE the content zone — the genre has no
7576
+ // `node`/`edge` spelling at all. Omitting them did not produce a line error;
7577
+ // it produced a WRONG FIGURE, which under this genre is worse. `lastContentLineIdx`
7578
+ // would find nothing but the `title` line, so every GUI insert landed at the TOP
7579
+ // of the document: a new lifeline became column 1 instead of the rightmost
7580
+ // column, and a new message became the FIRST occurrence in time instead of the
7581
+ // last. Both axes of a ladder are declaration order (draft §7), so "where the
7582
+ // line goes" is meaning here, not formatting.
7583
+ const CONTENT_KW=/^(figdown|title|node|state|lifeline|message|fragment|operand|group|external|edge|flowline|transition|flow|rank|threshold|band|bundle|class)\b/;
6328
7584
  // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: `state` is a node-declaring keyword under
6329
7585
  // `statechart`, so an id it declares must count as USED here or the GUI
6330
7586
  // hands out a colliding id. The list is spellings, not per-genre legality —
6331
7587
  // the parser owns legality.
6332
- const ID_DECL_KW=/^(node|state|group|external|bundle|class|bitfield|table|timing)\s+(\S+)/;
7588
+ // `SEQUENCE-SOURCE-STANDARD`-R182: `lifeline` `fragment` `operand` are the three
7589
+ // id-DECLARING keywords of `sequence` (its `state` REFERENCES a lifeline in
7590
+ // slot 1 and declares nothing, so it must stay out of this list under either
7591
+ // genre — it is already here for `statechart`, where slot 1 does declare, and
7592
+ // the shared spelling is exactly `SUBJECT-VOCABULARY-SCOPE`'s two-declarations-that-agree). Without
7593
+ // them `nextFreeId` handed out `n1` in a document that already had
7594
+ // `lifeline n1`, and the GUI authored `duplicate id "n1"`.
7595
+ const ID_DECL_KW=/^(node|state|lifeline|fragment|operand|group|external|bundle|class|bitfield|table|timing)\s+(\S+)/;
6333
7596
  function lastContentLineIdx(lines){
6334
7597
  let last=-1;
6335
7598
  const layoutIdx=lines.findIndex(l=>l.trim()==='layout');
@@ -6391,7 +7654,44 @@ function guiVersion(lines){
6391
7654
  // `flowline` into a `figdown 0.1 flowchart` document would make the editor
6392
7655
  // author a line error.
6393
7656
  const guiConnKw=lines=>connectorKwAt(guiGenre(lines), guiVersion(lines))||'edge';
6394
- const NODE_LINE_RE=new RegExp('^\\s*(?:'+[...NODE_SPELLINGS].join('|')+')\\s+');
7657
+ // `SEQUENCE-SOURCE-STANDARD`-R182: "a line that DECLARES a node" is a per-DOCUMENT question,
7658
+ // not a per-language one, because two genres may SHARE a spelling for two
7659
+ // different grammars. `state` is the case: under `statechart` it declares a
7660
+ // node, under `sequence` it declares an OCCURRENCE that references a lifeline.
7661
+ // The union pattern (`NODE_KW_ALT`, right for "any node-ish line, any genre")
7662
+ // therefore mis-targets inside a ladder — measured, both directions:
7663
+ // * `state c "BOUND"` written above `lifeline c` captured Fill and Rename,
7664
+ // which then painted / relabelled the OCCURRENCE and left the head alone;
7665
+ // * Raise/Lower took a `state` line for the neighbouring node line and swapped
7666
+ // a lifeline past it, silently moving that occurrence's slot in the time
7667
+ // axis — a button that moves a column moving a ROW instead.
7668
+ // So the pattern is built from the DOCUMENT'S OWN node word, the same registry
7669
+ // read `guiNodeKw` already makes for what to write.
7670
+ const nodeLineReAt=lines=>new RegExp('^\\s*'+guiNodeKw(lines)+'\\s+');
7671
+ // `SEQUENCE-SOURCE-STANDARD`-R182: ONE alternation, built from the engine's own
7672
+ // `NODE_SPELLINGS`, for every GUI pattern that has to find "the line that
7673
+ // declares this node". Three of them were spelling `node|state` by hand and so
7674
+ // went blind the moment a genre added a fourth word: under `sequence` a
7675
+ // `lifeline` could not be renamed, recoloured, raised, lowered or deleted,
7676
+ // because none of the three could find its line. A per-genre word list written
7677
+ // out by hand is the defect; this is the single source.
7678
+ const NODE_KW_ALT=[...NODE_SPELLINGS].join('|');
7679
+ // `SEQUENCE-SOURCE-STANDARD`-R182: DOES THIS GENRE HAVE A COORDINATE FOR A PIN TO SET?
7680
+ // `pin` is genre-free vocabulary (`LAYOUT-ZONE-NAMESPACE`, `LAYOUT_KW`), so it PARSES under every
7681
+ // genre — but it only MEANS anything where a scene lays the figure out. A
7682
+ // ladder's two axes are both declaration-ordered (draft §7), which is the same
7683
+ // fact that keeps `flow` out of `GENRE_KW.sequence`: a genre with no direction
7684
+ // key to turn has no coordinate for a pin to write either. So the test reads
7685
+ // the keyword table rather than naming genres, and the next genre answers it
7686
+ // by declaring its own vocabulary.
7687
+ //
7688
+ // This is a GUI-AFFORDANCE test and nothing else. It does not refuse the
7689
+ // directive — a `pin` under `sequence` is legal, ignored, and the author's to
7690
+ // keep or delete. What it refuses is OFFERING the gesture: a drag that writes
7691
+ // a line the renderer never reads springs the head back and leaves dead
7692
+ // layout in the document, which is a GUI action that is not the text edit it
7693
+ // claims to be (`EDITOR-REQUIREMENT`).
7694
+ const genrePositionsByPin=(genre)=>!!(GENRE_KW[genre]&&GENRE_KW[genre].has('flow'));
6395
7695
  function ensureFlowDirective(lines){
6396
7696
  if(lines.some(l=>/^flow\b/.test(l.trim()))) return lines;
6397
7697
  let genre='block';
@@ -6403,7 +7703,16 @@ function ensureFlowDirective(lines){
6403
7703
  if(m){ genre=m[1]; break; }
6404
7704
  }
6405
7705
  // pure typed genres have no scene layout axis
6406
- if(genre==='bitfield'||genre==='table'||genre==='timing') return lines;
7706
+ // `SEQUENCE-SOURCE-STANDARD`-R182: `sequence` joins them, and for the opposite
7707
+ // reason — it HAS two axes and the genre already fixes both. Columns run in
7708
+ // `lifeline` declaration order and time runs down the page in the order the
7709
+ // occurrence lines are written (draft §7), so `flow` is not a keyword of
7710
+ // this genre at all: writing `flow right` here is a LINE ERROR ("flow" is
7711
+ // not allowed in genre sequence), and the very first GUI insert into a
7712
+ // sequence document was authoring it. Even if it parsed it would be wrong —
7713
+ // a direction key could reverse an axis the genre fixes, and the drawing
7714
+ // would then disagree with the text (`DECLARATION-ORDER-SEMANTICS`).
7715
+ if(genre==='bitfield'||genre==='table'||genre==='timing'||genre==='sequence') return lines;
6407
7716
  const dir=genre==='flowchart'?'down':'right';
6408
7717
  let insertAt=lines.findIndex(l=>/^figdown\b/.test(l.trim()));
6409
7718
  if(insertAt<0){
@@ -6604,7 +7913,7 @@ function setNodeLabel(id,label){
6604
7913
  const lines=$('src').value.split('\n');
6605
7914
  const idx=nodeLineIdx(lines,id); if(idx<0) return;
6606
7915
  const {code,comment}=splitLineComment(lines[idx]);
6607
- const re=new RegExp('^(\\s*node\\s+'+id+')(?:\\s+("(?:\\\\.|[^"\\\\])*"|\\S+))?(.*)$');
7916
+ const re=new RegExp('^(\\s*(?:'+NODE_KW_ALT+')\\s+'+id+')(?:\\s+("(?:\\\\.|[^"\\\\])*"|\\S+))?(.*)$');
6608
7917
  const m=re.exec(code);
6609
7918
  if(!m) return;
6610
7919
  lines[idx]=joinCodeComment(m[1]+' "'+escapeFdString(label)+'"'+(m[3]||''), comment);
@@ -6738,7 +8047,17 @@ function installDirectEdit(){
6738
8047
  ev.preventDefault();
6739
8048
  const id=g.dataset.node, x0=+g.dataset.x, y0=+g.dataset.y, start=toModel(ev);
6740
8049
  let dx=0,dy=0,moved=false;
6741
- const mv=e=>{ const p=toModel(e); dx=p.x-start.x; dy=p.y-start.y;
8050
+ // `SEQUENCE-SOURCE-STANDARD`-R182: THE DRAG IS NOT OFFERED WHERE A PIN SETS NOTHING.
8051
+ // The pointer handler stays — a lifeline head must still be CLICKABLE, to
8052
+ // select it and to arm a link — but under a declaration-ordered genre the
8053
+ // pointer never becomes a drag: no ghost translate (so nothing to spring
8054
+ // back) and `moved` stays false, so the `up` below takes the click branch
8055
+ // and no pin is ever materialized. Before this, dragging a head wrote a
8056
+ // legal `pin` line the ladder does not read; the head snapped back and
8057
+ // the document kept the dead line.
8058
+ const pinnable=genrePositionsByPin(lastDoc&&lastDoc.genre);
8059
+ const mv=e=>{ if(!pinnable) return;
8060
+ const p=toModel(e); dx=p.x-start.x; dy=p.y-start.y;
6742
8061
  if(Math.abs(dx)+Math.abs(dy)>2) moved=true;
6743
8062
  g.setAttribute('transform','translate('+dx+','+dy+')'); };
6744
8063
  const up=()=>{ window.removeEventListener('pointermove',mv); window.removeEventListener('pointerup',up);
@@ -6770,7 +8089,11 @@ function installDirectEdit(){
6770
8089
  g.addEventListener('dblclick',ev=>{
6771
8090
  ev.preventDefault();
6772
8091
  const id=g.dataset.node;
6773
- const nd=lastDoc.nodes.find(n=>n.id===id);
8092
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the CURRENT label is looked up through the genre's own
8093
+ // collection — under `sequence` it is `doc.lifelines`, so asking
8094
+ // `lastDoc.nodes` offered an empty prompt for a lifeline that has a
8095
+ // label, and accepting it silently blanked the head.
8096
+ const nd=docNodes(lastDoc).find(n=>n.id===id);
6774
8097
  const nv=prompt('Label for node "'+id+'":', (nd&&nd.label)||'');
6775
8098
  if(nv!==null && nv!==''){ setNodeLabel(id,nv);
6776
8099
  $('status').textContent='double-click → rewrote node '+id+' label (GUI action = text edit)'; }
@@ -6778,7 +8101,11 @@ function installDirectEdit(){
6778
8101
  });
6779
8102
  }
6780
8103
  let selectedId=null, linkArm=null;
6781
- function nodeLineIdx(lines,id){ return lines.findIndex(l=>new RegExp('^\\s*(?:node|state)\\s+'+id+'\\b').test(l)); }
8104
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the DOCUMENT'S node word, not the union — see
8105
+ // `nodeLineReAt`. Every caller here is answering "which line declares the
8106
+ // element the user clicked", and under `sequence` the union answered with a
8107
+ // `state` occurrence line whenever one was written above the declaration.
8108
+ function nodeLineIdx(lines,id){ return lines.findIndex(l=>new RegExp('^\\s*'+guiNodeKw(lines)+'\\s+'+id+'\\b').test(l)); }
6782
8109
  function setNodeOption(id,key,val){
6783
8110
  // Rewrite/append the option on the CODE side of any trailing comment (P0).
6784
8111
  const lines=$('src').value.split('\n');
@@ -6790,6 +8117,7 @@ function setNodeOption(id,key,val){
6790
8117
  $('src').value=lines.join('\n'); refresh();
6791
8118
  }
6792
8119
  function deleteNode(id){
8120
+ const before=$('src').value.split('\n');
6793
8121
  // edges / paths / thresholds / bands via the parsed doc (line numbers);
6794
8122
  // rank and bundle member lists are rewritten so the document still
6795
8123
  // parses after the delete (P0: orphaned refs were left behind).
@@ -6800,11 +8128,21 @@ function deleteNode(id){
6800
8128
  for(const p of d.paths||[]) if(p.a===id||p.b===id) drop.add(p.line);
6801
8129
  for(const g of d.thresholds||[]) if(g.target===id) drop.add(g.line);
6802
8130
  for(const b of d.bands||[]) if(b.target===id) drop.add(b.line);
8131
+ // `SEQUENCE-SOURCE-STANDARD`-R182: `sequence` CASCADES, exactly as `statechart`
8132
+ // does — deleting a `state` there drops every `transition` touching it,
8133
+ // because those live in `doc.edges` and the loop above already sees them.
8134
+ // A sequence figure's connectors do NOT: a `message` is its own model row
8135
+ // (`doc.messages`), and so is a `state` occurrence (`doc.states`, which
8136
+ // REFERENCES a lifeline in `ref`). Neither was reachable from any loop
8137
+ // here, so deleting a lifeline used to leave its messages behind pointing
8138
+ // at an id the document no longer declares.
8139
+ for(const m of d.messages||[]) if(m.a===id||m.b===id) drop.add(m.line);
8140
+ for(const s of d.states||[]) if(s.ref===id) drop.add(s.line);
6803
8141
  }
6804
8142
  const lines=$('src').value.split('\n').map((l,li)=>{
6805
8143
  if(drop.has(li+1)) return null;
6806
8144
  const t=l.trim();
6807
- if(new RegExp('^(node|state|pin)\\s+'+id+'\\b').test(t)) return null;
8145
+ if(new RegExp('^('+NODE_KW_ALT+'|pin)\\s+'+id+'\\b').test(t)) return null;
6808
8146
  // 0.1: `rank` is ONE comma-delimited token. This rewrite used to
6809
8147
  // both READ and WRITE the retired space form, so deleting a node from a
6810
8148
  // ranked scene produced a document the engine now refuses.
@@ -6830,25 +8168,70 @@ function deleteNode(id){
6830
8168
  return l;
6831
8169
  }).filter(l=>l!==null);
6832
8170
  selectedId=null;
6833
- $('src').value=lines.join('\n'); refresh();
8171
+ $('src').value=dropEmptyContainers(lines, before).join('\n'); refresh();
8172
+ }
8173
+ // A `fragment` or an `operand` has no extent of its own — its extent IS the
8174
+ // span of the lines carrying `in=<id>`, and the engine refuses a container
8175
+ // with none ("a container with no extent asserts nothing"). So when a cascade
8176
+ // takes the last member with it, the declaration has to go too, or the delete
8177
+ // leaves a document that no longer parses. This is the SAME rule the `rank`
8178
+ // and `bundle` rewrites above already follow — a member list emptied by a
8179
+ // delete drops its line — and it repeats because an operand is itself a
8180
+ // member of its fragment, so removing one can empty the other.
8181
+ // Genre-safe without a genre read: no other genre spells these two words.
8182
+ // `before` is the source AS IT WAS, and it is what keeps this a cascade rather
8183
+ // than a cleaner: a container that was already empty before the delete is the
8184
+ // author's own error to see and fix, not something a delete may quietly remove.
8185
+ function dropEmptyContainers(lines, before){
8186
+ const hadMember=cid=>{
8187
+ const re=new RegExp('\\bin='+cid+'(?![\\w-])');
8188
+ return before.some(l=>re.test(l));
8189
+ };
8190
+ for(let changed=true; changed; ){
8191
+ changed=false;
8192
+ for(let i=0;i<lines.length;i++){
8193
+ const m=/^(fragment|operand)\s+(\S+)/.exec(lines[i].trim());
8194
+ if(!m || !hadMember(m[2])) continue;
8195
+ const re=new RegExp('\\bin='+m[2]+'(?![\\w-])');
8196
+ if(lines.some((l,j)=>j!==i&&re.test(l))) continue;
8197
+ lines.splice(i,1); i--; changed=true;
8198
+ }
8199
+ }
8200
+ return lines;
6834
8201
  }
6835
8202
  function moveNodeLine(id,dir){ // dir +1 = later line = painted on top
6836
8203
  const lines=$('src').value.split('\n');
6837
8204
  const idx=nodeLineIdx(lines,id); if(idx<0) return;
6838
- const isNode=l=>NODE_LINE_RE.test(l);
8205
+ const isNode=l=>nodeLineReAt(lines).test(l);
8206
+ // `SEQUENCE-SOURCE-STANDARD`-R182: what the move MEANS is the genre's, not the button's.
8207
+ // Where a scene lays the figure out, line order is paint order. Where both
8208
+ // axes are declaration-ordered (draft §7), moving a `lifeline` line moves its
8209
+ // COLUMN — the same keystroke, a different fact, and the status line may not
8210
+ // report the scene's one for a ladder.
8211
+ const scene=genrePositionsByPin(lastDoc&&lastDoc.genre);
6839
8212
  let j=idx+dir;
6840
8213
  while(j>=0&&j<lines.length&&!isNode(lines[j])) j+=dir;
6841
- if(j<0||j>=lines.length) { $('status').textContent='already at the '+(dir>0?'top':'bottom')+' of paint order'; return; }
8214
+ if(j<0||j>=lines.length) { $('status').textContent='already at the '+
8215
+ (scene?(dir>0?'top':'bottom')+' of paint order':(dir>0?'last':'first')+' column'); return; }
6842
8216
  [lines[idx],lines[j]]=[lines[j],lines[idx]];
6843
8217
  $('src').value=lines.join('\n'); refresh();
6844
- $('status').textContent=(dir>0?'raised':'lowered')+' '+id+' — its line moved '+(dir>0?'down':'up')+' in the text (line order = paint order)';
8218
+ $('status').textContent=(dir>0?'raised':'lowered')+' '+id+' — its line moved '+(dir>0?'down':'up')+
8219
+ ' in the text ('+(scene?'line order = paint order':'declaration order = column order')+')';
6845
8220
  }
6846
8221
  function select(id){
6847
8222
  selectedId=id;
6848
- const has=id!==null && lastDoc && lastDoc.nodes.some(n=>n.id===id);
8223
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the enablement test asks the GENRE'S collection, not
8224
+ // `doc.nodes`. Fill / Delete / Raise / Lower all act on the node LINE, and
8225
+ // the patterns that find it have known every genre's spelling since
8226
+ // 0.4, so every one of them already worked on a `lifeline` — this
8227
+ // enablement test was the only thing between the
8228
+ // buttons and the edits, and it was permanently false under `sequence`
8229
+ // because a lifeline is in `doc.lifelines`. Same shape as `guiNodeKw`:
8230
+ // resolve through the engine's registry rather than branching on the genre.
8231
+ const has=id!==null && docNodes(lastDoc).some(n=>n.id===id);
6849
8232
  ['fillpick','delnode','raise','lower'].forEach(b=>$(b).disabled=!has);
6850
8233
  if(has){
6851
- const nd=lastDoc.nodes.find(n=>n.id===id);
8234
+ const nd=docNodes(lastDoc).find(n=>n.id===id);
6852
8235
  if(nd.fill&&/^#([0-9a-fA-F]{6})$/.test(nd.fill)) $('fillpick').value=nd.fill;
6853
8236
  highlightSelection();
6854
8237
  }
@@ -6869,6 +8252,11 @@ function drawResizeHandle(){
6869
8252
  const svg=$('out').querySelector('svg'); if(!svg) return;
6870
8253
  svg.querySelectorAll('.rsz').forEach(x=>x.remove());
6871
8254
  if(!selectedId) return;
8255
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the corner handle writes `pin <id> width= height=`, so
8256
+ // it is the SECOND gesture that would author a line the ladder does not
8257
+ // read — and it only became reachable now that a lifeline can be selected.
8258
+ // Same test as the drag: where a pin sets nothing, the handle is not drawn.
8259
+ if(!genrePositionsByPin(lastDoc&&lastDoc.genre)) return;
6872
8260
  const g=svg.querySelector('[data-node="'+selectedId+'"]'); if(!g) return;
6873
8261
  const sh=g.querySelector('rect,polygon,ellipse'); if(!sh) return;
6874
8262
  const b=sh.getBBox();
@@ -6933,6 +8321,17 @@ function setCellColor(tid,r,c,color){
6933
8321
  let groupArm=false;
6934
8322
  function createGroup(ids){
6935
8323
  const lines=$('src').value.split('\n');
8324
+ // `SEQUENCE-PARTICIPANT-GROUPING`: `group` is REFUSED under `sequence`, so the marquee
8325
+ // cannot write one — it would author the genre's own refusal diagnostic. The
8326
+ // GUI says what the engine's diagnostic says to write instead, rather than
8327
+ // producing a broken document and letting the parser explain it afterwards.
8328
+ // Read off `GENRE_KW`, so a genre that simply has no `group` is covered too.
8329
+ const g=guiGenre(lines);
8330
+ if(GENRE_KW[g] && !GENRE_KW[g].has('group')){
8331
+ $('status').textContent='genre '+g+' has no "group" — write a `class` naming what these '+
8332
+ ids.length+' element(s) have in common and put class= on each of them';
8333
+ return;
8334
+ }
6936
8335
  const gid=nextFreeId(lines,'g');
6937
8336
  const gnum=gid.slice(1);
6938
8337
  let firstIdx=lines.length;
@@ -6988,10 +8387,26 @@ function refresh(){
6988
8387
  let r;
6989
8388
  if(docs && docs.length>1){
6990
8389
  const parts=docs.map(d=>render(d,{title:true}));
6991
- r={svg:stackSectionSvgs(parts), sceneMeta:parts[0].sceneMeta, pad:parts[0].pad};
8390
+ r={svg:stackSectionSvgs(parts), sceneMeta:parts[0].sceneMeta, pad:parts[0].pad,
8391
+ errs:parts.reduce((a,p)=>a.concat(p.errs||[]),[])};
6992
8392
  } else {
6993
8393
  r=render(doc,{title:true});
6994
8394
  }
8395
+ // GEOMETRY-TIME ERRORS get the parse channel, because the author cannot tell
8396
+ // the two apart and must not have to: a group band that encloses a node the
8397
+ // source never put in the group is a picture that states something the
8398
+ // document does not, and drawing it anyway is the defect. Same list, same
8399
+ // clickable line numbers, same refusal to render.
8400
+ if(r.errs&&r.errs.length){
8401
+ eEl.innerHTML=r.errs.map(x=>{
8402
+ const m=/^Line (\d+):/.exec(x);
8403
+ return '<div class="errline"'+(m?' data-line="'+m[1]+'"':'')+'>'+esc(x)+'</div>';
8404
+ }).join('');
8405
+ ok.textContent=''; out.innerHTML=''; lastSVG='';
8406
+ $('svgsrc').textContent=''; $('svgimg').removeAttribute('src');
8407
+ $('status').textContent=r.errs.length+' error(s) — nothing rendered (determinism over convenience)';
8408
+ return;
8409
+ }
6995
8410
  lastSVG=r.svg; lastMeta=r.sceneMeta; lastPad=r.pad; lastDoc=doc;
6996
8411
  out.innerHTML=r.svg;
6997
8412
  installDirectEdit();
@@ -7043,7 +8458,14 @@ function insertNodeLine(){
7043
8458
  const nnum=nid.slice(1);
7044
8459
  const last=lastContentLineIdx(lines);
7045
8460
  const kk=$('newkind').value;
7046
- lines.splice(last+1,0,guiNodeKw(lines)+' '+nid+' "Node '+nnum+'"'+(kk!=='box'?' shape='+kk:''));
8461
+ // `SEQUENCE-SOURCE-STANDARD`-R182: the kind picker only gets to speak if the genre's
8462
+ // node directive TAKES `shape=`. A `lifeline` does not — a participant column
8463
+ // has one drawn form and the option names nothing on it — so `+ Node` with
8464
+ // the picker off `box` was writing `lifeline n1 "Node 1" shape=rounded`,
8465
+ // i.e. `lifeline does not take shape=`. The option table is the authority,
8466
+ // the same way `guiNodeKw` reads the genre table rather than guessing.
8467
+ const shaped=(directiveOpts(guiNodeKw(lines), guiGenre(lines))||[]).includes('shape');
8468
+ lines.splice(last+1,0,guiNodeKw(lines)+' '+nid+' "Node '+nnum+'"'+(kk!=='box'&&shaped?' shape='+kk:''));
7047
8469
  $('src').value=lines.join('\n'); refresh();
7048
8470
  return nid;
7049
8471
  }