figdown 0.1.8 → 0.3.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,13 +121,56 @@ 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.1.8';
124
+ const FIGDOWN_VERSION = '0.3.0';
125
+ // `STATECHART-GENRE-SCOPE`: the language number moved for the first time. The dev
126
+ // counter does NOT reset (core §13.0.4 — `N` counts source states of the
127
+ // engine and only ever increases), so 0.1 is followed by
128
+ // 0.2, not by 0.2.
129
+ //
130
+ // LANGUAGE VERSIONS THIS ENGINE ACCEPTS (core §13.7 second bullet: an engine
131
+ // MUST state this, and stating the release version alone does not satisfy
132
+ // it). Declared here, in ONE place, so the header check and the documented
133
+ // interface cannot drift:
134
+ // `DRAWN-ANNOTATION-FORM`: `figdown 0.3` joins the set. `note=` is a NEW OPTION KEY,
135
+ // and core §13.0 makes a new key a `Y` change and not a `Z` one — "`Z`: Bug
136
+ // fixes only. No new features. The language does not move." Shipping `note=`
137
+ // under `v0.2.z` would make `figdown 0.2` name two different languages: the one
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'];
140
+ // Genres per declared language version. `Y` never removes (core §13.0), so
141
+ // each row is a superset of the one above it, and `figdown 0.1 <anything>`
142
+ // resolves against exactly the list it resolved against before `STATECHART-GENRE-SCOPE`.
143
+ const GENRES_BY_VERSION = {
144
+ '0.1': ['block','topology','flowchart','bitfield','table','timing'],
145
+ '0.2': ['block','topology','flowchart','bitfield','table','timing','statechart'],
146
+ '0.3': ['block','topology','flowchart','bitfield','table','timing','statechart']
147
+ };
148
+ // The version an OPTION KEY first becomes legal in — the `CONNECTOR_MIN_VERSION`
149
+ // device, applied to the option namespace. `DRAWN-ANNOTATION-FORM`: `note=` is gated on the
150
+ // declared version, and the gate's reason is specific to THIS key rather than
151
+ // generic to new keys. `note=` has a PRIOR MEANING on the record: it was the
152
+ // retired spelling of `description=` (`DESCRIPTION-KEY-SPELLING`) and its retirement
153
+ // diagnostic actively told authors to write `description=` for a tooltip.
154
+ // Accepting it silently under a `figdown 0.2` header would repaint a document
155
+ // whose author meant a never-drawn tooltip as one that puts ink on the page —
156
+ // core §13.0.1's named hazard, "a figure that looks right and means something
157
+ // else". A key that had never been spelled before would carry no such risk.
158
+ const OPT_MIN_VERSION={note:'0.3'};
159
+ // True when the document's declared version is older than the key's own.
160
+ // A document with no parsable header has already been diagnosed on line 1, so
161
+ // an absent version never gates a second time.
162
+ const belowOptVersion=(key,ver)=>{
163
+ const need=OPT_MIN_VERSION[key];
164
+ if(!need||!ver) return false;
165
+ const i=LANG_VERSIONS.indexOf(ver), j=LANG_VERSIONS.indexOf(need);
166
+ return i>=0 && j>=0 && i<j;
167
+ };
125
168
  // Retired shape VALUES keep a named diagnostic (PROCESS §5(d)), the same way
126
169
  // retired option keys do: `cloud` was the one value that named a domain
127
170
  // (the internet cloud) in an enum the language keeps purely geometric
128
171
  // (`SHAPE-ENUM-VOCABULARY`/`EXTERNAL-EDGE-ENDPOINTS`), so it was removed rather than demoted.
129
172
  const RETIRED_SHAPES = {
130
- cloud: 'shape=cloud has been retired: use shape=ellipse and put the meaning in the label or a class= (shapes are pure geometry, `SHAPE-ENUM-VOCABULARY`) (MIGRATIONS)'
173
+ cloud: 'shape=cloud has been retired: use shape=ellipse and put the meaning in the label or a class= (shapes are pure geometry, `SHAPE-ENUM-VOCABULARY`) (MIGRATIONS 0.1)'
131
174
  };
132
175
  // Colors are CSS hex (#rgb / #rrggbb) or CSS named colors (spec §1) — the
133
176
  // 147 CSS/SVG color keywords (lowercase) plus `transparent`. Anything else
@@ -305,7 +348,7 @@ function splitList(t,off){
305
348
  // - a key=value token with an unregistered key is an "unknown option"
306
349
  // line error (`UNKNOWN-OPTION-DEGRADATION`) — except inside timing `signal` lanes, where bare
307
350
  // tokens may contain '=' and stay positional (laneMode).
308
- // `fill` was registered here until this release solely to power a retired
351
+ // `fill` was registered here until 0.1 solely to power a retired
309
352
  // migration diagnostic on the old `line` directive; it left the registry with
310
353
  // the `fill` → `band` KEYWORD rename, and 0.1 gave the word back to the
311
354
  // option-key namespace as the primary presentation key (`color=` → `fill=`).
@@ -320,11 +363,11 @@ function splitList(t,off){
320
363
  // time the language gains no replacement — v0.1 has no author-facing label
321
364
  // colour at all (the default is derived, `LABEL-COLOUR-SOURCE`; the owner-level key that could
322
365
  // be added today is the wrong shape, core §9 `ANNOTATION-LOCATOR-SPLIT`). It stays registered so
323
- // the message can name BOTH eras: a `color=` written before this release meant
324
- // the FILL, one written meant the LABEL, and only a human
366
+ // the message can name BOTH eras: a `color=` written in one era meant
367
+ // the FILL, one written in another meant the LABEL, and only a human
325
368
  // knows which document this is. `text` and `z` stay registered
326
369
  // as RETIRED keys so each rename gets a named diagnostic. `offset` replaces
327
- // `threshold at=` (the directive was spelled `guide` until this release);
370
+ // `threshold at=` (the directive was spelled `guide` until 0.1);
328
371
  // `at` stays live on `pin`.
329
372
  // 0.1: `level` stays registered as a RETIRED key — the construct was
330
373
  // DELETED (`CHART-LEVEL-KEY`), and a registered-but-retired key is the only way the
@@ -355,7 +398,7 @@ const OPT_KEYS=new Set(['kind','type','shape','fill','color','stroke','text','in
355
398
  // - `external` is NEVER drawn (`EXTERNAL-EDGE-ENDPOINTS`) — no fill, no border, no dash; only its
356
399
  // label exists, so it takes `text=` (plus `plane=`, organizational exactly
357
400
  // as on a node);
358
- // - `band` carried NO label channel at all until this release (`BAND-LABEL-STATUS`); it now
401
+ // - `band` carried NO label channel at all until 0.1 (`BAND-LABEL-STATUS`); it now
359
402
  // takes a mandatory quoted label, so `color=` applies to it like any
360
403
  // other labelled element;
361
404
  // - typed blocks (`bitfield`/`table`/`timing`) stack in document order OUTSIDE
@@ -374,21 +417,48 @@ const OPT_KEYS=new Set(['kind','type','shape','fill','color','stroke','text','in
374
417
  // the key existed only because there was no label to colour.
375
418
  const DIRECTIVE_OPTS={
376
419
  figdown:[],
377
- node:['shape','fill','stroke','style','class','in','plane','width','height'],
420
+ // `DRAWN-ANNOTATION-FORM`: `title` gains its FIRST option key. It took one
421
+ // positional string and nothing else until now, and the key
422
+ // it gains carries the figure-level annotation — 14% of the measured demand,
423
+ // 10 instances that name no single element ("Total: 2 blocks × 8 ways × 1k
424
+ // sets = 16,384 entries", a four-signal legend, a TODO about the figure).
425
+ // The figure HAS a declaration line, so attachment-by-position reaches it and
426
+ // no standalone keyword is needed. `UNIVERSAL-CORE-KEYWORDS` fixes what `title` MEANS across genres;
427
+ // it is not a bar on the directive taking options, so the key exists in every
428
+ // genre at once, which is correct — every genre has figures.
429
+ title:['note'],
430
+ node:['shape','fill','stroke','style','class','in','width','height','note'],
378
431
  // `FLOWCHART-ROLE-KEYWORDS`: the three flowchart role keywords take EXACTLY the
379
432
  // option keys `node` takes — they ARE nodes, with a role recorded. Listing
380
433
  // `width`/`height` mirrors `node` so the same "use a pin line" diagnostic
381
434
  // fires rather than a bare `unknown option`.
382
- process:['shape','fill','stroke','style','class','in','plane','width','height'],
383
- decision:['shape','fill','stroke','style','class','in','plane','width','height'],
384
- terminator:['shape','fill','stroke','style','class','in','plane','width','height'],
385
- group:['fill','stroke','style','gap','class','plane'],
386
- external:['plane'],
387
- edge:['style','class','fill','stroke','plane','label','taillabel','headlabel'],
388
- plane:['z','z-index'], flow:[], rank:[],
389
- bundle:['fill','stroke','style','plane'],
390
- threshold:['in','at','offset','fill','stroke','style','plane'],
391
- band:['in','extend','fill','stroke','style','plane','from','to'],
435
+ process:['shape','fill','stroke','style','class','in','width','height','note'],
436
+ decision:['shape','fill','stroke','style','class','in','width','height','note'],
437
+ terminator:['shape','fill','stroke','style','class','in','width','height','note'],
438
+ // `GENRE-NODE-SPELLING`: `state` IS `node` under `statechart` — a rename, not a
439
+ // new directive, so it takes `node`'s keys exactly and nothing more.
440
+ state:['shape','fill','stroke','style','class','in','width','height','note'],
441
+ group:['fill','stroke','style','gap','class','note'],
442
+ // `PAINT-ORDER-CONSTRUCT`: `external` now takes NO option key at all. `plane=`
443
+ // was its only one — it is never drawn (`EXTERNAL-EDGE-ENDPOINTS`), so it has no fill, no border
444
+ // and no dash to set — and the withdrawal of `plane=` empties the row. An
445
+ // empty array is the declaration: every key falls through to the generic
446
+ // `external does not take <k>=`.
447
+ external:[],
448
+ edge:['style','class','fill','stroke','label','taillabel','headlabel','note'],
449
+ // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: same rename argument — the connector's option set is one set
450
+ // under three spellings, listed three times only because the tables are
451
+ // keyed by the surface word an author actually wrote.
452
+ flowline:['style','class','fill','stroke','label','taillabel','headlabel','note'],
453
+ transition:['style','class','fill','stroke','label','taillabel','headlabel','note'],
454
+ // `PAINT-ORDER-CONSTRUCT`: the `plane` row is GONE, not emptied — the keyword is
455
+ // withdrawn from the language, so it has no acceptor row at all, the shape
456
+ // `path`/`routing` left behind. `z-index=` goes with it: it
457
+ // was legal on `plane` and on nothing else.
458
+ flow:[], rank:[],
459
+ bundle:['fill','stroke','style'],
460
+ threshold:['in','at','offset','fill','stroke','style'],
461
+ band:['in','extend','fill','stroke','style','from','to'],
392
462
  // `ELEMENT-GEOMETRY-DIRECTIVE`: `size` merged into `pin`. ONE directive carries an
393
463
  // element's whole DECLARED geometry — `at=` places it, `width=`/`height=`
394
464
  // extend it — and one model object records it. All three keys are optional
@@ -399,7 +469,7 @@ const DIRECTIVE_OPTS={
399
469
  // derives its geometry from its content).
400
470
  pin:['at','width','height'],
401
471
  layout:[],
402
- 'class':['fill','stroke','style','plane'],
472
+ 'class':['fill','stroke','style'],
403
473
  // 0.1: `class=` is NOT accepted on the typed-block OPENERS. The
404
474
  // normative registry (core §10) lists its acceptors as node/group/edge/
405
475
  // field/cell — the block openers were an engine-only extra with 0 uses in
@@ -423,7 +493,7 @@ const DIRECTIVE_OPTS={
423
493
  // the field is ONE ELEMENT of a repeated run and gives the run's index
424
494
  // range; the engine derives the elision row and the index labels from it,
425
495
  // exactly as it derives the dash and the caption from `present=`.
426
- field:['fill','stroke','class','description','present','index'], 'break':[],
496
+ field:['fill','stroke','class','description','present','index','note'], 'break':[],
427
497
  cell:['fill','stroke','class'], width:[],
428
498
  signal:['data','fill','stroke'], gap:[]
429
499
  };
@@ -467,14 +537,14 @@ const ENUM_OPT_KEYS=['shape','style','numbering','extend','type'];
467
537
  // `color=` sets the TEXT — and once `color=` is live again the engine can no
468
538
  // longer diagnose a pre-0.1 document at all.
469
539
  const RETIRED_OPT_KEYS={
470
- w:'w= has been renamed: use width= (SVG, CSS, DOT, mxGraph and D2 all spell it in full — no standard abbreviates it) (MIGRATIONS)',
471
- h:'h= has been renamed: use height= (SVG, CSS, DOT, mxGraph and D2 all spell it in full — no standard abbreviates it) (MIGRATIONS)',
472
- dir:'dir= has been renamed: use extend= (HTML\'s dir= is text writing direction; this one says which way the band extends from its anchor) (MIGRATIONS)',
473
- text:'text= has been retired: v0.1 has NO label-colour key — the label colour is DERIVED from the fill it sits on (core §5), and the owner-level key that could replace it would colour an edge\'s [tail]/[mid]/[head] labels identically, which is the wrong shape (core §9 `ANNOTATION-LOCATOR-SPLIT`). Delete the key; if the distinction was knowledge, write it in the label or a class= meaning (§5, `PRESENTATION-AS-MEANING-CARRIER`) (MIGRATIONS)',
540
+ w:'w= has been renamed: use width= (SVG, CSS, DOT, mxGraph and D2 all spell it in full — no standard abbreviates it) (MIGRATIONS 0.1)',
541
+ h:'h= has been renamed: use height= (SVG, CSS, DOT, mxGraph and D2 all spell it in full — no standard abbreviates it) (MIGRATIONS 0.1)',
542
+ dir:'dir= has been renamed: use extend= (HTML\'s dir= is text writing direction; this one says which way the band extends from its anchor) (MIGRATIONS 0.1)',
543
+ text:'text= has been retired: v0.1 has NO label-colour key — the label colour is DERIVED from the fill it sits on (core §5), and the owner-level key that could replace it would colour an edge\'s [tail]/[mid]/[head] labels identically, which is the wrong shape (core §9 `ANNOTATION-LOCATOR-SPLIT`). Delete the key; if the distinction was knowledge, write it in the label or a class= meaning (§5, `PRESENTATION-AS-MEANING-CARRIER`) (MIGRATIONS 0.1)',
474
544
  // `COLOUR-KEY-STATUS`. This is the ONLY key in the language whose diagnostic
475
545
  // must name two eras and refuse to choose between them: the same six
476
- // characters meant the FILL before this release and the LABEL after
477
- // 0.1, and no engine can tell the two source files apart. Retiring
546
+ // characters meant the FILL in one era and the LABEL
547
+ // in another, and no engine can tell the two source files apart. Retiring
478
548
  // the key is what makes the difference DIAGNOSABLE at all — while it was
479
549
  // live, a pre-0.1 document parsed and drew a legal, wrong figure in
480
550
  // silence.
@@ -489,10 +559,19 @@ const RETIRED_OPT_KEYS={
489
559
  // those observables, says plainly when a file has neither, and cites the
490
560
  // release only as a MIGRATIONS lookup. Same rule as the migration tool's
491
561
  // `color=` family, which reads the same evidence to decide its refusals.
492
- color:'color= has been retired: the same six characters set the box FILL in one era of this language and the LABEL colour in another, and this line does not say which — which is why the key is gone rather than renamed. READ IT OFF THE REST OF THE DOCUMENT. A file that also writes fill= cannot be from the FILL era (the two keys never coexisted), so its color= was a LABEL colour: delete it and let the derived default apply (core §5). A file still writing the spellings that were retired before the LABEL era (w= h= unit= via= dir= kind= layer= boundary wrap optional) cannot be from that era, so its color= was a FILL: write fill= instead. A file with NEITHER carries no evidence at all, and the two readings then differ only in what was DRAWN — as a FILL the value painted the box interior, as a LABEL colour it painted only the text. If the colour carried meaning, put that meaning in the label or a class= (§5, `PRESENTATION-AS-MEANING-CARRIER`). tools/migrate-figdown.js reads this evidence for you and REFUSES the wrong --color-means=fill|text (MIGRATIONS)',
562
+ color:'color= has been retired: the same six characters set the box FILL in one era of this language and the LABEL colour in another, and this line does not say which — which is why the key is gone rather than renamed. READ IT OFF THE REST OF THE DOCUMENT. A file that also writes fill= cannot be from the FILL era (the two keys never coexisted), so its color= was a LABEL colour: delete it and let the derived default apply (core §5). A file still writing the spellings that were retired before the LABEL era (w= h= unit= via= dir= kind= layer= boundary wrap optional) cannot be from that era, so its color= was a FILL: write fill= instead. A file with NEITHER carries no evidence at all, and the two readings then differ only in what was DRAWN — as a FILL the value painted the box interior, as a LABEL colour it painted only the text. If the colour carried meaning, put that meaning in the label or a class= (§5, `PRESENTATION-AS-MEANING-CARRIER`). tools/migrate-figdown.js reads this evidence for you and REFUSES the wrong --color-means=fill|text (MIGRATIONS 0.1)',
493
563
  kind:'kind= has been renamed: on a node use shape= (geometric; the label text carries the device semantics — MIGRATIONS 0.1), on a chart use type= (Vega, Chart.js and ECharts all spell the chart-type key "type" — MIGRATIONS 0.1). One spelling was retired on node and live on plot at the same time, inside one namespace; 0.1 closed that.',
494
- layer:'layer= has been renamed: use plane= (mxGraph makes a layer a containment parent that establishes coordinates; Inkscape layers can carry a transform; OGC WMS layers carry an SRS; CSS @layer is cascade priority. None of those is what this key does, and SVG has no layer at all) (MIGRATIONS)',
495
- labels:'labels= has been renamed: use data= (WaveDrom\'s own key for exactly this is `data`, "an array of signal labels" — one per value cell of the lane) (MIGRATIONS)',
564
+ // `PAINT-ORDER-CONSTRUCT`: `layer=` was renamed `plane=`, and
565
+ // `plane=` has since been WITHDRAWN, so this message can no longer end at
566
+ // the rename — the `route`→`path` precedent, where a message
567
+ // pointing at a spelling that no longer exists had to state the whole chain.
568
+ layer:'layer= has been WITHDRAWN: it was renamed plane=, and plane= was withdrawn with the `plane` keyword (`PAINT-ORDER-CONSTRUCT`). There is no replacement spelling. Delete the key: everything paints in one plane, in document order. If the element is on a distinct logical layer of the SUBJECT — an overlay, a control plane — say so with a class= whose label states it, which is where that meaning belongs (core §5, `PRESENTATION-AS-MEANING-CARRIER`) (MIGRATIONS 0.3)',
569
+ labels:'labels= has been renamed: use data= (WaveDrom\'s own key for exactly this is `data`, "an array of signal labels" — one per value cell of the lane) (MIGRATIONS 0.1)',
570
+ // `PAINT-ORDER-CONSTRUCT`. `plane=` referenced a declared `plane`; with the
571
+ // keyword withdrawn from every genre the key would keep exactly ONE legal
572
+ // value — the implicit `base` — so it is withdrawn with it rather than left
573
+ // as a key that can only ever restate the default.
574
+ plane:'plane= has been WITHDRAWN with the `plane` keyword (`PAINT-ORDER-CONSTRUCT`): the construct is removed from the language, not renamed, so there is no spelling to migrate to. `plane=` named a declared plane, and with no way to declare one the key had a single legal value — `base`, the implicit plane every element is already on. Delete the key. What it did was PAINT ORDER, and paint order is document order: a later line paints on top. The measurement: stripping `plane` and `plane=` from examples/evpn-fabric.fd left the drawn SVG byte-identical but for one `data-edge` index, because the overlay meaning was carried by `class=overlay` throughout — which is where a logical layer of the SUBJECT belongs (core §5, `PRESENTATION-AS-MEANING-CARRIER`) (MIGRATIONS 0.3)',
496
575
  // 0.1 (`EDGE-GEOMETRY-CONSTRUCTS`). These six keys end in a WITHDRAWAL, not a rename, so
497
576
  // their messages have a shape no earlier retirement in this table has: they
498
577
  // name no replacement spelling, because there is none. `via=`/`src=`/`dst=`
@@ -506,28 +585,94 @@ const RETIRED_OPT_KEYS={
506
585
  tailport:'tailport= has been WITHDRAWN with the `path` directive (`EDGE-GEOMETRY-CONSTRUCTS`): the construct is removed from the language, not renamed, so there is no spelling to migrate to. Attachment to a named site addressed by semantic role IS inside the stable prior-art intersection; FigDown\'s realisation was not (a fraction on the EDGE is mxGraph-only, and written-order attachment has zero prior art in any surveyed system). Restoring it needs an edge-identity construct first. Delete the line; the edge draws under auto layout. The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md',
507
586
  headport:'headport= has been WITHDRAWN with the `path` directive (`EDGE-GEOMETRY-CONSTRUCTS`): the construct is removed from the language, not renamed, so there is no spelling to migrate to. Attachment to a named site addressed by semantic role IS inside the stable prior-art intersection; FigDown\'s realisation was not (a fraction on the EDGE is mxGraph-only, and written-order attachment has zero prior art in any surveyed system). Restoring it needs an edge-identity construct first. Delete the line; the edge draws under auto layout. The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md',
508
587
  routing:'routing= has been WITHDRAWN with the `path` directive (`EDGE-GEOMETRY-CONSTRUCTS`): the construct is removed from the language, not renamed, so there is no spelling to migrate to. The per-edge routing SCOPE was inside the stable prior-art intersection and is deliberately lost with its host line — an override needs an edge to address, and FigDown has no edge-identity construct. Delete the line. The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md',
509
- unit:'unit= has been renamed: use word= (RFC 2360 §3.1: "a sequence of long words in network byte order, with each word horizontal on the page"; RFC 791 §3.1 measures the header in "32 bit words". Mermaid names the identical setting bitsPerRow — semantically right, camelCase barred. `unit=32` also inverts count-vs-unit, reading as "the unit is 32", and C\'s "unit" is the addressable storage unit, not the row width) (MIGRATIONS)',
510
- z:'z= has been renamed: use z-index= (CSS spells the stacking concept z-index, and RULE 4.2 takes the standard spelling in full; a single-letter key is structurally risky next to the closed timing lane alphabet, `LANE-ALPHABET-KEY-RESERVATION`) (MIGRATIONS)',
511
- // `DESCRIPTION-KEY-SPELLING`. The spelling leaves the LANGUAGE, so the message fires
512
- // wherever it appears (the `w=`/`h=`/`unit=` placement test, RULE 6.2).
513
- note:'note= has been renamed: use description= (IEEE 1685-2022 spells this channel `description`; SystemRDL\'s `desc` is barred by RULE 4.2 as an abbreviation. The rename is defensive: `ANNOTATION-LOCATOR-SPLIT` files `note` as the highest-demand v0.2 annotation construct — ~66 figure-identities, 20 independent reinventions — and that one will be a DRAWN callout, so a never-drawing `note=` beside an always-drawing `note` would be one spelling with two opposite behaviours) (MIGRATIONS)',
514
- level:'level= has been DELETED, not renamed: it drew a reference plane through a 3-D bar chart, has zero uses in either downstream corpus and zero 3-D bar charts to draw it on, was the only construct whose caption the ENGINE wrote rather than the author, and its parseFloat grammar uniquely accepted 1e3 where every other number in the language is \\d+(\\.\\d+)? delete the key (MIGRATIONS)'
588
+ unit:'unit= has been renamed: use word= (RFC 2360 §3.1: "a sequence of long words in network byte order, with each word horizontal on the page"; RFC 791 §3.1 measures the header in "32 bit words". Mermaid names the identical setting bitsPerRow — semantically right, camelCase barred. `unit=32` also inverts count-vs-unit, reading as "the unit is 32", and C\'s "unit" is the addressable storage unit, not the row width) (MIGRATIONS 0.1)',
589
+ // `PAINT-ORDER-CONSTRUCT`: `z-index=` itself. Its ONLY acceptor was `plane`, so
590
+ // with the keyword withdrawn the key has no directive left to sit on. Left
591
+ // in OPT_KEYS with no acceptor row it would have produced `<directive> does
592
+ // not take z-index=` true, but it tells an author holding a 0.2 document
593
+ // that they picked the wrong host, when in fact there is no host. RULE 6.2
594
+ // placement: the spelling left the LANGUAGE, so it is reported wherever it
595
+ // appears.
596
+ '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
+ 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
599
+ // row is gone because the key is LIVE again — SYNTAX-STYLE RULE 4.9
600
+ // obligation 3 forbids leaving the retirement message standing past the
601
+ // revival, on the ground that a message telling an author to write
602
+ // `description=` where `note=` is now the correct key is worse than no
603
+ // message: it is the language actively misinforming its user. What replaces
604
+ // it is not silence but two NARROWER messages — NOTE_VERSION for a document
605
+ // that declares a language version older than the key, and NOTE_ON_FIELD for
606
+ // the one directive that keeps refusing it. See OPT_MIN_VERSION above.
607
+ level:'level= has been DELETED, not renamed: it drew a reference plane through a 3-D bar chart, has zero uses in either downstream corpus and zero 3-D bar charts to draw it on, was the only construct whose caption the ENGINE wrote rather than the author, and its parseFloat grammar uniquely accepted 1e3 where every other number in the language is \\d+(\\.\\d+)? — delete the key (MIGRATIONS 0.1)'
515
608
  };
609
+ // `DRAWN-ANNOTATION-FORM`. The two messages that REPLACE the `note=` retirement
610
+ // diagnostic. SYNTAX-STYLE RULE 4.9 obligation 3 requires the retirement
611
+ // message to be reversed in the same release that revives the spelling, and
612
+ // "reversed" does not mean "deleted": each of the two situations the old
613
+ // message used to cover keeps a named diagnostic of its own.
614
+ //
615
+ // (a) The document declares a language version older than the key. The gate is
616
+ // `KEYWORD-RENAME-SCOPE`'s device — name the version, offer the one-step fix — and its
617
+ // reason is stated in the message because `note=`'s prior meaning is what
618
+ // makes the gate necessary rather than merely tidy.
619
+ const NOTE_VERSION=(have)=>
620
+ 'note= requires figdown 0.3 (this document declares '+have+'): under figdown '+
621
+ have+' the spelling is still the RETIRED one that meant description=, and an '+
622
+ 'engine that accepted it here would repaint a tooltip as ink — a figure that '+
623
+ 'looks right and means something else (core §13.0.1). note= is the DRAWN '+
624
+ 'annotation: an explanation the human reader must SEE. Raise the header to '+
625
+ 'figdown 0.3, or write description= if you meant prose only a machine reads '+
626
+ '(MIGRATIONS 0.3)';
627
+ // (b) The directive is `field`, which refuses the key at EVERY version. The
628
+ // bitfield genre already has `description=` for machine-facing prose, and
629
+ // no measured figure needs a DRAWN per-field aside — granting a directive
630
+ // both keys with no evidence spends the distinction before anyone needs it.
631
+ // The message states the distinction rather than naming a replacement,
632
+ // because `description=` is not a replacement: it reaches a different
633
+ // reader.
634
+ const NOTE_ON_FIELD=
635
+ 'note= draws and is not accepted on field; use description= for prose a '+
636
+ 'machine reads. The two keys divide by AUDIENCE, not by length: description= '+
637
+ 'reaches the reading agent as an SVG <title> and puts no ink on the page, '+
638
+ 'while note= is an explanation the human must see. A field\'s presence '+
639
+ 'condition is present=, not either of them (MIGRATIONS 0.3)';
516
640
  // `PLANE-KEYWORD-SPELLING`: the keyword `plane`/`plane=` was spelled `layer`/`layer=`.
517
- const RETIRED_LAYER='layer has been renamed: use plane (in mxGraph — the geometry model FigDown adopted — a layer is a CONTAINMENT PARENT that establishes coordinates, so layer=overlay reads as "reparent and re-origin this element", which FigDown does not do; Inkscape layers are <g> and may carry a transform, OGC WMS layers each carry an SRS, and CSS @layer is cascade priority with no visual meaning. SVG has no layer concept at all. `plane` is claimed by no standard for a conflicting meaning and removes the layout/layer near-miss) (MIGRATIONS)';
641
+ // `PAINT-ORDER-CONSTRUCT`: `plane` is WITHDRAWN, so `layer`'s message states the
642
+ // whole chain and ends where `route`'s does — the precedent,
643
+ // when `path` was withdrawn out from under the spelling `route` pointed at.
644
+ // `PAINT-ORDER-CONSTRUCT`: `plane` is WITHDRAWN from the language. The construct
645
+ // left because every genre that could write it lost it at once, and for two
646
+ // different reasons that happen to converge:
647
+ // - `block` and `flowchart` had ZERO authored uses. Every authored use in
648
+ // the tree was a `topology` document.
649
+ // - `topology` had two, and they are the worst domain collision measured in
650
+ // the language: in networking a PLANE is the control / data / management
651
+ // partition of a device — one of the first distinctions the field teaches
652
+ // — and `topology` is precisely the genre network engineers author in.
653
+ // `examples/evpn-fabric.fd` showed the trap already closed: it wrote
654
+ // `plane overlay "VXLAN tunnels" z-index=2`, where `overlay` is itself a
655
+ // networking term, so the line read as a network-architectural assertion
656
+ // and was in fact a paint order.
657
+ // What replaced it was already there. Stripping both writings from that file
658
+ // left the drawn SVG byte-identical apart from one `data-edge` index, because
659
+ // `class=overlay` carried the meaning the whole time.
660
+ const WITHDRAWN_PLANE_WHERE=' The decision and its evidence: MIGRATIONS 0.3, decisions/registry.md.';
661
+ const RETIRED_PLANE='plane has been WITHDRAWN from the language (`PAINT-ORDER-CONSTRUCT`) — removed, not renamed, so there is no replacement spelling. It declared a DRAWING LAYER (a z-order), and in the genre that actually used it "plane" means the control / data / management partition of a network device, so the one word said the wrong thing to exactly the readers who write the figure. Delete the line and delete every plane= that referenced it: paint order is document order, a later line paints on top. If the elements form a logical layer of the SUBJECT, that is a class= whose label states it (core §5, `PRESENTATION-AS-MEANING-CARRIER`) — which is what the two authored uses were already doing alongside it.'+WITHDRAWN_PLANE_WHERE;
662
+ const RETIRED_LAYER='layer has been WITHDRAWN: it was renamed plane, and plane was withdrawn from the language (`PAINT-ORDER-CONSTRUCT`). There is no replacement spelling. Delete the line: everything paints in one plane and paint order is document order (a later line paints on top). A logical layer of the SUBJECT — an overlay, a control plane — is a class= whose label says so (core §5, `PRESENTATION-AS-MEANING-CARRIER`).'+WITHDRAWN_PLANE_WHERE;
518
663
  // `THRESHOLD-KEYWORD-SPELLING`: the scene keyword `guide` became `threshold`.
519
- 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)';
664
+ 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)';
520
665
  // `EXTERNAL-ENDPOINT-NAMING`: the scene keyword `boundary` became `external`.
521
- 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)';
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)';
522
667
  // `ROW-BREAK-NAMING`: the `bitfield` child keyword `wrap` became `break`.
523
- 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)';
668
+ 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)';
524
669
  // `PRESENCE-FLAG-SPELLING`: the 0.1 rename `optional` -> `conditional` (`PRESENCE-FLAG-SPELLING`)
525
670
  // is REVERTED. `conditional` has zero attestation as a wire-format field
526
671
  // marker and zero uses in the downstream corpus, while "optional" appears in
527
672
  // 34 downstream field LABELS — authors wrote the word in the label precisely
528
673
  // because the keyword no longer said it. The MODEL key moves with the surface
529
674
  // (`NORMATIVE-SEMANTIC-MODEL`): it is `optional` again on both sides.
530
- const RETIRED_FIELD_CONDITIONAL='the field flag "conditional" has been retired: write present="<the condition>" (or present="" if the condition is not stated). "conditional" was attested as a wire-format field marker nowhere — zero hits in RFC 2784, ASN.1 X.680, draft-mcquistin-augmented-ascii-diagrams, SystemRDL, IP-XACT, Kaitai Struct and protobuf — and the flag it briefly replaced, "optional", is retired too: an option key carrying the CONDITION says what a bare flag could not (MIGRATIONS)';
675
+ const RETIRED_FIELD_CONDITIONAL='the field flag "conditional" has been retired: write present="<the condition>" (or present="" if the condition is not stated). "conditional" was attested as a wire-format field marker nowhere — zero hits in RFC 2784, ASN.1 X.680, draft-mcquistin-augmented-ascii-diagrams, SystemRDL, IP-XACT, Kaitai Struct and protobuf — and the flag it briefly replaced, "optional", is retired too: an option key carrying the CONDITION says what a bare flag could not (MIGRATIONS 0.1)';
531
676
  // `PRESENCE-CONDITION-EXPRESSION`: the positional flag `optional` becomes the option key
532
677
  // `present=`, whose VALUE is the presence condition. A bare flag could say
533
678
  // only THAT a field is conditional; every RFC that draws one also states WHY
@@ -536,7 +681,7 @@ const RETIRED_FIELD_CONDITIONAL='the field flag "conditional" has been retired:
536
681
  // prose the model may not read. `present` is the attested spelling: X.680
537
682
  // PRESENT, IP-XACT isPresent, SystemRDL ispresent, RFC 2784 "present only
538
683
  // if", draft-mcquistin "present only when".
539
- const RETIRED_FIELD_OPTIONAL='the field flag "optional" has been retired and replaced by an option key that carries the CONDITION: write present="<the condition>" (e.g. field "Checksum" 16 present="C = 1"), or present="" when the condition is not stated. The bare flag could say only THAT the field was conditional, so the condition had to live in note= — invisible to the human reading the figure, and prose the model may not parse (`BITFIELD-CONDITIONAL-OFFSETS`). present= DRAWS: the field stays dashed and a stated condition becomes a caption under the block (MIGRATIONS)';
684
+ const RETIRED_FIELD_OPTIONAL='the field flag "optional" has been retired and replaced by an option key that carries the CONDITION: write present="<the condition>" (e.g. field "Checksum" 16 present="C = 1"), or present="" when the condition is not stated. The bare flag could say only THAT the field was conditional, so the condition had to live in the field\'s documentation prose — the key spelled note= at the time and description= where it was invisible to the human reading the figure, and prose the model may not parse (`BITFIELD-CONDITIONAL-OFFSETS`). (Today\'s note= is a different key: it is the DRAWN annotation revived, it is refused on field, and it is not where a presence condition belongs either.) present= DRAWS: the field stays dashed and a stated condition becomes a caption under the block (MIGRATIONS 0.1)';
540
685
  // `TIMING-GENRE-NAMING`: the EXPERIMENTAL genre `wave` became `timing`, both as
541
686
  // the header genre token and as the block opener. The old name was WaveDrom's
542
687
  // MEMBER KEY, not its figure name: in WaveJSON `signal` is the root object and
@@ -553,8 +698,8 @@ const RETIRED_FIELD_OPTIONAL='the field flag "optional" has been retired and rep
553
698
  // to know that the keys survive unchanged and only their carrier moved.
554
699
  // RULE 6.2 placement: the spelling left the LANGUAGE, so this fires wherever
555
700
  // it appears at line start, in every genre, ahead of the `GENRE-KEYWORD-ALLOWLIST` allowlist.
556
- const RETIRED_SIZE='size has been retired: its keys moved onto pin — write pin <id> width=<px> height=<px> (one directive carries an element\'s whole declared geometry: at= places it, width=/height= extend it; all three keys are optional and a pin with none of them declares nothing) (MIGRATIONS)';
557
- const RETIRED_WAVE='wave has been renamed: use timing (in WaveJSON `signal` is the root object and `wave` is a PROPERTY of one signal — its lane activity string — so `wave` named a member key, not a figure kind; WaveDrom\'s own name for the figure is "Digital Timing Diagram", "timing diagram" is the datasheet/JEDEC term for it, and UML 2.5.1\'s Timing Diagram is the same concept. The rename frees `wave` for the lane) (MIGRATIONS)';
701
+ const RETIRED_SIZE='size has been retired: its keys moved onto pin — write pin <id> width=<px> height=<px> (one directive carries an element\'s whole declared geometry: at= places it, width=/height= extend it; all three keys are optional and a pin with none of them declares nothing) (MIGRATIONS 0.1)';
702
+ const RETIRED_WAVE='wave has been renamed: use timing (in WaveJSON `signal` is the root object and `wave` is a PROPERTY of one signal — its lane activity string — so `wave` named a member key, not a figure kind; WaveDrom\'s own name for the figure is "Digital Timing Diagram", "timing diagram" is the datasheet/JEDEC term for it, and UML 2.5.1\'s Timing Diagram is the same concept. The rename frees `wave` for the lane) (MIGRATIONS 0.1)';
558
703
  // `EDGE-GEOMETRY-CONSTRUCTS`: `path` and `routing` are WITHDRAWN from the language.
559
704
  // These two diagnostics are a NEW SHAPE for this table. Every retirement
560
705
  // before them named a replacement spelling — `size` named `pin`, `guide` named
@@ -568,19 +713,19 @@ const RETIRED_WAVE='wave has been renamed: use timing (in WaveJSON `signal` is t
568
713
  // not "use X"), says what an author should do instead (delete the line and let
569
714
  // auto layout draw it, with the content-zone means named), and points at where
570
715
  // the decision is RECORDED so the reasoning is one lookup away.
571
- const WITHDRAWN_WHERE=' The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md';
716
+ const WITHDRAWN_WHERE=' The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md.';
572
717
  const RETIRED_PATH='path has been WITHDRAWN from the language (`EDGE-GEOMETRY-CONSTRUCTS`) — removed, not renamed, so there is no replacement spelling. A prior-art study of Visio, draw.io/mxGraph, Graphviz and ELK found author waypoints OUTSIDE the stable intersection: only 2 of the 4 model them, and those 2 disagree on what happens when an endpoint moves. The dock realisation was outside it too — written-order attachment has zero prior art in any surveyed system. Delete the line: the edge draws under auto layout, and `rank`, `flow`, declaration order and `pin` are the content-zone means of shaping it.'+WITHDRAWN_WHERE;
573
718
  const RETIRED_ROUTING='routing has been WITHDRAWN from the language (`EDGE-GEOMETRY-CONSTRUCTS`) — removed, not renamed, so there is no replacement spelling. Two routing modes and two scopes ARE inside the stable prior-art intersection, so the need is recognised and its shape is known; what is missing is the evidence and the implementation (6 of the 8 in-repo `routing=orthogonal` writings were provable no-ops, and downstream adoption was zero), and the per-edge scope cannot be restored without an edge-identity construct FigDown does not have. Delete the line; the edges draw straight.'+WITHDRAWN_WHERE;
574
719
  // `TIMING-LANE-ALPHABET`: the timing lane digits `2`-`9` left the closed alphabet.
575
- const RETIRED_LANE_DIGIT='timing lane digits 2-9 have been retired: write "=" for a data cell and name it in data= (WaveDrom defines 2..9 as "value with color N" and "=" as "value (default color 2)" — the same brick with a palette index, while FigDown drew the digit character itself as the box label and consumed no data entry, so the two readings of one lane differed silently) (MIGRATIONS)';
720
+ const RETIRED_LANE_DIGIT='timing lane digits 2-9 have been retired: write "=" for a data cell and name it in data= (WaveDrom defines 2..9 as "value with color N" and "=" as "value (default color 2)" — the same brick with a palette index, while FigDown drew the digit character itself as the box label and consumed no data entry, so the two readings of one lane differed silently) (MIGRATIONS 0.1)';
576
721
  // 0.1 (§8.4): `edge`, `threshold` and `bundle` have NO interior, so
577
722
  // `fill=` and `stroke=` named the SAME channel and `stroke=` won silently —
578
723
  // two keys for one channel, resolved by an undocumented precedence that
579
724
  // produced a legal, wrong figure whenever both were written (16 lines in
580
725
  // this repository, 3 of them writing both on one line). Same defect shape as
581
726
  // the retired `color=`; same cure, a named diagnostic.
582
- const NO_INTERIOR=new Set(['edge','threshold','bundle']);
583
- const FILL_NO_INTERIOR=k=>k+' has no interior, so fill= and stroke= name the same channel (stroke= won silently) — write stroke= (MIGRATIONS)';
727
+ const NO_INTERIOR=new Set(['edge','flowline','transition','threshold','bundle']);
728
+ const FILL_NO_INTERIOR=k=>k+' has no interior, so fill= and stroke= name the same channel (stroke= won silently) — write stroke= (MIGRATIONS 0.1)';
584
729
  // `STYLE-KEY-SCOPE`: `style=` left these three directives (it stays live on
585
730
  // node/group/edge/class/bundle/threshold/band). The generic
586
731
  // "<directive> does not take style=" would be true but would not say why, and
@@ -591,11 +736,11 @@ const FILL_NO_INTERIOR=k=>k+' has no interior, so fill= and stroke= name the sam
591
736
  // are closed here the way every other one-channel-two-keys collision in this
592
737
  // language was closed — a named line error, not a precedence rule. A
593
738
  // precedence rule is what `STYLE-KEY-SCOPE` had just finished removing from `field`.
594
- const CELL_HL_ON_CELL='highlight is a ROW mark and takes the single-valued row form (cell <row> highlight) — on a cell address it was SILENTLY DISCARDED and never reached the model, while the cell fill drew. A row tint and a cell fill paint the same channel, so writing both for one cell has no honest resolution: tint the row (cell <row> highlight) or paint the cell (cell (<row>,<col>) fill=…/class=…), not both (MIGRATIONS)';
595
- const CELL_HL_ROW_CONFLICT=(r,c)=>'cell ('+r+','+c+') resolves to a fill on row '+r+', which is highlighted — the cell fill overrides the row tint, so the model says "row '+r+' is highlighted" while the drawing shows only part of the row tinted (`PRESENTATION-AS-MEANING-CARRIER`: presentation may render meaning, never delete it). Drop the row highlight, or move the cell fill to a row that carries none (MIGRATIONS)';
739
+ const CELL_HL_ON_CELL='highlight is a ROW mark and takes the single-valued row form (cell <row> highlight) — on a cell address it was SILENTLY DISCARDED and never reached the model, while the cell fill drew. A row tint and a cell fill paint the same channel, so writing both for one cell has no honest resolution: tint the row (cell <row> highlight) or paint the cell (cell (<row>,<col>) fill=…/class=…), not both (MIGRATIONS 0.1)';
740
+ const CELL_HL_ROW_CONFLICT=(r,c)=>'cell ('+r+','+c+') resolves to a fill on row '+r+', which is highlighted — the cell fill overrides the row tint, so the model says "row '+r+' is highlighted" while the drawing shows only part of the row tinted (`PRESENTATION-AS-MEANING-CARRIER`: presentation may render meaning, never delete it). Drop the row highlight, or move the cell fill to a row that carries none (MIGRATIONS 0.1)';
596
741
  const NO_ITEM_STYLE=new Set(['field','cell','signal']);
597
742
  const STYLE_NO_ITEM=k=>k+' does not take style= — '+(k==='field'
598
- ? 'on a field the dash IS conditional presence (`present=`, spelled `optional` until this release), and style=solid erased it while the model still recorded the field as conditionally present (`PRESENTATION-AS-MEANING-CARRIER`: presentation may render meaning, never be its only carrier)'
743
+ ? 'on a field the dash IS conditional presence (`present=`, spelled `optional` until 0.1), and style=solid erased it while the model still recorded the field as conditionally present (`PRESENTATION-AS-MEANING-CARRIER`: presentation may render meaning, never be its only carrier)'
599
744
  : 'a dash on one '+k+' carried no meaning the block does not already carry, and 0 documents outside this repository wrote it')
600
745
  // `DESCRIPTION-KEY-SPELLING` corrected the second half of this message. It used to
601
746
  // offer `note=` as a place to put knowledge, which was wrong twice over:
@@ -604,7 +749,7 @@ const STYLE_NO_ITEM=k=>k+' does not take style= — '+(k==='field'
604
749
  // human, which is the same defect `STYLE-KEY-SCOPE` exists to close. The two channels
605
750
  // that DRAW are the name/label and a `class` meaning (which also earns a
606
751
  // legend entry).
607
- +'. Delete the key; if the distinction is knowledge, write it in the name/label or in a class= meaning — both of which DRAW; description= is documentation prose and produces no ink beyond a tooltip (MIGRATIONS)';
752
+ +'. Delete the key; if the distinction is knowledge, write it in the name/label or in a class= meaning — both of which DRAW; description= is documentation prose and produces no ink beyond a tooltip (MIGRATIONS 0.1)';
608
753
  function splitOpts(toks, laneMode){
609
754
  // a repeated option key on one line is a line error, never
610
755
  // silent last-wins. `dup` names the first key that appeared twice.
@@ -654,7 +799,7 @@ const ID_RE=/^[A-Za-z_][A-Za-z0-9_-]*$/;
654
799
  // `a`+`x--b`) and the greedy member regex silently committed to the first,
655
800
  // making the second unreachable and undiagnosed — RULE 6.3 says malformed
656
801
  // input is an error, never a guess. Zero ids in either corpus contain `--`.
657
- const DD_ID='"--" is not allowed inside an id — it is the link operator (edge a -- b, bundle t1 a--b); write a single "-" or "_" (MIGRATIONS)';
802
+ const DD_ID='"--" is not allowed inside an id — it is the link operator (edge a -- b, bundle t1 a--b); write a single "-" or "_" (MIGRATIONS 0.1)';
658
803
  // `QUOTED-IDS`: ONE wording for every id position. It covers both
659
804
  // halves of the defect it closes — a needlessly quoted legal id
660
805
  // (`node "a"`, silently accepted before) and a quoted token that is not a
@@ -673,7 +818,7 @@ const DD_ID='"--" is not allowed inside an id — it is the link operator (edge
673
818
  // old failure named the wrong thing (`node a Cache miss` reported
674
819
  // `unexpected argument "miss"` — a surplus argument, when the defect was a
675
820
  // missing quote).
676
- const Q_WHY='whitespace also separates positionals, so a bare token cannot express a phrase (MIGRATIONS)';
821
+ const Q_WHY='whitespace also separates positionals, so a bare token cannot express a phrase (MIGRATIONS 0.1)';
677
822
  const ID_RULE='ids are bare and match [A-Za-z_][A-Za-z0-9_-]* — text with spaces or punctuation belongs in the label: node <id> "your text"';
678
823
  // isId: the whole id test, used at every id position in the language.
679
824
  const isId=v=>typeof v==='string'&&ID_RE.test(v)&&!v.includes('--');
@@ -861,9 +1006,55 @@ function parseIndexRange(v){
861
1006
  const CORE_KW=['figdown','title','layout'];
862
1007
  const LAYOUT_KW=['pin']; // `LAYOUT-ZONE-NAMESPACE`, NORMATIVE — the whole namespace
863
1008
  const GENRE_FREE_KW=CORE_KW.concat(LAYOUT_KW);
864
- const SCENE_KW_TOP=['node','group','external','edge','class','flow','rank'];
865
- const SCENE_EXP_KW=['threshold','band','bundle','plane'];
866
- const SCENE_HOST_KW=GENRE_FREE_KW.concat(SCENE_KW_TOP, SCENE_EXP_KW, ['bitfield','table','timing','chart']);
1009
+ // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: the NODE and CONNECTOR spellings are per genre, so
1010
+ // they are NOT in the shared list — every scene genre concats its own two.
1011
+ //
1012
+ // `SUBJECT-VOCABULARY-SCOPE`: SUBJECT VOCABULARY IS PER GENRE, AND THERE IS NO SHARED
1013
+ // LIST OF IT. `SCENE_KW_TOP` and `SCENE_EXP_KW` are gone. They held the words
1014
+ // that say what a figure is OF — `group`, `external`, `threshold`, `band`,
1015
+ // `bundle`, `plane` — in one array concatenated into four genres, which is
1016
+ // the same defect core §3's "scene keywords" sentence recorded: an
1017
+ // INTERSECTION written down as if it were a namespace. Under `GENRE-VOCABULARY-OBLIGATION` a genre owns
1018
+ // its words, so each scene genre now names its own subject vocabulary in its
1019
+ // own array below. Two arrays agreeing is TWO DECLARATIONS that agree today,
1020
+ // never one declaration inherited, and either may be withdrawn, renamed or
1021
+ // constrained without touching the other.
1022
+ //
1023
+ // What stays shared, and why that is not a contradiction:
1024
+ // - `class` is STYLING declaration and `flow`/`rank` are LAYOUT INTENT.
1025
+ // None of the three describes a referent, so no genre's domain holds a
1026
+ // competing meaning for them and no genre can independently earn or lose
1027
+ // one. They are nearer `LAYOUT-ZONE-NAMESPACE`'s genre-independent layout namespace than `GENRE-VOCABULARY-OBLIGATION`.
1028
+ // - `bitfield`/`table`/`timing`/`chart` are `GENRE-COMPOSITION` REGION OPENERS: composition,
1029
+ // not subject vocabulary. The region's own namespace is the nested
1030
+ // genre's.
1031
+ const SCENE_STYLE_KW=['class','flow','rank'];
1032
+ const SCENE_REGION_KW=['bitfield','table','timing','chart'];
1033
+ const SCENE_HOST_KW=GENRE_FREE_KW.concat(SCENE_STYLE_KW, SCENE_REGION_KW);
1034
+ // --- Each scene genre's OWN subject vocabulary. One array per genre. ---
1035
+ // `block` (NORMATIVE): `group` and `external` are normative; `threshold` and
1036
+ // `band` are EXPERIMENTAL and are the `GENRE-EARNING-THRESHOLD` INTERIM scalar-marker pair, held here
1037
+ // deliberately unfrozen so the future scalar-marker genre can name them once
1038
+ // WITH a scale. They are not renamed now — a rename would hand that genre a
1039
+ // retired word.
1040
+ const BLOCK_SUBJECT_KW=['group','external','threshold','band'];
1041
+ // `topology` (EXPERIMENTAL): `bundle` is the one construct whose domain
1042
+ // reading and drawn reading are the same reading — a LAG (IEEE 802.1AX), an
1043
+ // ECMP set, an EVPN Ethernet Segment. `group`/`external` keep their block
1044
+ // spellings because every networking synonym is more taken (`zone` DNS and
1045
+ // firewall, `cluster` RFC 4456, `domain` RFC 7926, `area` OSPF, `site` EVPN)
1046
+ // and their collisions are SOFT — the picture contradicts the wrong reading.
1047
+ const TOPOLOGY_SUBJECT_KW=['group','external','bundle'];
1048
+ // `flowchart` (EXPERIMENTAL): `external` only — the off-page terminus, ISO
1049
+ // 5807 §9.4.2 *Terminator* being ISO's word for the concept and already this
1050
+ // genre's live keyword, so the spelling stays. `group` had one occurrence in
1051
+ // the whole tree and it was this genre's own reference figure.
1052
+ const FLOWCHART_SUBJECT_KW=['external'];
1053
+ // `statechart` (EXPERIMENTAL): NONE, and the empty array is the declaration.
1054
+ // Three authored statechart figures, all transcribed from RFCs, reach for
1055
+ // none of the six; `external` is additionally UML 2.5.1 §14's own
1056
+ // `TransitionKind` literal and is reserved for it (`RESERVED-SPELLINGS`).
1057
+ const STATECHART_SUBJECT_KW=[];
867
1058
  // `FLOWCHART-ROLE-KEYWORDS`: the flowchart ROLE vocabulary — the FIRST exercise of
868
1059
  // `GENRE-NAMESPACE` `GENRE-VOCABULARY-OBLIGATION` ("a genre owns its words"). These three are legal ONLY under
869
1060
  // `figdown 0.1 flowchart`; `GENRE-NAMESPACE`'s allowlist is what makes `decision x` a line
@@ -877,10 +1068,163 @@ const FLOWCHART_ROLE_KW=['process','decision','terminator'];
877
1068
  // only its default drawing, and `shape=` on the same line overrides the
878
1069
  // drawing without touching the role (§12.7).
879
1070
  const ROLE_SHAPE={process:'box',decision:'diamond',terminator:'rounded'};
1071
+
1072
+ // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: PER-GENRE NODE AND CONNECTOR SPELLINGS.
1073
+ // Each scene genre takes the word its own domain uses for the line between
1074
+ // two things, and for the thing itself. The criterion is THE TERM THE DOMAIN
1075
+ // ACTUALLY USES, not the standard's exact orthography (see the vocabulary
1076
+ // rows: `flowline` carries the same ISO-clause verification debt
1077
+ // `terminator` does, recorded rather than claimed away).
1078
+ //
1079
+ // block / topology node edge (DOT, unchanged)
1080
+ // flowchart node flowline (ISO 5807)
1081
+ // statechart state transition (OMG UML 2.5.1 §14)
1082
+ //
1083
+ // The ASYMMETRY is deliberate and is the whole of `GENRE-CONNECTOR-SPELLING` vs `GENRE-NODE-SPELLING`: `node`
1084
+ // STAYS in `flowchart`, because there a stage can have a role THE SOURCE
1085
+ // DOES NOT STATE, and `node` is the only spelling for that — forcing
1086
+ // `process` would make the transcriber assert what the source never said.
1087
+ // `statechart` has exactly ONE kind of node, so nothing can be left unstated
1088
+ // and `state` loses nothing. `BARE-NODE-MEANING` CORRECTS the reason this
1089
+ // comment used to give ("ISO has ~ten symbol kinds and we carry three"): a
1090
+ // symbol this genre cannot spell is a COVERAGE GAP in FigDown, not a state
1091
+ // of the figure, and `node` is not its spelling — see
1092
+ // 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']);
1097
+ // `KEYWORD-RENAME-SCOPE`: the flowchart rename is GATED BY THE DECLARED LANGUAGE
1098
+ // VERSION, because `GENRE-CONNECTOR-SPELLING` applied it to `figdown 0.1` and that BROKE documents
1099
+ // legal at v0.1.8 — `figdown 0.1 flowchart` + `edge` stopped parsing, with
1100
+ // nothing recording the break as a decision. Under core §13.0 only X removes,
1101
+ // so 0.1 keeps its spelling:
1102
+ //
1103
+ // figdown 0.1 flowchart `edge` legal, `flowline` is a VERSION error
1104
+ // figdown 0.2 flowchart `flowline` legal, `edge` is the WRONG_WORD error
1105
+ //
1106
+ // Two spellings inside ONE version is what the no-alias rule (`IDENTITY-ASSERTION`)
1107
+ // forbids; two spellings across VERSIONS is ordinary language evolution, and
1108
+ // each version accepts exactly one. `statechart` needs no gate of its own —
1109
+ // the GENRE requires 0.2 (GENRES_BY_VERSION), so `state`/`transition` cannot
1110
+ // be reached from a 0.1 document at all.
1111
+ const GENRE_CONNECTOR_KW_AT={
1112
+ '0.1':{block:'edge',topology:'edge',flowchart:'edge'},
1113
+ '0.2':GENRE_CONNECTOR_KW
1114
+ };
1115
+ const connectorKwAt=(genre,ver)=>
1116
+ ((GENRE_CONNECTOR_KW_AT[ver]||GENRE_CONNECTOR_KW)[genre])||undefined;
1117
+ // The version a connector spelling FIRST becomes legal in, so the diagnostic
1118
+ // can name it the way the genre gate already names `figdown 0.2`.
1119
+ const CONNECTOR_MIN_VERSION={flowline:'0.2',transition:'0.2'};
1120
+ const WRONG_VERSION_WORD=(surf,want,genre,need,have)=>
1121
+ '"'+surf+'" requires figdown '+need+' (this document declares '+have+'): '+
1122
+ 'under figdown '+have+' genre '+genre+' spells this "'+want+'". The rename is '+
1123
+ 'gated by the language version — a figdown '+have+' document keeps the spelling it '+
1124
+ 'was written with (core §13.0: only a MAJOR version removes) — so raise the header '+
1125
+ 'to figdown '+need+' or write "'+want+'" (MIGRATIONS 0.2)';
1126
+ // Why each genre's word is its word — quoted in the diagnostic, because
1127
+ // "not allowed in genre X" tells an author nothing about what to write.
1128
+ const WORD_WHY={
1129
+ edge:'a block or topology figure is a graph, and `edge` is the graph word (DOT)',
1130
+ 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
+ transition:'the connecting line in a statechart is a TRANSITION — the term UML 2.5.1 §14 uses for it',
1132
+ 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)'
1134
+ };
1135
+ // The named diagnostic `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING` owe: it says WHICH word this genre uses and
1136
+ // WHY, and it names the migration, because every connector line in a
1137
+ // reclassified document has to be rewritten (the cost `GENRE-CONNECTOR-SPELLING` accepted).
1138
+ const WRONG_WORD=(surf,want,genre)=>
1139
+ '"'+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)';
1141
+ // `SCENE-KEYWORD-MEMBERSHIP`: a word WITHDRAWN FROM ONE GENRE is not an unknown word,
1142
+ // 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
1144
+ // WHY that genre no longer declares it — the ruling's own ground, per cell,
1145
+ // because the grounds differ and a single sentence could not carry them.
1146
+ // Every one of these withdrawals was FREE: `topology`, `flowchart` and
1147
+ // `statechart` are EXPERIMENTAL genres outside the compatibility promise, and
1148
+ // in `block` the two withdrawn words were EXPERIMENTAL keywords (`EDGE-GEOMETRY-CONSTRUCTS` precedent:
1149
+ // experimental withdrawal, no gate, no rewrite owed).
1150
+ const WITHDREW_AT=' (withdrawn, `SCENE-KEYWORD-MEMBERSHIP`; MIGRATIONS 0.3)';
1151
+ const GENRE_WITHDRAWN={
1152
+ block:{
1153
+ bundle:'`bundle` is now declared by `topology` only. It had ZERO authored uses under `block` — every authored link bundle in the corpus is a topology document — and the construct is defined by a REFERENT that only that genre has: a LAG (IEEE 802.1AX), an ECMP set, an EVPN Ethernet Segment. Under `block` it was a ring around parallel edges with nothing to name.'
1154
+ },
1155
+ topology:{
1156
+ threshold:'`threshold` is now declared by `block` only. It had ZERO occurrences under `topology` in the whole corpus, and in this genre\'s domain a threshold is a QUEUE DEPTH WITH A NUMERIC VALUE (RFC 2309 minth/maxth, RFC 7567) — while FigDown\'s takes no value= and its offset= is a fraction of the target\'s rendered extent, not a quantity. Author the figure as `block`, where the `GENRE-EARNING-THRESHOLD` scalar-marker evidence lives.',
1157
+ band:'`band` is now declared by `block` only. Its two occurrences under `topology` were both conformance fixtures, never a figure anyone needed, and in this genre a BAND is a frequency band — radio, wireless, microwave, optical transport — which is exactly the kind of figure a topology document draws. Author the figure as `block`.'
1158
+ },
1159
+ flowchart:{
1160
+ group:'`flowchart` no longer declares `group`. Its one occurrence in the corpus was this genre\'s own reference figure, which exists to demonstrate every form of every keyword — so citing it as evidence of need is circular. There were no authored uses.',
1161
+ threshold:'`flowchart` no longer declares `threshold`. Zero occurrences, and the construct does not apply: a threshold is a labelled reference value drawn at a percentage of the target\'s RENDERED EXTENT, and a process box\'s extent is an artifact of its label length, so the line asserts nothing a reader can read.',
1162
+ band:'`flowchart` no longer declares `band`. Zero occurrences, and a band is a RANGE over that same meaningless extent.',
1163
+ bundle:'`flowchart` no longer declares `bundle`. Zero occurrences, and parallel flowlines between the same two stages are different CONDITIONS; drawing a ring round them hides what the figure is for.'
1164
+ },
1165
+ statechart:{
1166
+ group:'`statechart` declares NO subject vocabulary at all. UML\'s grouping construct is the COMPOSITE STATE and its REGIONS, and under the single-source-vocabulary rule a statechart that needed grouping should take UML 2.5.1 §14\'s word for it — declared in this genre\'s own document — rather than inherit another genre\'s.',
1167
+ external:'`statechart` declares NO subject vocabulary at all, and `external` is additionally RESERVED here: UML 2.5.1 §14 defines TransitionKind as `external | internal | local`, so in this genre\'s own source standard "external" already names A TRANSITION THAT EXITS AND RE-ENTERS ITS SOURCE STATE. FigDown\'s `external` means an endpoint outside the figure that is never drawn — same word, same genre, same standard, unrelated meanings.',
1168
+ threshold:'`statechart` declares NO subject vocabulary at all. A state has no extent that means anything — its box is sized by its label — so a reference value drawn 60% down it asserts nothing.',
1169
+ band:'`statechart` declares NO subject vocabulary at all, and a band is a range over that same meaningless extent.',
1170
+ bundle:'`statechart` declares NO subject vocabulary at all, and here `bundle` is an ANTI-FEATURE: two transitions between the same pair of states are two different TRIGGERS, and the trigger is the whole content of the arc. Bundling them draws away exactly what the figure is for.'
1171
+ }
1172
+ };
1173
+ const WITHDRAWN_FROM_GENRE=(kw,genre)=>
1174
+ '"'+kw+'" is not allowed in genre '+genre+' — it was WITHDRAWN from this genre, not misspelled: '+
1175
+ GENRE_WITHDRAWN[genre][kw]+
1176
+ ' Subject vocabulary is per genre (core §3, `GENRE-VOCABULARY-OBLIGATION`): a spelling accepted by several genres is several '+
1177
+ 'independent declarations, and this genre\'s was withdrawn without touching any other\'s.'+WITHDREW_AT;
1178
+ // `MEMBERSHIP-KEY-ACCEPTANCE`: THE OPTION-KEY HALF OF `SCENE-KEYWORD-MEMBERSHIP`. A per-genre withdrawal can
1179
+ // strand an option KEY as easily as it strands a keyword: `in=` states
1180
+ // membership and its ONLY value domain is the id of a containing `group`, so
1181
+ // once `SCENE-KEYWORD-MEMBERSHIP` stopped `flowchart` and `statechart` from declaring a `group` the
1182
+ // key stayed accepted with nothing it could name. The measured symptom was
1183
+ // `process a "A" in=g` answering `unknown group "g"` with NO spelling that
1184
+ // succeeds — a dangling reference every author reaches by writing the key at
1185
+ // all. The other acceptors of `in=` in the language are `threshold` and
1186
+ // `band`, whose domain `MARKER-TARGET-KINDS` widened to REGION ids; neither is a keyword of
1187
+ // either genre since `SCENE-KEYWORD-MEMBERSHIP`, and the widening never reached `node`, so a
1188
+ // `flowchart` document that declares `table q` still answers `unknown group
1189
+ // "q"` for `node a "A" in=q`. Nothing in either genre was left un-stranded,
1190
+ // which is why the withdrawal is by KEY here and not directive by directive.
1191
+ //
1192
+ // The grounds differ per genre and are stated per cell, as `SCENE-KEYWORD-MEMBERSHIP`'s are:
1193
+ // `flowchart`'s is that every value is a dead end, `statechart`'s is that the
1194
+ // spelling is RESERVED for a different domain. Both genres are EXPERIMENTAL,
1195
+ // so both withdrawals are free — the `EDGE-GEOMETRY-CONSTRUCTS` precedent, no gate and no rewrite
1196
+ // owed — which is also what makes re-adding `in=` to `statechart` later with
1197
+ // a `state`-id domain cost nothing.
1198
+ const WITHDREW_OPT_AT=' (withdrawn, `MEMBERSHIP-KEY-ACCEPTANCE`; MIGRATIONS 0.3)';
1199
+ const GENRE_WITHDRAWN_OPT={
1200
+ flowchart:{
1201
+ in:'`flowchart` no longer accepts `in=`. Its only value domain was the id of a containing `group`, and this genre has not declared `group` (`SCENE-KEYWORD-MEMBERSHIP`) — so EVERY value was a dead end: `in=x` answered `unknown group "x"` and no spelling succeeded. An unknown-option error that names the reason beats a dangling reference no author can satisfy. What expresses membership TODAY is `class=`: declare `class ingress "Ingress phase"` and write `class=ingress` on each stage — it earns a legend entry and applies to every member at once. Containment in a flowchart is an OPEN question and the construct the need is waiting on is a swimlane, not a box (spec/genres/experimental/flowchart.md, What is excluded).'
1202
+ },
1203
+ statechart:{
1204
+ in:'`statechart` no longer accepts `in=`, and the spelling is RESERVED rather than merely dropped. This genre declares NO subject vocabulary (`SUBJECT-VOCABULARY-SCOPE`), so the `group` id that was `in=`\'s only value domain cannot exist here and every value was a dead end. The reason for withdrawing rather than leaving it is the RESERVATION: `in=`\'s future domain in this genre is a STATE id — UML 2.5.1 §14.2.3.4 composite states would arrive as nesting on `state`, through this exact key — so a live key with a group-id domain taught the WRONG model using the very spelling reserved for the right one. Delete the key: a category shared by several states is a `class=` meaning. `in=` is expected back with a state-id domain when composite-state nesting is earned (`MEMBERSHIP-KEY-ACCEPTANCE`).'
1205
+ }
1206
+ };
1207
+ const WITHDRAWN_OPT_FROM_GENRE=(key,genre)=>
1208
+ key+'= is not allowed in genre '+genre+' — it was WITHDRAWN from this genre, not misspelled: '+
1209
+ GENRE_WITHDRAWN_OPT[genre][key]+
1210
+ ' An option key is per genre for the same reason a keyword is (core §3, `GENRE-VOCABULARY-OBLIGATION`): the key is accepted '+
1211
+ 'by the directive AND by the genre, and this genre\'s acceptance was withdrawn without touching any other\'s.'+
1212
+ WITHDREW_OPT_AT;
880
1213
  const GENRE_KW={
881
- block:new Set(SCENE_HOST_KW),
882
- topology:new Set(SCENE_HOST_KW),
883
- flowchart:new Set(SCENE_HOST_KW.concat(FLOWCHART_ROLE_KW)),
1214
+ block:new Set(SCENE_HOST_KW.concat(BLOCK_SUBJECT_KW, ['node','edge'])),
1215
+ topology:new Set(SCENE_HOST_KW.concat(TOPOLOGY_SUBJECT_KW, ['node','edge'])),
1216
+ flowchart:new Set(SCENE_HOST_KW.concat(FLOWCHART_SUBJECT_KW, ['node','flowline'], FLOWCHART_ROLE_KW)),
1217
+ // `STATECHART-GENRE-SCOPE`: `statechart` added no keyword of its own — it was the
1218
+ // scene host set and nothing else. `GENRE-NODE-SPELLING` gives it its two: the
1219
+ // scene host set with `state` and `transition` in the slots `node` and
1220
+ // `edge` occupy elsewhere. It still does NOT inherit `process`/`decision`/
1221
+ // `terminator`: those are flowchart's words (`GENRE-NAMESPACE` `GENRE-VOCABULARY-OBLIGATION`), and a `decision` in a
1222
+ // statechart is a category error, not a shorthand. The allowlist is what
1223
+ // makes that a line error with no extra code.
1224
+ // `SUBJECT-VOCABULARY-SCOPE`: its subject vocabulary is the empty array above, so
1225
+ // `state` + `transition` + core + layout + styling + region openers is now
1226
+ // the WHOLE of what a statechart document may write at top level.
1227
+ statechart:new Set(SCENE_HOST_KW.concat(STATECHART_SUBJECT_KW, ['state','transition'])),
884
1228
  bitfield:new Set(GENRE_FREE_KW.concat(['class','bitfield'])),
885
1229
  // chart is experimental and attaches to a table id in the same document
886
1230
  table:new Set(GENRE_FREE_KW.concat(['class','table','chart'])),
@@ -943,7 +1287,10 @@ function parseOne(text){
943
1287
  // `EMPTY-LABEL-STATE`: `title` and a plane label start ABSENT (null), never as an empty
944
1288
  // string — an author who writes `title ""` has made a distinction the model
945
1289
  // must keep, and the implicit `base` plane wrote no label at all.
946
- const doc={title:null,nodes:[],groups:[],edges:[],planes:[{id:'base',label:null,z:0}],
1290
+ // `DRAWN-ANNOTATION-FORM`: `note` sits beside `title` and starts ABSENT (null), on
1291
+ // `EMPTY-LABEL-STATE`'s rule for `title` itself — an author who writes `note=""` has made
1292
+ // a distinction the model must keep.
1293
+ const doc={title:null,note:null,nodes:[],groups:[],edges:[],planes:[{id:'base',label:null,z:0}],
947
1294
  flow:'right',ranks:[],pins:{},blocks:[],trunks:[],thresholds:[],bands:[],
948
1295
  classes:[],boundaries:[]};
949
1296
  const nodeIds=new Set(), groupIds=new Set(), planeIds=new Set(['base']), classIds=new Set(),
@@ -983,8 +1330,13 @@ function parseOne(text){
983
1330
  // operator into halves: -[x]- -[x]-> <-[x]- <-[x]->. Bracket content:
984
1331
  // balanced brackets nest verbatim ([flags[3:0]] just works); ["..."] takes
985
1332
  // the standard quoted-string escapes for unbalanced brackets / \n.
986
- function parseEdgeLine(s,n){
987
- let i=4; // past 'edge'
1333
+ // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: `kw` is the SURFACE spelling the author wrote —
1334
+ // `edge`, `flowline` or `transition`. One scanner, three words: every
1335
+ // message names the word on the line, and nothing downstream of here knows
1336
+ // the difference (the model records a connector, not a spelling).
1337
+ function parseEdgeLine(s,n,kw){
1338
+ kw=kw||'edge';
1339
+ let i=kw.length; // past the connector keyword
988
1340
  const ws=()=>{ while(i<s.length&&/\s/.test(s[i])) i++; };
989
1341
  // `LINK-OPERATOR-IN-IDS`: a hyphen is an id character only when it is NOT
990
1342
  // followed by a second one, because `--` is the link operator. This is
@@ -1032,7 +1384,7 @@ function parseOne(text){
1032
1384
  ws();
1033
1385
  if(s[i]==='"'){ err(n,ID_RULE); return; }
1034
1386
  const a=readId();
1035
- if(!a){ err(n, idHere()?ID_RULE:'edge needs <id> ->|<-|--|<-> <id>'); return; }
1387
+ if(!a){ err(n, idHere()?ID_RULE:kw+' needs <id> ->|<-|--|<-> <id>'); return; }
1036
1388
  if(idHere()){ err(n,ID_RULE); return; }
1037
1389
  ws(); let tail=null;
1038
1390
  if(s[i]==='['){ const r=readLbl(); if(r.error){ err(n,r.error); return; } tail=r.v; }
@@ -1040,7 +1392,7 @@ function parseOne(text){
1040
1392
  let lh=null;
1041
1393
  if(s.startsWith('<-',i)){ lh='<-'; i+=2; }
1042
1394
  else if(s[i]==='-'){ lh='-'; i++; }
1043
- else { err(n,'edge needs an operator: -> <- -- <-> (a [mid] label splits it: -[x]->)'); return; }
1395
+ else { err(n,kw+' needs an operator: -> <- -- <-> (a [mid] label splits it: -[x]->)'); return; }
1044
1396
  let mid=null, op=null;
1045
1397
  if(s[i]==='['){
1046
1398
  const r=readLbl(); if(r.error){ err(n,r.error); return; } mid=r.v;
@@ -1052,14 +1404,14 @@ function parseOne(text){
1052
1404
  } else {
1053
1405
  if(s[i]==='-'){ op='--'; i++; }
1054
1406
  else if(s[i]==='>'){ op='->'; i++; }
1055
- else { err(n,'edge needs an operator: -> <- -- <->'); return; }
1407
+ else { err(n,kw+' needs an operator: -> <- -- <->'); return; }
1056
1408
  }
1057
1409
  ws(); let head=null;
1058
1410
  if(s[i]==='['){ const r=readLbl(); if(r.error){ err(n,r.error); return; } head=r.v; }
1059
1411
  ws();
1060
1412
  if(s[i]==='"'){ err(n,ID_RULE); return; }
1061
1413
  const b=readId();
1062
- if(!b){ err(n, idHere()?ID_RULE:'edge needs a target id after the operator'); return; }
1414
+ if(!b){ err(n, idHere()?ID_RULE:kw+' needs a target id after the operator'); return; }
1063
1415
  if(idHere()){ err(n,ID_RULE); return; }
1064
1416
  const tk2=tokenize(s.slice(i).trim());
1065
1417
  if(tk2.error){ err(n,tk2.error); return; }
@@ -1073,10 +1425,10 @@ function parseOne(text){
1073
1425
  for(const rk in RETIRED_OPT_KEYS)
1074
1426
  if(o2[rk]!==undefined){ err(n,RETIRED_OPT_KEYS[rk]); return; }
1075
1427
  for(const k in o2)
1076
- if(!DIRECTIVE_OPTS.edge.includes(k)){ err(n,'edge does not take '+k+'='); return; }
1428
+ if(!DIRECTIVE_OPTS[kw].includes(k)){ err(n,kw+' does not take '+k+'='); return; }
1077
1429
  for(const k of ['label','taillabel','headlabel'])
1078
- if(o2[k]!==undefined){ err(n,k+'= is retired — write the label inline: edge A [tail] -[mid]-> [head] B (MIGRATIONS)'); return; }
1079
- if(o2.fill!==undefined){ err(n,FILL_NO_INTERIOR('edge')); return; }
1430
+ if(o2[k]!==undefined){ err(n,k+'= is retired — write the label inline: '+kw+' A [tail] -[mid]-> [head] B (MIGRATIONS 0.1)'); return; }
1431
+ if(o2.fill!==undefined){ err(n,FILL_NO_INTERIOR(kw)); return; }
1080
1432
  for(const k of ['fill','stroke'])
1081
1433
  if(o2[k]!==undefined && !isColor(o2[k])){ err(n,'unknown color "'+o2[k]+'" (#hex or CSS color name)'); return; }
1082
1434
  // `RULE-POSITION-ENUMERATION`: `edge` was the ONE id position in the language that
@@ -1091,6 +1443,16 @@ function parseOne(text){
1091
1443
  const e=idErr(o2.plane, optHasQ(oT2,'plane'), null);
1092
1444
  if(e){ err(n,e); return; }
1093
1445
  }
1446
+ // `DRAWN-ANNOTATION-FORM`: the connector's copy of the `note=` version gate and
1447
+ // of the `QUOTING-RULES` quoted-prose rule. A connector is the acceptor the ruling
1448
+ // called decisive — an edge has no id, so an attribute is the ONLY form
1449
+ // that can reach it — and this scanner has to carry every language-wide
1450
+ // check itself or the one construct that most needs the key is the one
1451
+ // construct where the key is unchecked.
1452
+ if(o2.note!==undefined){
1453
+ if(belowOptVersion('note',doc.version)){ err(n,NOTE_VERSION(doc.version)); return; }
1454
+ if(!optQ(oT2,'note')){ err(n,'note= must be quoted: note="'+o2.note+'" — '+Q_WHY); return; }
1455
+ }
1094
1456
  // `RULE-POSITION-ENUMERATION`: and the enum half of RULE 2.4, for the one enum key `edge` takes.
1095
1457
  // Checked before the value, exactly as `badOpts` does it.
1096
1458
  if(o2.style!==undefined && optHasQ(oT2,'style')){ err(n,ENUM_BARE('style='+o2.style)); return; }
@@ -1105,7 +1467,7 @@ function parseOne(text){
1105
1467
  // and `fill=` name the same channel (`stroke=` wins when both are
1106
1468
  // written); `text=` colours the [tail]/[mid]/[head] labels.
1107
1469
  doc.edges.push({a,b,op,tail,mid,head,style:o2.style,cls:ecls,
1108
- stroke:o2.stroke,
1470
+ stroke:o2.stroke,note:o2.note,
1109
1471
  plane:o2.plane||'base',line:n});
1110
1472
  }
1111
1473
 
@@ -1178,17 +1540,41 @@ function parseOne(text){
1178
1540
  if(findReservedSemi(raw)>=0){ err(n,RESERVED_SEMI); continue; }
1179
1541
  // edge lines carry inline [labels] with free text — dedicated scanner,
1180
1542
  // not the generic tokenizer
1181
- if(/^edge(\s|$)/.test(raw.trim())){
1543
+ // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: all THREE connector spellings are scanned here.
1544
+ // The wrong one for the genre must reach the named diagnostic below, so
1545
+ // the dispatch cannot be narrowed to the genre's own word — a `flowline`
1546
+ // under `block` would then fall through to `unrecognized line`, which is
1547
+ // exactly the answer these rulings owe an author better than.
1548
+ const mConn=/^(edge|flowline|transition)(\s|$)/.exec(raw.trim());
1549
+ if(mConn){
1550
+ const ckw=mConn[1];
1182
1551
  if(firstContent){ firstContent=false; err(n,'first line must be "figdown 0.1 <genre>"'); }
1183
1552
  cur=null;
1184
- // `CONTENT-LAYOUT-ZONE-SPLIT`: `edge` is a semantic directive, and it is dispatched here —
1553
+ // `CONTENT-LAYOUT-ZONE-SPLIT`: a connector is a semantic directive, and it is dispatched here —
1185
1554
  // before the generic layout-zone gate below — so it needs its own copy
1186
1555
  // of that gate, or it is the one semantic line that escapes the zone.
1187
- if(sawLayout){ err(n,'"edge" is a semantic directive — it must appear before the layout zone (`CONTENT-LAYOUT-ZONE-SPLIT`)'); continue; }
1188
- // `GENRE-KEYWORD-ALLOWLIST`: edge is scene vocabulary only
1189
- if(sawHeader && doc.genre && GENRE_KW[doc.genre] && !GENRE_KW[doc.genre].has('edge')){
1190
- err(n,'"edge" is not allowed in genre '+doc.genre); continue; }
1191
- parseEdgeLine(raw.trim(),n);
1556
+ if(sawLayout){ err(n,'"'+ckw+'" is a semantic directive — it must appear before the layout zone (`CONTENT-LAYOUT-ZONE-SPLIT`)'); continue; }
1557
+ // `GENRE-KEYWORD-ALLOWLIST`: a connector is scene vocabulary only — and in a scene genre it is
1558
+ // THAT genre's connector word (`GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`), not any of the three.
1559
+ // `KEYWORD-RENAME-SCOPE`: the word this genre uses is read AT THE DECLARED VERSION, not
1560
+ // at the newest one. A genre with no connector at all (bitfield, table,
1561
+ // timing) still gets the allowlist message.
1562
+ if(sawHeader && doc.genre && GENRE_KW[doc.genre]){
1563
+ const want=connectorKwAt(doc.genre, doc.version);
1564
+ if(!want){ err(n,'"'+ckw+'" is not allowed in genre '+doc.genre); continue; }
1565
+ if(ckw!==want){
1566
+ const need=CONNECTOR_MIN_VERSION[ckw];
1567
+ // The author wrote a word this genre really does use — just not in
1568
+ // the version they declared. That is a different mistake from the
1569
+ // wrong domain's word, and it has a different one-step fix.
1570
+ if(need && doc.version && need>doc.version &&
1571
+ GENRE_CONNECTOR_KW[doc.genre]===ckw)
1572
+ err(n, WRONG_VERSION_WORD(ckw,want,doc.genre,need,doc.version));
1573
+ else
1574
+ err(n, WRONG_WORD(ckw,want,doc.genre));
1575
+ continue; }
1576
+ }
1577
+ parseEdgeLine(raw.trim(),n,ckw);
1192
1578
  continue;
1193
1579
  }
1194
1580
  const tk=tokenize(raw.trim());
@@ -1207,6 +1593,19 @@ function parseOne(text){
1207
1593
  // same-line repeated option key (last-wins was silent data loss)
1208
1594
  if(dup){ err(n,'duplicate option "'+dup+'=" on one line'); bad=true; }
1209
1595
  for(const u of unk){ err(n,'unknown option "'+u+'="'); bad=true; }
1596
+ // `MEMBERSHIP-KEY-ACCEPTANCE`: the PER-GENRE option-key withdrawal, checked here —
1597
+ // after `unknown option`, so a key the LANGUAGE does not have keeps its
1598
+ // own answer, and before every value check, so a withdrawn key is never
1599
+ // told what its value would have meant. `gwHit` suppresses the id-value
1600
+ // rule below for the same key: ONE token, ONE error, the convention
1601
+ // `enumQ` already follows. The line is abandoned by the caller
1602
+ // (`if(badOpts(kw)) continue;`), so no cascade reaches the resolver and
1603
+ // the author never sees the `unknown group "…"` this ruling removes.
1604
+ const gwOpt=(doc.genre&&GENRE_WITHDRAWN_OPT[doc.genre])||null;
1605
+ const gwHit=new Set();
1606
+ if(gwOpt) for(const o in opts)
1607
+ if(gwOpt[o]!==undefined && allowed.includes(o)){
1608
+ err(n,WITHDRAWN_OPT_FROM_GENRE(o,doc.genre)); gwHit.add(o); bad=true; }
1210
1609
  // Retired spelling: `color=` → `fill=`. Fires only where
1211
1610
  // the key was accepted; on a directive that never took it the existing
1212
1611
  // `<directive> does not take color=` is still the right answer.
@@ -1216,6 +1615,19 @@ function parseOne(text){
1216
1615
  // spelling left the language rather than moving between directives.
1217
1616
  for(const rk in RETIRED_OPT_KEYS)
1218
1617
  if(opts[rk]!==undefined){ err(n,RETIRED_OPT_KEYS[rk]); bad=true; }
1618
+ // `DRAWN-ANNOTATION-FORM`: the two `note=` refusals, in the order that gives
1619
+ // ONE error per line. `field` is checked first and unconditionally,
1620
+ // because it refuses the key at every version — telling a `figdown 0.2`
1621
+ // bitfield author to raise their header would send them to a version
1622
+ // that still refuses them. Every directive that does NOT list `note` in
1623
+ // its row falls through to the generic `<directive> does not take note=`
1624
+ // below, which is the right answer for `external`, `threshold`, `band`,
1625
+ // `bundle`, `plane`, `class` and `cell`: the key is in OPT_KEYS, so none
1626
+ // of them can report `unknown option` for a spelling the language has.
1627
+ if(opts.note!==undefined && allowed.includes('note')){
1628
+ if(k==='field'){ err(n,NOTE_ON_FIELD); bad=true; }
1629
+ else if(belowOptVersion('note',doc.version)){ err(n,NOTE_VERSION(doc.version)); bad=true; }
1630
+ }
1219
1631
  // `RULE-POSITION-ENUMERATION`: RULE 2.4's enum half on the OPTION keys. One loop
1220
1632
  // for every enum-valued key, the same device the id-valued keys below
1221
1633
  // use — so a key that gains an enum grammar later is covered by
@@ -1264,7 +1676,11 @@ function parseOne(text){
1264
1676
  // until then) and `present=` (`PRESENCE-CONDITION-EXPRESSION`). `present=""` is legal and is the
1265
1677
  // "conditional, condition not stated" form — an EMPTY quoted value,
1266
1678
  // not an unquoted one, so the same rule admits it.
1267
- for(const sk of ['description','present'])
1679
+ // `DRAWN-ANNOTATION-FORM`: THREE since `note=` revived. It is prose in the
1680
+ // same sense, and it revives with the value shape it retired with —
1681
+ // which is RULE 4.9 obligation 2 satisfied in the parser rather than
1682
+ // only on paper.
1683
+ for(const sk of ['description','present','note'])
1268
1684
  if(opts[sk]!==undefined && allowed.includes(sk) && !optQ(optT,sk)){
1269
1685
  err(n,sk+'= must be quoted: '+sk+'="'+opts[sk]+'" — '+Q_WHY); bad=true; }
1270
1686
  // `QUOTED-IDS`: `in=` and `plane=` are ID-VALUED options, so the
@@ -1272,7 +1688,7 @@ function parseOne(text){
1272
1688
  // keeps its directive-specific message (`threshold needs in=…`); a written
1273
1689
  // one that is quoted or not a legal id gets the ID RULE.
1274
1690
  for(const k of ['in','plane'])
1275
- if(opts[k]!==undefined && allowed.includes(k)){
1691
+ if(opts[k]!==undefined && allowed.includes(k) && !gwHit.has(k)){
1276
1692
  const e=idErr(opts[k], optHasQ(optT,k), null);
1277
1693
  if(e){ err(n,e); bad=true; }
1278
1694
  }
@@ -1300,25 +1716,53 @@ function parseOne(text){
1300
1716
  // line. So the quoting check runs first and suppresses the value
1301
1717
  // check for that token, exactly as `badOpts` does for `shape=`.
1302
1718
  const hq1=!!posq[1], hq2=!!posq[2];
1719
+ // `STATECHART-GENRE-SCOPE`: the language version is now a SET, not a constant, so every
1720
+ // message that echoes a corrected header echoes the version the
1721
+ // author actually declared — an author on `0.2` must not be shown a
1722
+ // `0.1` example. An unrecognised version falls back to `0.1` for the
1723
+ // purpose of these examples only; it has already errored on its own.
1724
+ const verOK=!hq1 && LANG_VERSIONS.includes(pos[1]);
1725
+ const ver=verOK?pos[1]:'0.1';
1303
1726
  if(hq1) err(n,ENUM_BARE('figdown '+pos[1]+' <genre>'));
1304
- else if(hq2) err(n,ENUM_BARE('figdown 0.1 '+pos[2]));
1305
- if(!hq1 && pos[1]!=='0.1') err(n,'unsupported version "'+(pos[1]||'')+'" (expected 0.1)');
1306
- const GENRES=['block','topology','flowchart','bitfield','table','timing'];
1727
+ else if(hq2) err(n,ENUM_BARE('figdown '+ver+' '+pos[2]));
1728
+ if(!hq1 && !LANG_VERSIONS.includes(pos[1])) err(n,'unsupported version "'+(pos[1]||'')+'" (expected '+LANG_VERSIONS.join(' or ')+')');
1307
1729
  // 0.1: the genre token is REQUIRED. `bitfield`/`table`/`timing`
1308
1730
  // documents declare their kind in their content, but `block`,
1309
1731
  // `topology` and `flowchart` share the SAME vocabulary
1310
1732
  // (node/edge/group) and differ only in default flow — so the header
1311
1733
  // is the ONLY place such a document states which kind of figure it
1312
1734
  // is, and omitting it destroys the distinction with no recoverable
1313
- // fallback. The message lists the six legal values so an authoring
1314
- // agent fixes it in one step.
1315
- if(pos[2]===undefined) err(n,'figdown header requires a genre (block|topology|flowchart|bitfield|table|timing)');
1735
+ // fallback. The message lists the legal values so an authoring
1736
+ // agent fixes it in one step — the values legal AT THE DECLARED
1737
+ // VERSION, since listing `statechart` to a `figdown 0.1` author would
1738
+ // name a genre that document may not use.
1739
+ const GENRES=GENRES_BY_VERSION[ver];
1740
+ const GLIST='('+GENRES.join('|')+')';
1741
+ if(pos[2]===undefined) err(n,'figdown header requires a genre '+GLIST);
1316
1742
  else if(hq2){ /* the quoting error above is this token's one error */ }
1317
1743
  else if(pos[2]==='wave') err(n,RETIRED_WAVE);
1318
- else if(!GENRES.includes(pos[2])) err(n,'unknown genre "'+pos[2]+'" (block|topology|flowchart|bitfield|table|timing)');
1744
+ else if(!GENRES.includes(pos[2])){
1745
+ // A genre that exists but only LATER is its own diagnostic, not
1746
+ // `unknown genre`. core §13.7 forbids guessing a version, and the
1747
+ // author's one-step fix is to raise the header — which the message
1748
+ // SPELLS OUT, because "unknown genre statechart" would send them
1749
+ // looking for a typo that is not there. The trailing `— write: …`
1750
+ // is the same shape WRONG_VERSION_WORD ends with: name the fix, do
1751
+ // not leave the author to derive it.
1752
+ const later=Object.keys(GENRES_BY_VERSION).sort()
1753
+ .find(v=>v>ver && GENRES_BY_VERSION[v].includes(pos[2]));
1754
+ if(later) err(n,'genre "'+pos[2]+'" requires figdown '+later+' (this document declares '+ver+') — write: figdown '+later+' '+pos[2]);
1755
+ else err(n,'unknown genre "'+pos[2]+'" '+GLIST);
1756
+ }
1319
1757
  else{ doc.genre=pos[2];
1758
+ // The DECLARED language version is part of the model (core §12.5
1759
+ // `header.version`): a reader must be able to tell what contract
1760
+ // the author wrote against without re-reading the source.
1761
+ doc.version=ver;
1320
1762
  // genre defaults (`GENRE-NAMESPACE`/`DEFAULT-VALUE-SELECTION`): flowchart figures flow down —
1321
- // the census-dominant direction; an explicit flow line overrides
1763
+ // the census-dominant direction; an explicit `flow` directive overrides.
1764
+ // `statechart` takes the SCENE default (`right`) and adds no rule
1765
+ // of its own — `STATECHART-GENRE-SCOPE` lands a dispatch point, not a layout.
1322
1766
  if(pos[2]==='flowchart') doc.flow='down'; }
1323
1767
  // §1: a directive line carrying positional arguments its grammar does
1324
1768
  // not accept MUST be rejected — the header is a directive like any other
@@ -1353,7 +1797,7 @@ function parseOne(text){
1353
1797
  // noun in a topology figure (`SHAPE-ENUM-VOCABULARY`: no domain nouns in the presentation
1354
1798
  // vocabulary) while the directive means geometric waypoints;
1355
1799
  // - `render` was a verb naming a zone that admits only geometry
1356
- // (`pin` — and, until this release, `path` and `routing`), and it
1800
+ // (`pin` — and, until 0.1, `path` and `routing`), and it
1357
1801
  // collided with the renderer
1358
1802
  // and the render options of §7. `layout` is the cross-tool word for
1359
1803
  // this half of a diagram language, and the zone it opens carried the
@@ -1362,19 +1806,25 @@ function parseOne(text){
1362
1806
  // the first entries in it that name NO replacement. `route`'s own message
1363
1807
  // has to change with them: it pointed at `path`, which no longer exists,
1364
1808
  // so it now states the whole chain and ends where the others end.
1365
- if(kw==='line'){ err(n,'line has been renamed: use threshold (a labelled reference value drawn across the target'+"'"+'s box; "line" now only names a source line number, and the 0.1 replacement `guide` was itself retired) (MIGRATIONS)'); continue; }
1809
+ if(kw==='line'){ err(n,'line has been renamed: use threshold (a labelled reference value drawn across the target'+"'"+'s box; "line" now only names a source line number, and the 0.1 replacement `guide` was itself retired) (MIGRATIONS 0.1)'); continue; }
1366
1810
  if(kw==='fill'){ err(n,'fill has been renamed: use band (a range band; the KEYWORD is retired — fill= is the presentation option key)'); continue; }
1367
1811
  if(kw==='route'){ err(n,'route has been WITHDRAWN: it was renamed path, and path was withdrawn from the language (`EDGE-GEOMETRY-CONSTRUCTS`). There is no replacement spelling. Delete the line: the edge draws under auto layout.'+WITHDRAWN_WHERE); continue; }
1368
1812
  if(kw==='path'){ err(n,RETIRED_PATH); continue; }
1369
1813
  if(kw==='routing'){ err(n,RETIRED_ROUTING); continue; }
1370
- if(kw==='render'){ err(n,'render has been renamed: use layout (the zone takes only pin — geometry, not presentation) (MIGRATIONS)'); continue; }
1814
+ if(kw==='render'){ err(n,'render has been renamed: use layout (the zone takes only pin — geometry, not presentation) (MIGRATIONS 0.1)'); continue; }
1371
1815
  if(kw==='wrap'){ err(n,RETIRED_WRAP); continue; }
1372
1816
  if(kw==='boundary'){ err(n,RETIRED_BOUNDARY); continue; }
1373
1817
  if(kw==='layer'){ err(n,RETIRED_LAYER); continue; }
1818
+ // `PAINT-ORDER-CONSTRUCT`: `plane` joins this block. RULE 6.2 placement — the
1819
+ // spelling left the LANGUAGE, not one genre, so it fires wherever it
1820
+ // appears at line start, in every genre, AHEAD of the `GENRE-KEYWORD-ALLOWLIST` allowlist. A
1821
+ // `plane` line under `bitfield` gets the withdrawal, not "not allowed in
1822
+ // genre bitfield", which would be true and useless.
1823
+ if(kw==='plane'){ err(n,RETIRED_PLANE); continue; }
1374
1824
  if(kw==='guide'){ err(n,RETIRED_GUIDE); continue; }
1375
1825
  if(kw==='wave'){ err(n,RETIRED_WAVE); continue; }
1376
1826
  if(kw==='size'){ err(n,RETIRED_SIZE); continue; }
1377
- if(kw==='plot'){ err(n,'plot has been renamed: use chart (plot reads as an imperative — the reason render was retired — while every other block opener is a noun; ECharts, Chart.js and Mermaid all name the object a chart) (MIGRATIONS)'); continue; }
1827
+ if(kw==='plot'){ err(n,'plot has been renamed: use chart (plot reads as an imperative — the reason render was retired — while every other block opener is a noun; ECharts, Chart.js and Mermaid all name the object a chart) (MIGRATIONS 0.1)'); continue; }
1378
1828
 
1379
1829
  // typed-block children
1380
1830
  if(cur && ['field','break','cell','width','signal','gap'].includes(kw)){
@@ -1385,7 +1835,7 @@ function parseOne(text){
1385
1835
  // per-field options. Classic form: field <name> <width> [options].
1386
1836
  // Classic form: field <name> <width-in-bits|*> [fill=] [description=]
1387
1837
  // [present=]
1388
- // Conditional presence was a POSITIONAL FLAG until this release:
1838
+ // Conditional presence was a POSITIONAL FLAG until 0.1:
1389
1839
  // `optional` (…0.1), `conditional` (0.1…0.1),
1390
1840
  // `optional` again (`PRESENCE-FLAG-SPELLING`). `PRESENCE-CONDITION-EXPRESSION` replaces the flag with
1391
1841
  // `present=`, an option key whose value is the presence CONDITION as
@@ -1452,7 +1902,7 @@ function parseOne(text){
1452
1902
  if(opts.index!==undefined){
1453
1903
  err(n,'index= is not available on the compact field form — the range would apply LINE-wide, saying that every item repeats over the same indices. Write the repeated element in the classic form on its own line: field "<name>" <width> index=0..7'); continue; }
1454
1904
  if(items0.length>1){
1455
- err(n,'field: the item list is ONE comma-delimited token — unexpected argument "'+items0[1]+'" (write field a:1,b:2 with no space after the comma; quote a name that contains whitespace: "Long Name":16) (MIGRATIONS)'); continue; }
1905
+ err(n,'field: the item list is ONE comma-delimited token — unexpected argument "'+items0[1]+'" (write field a:1,b:2 with no space after the comma; quote a name that contains whitespace: "Long Name":16) (MIGRATIONS 0.1)'); continue; }
1456
1906
  let bad=null; const parsed=[];
1457
1907
  for(const el of splitList(posT[1],0)){
1458
1908
  const it=el.v; if(!it) continue;
@@ -1472,7 +1922,7 @@ function parseOne(text){
1472
1922
  // the overflow.
1473
1923
  if(m[2]!=='*' && +m[2]>cur.word){
1474
1924
  // The suggestion spells the CLASSIC form, and the classic name is
1475
- // QUOTED (`QUOTING-RULES`). Until this release this string said
1925
+ // QUOTED (`QUOTING-RULES`). Until 0.1 this string said
1476
1926
  // `write "field P 64"` — a second line error, so a user who
1477
1927
  // followed the diagnostic was told off twice.
1478
1928
  bad='"'+nm+':'+m[2]+'" is wider than word='+cur.word+' — a compact item must fit one row; write it in the classic form to span rows: field "'+nm+'" '+m[2]; break; }
@@ -1498,7 +1948,7 @@ function parseOne(text){
1498
1948
  // `break` ends the row after the fields declared since the block
1499
1949
  // opened (or since the previous break). With none there is nothing to
1500
1950
  // break — genre doc: "break with no preceding field in the current row".
1501
- // Spelled `wrap` until this release (`ROW-BREAK-NAMING`): in CSS/typography `wrap` is
1951
+ // Spelled `wrap` until 0.1 (`ROW-BREAK-NAMING`): in CSS/typography `wrap` is
1502
1952
  // AUTOMATIC reflow — a mode — while this is an EXPLICIT break, an
1503
1953
  // event; CSS Fragmentation calls exactly this "a forced break …
1504
1954
  // explicitly indicated by the … author" and HTML spells it `br`.
@@ -1517,7 +1967,7 @@ function parseOne(text){
1517
1967
  // `POSITIONAL-LIST-SPELLING`: comma form only; the space form is retired.
1518
1968
  const wtoks=pos.slice(1);
1519
1969
  if(wtoks.length>1){
1520
- err(n,'width takes ONE comma-delimited token: write width '+joinListForm(wtoks)+' — the space form is retired (MIGRATIONS)'); continue; }
1970
+ err(n,'width takes ONE comma-delimited token: write width '+joinListForm(wtoks)+' — the space form is retired (MIGRATIONS 0.1)'); continue; }
1521
1971
  const vals=wtoks.length?splitList(posT[1],0).map(e=>e.v):[];
1522
1972
  if(!vals.length){ err(n,'width needs one value per column (auto | <px> | <n>%)'); continue; }
1523
1973
  let badw=null;
@@ -1546,7 +1996,7 @@ function parseOne(text){
1546
1996
  // stays bare, exactly as `threshold offset=50%` does.
1547
1997
  const cadr=pos[1]||'';
1548
1998
  if(/^h?\d+,\d+$/.test(cadr)){
1549
- err(n,'cell address is now a paren point: cell ('+cadr+') — a bare comma pair is a list of two numbers, not an address (MIGRATIONS)'); continue; }
1999
+ err(n,'cell address is now a paren point: cell ('+cadr+') — a bare comma pair is a list of two numbers, not an address (MIGRATIONS 0.1)'); continue; }
1550
2000
  const rc=/^\((h?)(\d+),(\d+)\)$/.exec(cadr)||/^(h?)(\d+)$/.exec(cadr);
1551
2001
  // `RULE-POSITION-ENUMERATION`: `highlight` is a bare keyword FLAG
1552
2002
  // (vocabulary-sources.tsv `cell.highlight`), so RULE 2.4 governs it
@@ -1597,7 +2047,7 @@ function parseOne(text){
1597
2047
  // / `TYPED-BLOCK-SILENT-FALLBACK`: data= is ABSENCE vs presence. An empty value, empty
1598
2048
  // members (a,b), or a count that does not match the lane's `=`
1599
2049
  // cells are all line errors — never silent drop or shift.
1600
- // Spelled `labels=` until this release (`SIGNAL-DATA-KEY-SPELLING`): WaveDrom's own key is
2050
+ // Spelled `labels=` until 0.1 (`SIGNAL-DATA-KEY-SPELLING`): WaveDrom's own key is
1601
2051
  // `data`, "an array of signal labels" naming every value cell, and
1602
2052
  // after the `2`-`9` retirement (`TIMING-LANE-ALPHABET`) the two scopes coincide exactly.
1603
2053
  let labels;
@@ -1641,7 +2091,21 @@ function parseOne(text){
1641
2091
  // must be in the header genre allowlist. Child keywords still use the
1642
2092
  // "needs a bitfield/table/timing above" path when they appear with no cur.
1643
2093
  if(sawHeader && doc.genre && GENRE_KW[doc.genre] && !CHILD_KW.has(kw) && !GENRE_KW[doc.genre].has(kw)){
1644
- err(n,'"'+kw+'" is not allowed in genre '+doc.genre);
2094
+ // `GENRE-NODE-SPELLING`: a word this genre SPELLS DIFFERENTLY is not an
2095
+ // unknown word, and "not allowed in genre statechart" would send an
2096
+ // author looking for a construct they cannot have instead of the one
2097
+ // they already wrote. The named diagnostic says which word and why.
2098
+ // (`edge`/`flowline`/`transition` never reach here — they are
2099
+ // dispatched by their own scanner above.)
2100
+ if(NODE_SPELLINGS.has(kw) && GENRE_NODE_KW[doc.genre])
2101
+ err(n, WRONG_WORD(kw, GENRE_NODE_KW[doc.genre], doc.genre));
2102
+ // `SCENE-KEYWORD-MEMBERSHIP`: same argument one step further. A word this genre
2103
+ // WITHDREW is not an unknown word either, and the author holding it
2104
+ // needs the ground, not a spellcheck.
2105
+ else if(GENRE_WITHDRAWN[doc.genre] && GENRE_WITHDRAWN[doc.genre][kw])
2106
+ err(n, WITHDRAWN_FROM_GENRE(kw, doc.genre));
2107
+ else
2108
+ err(n,'"'+kw+'" is not allowed in genre '+doc.genre);
1645
2109
  continue;
1646
2110
  }
1647
2111
  if(badOpts(kw)) continue;
@@ -1666,10 +2130,23 @@ function parseOne(text){
1666
2130
  // different values for the same visible text. One form, one
1667
2131
  // meaning: the token is a normal quoted string and the generic
1668
2132
  // tokenizer above has already resolved its escapes.
1669
- const t0=tk.toks[1];
1670
- if(!t0||!t0.q){ err(n,'title needs a quoted string: title "<text>" (MIGRATIONS)'); break; }
1671
- if(tk.toks.length>2){ err(n,'unexpected argument "'+tk.toks[2].v+'"'); break; }
1672
- doc.title=t0.v; sawTitle=true; break;
2133
+ // `DRAWN-ANNOTATION-FORM`: read the POSITIONALS, not the raw token stream.
2134
+ // `title` took no options until this release, so `tk.toks[1]` and
2135
+ // `tk.toks.length>2` were the same thing as `pos[1]` and
2136
+ // `pos.length>2`. They stop being the same thing the moment the line
2137
+ // may carry `note=`, and testing the raw stream would report the
2138
+ // annotation as a surplus positional. This is `OPTION-POSITION-PARSING`'s lesson (`bundle`
2139
+ // and `threshold` read `posq` for exactly this reason) applied to the
2140
+ // one directive that had never needed it.
2141
+ const t0v=pos[1], t0q=posq[1];
2142
+ if(t0v===undefined||!t0q){ err(n,'title needs a quoted string: title "<text>" (MIGRATIONS 0.1)'); break; }
2143
+ if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
2144
+ doc.title=t0v; sawTitle=true;
2145
+ // The figure-level note lives on the document, not on an element —
2146
+ // there is no element for it to live on, which is the whole reason
2147
+ // `title` is an acceptor.
2148
+ if(opts.note!==undefined) doc.note=opts.note;
2149
+ break;
1673
2150
  }
1674
2151
  case 'class': {
1675
2152
  // semantic class (`CATEGORICAL-MEANING-MAPPING`): meaning + presentation defaults declared
@@ -1692,30 +2169,11 @@ function parseOne(text){
1692
2169
  if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
1693
2170
  classIds.add(id);
1694
2171
  // `plane=` on a class is the members' default plane (an element's own
1695
- // plane= wins, rigidity `LAYOUT-STABILITY`) — the class carries all five §5 attributes.
2172
+ // `PAINT-ORDER-CONSTRUCT`: `plane=` is withdrawn, so a class carries the
2173
+ // FOUR §5 attributes it can still set. The paint-order attribute is
2174
+ // gone from the language, not merely off this directive.
1696
2175
  doc.classes.push({id,label:pos[2],fill:opts.fill,stroke:opts.stroke,
1697
- style:opts.style,plane:opts.plane,line:n});
1698
- break;
1699
- }
1700
- case 'plane': {
1701
- const id=pos[1];
1702
- { const e=idErr(id,posq[1],'plane needs an id'); if(e){ err(n,e); break; } }
1703
- if(planeIds.has(id)){ err(n,'duplicate plane id "'+id+'"'); break; }
1704
- if(pos[2]!==undefined&&!posq[2]){ err(n,'plane label must be quoted: plane '+id+' "'+pos[2]+'" — '+Q_WHY); break; }
1705
- if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
1706
- // 0.1: `z=` -> `z-index=` (CSS's own spelling for the
1707
- // stacking concept, taken in full per RULE 4.2). The retired `z=`
1708
- // is caught language-wide in RETIRED_OPT_KEYS before this runs.
1709
- let z=doc.planes.length;
1710
- const zi=opts['z-index'];
1711
- if(zi!==undefined){
1712
- if(!/^-?\d+$/.test(zi)){ err(n,'z-index must be a number'); break; }
1713
- z=parseInt(zi,10);
1714
- }
1715
- planeIds.add(id);
1716
- // `EMPTY-LABEL-STATE`: absent is absent, `""` is a written value — same
1717
- // non-collapsing form as node/group/bundle and the typed blocks.
1718
- doc.planes.push({id,label:pos[2]!==undefined?pos[2]:null,z});
2176
+ style:opts.style,line:n});
1719
2177
  break;
1720
2178
  }
1721
2179
  // `FLOWCHART-ROLE-KEYWORDS`: `process` / `decision` / `terminator` DESUGAR to
@@ -1729,9 +2187,15 @@ function parseOne(text){
1729
2187
  // being wrong is harmless, and a flowchart node may be a datastore, an
1730
2188
  // annotation or a state, so `role:"process"` by default would let the
1731
2189
  // model assert a falsehood the figure cannot be inspected to catch.
2190
+ // `GENRE-NODE-SPELLING`: `state` joins the same case. It is `node` renamed
2191
+ // for one genre, not a role — a statechart node has exactly one kind,
2192
+ // so there is no role to record and `role` stays null, exactly as a
2193
+ // bare `node` does. The DRAWING is unchanged too (`shape=box` default),
2194
+ // which is what let the five corpus figures migrate byte-identically.
1732
2195
  case 'process': case 'decision': case 'terminator':
2196
+ case 'state':
1733
2197
  case 'node': {
1734
- const role=kw==='node'?null:kw;
2198
+ const role=(kw==='node'||kw==='state')?null:kw;
1735
2199
  const id=pos[1];
1736
2200
  { const e=idErr(id,posq[1],kw+' needs an id'); if(e){ err(n,e); break; } }
1737
2201
  if(dupId(id)){ err(n,'duplicate id "'+id+'"'); break; }
@@ -1752,6 +2216,7 @@ function parseOne(text){
1752
2216
  // Display falls back to the id in render(), so the figure is unchanged.
1753
2217
  doc.nodes.push({id,label:pos[2]!==undefined?pos[2]:null,shape,role,fill:opts.fill,stroke:opts.stroke,
1754
2218
  style:opts.style,cls:parseClassList(opts['class'],optList(optT,'class')).ids,
2219
+ note:opts.note,
1755
2220
  group:opts['in']||null,plane:opts.plane||'base',line:n});
1756
2221
  break;
1757
2222
  }
@@ -1771,6 +2236,7 @@ function parseOne(text){
1771
2236
  }
1772
2237
  doc.groups.push({id,label:pos[2]!==undefined?pos[2]:null,fill:opts.fill,stroke:opts.stroke,
1773
2238
  style:opts.style,gap:ggap,cls:parseClassList(opts['class'],optList(optT,'class')).ids,
2239
+ note:opts.note,
1774
2240
  plane:opts.plane||null,line:n});
1775
2241
  break;
1776
2242
  }
@@ -1782,7 +2248,7 @@ function parseOne(text){
1782
2248
  // Shares the node/group/block id namespace. Of the §5 attributes it
1783
2249
  // can carry only the two that need no drawn shape: `text=` (the label
1784
2250
  // colour) and `plane=` (organizational, exactly as on a node).
1785
- // Spelled `boundary` until this release (`EXTERNAL-ENDPOINT-NAMING`): three standards claim that
2251
+ // Spelled `boundary` until 0.1 (`EXTERNAL-ENDPOINT-NAMING`): three standards claim that
1786
2252
  // word for the OPPOSITE meaning (UML ECB «boundary» is an internal
1787
2253
  // interface object, C4 System_Boundary is a dashed grouping container,
1788
2254
  // BPMN's is an event), and this spec's own prose had already stopped
@@ -1800,7 +2266,7 @@ function parseOne(text){
1800
2266
  break;
1801
2267
  }
1802
2268
  case 'flow': {
1803
- if(sawFlow){ err(n,'duplicate flow line'); break; }
2269
+ if(sawFlow){ err(n,'duplicate flow directive'); break; }
1804
2270
  // `RULE-POSITION-ENUMERATION`: `flow.direction` is an enum position — bare.
1805
2271
  if(posq[1]){ err(n,ENUM_BARE('flow '+pos[1])); break; }
1806
2272
  if(!['right','down','left','up'].includes(pos[1])){ err(n,'flow needs right|down|left|up'); break; }
@@ -1833,7 +2299,7 @@ function parseOne(text){
1833
2299
  // could not even detect a half-converted line.
1834
2300
  const rtoks=pos.slice(1), rT=posT.slice(1);
1835
2301
  if(rtoks.length>1){
1836
- err(n,'rank takes ONE comma-delimited token: write rank '+joinListForm(rtoks)+' — the space form is retired (MIGRATIONS)'); break; }
2302
+ err(n,'rank takes ONE comma-delimited token: write rank '+joinListForm(rtoks)+' — the space form is retired (MIGRATIONS 0.1)'); break; }
1837
2303
  const rels=rtoks.length?splitList(rT[0],0):[];
1838
2304
  let badr=null;
1839
2305
  for(const e of rels){
@@ -1873,7 +2339,7 @@ function parseOne(text){
1873
2339
  // is now detectable — a second positional token after the label is
1874
2340
  // the half-converted line the old tolerance could not report.
1875
2341
  if(restT.length>1){
1876
- err(n,'bundle members take ONE comma-delimited token: write bundle '+id+(tlabel===null?'':' "'+tlabel+'"')+' '+joinListForm(restT.map(t=>t.v))+' — the space form is retired (MIGRATIONS)'); break; }
2342
+ err(n,'bundle members take ONE comma-delimited token: write bundle '+id+(tlabel===null?'':' "'+tlabel+'"')+' '+joinListForm(restT.map(t=>t.v))+' — the space form is retired (MIGRATIONS 0.1)'); break; }
1877
2343
  const pairs=[]; let badp=null;
1878
2344
  outerB:
1879
2345
  for(const t of restT){
@@ -1923,7 +2389,7 @@ function parseOne(text){
1923
2389
  // percentage" `offset` (on <stop>), which is the same source the
1924
2390
  // paint keys come from, so nothing is invented.
1925
2391
  if(opts.at!==undefined){
1926
- err(n,'threshold at= has been renamed: use offset=<0..100>% (at= is the pin POINT in canvas px; SVG spells a position along an extent "offset") (MIGRATIONS)'); break; }
2392
+ err(n,'threshold at= has been renamed: use offset=<0..100>% (at= is the pin POINT in canvas px; SVG spells a position along an extent "offset") (MIGRATIONS 0.1)'); break; }
1927
2393
  const m=/^(\d+(?:\.\d+)?)%$/.exec(opts.offset||''); // % is mandatory (`BARE-FRACTION-VALUES`)
1928
2394
  if(!m||+m[1]<0||+m[1]>100){ err(n,'threshold needs offset=<0..100>% (with the % sign)'); break; }
1929
2395
  // §5: `stroke=` is the marker colour, `color=` the label colour,
@@ -1937,7 +2403,7 @@ function parseOne(text){
1937
2403
  case 'chart': {
1938
2404
  // chart family: chart <table-id> [type=bar3d]
1939
2405
  // rows -> X, columns -> Y, numeric cells -> Z (the table IS the data)
1940
- // Spelled `plot` with `kind=bars3d` until this release (`CHART-BLOCK-NAMING`).
2406
+ // Spelled `plot` with `kind=bars3d` until 0.1 (`CHART-BLOCK-NAMING`).
1941
2407
  // 0.1 (`CHART-LEVEL-KEY`): `level=` is DELETED. Zero uses corpus-wide, zero
1942
2408
  // 3-D bar charts, zero requests; one in-repo example and two fixtures.
1943
2409
  // It was the only construct whose caption the ENGINE wrote rather than
@@ -1974,11 +2440,11 @@ function parseOne(text){
1974
2440
  // reads the same way. `OPTION-POSITION-PARSING` quotedness is `pos`-aligned, so an option
1975
2441
  // may still precede it.
1976
2442
  const flabel=posq[1]?pos[1]:null;
1977
- if(flabel===null){ err(n,'band needs a quoted "<label>" first: band "<name>" <a>..<b>% in=<node-or-group-id> (a band with no label asserts nothing a reader may keep — fill= is presentation, and §5 forbids meaning riding on colour alone) (MIGRATIONS)'); break; }
2443
+ if(flabel===null){ err(n,'band needs a quoted "<label>" first: band "<name>" <a>..<b>% in=<node-or-group-id> (a band with no label asserts nothing a reader may keep — fill= is presentation, and §5 forbids meaning riding on colour alone) (MIGRATIONS 0.1)'); break; }
1978
2444
  if(!opts['in']){ err(n,'band needs in=<node-or-group-id>'); break; }
1979
2445
  // 0.1: the `%` is MANDATORY, matching `threshold offset=` (`BARE-FRACTION-VALUES`).
1980
2446
  // `band 15`, `band 15-35` and `band 15%-35` all parsed before (the
1981
- // separator was a hyphen until this release); one concept in one
2447
+ // separator was a hyphen until 0.1); one concept in one
1982
2448
  // document must not have two value grammars (RULE 4.4).
1983
2449
  // `RANGE-SPELLING`: the separator is `..`, and the HYPHEN form it
1984
2450
  // replaces gets its own named diagnostic. `15-35%` reads as
@@ -1989,7 +2455,7 @@ function parseOne(text){
1989
2455
  // cited by `bitfield`. `band` is EXPERIMENTAL (`CONSTRUCT-STATUS-TIERS`), so no
1990
2456
  // compatibility promise is owed; the diagnostic is owed anyway.
1991
2457
  if(/^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)%$/.test(pos[2]||'')){
1992
- err(n,'the hyphen range "'+pos[2]+'" is no longer the spelling: write band "'+flabel+'" '+String(pos[2]).replace('-','..')+' in=… — FigDown has ONE range grammar, "..", and a hyphen between two numbers reads as subtraction (MIGRATIONS)'); break; }
2458
+ err(n,'the hyphen range "'+pos[2]+'" is no longer the spelling: write band "'+flabel+'" '+String(pos[2]).replace('-','..')+' in=… — FigDown has ONE range grammar, "..", and a hyphen between two numbers reads as subtraction (MIGRATIONS 0.1)'); break; }
1993
2459
  const m=/^(\d+(?:\.\d+)?)%$|^(\d+(?:\.\d+)?)\.\.(\d+(?:\.\d+)?)%$/.exec(pos[2]||'');
1994
2460
  if(!m){ err(n,'band needs a range with the % sign: band "<label>" <pct>% or band "<label>" <a>..<b>%'); break; }
1995
2461
  if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
@@ -2022,7 +2488,7 @@ function parseOne(text){
2022
2488
  if(!id){ err(n,PIN_SHAPE); break; }
2023
2489
  // 0.1 (RULE 1.1a): the pair is PARENTHESISED — at=(x,y).
2024
2490
  if(at!==undefined&&/^-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?$/.test(at)){
2025
- err(n,'pin at= now takes a paren point: at=('+at+') — a bare comma pair is a list of two numbers, not a point (MIGRATIONS)'); break; }
2491
+ err(n,'pin at= now takes a paren point: at=('+at+') — a bare comma pair is a list of two numbers, not a point (MIGRATIONS 0.1)'); break; }
2026
2492
  const m=at===undefined?null:/^\((-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)\)$/.exec(at);
2027
2493
  // A WRITTEN `at=` that does not parse is its own error, never the
2028
2494
  // whole-line shape message: the author declared a point and got the
@@ -2076,7 +2542,7 @@ function parseOne(text){
2076
2542
  // the cell border colour, `text=` the block caption colour.
2077
2543
  // `TYPED-BLOCK-SILENT-FALLBACK`: word= empty or non-integer was a silent fallback/truncation
2078
2544
  // (word= → 32, word=8.5 → 8). Positive integer only; absence → 32.
2079
- // Spelled `unit=` until this release (`BITS-PER-ROW-KEY-NAMING`).
2545
+ // Spelled `unit=` until 0.1 (`BITS-PER-ROW-KEY-NAMING`).
2080
2546
  let word=32;
2081
2547
  if(opts.word!==undefined){
2082
2548
  if(opts.word===''||!/^\d+$/.test(opts.word)||+opts.word<1){
@@ -2153,11 +2619,39 @@ function parseOne(text){
2153
2619
  }
2154
2620
  for(const r of doc.ranks) for(const id of r.ids)
2155
2621
  if(!nodeIds.has(id)) errs.push('Line '+r.line+': unknown node "'+id+'" in rank');
2622
+ // `MARKER-TARGET-KINDS`: `in=` on `threshold`/`band` also resolves a REGION id —
2623
+ // a `bitfield`, `table` or `timing` block. This is a WIDENING of the value
2624
+ // domain, not a third sense of `in=` and not a new spelling: the relation is
2625
+ // sense 2 verbatim, *the element this one is drawn across*, and what changes
2626
+ // is only which declared ids the resolver will bind.
2627
+ //
2628
+ // It is UNGATED, and that is argued rather than assumed. A region-targeted
2629
+ // threshold did not merely mean something else before this release — it did
2630
+ // not PARSE. `threshold "Max" in=q offset=50%` over a `table q` answered
2631
+ // `unknown target "q" for threshold`, the same error a nonexistent id gets,
2632
+ // because this set was hard-coded to nodes and groups while `table <id>`
2633
+ // makes the id mandatory and `chart <table-id>` already consumes it from
2634
+ // another directive. So no `figdown 0.1` or `figdown 0.2` document changes
2635
+ // meaning and none becomes invalid; the only documents affected are ones
2636
+ // that produced no figure at all. Core §13.0.1's hazard — "a figure that
2637
+ // looks right and means something else" — needs two readings to choose
2638
+ // between, and here the alternative reading was an error message. That is
2639
+ // exactly why `note=` IS gated a few hundred lines up and this is not: the
2640
+ // key had a prior meaning, this had none. Nothing is added to the option
2641
+ // registry, so a reader of `figdown 0.2` consulting core §10 finds the same
2642
+ // 45 rows either way.
2643
+ //
2644
+ // The two WRED figures this unblocks are the whole of the measured demand
2645
+ // (core §9 `ANNOTATION-LOCATOR-SPLIT`). The locator COORDINATE grammar — `in=q(3)`, addressing a
2646
+ // row inside the region — is designed and deliberately NOT built: it has no
2647
+ // shipping consumer, and RULE 4.7 argues against spending a grammar before
2648
+ // one exists.
2649
+ const regionTarget=id=>blockIds.has(id);
2156
2650
  for(const gl of doc.thresholds)
2157
- if(!groupIds.has(gl.target)&&!nodeIds.has(gl.target))
2651
+ if(!groupIds.has(gl.target)&&!nodeIds.has(gl.target)&&!regionTarget(gl.target))
2158
2652
  errs.push('Line '+gl.line+': unknown target "'+gl.target+'" for threshold');
2159
2653
  for(const f of doc.bands)
2160
- if(!groupIds.has(f.target)&&!nodeIds.has(f.target))
2654
+ if(!groupIds.has(f.target)&&!nodeIds.has(f.target)&&!regionTarget(f.target))
2161
2655
  errs.push('Line '+f.line+': unknown target "'+f.target+'" for band');
2162
2656
  for(const t of doc.trunks) for(const [a,b] of t.pairs){
2163
2657
  if((!nodeIds.has(a)&&!boundaryIds.has(a))||(!nodeIds.has(b)&&!boundaryIds.has(b))){ errs.push('Line '+t.line+': unknown endpoint in "'+a+'--'+b+'"'); continue; }
@@ -2216,7 +2710,7 @@ function parseOne(text){
2216
2710
  // b class=p` was accepted, drew a #555 line, and rendered a legend swatch
2217
2711
  // that showed nothing, so the class's meaning was invisible in its own
2218
2712
  // derived legend. With `color=` retired (`COLOUR-KEY-STATUS`) the remaining shape of the
2219
- // hole is a class carrying only `style=` and/or `plane=`, or nothing at
2713
+ // hole is a class carrying only `style=`, or nothing at
2220
2714
  // all: the edge silently takes the default colour and the author who
2221
2715
  // declared a class to CLASSIFY the edge gets no colour and no warning.
2222
2716
  // Both halves are the same rule — a class an edge joins must declare at
@@ -2231,9 +2725,9 @@ function parseOne(text){
2231
2725
  if(!c||c.stroke!==undefined) continue;
2232
2726
  if(c.fill===undefined&&c.style!==undefined) continue;
2233
2727
  if(c.fill!==undefined)
2234
- 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)');
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)');
2235
2729
  else
2236
- 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)');
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)');
2237
2731
  }
2238
2732
  }
2239
2733
  { // class references must resolve (closed grammar)
@@ -2386,7 +2880,7 @@ function stackSectionSvgs(results){
2386
2880
  // became the node's LABEL. It is now a line error wherever it is part of the
2387
2881
  // GRAMMAR — that is, everywhere except the FOUR verbatim regions (this
2388
2882
  // function handles three of them; the pipe row is the caller's, see below).
2389
- // The count read "three" until this release, listing four:
2883
+ // The count read "three" until 0.1, listing four:
2390
2884
  // - inside a quoted string ("…;…"),
2391
2885
  // - inside a comment (already stripped before this runs),
2392
2886
  // - inside an edge label (edge a -[packet arrives; TMR != 0]-> b),
@@ -2410,7 +2904,7 @@ function findReservedSemi(s){
2410
2904
  }
2411
2905
  return -1;
2412
2906
  }
2413
- const RESERVED_SEMI='";" is reserved for a future statement separator and has no meaning in v0.1 — write one directive per line; a literal ";" belongs inside a quoted string, an [edge label] or a comment (MIGRATIONS)';
2907
+ const RESERVED_SEMI='";" is reserved for a future statement separator and has no meaning in v0.1 — write one directive per line; a literal ";" belongs inside a quoted string, an [edge label] or a comment (MIGRATIONS 0.1)';
2414
2908
 
2415
2909
  function findComment(s){
2416
2910
  // '#' starts a comment only at line start or after whitespace,
@@ -2532,6 +3026,92 @@ function cwMax(s){ return Math.max(...String(s).split('\n').map(cw)); }
2532
3026
  // §5 style= → SVG dash pattern. `def` is the construct's conventional
2533
3027
  // default (the bundle ring and the threshold line are dashed by convention);
2534
3028
  // an explicit style= always wins.
3029
+ // ── the note box (`DRAWN-ANNOTATION-FORM`) ───────────────────────────────────────
3030
+ // The drawn annotation's whole appearance lives in these three functions,
3031
+ // because `DOMAIN-CONVENTION-DIRECTIVES` gives the engine the drawing convention outright: `note=` takes
3032
+ // no `at=`, no `side=`, no colour and no size, so there is exactly one look and
3033
+ // it is decided here rather than by an author.
3034
+ //
3035
+ // The look is the UML note symbol — a rectangle with a folded top-right corner
3036
+ // — which is the notation of the metaclass the SPELLING is borrowed from
3037
+ // (UML 2.5.1's `Comment`; RULE 4.1 takes the standard's word, and taking its
3038
+ // glyph with it is what lets a reader recognise the box as an aside without a
3039
+ // legend entry). It is deliberately unlike a `node`: no rounded corners, a
3040
+ // paler wash, smaller type, and a corner no node shape has.
3041
+ const NOTE_FS=10, NOTE_PAD=6, NOTE_FOLD=9, NOTE_MAXCH=30;
3042
+ const NOTE_FILL='#fdfaf0', NOTE_STROKE='#c9c4b2', NOTE_INK='#5c584c';
3043
+ // The leader is deliberately DARKER than the box outline. Drawn in the box's
3044
+ // own stroke it was legible in the SVG and invisible on the page at 1x — a
3045
+ // leader nobody can see is a leader that is not there, and the note then reads
3046
+ // as annotating whatever it happens to sit above. Checked by eye, not by a
3047
+ // contrast number: the box is a surface and may recede, the leader is a
3048
+ // statement of attachment and may not.
3049
+ const NOTE_LEADER='#9c968a';
3050
+ // Deterministic greedy word wrap. Author newlines are honoured and never
3051
+ // merged; a run longer than the wrap width is broken only between words, so a
3052
+ // long identifier keeps its shape and simply widens the box.
3053
+ function noteWrap(text){
3054
+ const out=[];
3055
+ for(const para of String(text).split('\n')){
3056
+ const words=para.split(/ +/).filter(w=>w.length);
3057
+ if(!words.length){ out.push(''); continue; }
3058
+ let cur=words[0];
3059
+ for(let i=1;i<words.length;i++){
3060
+ if((cur+' '+words[i]).length<=NOTE_MAXCH) cur+=' '+words[i];
3061
+ else { out.push(cur); cur=words[i]; }
3062
+ }
3063
+ out.push(cur);
3064
+ }
3065
+ return out;
3066
+ }
3067
+ function noteBox(text){
3068
+ const lines=noteWrap(text);
3069
+ const lh=NOTE_FS*1.35;
3070
+ const w=Math.max(28, Math.max.apply(null,lines.map(tw))+NOTE_PAD*2+NOTE_FOLD);
3071
+ const h=lines.length*lh+NOTE_PAD*2-lh*0.15;
3072
+ return {w:Math.round(w*100)/100, h:Math.round(h*100)/100, lines, lh};
3073
+ }
3074
+ // The folded-corner outline, plus the small triangle that reads as the back of
3075
+ // the fold. One path each, so the shape is one primitive and the output is
3076
+ // byte-stable.
3077
+ function noteSvg(x,y,box,carrier){
3078
+ const F=NOTE_FOLD, w=box.w, h=box.h;
3079
+ const d='M'+x+' '+y+' H'+(x+w-F)+' L'+(x+w)+' '+(y+F)+' V'+(y+h)+' H'+x+' Z';
3080
+ const fold='M'+(x+w-F)+' '+y+' V'+(y+F)+' H'+(x+w)+' Z';
3081
+ const out=['<g class="fd-note"'+(carrier&&carrier.kind?' data-note-on="'+carrier.kind+'"':'')+'>',
3082
+ '<path d="'+d+'" fill="'+NOTE_FILL+'" stroke="'+NOTE_STROKE+'" stroke-width="1"/>',
3083
+ '<path d="'+fold+'" fill="'+NOTE_STROKE+'" fill-opacity="0.35" stroke="'+NOTE_STROKE+'" stroke-width="1"/>'];
3084
+ const first=y+NOTE_PAD+NOTE_FS*0.85;
3085
+ box.lines.forEach((ln,i)=>{
3086
+ out.push('<text x="'+(x+NOTE_PAD)+'" y="'+Math.round((first+i*box.lh)*100)/100+
3087
+ '" font-size="'+NOTE_FS+'" text-anchor="start" fill="'+NOTE_INK+'">'+esc(ln)+'</text>');
3088
+ });
3089
+ out.push('</g>');
3090
+ return out.join('');
3091
+ }
3092
+ // The leader is drawn ONLY when adjacency failed, and it is drawn AFTER the
3093
+ // box is placed, so it is correct by construction: it runs from the box edge
3094
+ // facing the carrier to the carrier's nearest point, and cannot be stale.
3095
+ function noteLeader(best,box,rect){
3096
+ const bx=best.x, by=best.y, bw=box.w, bh=box.h;
3097
+ const cx=rect.x+rect.w/2, cy=rect.y+rect.h/2;
3098
+ let x1,y1;
3099
+ if(best.side==='right') { x1=bx; y1=by+bh/2; }
3100
+ else if(best.side==='left') { x1=bx+bw; y1=by+bh/2; }
3101
+ else if(best.side==='below') { x1=bx+bw/2; y1=by; }
3102
+ else { x1=bx+bw/2; y1=by+bh; }
3103
+ // land on the carrier's border, not its centre, so the line stops at the
3104
+ // thing it points at
3105
+ const x2=Math.max(rect.x, Math.min(rect.x+rect.w, x1));
3106
+ const y2=Math.max(rect.y, Math.min(rect.y+rect.h, y1));
3107
+ return '<line x1="'+x1+'" y1="'+y1+'" x2="'+(rect.w||rect.h?x2:cx)+'" y2="'+(rect.w||rect.h?y2:cy)+
3108
+ '" stroke="'+NOTE_LEADER+'" stroke-width="1" stroke-dasharray="4 3"/>';
3109
+ }
3110
+ // A band's optional edge stroke. `renderScene` has had this as a local since
3111
+ // 0.1; `MARKER-TARGET-KINDS` needs the same rule for a REGION-scope band, which is drawn
3112
+ // outside the scene, so the one expression moves to module scope rather than
3113
+ // being written twice with a chance to drift.
3114
+ const bandEdgeOf=f=>(f.stroke||f.style)?' stroke="'+(f.stroke||'#8a8880')+'"'+dashOf(f.style,''):'';
2535
3115
  function dashOf(style,def){
2536
3116
  const p = style==='dashed'?'6 4' : style==='dotted'?'2 4' : style==='solid'?'' : def;
2537
3117
  return p?' stroke-dasharray="'+p+'"':'';
@@ -2727,21 +3307,17 @@ function render(doc,ropts){
2727
3307
  if(C[id] && C[id][k]!==undefined) x[k]=C[id][k];
2728
3308
  }
2729
3309
  };
2730
- // a class carries all five §5 attributes; the element's own value wins (`LAYOUT-STABILITY`).
2731
- // `plane` is materialized to 'base' at parse time, so "unset" is 'base' here.
2732
- const rsl=(x)=>{
2733
- if(!(x.plane===undefined||x.plane===null||x.plane==='base')) return;
2734
- for(const id of classIdsOf(x)){
2735
- if(C[id] && C[id].plane!==undefined) x.plane=C[id].plane;
2736
- }
2737
- };
2738
- const rsAll=(x)=>{ rs(x,'fill'); rs(x,'stroke'); rs(x,'style'); rsl(x); };
3310
+ // `PAINT-ORDER-CONSTRUCT`: a class carries the FOUR §5 attributes that survive.
3311
+ // The fifth was `plane`, and its cascade helper (`rsl`) is gone with the
3312
+ // key — a class can no longer set a paint order because the language has
3313
+ // no paint order to set. Everything is on the implicit `base` plane.
3314
+ const rsAll=(x)=>{ rs(x,'fill'); rs(x,'stroke'); rs(x,'style'); };
2739
3315
  for(const n of doc.nodes){ rsAll(n); if(n.style===undefined) n.style='solid'; }
2740
3316
  for(const g of doc.groups){ rsAll(g); }
2741
3317
  // 0.1 (§8.4): an edge has no interior, so it takes every class
2742
3318
  // channel EXCEPT `fill` — which the parser has already guaranteed is
2743
3319
  // accompanied by a `stroke` on any class an edge joins.
2744
- for(const e of doc.edges){ rs(e,'stroke'); rs(e,'style'); rsl(e); if(e.style===undefined) e.style='solid'; }
3320
+ for(const e of doc.edges){ rs(e,'stroke'); rs(e,'style'); if(e.style===undefined) e.style='solid'; }
2745
3321
  for(const b of doc.blocks){
2746
3322
  rsAll(b);
2747
3323
  if(b.fields) for(const f of b.fields) rsAll(f);
@@ -2755,13 +3331,54 @@ function render(doc,ropts){
2755
3331
  const s=renderScene(doc,y); parts.push(s.svg); y=s.y; maxW=Math.max(maxW,s.w);
2756
3332
  sceneMeta=s.meta;
2757
3333
  }
3334
+ // `MARKER-TARGET-KINDS`: a region-scope `threshold`/`band` is drawn HERE and not
3335
+ // in `renderScene`, because a region is not in the scene. Typed blocks stack
3336
+ // in document order OUTSIDE the scene (core §2, the `plane=` carve-out says
3337
+ // so in as many words), so at the moment `renderScene` emits its own
3338
+ // thresholds the region has no geometry yet and sits at a `y` the scene never
3339
+ // sees. The mark therefore travels with its target: each block reports its
3340
+ // box, and the marks that name it are painted over that box in the same
3341
+ // coordinate shape (`x0`/`x1`/`yA`/`yB`) the scene uses for a group.
3342
+ const regionBox={};
2758
3343
  for(const b of doc.blocks){
2759
3344
  let s;
2760
3345
  if(b.type==='bitfield') s=renderBitfield(b,y);
2761
3346
  else if(b.type==='table') s=renderTable(b,y);
2762
3347
  else if(b.type==='chart') s=renderChart(b,y,doc);
2763
3348
  else s=renderTiming(b,y);
2764
- parts.push(s.svg); y=s.y+24; maxW=Math.max(maxW,s.w);
3349
+ parts.push(s.svg);
3350
+ if(s.box) regionBox[b.id]=s.box;
3351
+ y=s.y+24; maxW=Math.max(maxW,s.w);
3352
+ }
3353
+ {
3354
+ const rsvg=[];
3355
+ for(const f of (doc.bands||[])){
3356
+ const B=regionBox[f.target]; if(!B) continue;
3357
+ const w=B.x1-B.x0, h=B.yB-B.yA;
3358
+ let bx,by,bw,bh;
3359
+ if(f.dir==='up') { bx=B.x0; by=B.yB-h*f.to/100; bw=w; bh=h*(f.to-f.from)/100; }
3360
+ else if(f.dir==='down') { bx=B.x0; by=B.yA+h*f.from/100; bw=w; bh=h*(f.to-f.from)/100; }
3361
+ else if(f.dir==='right'){ bx=B.x0+w*f.from/100; by=B.yA; bw=w*(f.to-f.from)/100; bh=h; }
3362
+ else { bx=B.x1-w*f.to/100; by=B.yA; bw=w*(f.to-f.from)/100; bh=h; }
3363
+ rsvg.push('<rect x="'+bx+'" y="'+by+'" width="'+bw+'" height="'+bh+'" fill="'+f.fill+'" opacity="0.35"'+bandEdgeOf(f)+'/>');
3364
+ rsvg.push(textEl(bx+bw/2, by+bh/2+4, 11, 'middle', labelInk(f.fill,'#334155'), f.label,
3365
+ ' paint-order="stroke" stroke="#fff" stroke-width="3"'));
3366
+ }
3367
+ for(const gl of (doc.thresholds||[])){
3368
+ const B=regionBox[gl.target]; if(!B) continue;
3369
+ const ly=B.yB-(B.yB-B.yA)*gl.pct/100;
3370
+ const col=gl.stroke||'#ef4444';
3371
+ rsvg.push('<line x1="'+B.x0+'" y1="'+ly+'" x2="'+B.x1+'" y2="'+ly+'" stroke="'+col+
3372
+ '" stroke-width="'+(gl.pct>=100?4:2)+'"'+dashOf(gl.style,'7 4')+'/>');
3373
+ rsvg.push(textEl(B.x1+8, ly+4, 11, 'start', col, gl.label,' paint-order="stroke" stroke="#fff" stroke-width="3"'));
3374
+ maxW=Math.max(maxW, B.x1+8+tw(gl.label));
3375
+ }
3376
+ // A band is a translucent wash UNDER the grid ink; a threshold is a mark
3377
+ // OVER it. The region has already been pushed, so both go after it and the
3378
+ // band leans on opacity rather than paint order for the "under" reading —
3379
+ // the same compromise `renderChart` makes, and the reason the opacity here
3380
+ // is lower than the scene's 0.9.
3381
+ if(rsvg.length) parts.push(rsvg.join(''));
2765
3382
  }
2766
3383
  // 0.1 (`CLASS-EMPTY-MEANING`): a class whose meaning is the EMPTY string claims no
2767
3384
  // meaning, so it has nothing to explain and draws NO legend entry — it is
@@ -2799,6 +3416,30 @@ function render(doc,ropts){
2799
3416
  parts.push(es.join(''));
2800
3417
  y=ly+rowH;
2801
3418
  }
3419
+ // `DRAWN-ANNOTATION-FORM`: the FIGURE-level note — `title "…" note="…"`. It carries
3420
+ // the 14% of measured annotations that name no single element ("Total: 8k
3421
+ // tunnel indexes", a four-signal legend, a TODO about the whole figure), and
3422
+ // it is the acceptor that removes the last argument for a standalone `note`
3423
+ // keyword: the figure HAS a declaration line, so attachment-by-position
3424
+ // reaches it too.
3425
+ //
3426
+ // It has no geometry to sit beside, so it takes no candidates and NEVER takes
3427
+ // a leader — a leader must point at something, and "the figure" is not a
3428
+ // thing on the canvas. It is placed with the caption, at the bottom, after
3429
+ // the scene, the regions and the derived legend. That is a placement rule and
3430
+ // not an author's choice (`DOMAIN-CONVENTION-DIRECTIVES`), and it is deterministic by construction:
3431
+ // there is exactly one figure-level note and exactly one place for it.
3432
+ //
3433
+ // The title itself is NOT drawn by default (`DEFAULT-VALUE-SELECTION` — an embedded figure sits
3434
+ // under a host caption), and the note does not follow it: the note is the
3435
+ // thing that draws. An author who wants the sentence in the picture writes
3436
+ // it here whether or not the renderer is showing the title.
3437
+ if(doc.note!==null&&doc.note!==undefined){
3438
+ const nb=noteBox(doc.note);
3439
+ const ny=y+8;
3440
+ parts.push(noteSvg(0,ny,nb,{kind:'title'}));
3441
+ y=ny+nb.h+2; maxW=Math.max(maxW,nb.w);
3442
+ }
2802
3443
  const PADL=18, PADT=6;
2803
3444
  const W=Math.ceil(maxW)+PADL+8, H=Math.ceil(y)+PADT+4;
2804
3445
  return {svg:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" font-family="system-ui,sans-serif">'
@@ -3283,7 +3924,7 @@ function renderScene(doc,y0){
3283
3924
  const halo=' paint-order="stroke" stroke="#fff" stroke-width="3"';
3284
3925
  const seg=(p,q,t,lbl,fs)=>reqLabel({p,q,t0:t,text:lbl,fs,col:ecol,halo,e,A,B,kind:'end'});
3285
3926
  if(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)){
3286
- // ── ROUTING-CHANGE ARCHITECTURE NOTE ──────────
3927
+ // ── ROUTING-CHANGE ARCHITECTURE NOTE (`SELF-EDGE-DRAWING`/`EDGE-BEND-RETENTION`) ──────────
3287
3928
  // Edge labels are DEFERRED: every label is registered against its
3288
3929
  // FINAL segment geometry (reqLabel/lblReq above) and placed by ONE
3289
3930
  // greedy pass after all edges are drawn; arrowheads are computed from
@@ -3295,7 +3936,7 @@ function renderScene(doc,y0){
3295
3936
  // arrowheads, orphaned labels): that was external splicing, not an
3296
3937
  // engine gap. Patch routing here; do not "fix" the label machinery.
3297
3938
  if(A===B){
3298
- // Self-transition: a small side loop on the node, the
3939
+ // Self-transition (`SELF-EDGE-DRAWING`): a small side loop on the node, the
3299
3940
  // convention of every drawing tool — never a lap of the figure
3300
3941
  // through the back-edge channel. Side order r,l,b,t; first side
3301
3942
  // whose loop box overlaps no other node wins (deterministic).
@@ -3448,7 +4089,7 @@ function renderScene(doc,y0){
3448
4089
  // their neighbours turns a 40-point staircase into the 2–4 bends a
3449
4090
  // dummy-vertex chain should have, without moving the drawn line.
3450
4091
  simplifyPts(pts);
3451
- // Waypoint prune: after collinear simplification a
4092
+ // Waypoint prune (`EDGE-BEND-RETENTION`): after collinear simplification a
3452
4093
  // chain can still carry a staircase of near-collinear jogs — the drift
3453
4094
  // clamp allows only a few px of sideways movement per rank, so a run
3454
4095
  // that wants to move 35px sideways alternates short diagonals and
@@ -3560,7 +4201,13 @@ function renderScene(doc,y0){
3560
4201
  // the arrowheads, and the other edges — plus a pull back toward the
3561
4202
  // preferred point on the segment. The lowest score wins. No randomness,
3562
4203
  // no iteration to a fixed point: one deterministic pass.
3563
- if(lblReq.length){
4204
+ // `DRAWN-ANNOTATION-FORM`: this block used to be guarded by `if(lblReq.length)`,
4205
+ // with `obst`, `ovl`, `segHit` and `placed` local to it. The note pass below
4206
+ // is a SECOND claimant on exactly that machinery and must see exactly the
4207
+ // same `placed` list — a note that did not know where the edge labels went
4208
+ // could not yield to them, which is the first of the four placement rules.
4209
+ // So the scaffolding is hoisted and only the LOOP keeps the guard.
4210
+ {
3564
4211
  const obst=nodes.filter(n=>!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h,n}));
3565
4212
  const ovl=(a,b)=>{
3566
4213
  const ix=Math.min(a.x+a.w,b.x+b.w)-Math.max(a.x,b.x);
@@ -3598,7 +4245,7 @@ function renderScene(doc,y0){
3598
4245
  return {x,y:by+up,anchor,t,side,box:{x:bx,y:by,w,h}};
3599
4246
  };
3600
4247
  const placed=[];
3601
- for(const r of lblReq){
4248
+ if(lblReq.length) for(const r of lblReq){
3602
4249
  const dx=r.q[0]-r.p[0], dy=r.q[1]-r.p[1];
3603
4250
  const across=Math.abs(dx)>=Math.abs(dy);
3604
4251
  let sides, ts, tPref;
@@ -3638,6 +4285,146 @@ function renderScene(doc,y0){
3638
4285
  W=Math.max(W, best.box.x+best.box.w+4);
3639
4286
  Hh=Math.max(Hh, best.box.y+best.box.h+4-y0-20);
3640
4287
  }
4288
+ // ── note placement (`DRAWN-ANNOTATION-FORM`) — the SAME pass, entered LAST ────────────────
4289
+ // Four rules, and they are in the spec rather than only here because two
4290
+ // engines have to agree on them:
4291
+ //
4292
+ // 1. Notes register LAST, after every edge label and arrowhead. The
4293
+ // reason is semantic, not convenient: a label is ON the thing it names
4294
+ // and an arrowhead IS part of the connector, whereas a note is BESIDE
4295
+ // what it is about. A NOTE YIELDS; NOTHING YIELDS TO A NOTE. That is
4296
+ // why this loop runs after the one above, reads the same `placed`, and
4297
+ // is read by nothing after it.
4298
+ // 2. Candidates are generated around the CARRIER's final geometry — a
4299
+ // node box, a group rect, or the edge's segment list, all of which are
4300
+ // rects or segments by the time this pass runs — and scored by the same
4301
+ // overlap function against `placed`, `obst`, `arrowBox` and `edgeSegs`.
4302
+ // 3. A LEADER LINE is drawn ONLY when the box could not be placed adjacent
4303
+ // to its carrier. This is where attachment-by-syntax pays off twice:
4304
+ // the carrier is known from the line, so the PREFERRED position is
4305
+ // always adjacency and the leader is a fallback the engine reaches for
4306
+ // rather than a permanent part of the construct. It is drawn AFTER
4307
+ // placement, so it is correct by construction — the property `SELF-EDGE-DRAWING`/`EDGE-BEND-RETENTION`
4308
+ // already records for arrowheads.
4309
+ // 4. Determinism is not optional. `RENDERING-DETERMINISM` promises byte-reproducible output,
4310
+ // and one greedy pass in registration order with no iteration is what
4311
+ // delivers it. Registration order here is DOCUMENT order — carriers are
4312
+ // sorted by source line, across kinds — so moving a `group` line above
4313
+ // a `node` line moves the notes with it and nothing else changes.
4314
+ //
4315
+ // `DOMAIN-CONVENTION-DIRECTIVES` binds throughout: `note=` accepts no `at=`, no `side=`, no
4316
+ // `left of`/`right of`. The author names the meaning; the engine owns the
4317
+ // drawing convention. The convention is the UML note symbol — a rectangle
4318
+ // with a folded top-right corner — which is what makes a note readable AS
4319
+ // a note without a legend entry, and is the notation belonging to the
4320
+ // metaclass the spelling is borrowed from (RULE 4.1).
4321
+ const noteCarriers=[];
4322
+ for(const n of nodes) if(n.note!==undefined&&n.note!==null&&!n.boundary)
4323
+ noteCarriers.push({line:n.line,text:n.note,kind:'node',rect:{x:n.x,y:n.y,w:n.w,h:n.h},n});
4324
+ for(const g of doc.groups) if(g.note!==undefined&&g.note!==null){
4325
+ const B=gBox[g.id]; if(!B) continue;
4326
+ noteCarriers.push({line:g.line,text:g.note,kind:'group',
4327
+ rect:{x:B.x0,y:B.yA,w:B.x1-B.x0,h:B.yB-B.yA}});
4328
+ }
4329
+ for(const e of doc.edges) if(e.note!==undefined&&e.note!==null){
4330
+ // An edge is a polyline, not a rect. Its carrier POINT is the midpoint of
4331
+ // the middle registered segment — deterministic, and it is the same
4332
+ // "middle of the run" an author means when they annotate a wire. A note
4333
+ // whose edge never made it to the canvas (an endpoint that did not
4334
+ // resolve) simply has no carrier and is not drawn; the missing endpoint
4335
+ // is already its own line error.
4336
+ const segs=edgeSegs.filter(g=>g.e===e); if(!segs.length) continue;
4337
+ const m=segs[Math.floor((segs.length-1)/2)];
4338
+ const cx=(m.p[0]+m.q[0])/2, cy=(m.p[1]+m.q[1])/2;
4339
+ noteCarriers.push({line:e.line,text:e.note,kind:'edge',rect:{x:cx,y:cy,w:0,h:0},e});
4340
+ }
4341
+ noteCarriers.sort((a,b)=>a.line-b.line);
4342
+ for(const c of noteCarriers){
4343
+ const box=noteBox(c.text);
4344
+ // Adjacency first, then the same four sides pushed out far enough that a
4345
+ // leader is legible. `si` orders the sides; `far` is what decides the
4346
+ // leader, and it costs enough that adjacency wins every time adjacency is
4347
+ // merely imperfect rather than blocked.
4348
+ // Sides in preference order, and for the two that straddle the carrier
4349
+ // an ALIGNMENT as well. Centring a wide note over a narrow carrier at the
4350
+ // left edge of the canvas puts the box off it; aligning the box's left
4351
+ // edge with the carrier's is the same "beside this thing" reading and
4352
+ // stays on the page. Found by eye — see the off-canvas note below.
4353
+ const SIDES=[['right','c'],['left','c'],
4354
+ ['below','c'],['below','l'],['below','r'],
4355
+ ['above','c'],['above','l'],['above','r']];
4356
+ // Three distance tiers, not two. `near` is adjacency and takes no leader;
4357
+ // the two `far` tiers do. The third exists because a crowded figure can
4358
+ // have NO free space within one leader length of the carrier — a note
4359
+ // then had to sit on top of an edge label, which is the exact inversion
4360
+ // of rule 1 (a note yields; nothing yields to a note). Given somewhere
4361
+ // further to go, it goes there and the canvas grows to fit.
4362
+ let best=null,bestS=Infinity, fallback=null,fallbackS=Infinity;
4363
+ for(const tier of [0,1,2]){
4364
+ const far=tier>0, gap=[10,46,96][tier];
4365
+ for(let si=0;si<SIDES.length;si++){
4366
+ const side=SIDES[si][0], al=SIDES[si][1];
4367
+ let bx,by;
4368
+ if(side==='right'){ bx=c.rect.x+c.rect.w+gap; by=c.rect.y+c.rect.h/2-box.h/2; }
4369
+ else if(side==='left'){ bx=c.rect.x-gap-box.w; by=c.rect.y+c.rect.h/2-box.h/2; }
4370
+ else {
4371
+ bx=al==='l'?c.rect.x
4372
+ :al==='r'?c.rect.x+c.rect.w-box.w
4373
+ :c.rect.x+c.rect.w/2-box.w/2;
4374
+ by=side==='below'?c.rect.y+c.rect.h+gap:c.rect.y-gap-box.h;
4375
+ }
4376
+ const cb={x:bx,y:by,w:box.w,h:box.h};
4377
+ let s=0;
4378
+ // A NOTE YIELDS; NOTHING YIELDS TO A NOTE. `placed` holds the edge
4379
+ // labels and the notes already sited, and its weight is the HIGHEST
4380
+ // of the three — higher than a node's — because a label is a small
4381
+ // box and an AREA-weighted penalty would otherwise let a note sit on
4382
+ // one for less than it costs to clip a node's corner. Found by eye: a
4383
+ // transition note landed across two edge labels while a node overlap
4384
+ // three times the area scored higher.
4385
+ for(const b of placed) s+=10*ovl(cb,b);
4386
+ for(const o of obst) s+=6*ovl(cb,o);
4387
+ for(const a of arrowBox) s+=8*ovl(cb,a);
4388
+ for(const g of edgeSegs) if(segHit(g.p,g.q,cb)) s+=26;
4389
+ for(const g of doc.groups){ const B=gBox[g.id];
4390
+ if(B) s+=1.2*ovl(cb,{x:B.x0,y:B.yA,w:B.x1-B.x0,h:B.yB-B.yA}); }
4391
+ s+=si*12;
4392
+ s+=tier*900; // the leader is a LAST resort
4393
+ // Falling off the top or the left margin is not a BAD placement, it
4394
+ // is NO placement. The canvas grows right and down but has no
4395
+ // mechanism here to grow up or left, so such a box is clipped away
4396
+ // and the annotation VANISHES — the author wrote a sentence and the
4397
+ // reader never sees it, which is the worst outcome available. So it
4398
+ // is a HARD FILTER and not a score term: every other penalty is an
4399
+ // AREA and grows without bound, so no constant can outrank one
4400
+ // reliably. Found by eye on a statechart, where the note on the
4401
+ // leftmost state was emitted at x=-267.8 and simply did not appear —
4402
+ // and then found AGAIN when a large constant was tried first and the
4403
+ // box landed on top of two states instead.
4404
+ // `<0`, not `<2`. The edge-label pass keeps a 2px margin because a
4405
+ // label is loose text; a note is a BOX whose left edge at x=0 IS the
4406
+ // canvas origin and is perfectly placed. With the label pass's
4407
+ // threshold copied over, the one candidate that rescues a wide note
4408
+ // on a leftmost element — below, left-aligned, at exactly x=0 — was
4409
+ // filtered as off-canvas, and the note went to the only survivor: on
4410
+ // top of the next two states.
4411
+ if(cb.x<0||cb.y<y0){
4412
+ if(s<fallbackS-1e-9){ fallbackS=s; fallback={x:bx,y:by,far,side}; }
4413
+ continue;
4414
+ }
4415
+ if(s<bestS-1e-9){ bestS=s; best={x:bx,y:by,far,side}; }
4416
+ }
4417
+ }
4418
+ // Only if EVERY candidate was off-canvas: take the least-bad one and
4419
+ // clamp it on. It may overlap something; visible and overlapping is
4420
+ // recoverable by an author, invisible is not.
4421
+ if(!best){ best=fallback; best.x=Math.max(0,best.x); best.y=Math.max(y0,best.y); }
4422
+ lblsvg.push(noteSvg(best.x,best.y,box,c));
4423
+ if(best.far) lblsvg.push(noteLeader(best,box,c.rect));
4424
+ placed.push({x:best.x,y:best.y,w:box.w,h:box.h});
4425
+ W=Math.max(W, best.x+box.w+4);
4426
+ Hh=Math.max(Hh, best.y+box.h+4-y0-20);
4427
+ }
3641
4428
  }
3642
4429
  // nodes on top (each wrapped in a draggable, identifiable group)
3643
4430
  const nsvg=[];
@@ -3950,7 +4737,7 @@ function renderBitfield(b,y0){
3950
4737
  //
3951
4738
  // and 0.1 already ruled that a spanning field follows the RFC's
3952
4739
  // drawing rather than a FigDown one; this is the same ruling applied to the
3953
- // other construct in the same figure. Until this release the engine drew ONE
4740
+ // other construct in the same figure. Until 0.1 the engine drew ONE
3954
4741
  // occurrence and hung `[first] … [last]` on the strip — a FigDown invention
3955
4742
  // where a convention already existed.
3956
4743
  //
@@ -3997,7 +4784,7 @@ function renderBitfield(b,y0){
3997
4784
  const shiftFor=(row)=>elisBands(row)*EL_H;
3998
4785
  // `FIELD-WIDER-THAN-WORD`: ONE FIELD IS ONE BOX.
3999
4786
  //
4000
- // A field wider than `word=` occupies several rows. Until this release each
4787
+ // A field wider than `word=` occupies several rows. Until 0.1 each
4001
4788
  // row was a separate fully-bordered <rect> carrying the full label, so a
4002
4789
  // 128-bit address at word=32 drew as FOUR captioned boxes and a reader saw
4003
4790
  // four fields where the model has one. In examples/srh.fd it was worse: the
@@ -4193,7 +4980,7 @@ function renderBitfield(b,y0){
4193
4980
  }
4194
4981
  boxes.forEach(function(bx,bi){
4195
4982
  // `DESCRIPTION-KEY-SPELLING`: the `<title>` is a CHILD of the shape it names, not
4196
- // a sibling. Until this release it was pushed into the block's stream
4983
+ // a sibling. Until 0.1 it was pushed into the block's stream
4197
4984
  // after the rect and the label, so it landed under the figure's single
4198
4985
  // <g> — and SVG says a <title> names its PARENT, so every description in
4199
4986
  // a figure named the same <g> and a conforming UA showed one arbitrary
@@ -4352,7 +5139,8 @@ function renderBitfield(b,y0){
4352
5139
  }
4353
5140
  yb+=2;
4354
5141
  }
4355
- return {svg:svg.join(''), y:yb, w:wb};
5142
+ return {svg:svg.join(''), y:yb, w:wb,
5143
+ box:{x0:0, x1:wb, yA:y0+18, yB:yb}};
4356
5144
  }
4357
5145
 
4358
5146
  // ---- table (with ^ rowspan / < colspan merging and per-cell marks) ----
@@ -4448,7 +5236,20 @@ function renderTable(t,y0){
4448
5236
  i=>[xAt[i], xAt[i+1]], i=>[cellAt(r-1,i), cellAt(r,i)]));
4449
5237
  svg.push(edgeSvg(EDG, DEF));
4450
5238
  const yEnd=yTop+yAt[grid.length];
4451
- return {svg:svg.join(''), y:yEnd+6, w:totalW+2};
5239
+ // `MARKER-TARGET-KINDS`: the GRID's box, so a region-scope `threshold`/`band`
5240
+ // can be drawn across it. It is the grid and not the returned slot: the slot
5241
+ // includes the caption row and the trailing gap, and `offset=50%` on a table
5242
+ // must mean half way down the ROWS, not half way down the whitespace.
5243
+ // `MARKER-TARGET-KINDS`: the box a region-scope `threshold`/`band` is measured
5244
+ // against spans the DATA ROWS, not the whole grid. Measured over the grid,
5245
+ // `offset=85%` on a three-row table lands on the COLUMN HEADINGS and strikes
5246
+ // through them — found by eye on the WRED figure this widening exists for.
5247
+ // The header tiers are chrome: they name the columns, they are not values,
5248
+ // and a threshold is a statement about values. `h1..hN` and `1..` are already
5249
+ // separate address spaces in this genre (genres/table.md), so the split is
5250
+ // the genre's own and not invented here.
5251
+ return {svg:svg.join(''), y:yEnd+6, w:totalW+2,
5252
+ box:{x0:0, x1:totalW, yA:yTop+yAt[H], yB:yEnd}};
4452
5253
  }
4453
5254
 
4454
5255
  // ---- chart bar3d: deterministic isometric projection of a table ----
@@ -4552,7 +5353,8 @@ function renderTiming(w,y0){
4552
5353
  svg.push('<path d="M'+x+','+(y+4)+' q4,'+(hTotal/4)+' 0,'+(hTotal/2)+' q-4,'+(hTotal/4)+' 0,'+(hTotal/2)+'" fill="none" stroke="#999" stroke-width="2"/>');
4553
5354
  }
4554
5355
  const H=y+8+w.signals.length*(laneH+laneGap);
4555
- return {svg:svg.join(''), y:H, w:nameW+cycles*cycleW+2};
5356
+ return {svg:svg.join(''), y:H, w:nameW+cycles*cycleW+2,
5357
+ box:{x0:nameW, x1:nameW+cycles*cycleW, yA:y0+18, yB:H}};
4556
5358
  }
4557
5359
 
4558
5360
  // ============================================================
@@ -4586,7 +5388,7 @@ edge l3 -> mmu
4586
5388
  edge acl -> mmu`,
4587
5389
 
4588
5390
  'flowchart roles':
4589
- `figdown 0.1 flowchart
5391
+ `figdown 0.2 flowchart
4590
5392
  title "Packet ingress (roles)"
4591
5393
  terminator rx "Frame received"
4592
5394
  process l2 "Read the L2 header"
@@ -4597,15 +5399,15 @@ process vlan "Pop one VLAN tag"
4597
5399
  terminator drop "Drop"
4598
5400
  terminator fwd "Forward"
4599
5401
  rank ipv4,arp,drop,vlan
4600
- edge rx -> l2
4601
- edge l2 -> et
4602
- edge et -[0x0800 IPv4]-> ipv4
4603
- edge et -[0x0806 ARP]-> arp
4604
- edge et -[other]-> drop
4605
- edge et -[0x8100 VLAN]-> vlan
4606
- edge vlan -[re-read]-> et
4607
- edge ipv4 -> fwd
4608
- edge arp -> fwd`,
5402
+ flowline rx -> l2
5403
+ flowline l2 -> et
5404
+ flowline et -[0x0800 IPv4]-> ipv4
5405
+ flowline et -[0x0806 ARP]-> arp
5406
+ flowline et -[other]-> drop
5407
+ flowline et -[0x8100 VLAN]-> vlan
5408
+ flowline vlan -[re-read]-> et
5409
+ flowline ipv4 -> fwd
5410
+ flowline arp -> fwd`,
4609
5411
 
4610
5412
  'bitfield (census #1)':
4611
5413
  `figdown 0.1 bitfield
@@ -4667,7 +5469,7 @@ pin mon at=(340,0)`,
4667
5469
 
4668
5470
  'EVPN fabric (topology + supplementary tables)':
4669
5471
  `figdown 0.1 topology
4670
- title "VXLAN/EVPN Leaf-Spine Fabric — topology with plane notes"
5472
+ title "VXLAN/EVPN Leaf-Spine Fabric — underlay and overlay"
4671
5473
 
4672
5474
  # ── Topology ────────────────────────────────────────────────────────
4673
5475
  node sp1 "Spine-1" shape=rounded fill=#e0e7ff
@@ -4694,8 +5496,7 @@ edge lf2 -- h2
4694
5496
  edge lf3 -- h3
4695
5497
 
4696
5498
  # overlay: VXLAN tunnel between the two VTEPs sharing VNI 10010
4697
- plane overlay "VXLAN tunnels" z-index=2
4698
- edge lf1 <-[VXLAN VNI 10010]-> lf2 style=dashed stroke=#dc2626 plane=overlay
5499
+ edge lf1 <-[VXLAN VNI 10010]-> lf2 style=dashed stroke=#dc2626
4699
5500
 
4700
5501
  # ── Supplementary knowledge ─────────────────────────────────────────
4701
5502
  table vni "VNI mapping"
@@ -4949,8 +5750,16 @@ let lastSVG='', lastMeta=null, lastPad={x:0,y:0}, lastDoc=null;
4949
5750
  // pin/layout are layout-zone only — they must never
4950
5751
  // be treated as "last scene line" (P0: drag then +Node was writing
4951
5752
  // into the layout zone → "node is a semantic directive" white screen).
4952
- const CONTENT_KW=/^(figdown|title|node|group|external|edge|plane|flow|rank|threshold|band|bundle|class)\b/;
4953
- const ID_DECL_KW=/^(node|group|external|bundle|class|plane|bitfield|table|timing)\s+(\S+)/;
5753
+ // `PAINT-ORDER-CONSTRUCT`: `plane` leaves this list with the keyword. The list is
5754
+ // SPELLINGS, not per-genre legality (the parser owns legality), so the
5755
+ // per-genre withdrawals of `SCENE-KEYWORD-MEMBERSHIP` change nothing here — `threshold`, `band` and
5756
+ // `bundle` are still content lines in the genres that still declare them.
5757
+ const CONTENT_KW=/^(figdown|title|node|state|group|external|edge|flowline|transition|flow|rank|threshold|band|bundle|class)\b/;
5758
+ // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: `state` is a node-declaring keyword under
5759
+ // `statechart`, so an id it declares must count as USED here or the GUI
5760
+ // hands out a colliding id. The list is spellings, not per-genre legality —
5761
+ // the parser owns legality.
5762
+ const ID_DECL_KW=/^(node|state|group|external|bundle|class|bitfield|table|timing)\s+(\S+)/;
4954
5763
  function lastContentLineIdx(lines){
4955
5764
  let last=-1;
4956
5765
  const layoutIdx=lines.findIndex(l=>l.trim()==='layout');
@@ -4990,11 +5799,37 @@ function nextFreeId(lines, prefix){
4990
5799
  // (pins only, no flow) so the strip test left orphan geometry. Genre default flow
4991
5800
  // is content-zone meaning for reading order — write it when the first scene
4992
5801
  // element appears via GUI, not only when the author types it.
4993
- function ensureFlowLine(lines){
5802
+ // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: every GUI action is a TEXT EDIT, so it has to
5803
+ // write the word the document's own genre uses. These read the header the
5804
+ // same way ensureFlowDirective does; the engine tables above are the authority.
5805
+ function guiGenre(lines){
5806
+ for(const l of lines){
5807
+ const m=/^figdown\s+\d+\.\d+\s+(\S+)/.exec(l.trim());
5808
+ if(m) return m[1];
5809
+ }
5810
+ return 'block';
5811
+ }
5812
+ const guiNodeKw=lines=>GENRE_NODE_KW[guiGenre(lines)]||'node';
5813
+ function guiVersion(lines){
5814
+ for(const l of lines){
5815
+ const m=/^figdown\s+(\d+\.\d+)\b/.exec(l.trim());
5816
+ if(m) return m[1];
5817
+ }
5818
+ return '0.1';
5819
+ }
5820
+ // `KEYWORD-RENAME-SCOPE`: the GUI writes the word legal AT THE DECLARED VERSION — inserting
5821
+ // `flowline` into a `figdown 0.1 flowchart` document would make the editor
5822
+ // author a line error.
5823
+ const guiConnKw=lines=>connectorKwAt(guiGenre(lines), guiVersion(lines))||'edge';
5824
+ const NODE_LINE_RE=new RegExp('^\\s*(?:'+[...NODE_SPELLINGS].join('|')+')\\s+');
5825
+ function ensureFlowDirective(lines){
4994
5826
  if(lines.some(l=>/^flow\b/.test(l.trim()))) return lines;
4995
5827
  let genre='block';
4996
5828
  for(const l of lines){
4997
- const m=/^figdown\s+0\.1\s+(\S+)/.exec(l.trim());
5829
+ // `STATECHART-GENRE-SCOPE`: any declared language version, not `0.1` alone — a `figdown 0.2`
5830
+ // document read by a `0\.1`-only regex fell through to the `block`
5831
+ // default and got the wrong axis written into it.
5832
+ const m=/^figdown\s+\d+\.\d+\s+(\S+)/.exec(l.trim());
4998
5833
  if(m){ genre=m[1]; break; }
4999
5834
  }
5000
5835
  // pure typed genres have no scene layout axis
@@ -5094,7 +5929,7 @@ function upsertPin(lines, id, patch){
5094
5929
  }
5095
5930
  function applyPins(pins){ // pins: array of [id, px, py] — one text update, one refresh
5096
5931
  let lines=$('src').value.split('\n');
5097
- lines=ensureFlowLine(lines);
5932
+ lines=ensureFlowDirective(lines);
5098
5933
  for(const [id,px,py] of pins){
5099
5934
  lines=upsertPin(lines, id, {at:'('+px+','+py+')'});
5100
5935
  }
@@ -5342,11 +6177,12 @@ function installDirectEdit(){
5342
6177
  if(linkArm===undefined){ linkArm=id; $('status').textContent='Link mode: now click the target node'; return; }
5343
6178
  if(linkArm!==id){
5344
6179
  let lines=$('src').value.split('\n');
5345
- lines=ensureFlowLine(lines);
6180
+ lines=ensureFlowDirective(lines);
5346
6181
  const last=lastContentLineIdx(lines);
5347
- lines.splice(last+1,0,'edge '+linkArm+' -> '+id);
6182
+ const ck=guiConnKw(lines);
6183
+ lines.splice(last+1,0,ck+' '+linkArm+' -> '+id);
5348
6184
  $('src').value=lines.join('\n'); refresh();
5349
- $('status').textContent='link → wrote "edge '+linkArm+' -> '+id+'" (GUI action = text edit)';
6185
+ $('status').textContent='link → wrote "'+ck+' '+linkArm+' -> '+id+'" (GUI action = text edit)';
5350
6186
  }
5351
6187
  linkArm=null; $('linkmode').style.fontWeight='';
5352
6188
  return;
@@ -5372,7 +6208,7 @@ function installDirectEdit(){
5372
6208
  });
5373
6209
  }
5374
6210
  let selectedId=null, linkArm=null;
5375
- function nodeLineIdx(lines,id){ return lines.findIndex(l=>new RegExp('^\\s*node\\s+'+id+'\\b').test(l)); }
6211
+ function nodeLineIdx(lines,id){ return lines.findIndex(l=>new RegExp('^\\s*(?:node|state)\\s+'+id+'\\b').test(l)); }
5376
6212
  function setNodeOption(id,key,val){
5377
6213
  // Rewrite/append the option on the CODE side of any trailing comment (P0).
5378
6214
  const lines=$('src').value.split('\n');
@@ -5398,7 +6234,7 @@ function deleteNode(id){
5398
6234
  const lines=$('src').value.split('\n').map((l,li)=>{
5399
6235
  if(drop.has(li+1)) return null;
5400
6236
  const t=l.trim();
5401
- if(new RegExp('^(node|pin)\\s+'+id+'\\b').test(t)) return null;
6237
+ if(new RegExp('^(node|state|pin)\\s+'+id+'\\b').test(t)) return null;
5402
6238
  // 0.1: `rank` is ONE comma-delimited token. This rewrite used to
5403
6239
  // both READ and WRITE the retired space form, so deleting a node from a
5404
6240
  // ranked scene produced a document the engine now refuses.
@@ -5429,7 +6265,7 @@ function deleteNode(id){
5429
6265
  function moveNodeLine(id,dir){ // dir +1 = later line = painted on top
5430
6266
  const lines=$('src').value.split('\n');
5431
6267
  const idx=nodeLineIdx(lines,id); if(idx<0) return;
5432
- const isNode=l=>/^\s*node\s+/.test(l);
6268
+ const isNode=l=>NODE_LINE_RE.test(l);
5433
6269
  let j=idx+dir;
5434
6270
  while(j>=0&&j<lines.length&&!isNode(lines[j])) j+=dir;
5435
6271
  if(j<0||j>=lines.length) { $('status').textContent='already at the '+(dir>0?'top':'bottom')+' of paint order'; return; }
@@ -5632,18 +6468,18 @@ $('raise').onclick=()=>{ if(selectedId) moveNodeLine(selectedId,+1); };
5632
6468
  $('lower').onclick=()=>{ if(selectedId) moveNodeLine(selectedId,-1); };
5633
6469
  function insertNodeLine(){
5634
6470
  let lines=$('src').value.split('\n');
5635
- lines=ensureFlowLine(lines);
6471
+ lines=ensureFlowDirective(lines);
5636
6472
  const nid=nextFreeId(lines,'n');
5637
6473
  const nnum=nid.slice(1);
5638
6474
  const last=lastContentLineIdx(lines);
5639
6475
  const kk=$('newkind').value;
5640
- lines.splice(last+1,0,'node '+nid+' "Node '+nnum+'"'+(kk!=='box'?' shape='+kk:''));
6476
+ lines.splice(last+1,0,guiNodeKw(lines)+' '+nid+' "Node '+nnum+'"'+(kk!=='box'?' shape='+kk:''));
5641
6477
  $('src').value=lines.join('\n'); refresh();
5642
6478
  return nid;
5643
6479
  }
5644
6480
  $('addnode').onclick=()=>{
5645
6481
  const id=insertNodeLine(); select(id);
5646
- $('status').textContent='+ Node → wrote "node '+id+'" line — drag it into place';
6482
+ $('status').textContent='+ Node → wrote "'+guiNodeKw($('src').value.split('\n'))+' '+id+'" line — drag it into place';
5647
6483
  };
5648
6484
  function addNodeAt(x,y){
5649
6485
  const id=insertNodeLine();