figdown 0.1.7 → 0.2.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,30 @@ 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.7';
124
+ const FIGDOWN_VERSION = '0.2.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
+ const LANG_VERSIONS = ['0.1', '0.2'];
135
+ // Genres per declared language version. `Y` never removes (core §13.0), so
136
+ // each row is a superset of the one above it, and `figdown 0.1 <anything>`
137
+ // resolves against exactly the list it resolved against before `STATECHART-GENRE-SCOPE`.
138
+ const GENRES_BY_VERSION = {
139
+ '0.1': ['block','topology','flowchart','bitfield','table','timing'],
140
+ '0.2': ['block','topology','flowchart','bitfield','table','timing','statechart']
141
+ };
125
142
  // Retired shape VALUES keep a named diagnostic (PROCESS §5(d)), the same way
126
143
  // retired option keys do: `cloud` was the one value that named a domain
127
144
  // (the internet cloud) in an enum the language keeps purely geometric
128
145
  // (`SHAPE-ENUM-VOCABULARY`/`EXTERNAL-EDGE-ENDPOINTS`), so it was removed rather than demoted.
129
146
  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)'
147
+ 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
148
  };
132
149
  // Colors are CSS hex (#rgb / #rrggbb) or CSS named colors (spec §1) — the
133
150
  // 147 CSS/SVG color keywords (lowercase) plus `transparent`. Anything else
@@ -382,9 +399,17 @@ const DIRECTIVE_OPTS={
382
399
  process:['shape','fill','stroke','style','class','in','plane','width','height'],
383
400
  decision:['shape','fill','stroke','style','class','in','plane','width','height'],
384
401
  terminator:['shape','fill','stroke','style','class','in','plane','width','height'],
402
+ // `GENRE-NODE-SPELLING`: `state` IS `node` under `statechart` — a rename, not a
403
+ // new directive, so it takes `node`'s keys exactly and nothing more.
404
+ state:['shape','fill','stroke','style','class','in','plane','width','height'],
385
405
  group:['fill','stroke','style','gap','class','plane'],
386
406
  external:['plane'],
387
407
  edge:['style','class','fill','stroke','plane','label','taillabel','headlabel'],
408
+ // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: same rename argument — the connector's option set is one set
409
+ // under three spellings, listed three times only because the tables are
410
+ // keyed by the surface word an author actually wrote.
411
+ flowline:['style','class','fill','stroke','plane','label','taillabel','headlabel'],
412
+ transition:['style','class','fill','stroke','plane','label','taillabel','headlabel'],
388
413
  plane:['z','z-index'], flow:[], rank:[],
389
414
  bundle:['fill','stroke','style','plane'],
390
415
  threshold:['in','at','offset','fill','stroke','style','plane'],
@@ -467,10 +492,10 @@ const ENUM_OPT_KEYS=['shape','style','numbering','extend','type'];
467
492
  // `color=` sets the TEXT — and once `color=` is live again the engine can no
468
493
  // longer diagnose a pre-0.1 document at all.
469
494
  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)',
495
+ 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)',
496
+ 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)',
497
+ 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)',
498
+ 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
499
  // `COLOUR-KEY-STATUS`. This is the ONLY key in the language whose diagnostic
475
500
  // must name two eras and refuse to choose between them: the same six
476
501
  // characters meant the FILL before this release and the LABEL after
@@ -489,10 +514,10 @@ const RETIRED_OPT_KEYS={
489
514
  // those observables, says plainly when a file has neither, and cites the
490
515
  // release only as a MIGRATIONS lookup. Same rule as the migration tool's
491
516
  // `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)',
517
+ 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
518
  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)',
519
+ 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 0.1)',
520
+ 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)',
496
521
  // 0.1 (`EDGE-GEOMETRY-CONSTRUCTS`). These six keys end in a WITHDRAWAL, not a rename, so
497
522
  // their messages have a shape no earlier retirement in this table has: they
498
523
  // name no replacement spelling, because there is none. `via=`/`src=`/`dst=`
@@ -506,28 +531,28 @@ const RETIRED_OPT_KEYS={
506
531
  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
532
  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
533
  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)',
534
+ 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)',
535
+ 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 0.1)',
511
536
  // `DESCRIPTION-KEY-SPELLING`. The spelling leaves the LANGUAGE, so the message fires
512
537
  // 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)'
538
+ 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 0.1)',
539
+ 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
540
  };
516
541
  // `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)';
542
+ 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 0.1)';
518
543
  // `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)';
544
+ 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
545
  // `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)';
546
+ 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
547
  // `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)';
548
+ 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
549
  // `PRESENCE-FLAG-SPELLING`: the 0.1 rename `optional` -> `conditional` (`PRESENCE-FLAG-SPELLING`)
525
550
  // is REVERTED. `conditional` has zero attestation as a wire-format field
526
551
  // marker and zero uses in the downstream corpus, while "optional" appears in
527
552
  // 34 downstream field LABELS — authors wrote the word in the label precisely
528
553
  // because the keyword no longer said it. The MODEL key moves with the surface
529
554
  // (`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)';
555
+ 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
556
  // `PRESENCE-CONDITION-EXPRESSION`: the positional flag `optional` becomes the option key
532
557
  // `present=`, whose VALUE is the presence condition. A bare flag could say
533
558
  // only THAT a field is conditional; every RFC that draws one also states WHY
@@ -536,7 +561,7 @@ const RETIRED_FIELD_CONDITIONAL='the field flag "conditional" has been retired:
536
561
  // prose the model may not read. `present` is the attested spelling: X.680
537
562
  // PRESENT, IP-XACT isPresent, SystemRDL ispresent, RFC 2784 "present only
538
563
  // 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)';
564
+ 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 0.1)';
540
565
  // `TIMING-GENRE-NAMING`: the EXPERIMENTAL genre `wave` became `timing`, both as
541
566
  // the header genre token and as the block opener. The old name was WaveDrom's
542
567
  // MEMBER KEY, not its figure name: in WaveJSON `signal` is the root object and
@@ -553,8 +578,8 @@ const RETIRED_FIELD_OPTIONAL='the field flag "optional" has been retired and rep
553
578
  // to know that the keys survive unchanged and only their carrier moved.
554
579
  // RULE 6.2 placement: the spelling left the LANGUAGE, so this fires wherever
555
580
  // 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)';
581
+ 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)';
582
+ 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
583
  // `EDGE-GEOMETRY-CONSTRUCTS`: `path` and `routing` are WITHDRAWN from the language.
559
584
  // These two diagnostics are a NEW SHAPE for this table. Every retirement
560
585
  // before them named a replacement spelling — `size` named `pin`, `guide` named
@@ -572,15 +597,15 @@ const WITHDRAWN_WHERE=' The decision and its evidence: MIGRATIONS 0.1, core §9
572
597
  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
598
  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
599
  // `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)';
600
+ 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
601
  // 0.1 (§8.4): `edge`, `threshold` and `bundle` have NO interior, so
577
602
  // `fill=` and `stroke=` named the SAME channel and `stroke=` won silently —
578
603
  // two keys for one channel, resolved by an undocumented precedence that
579
604
  // produced a legal, wrong figure whenever both were written (16 lines in
580
605
  // this repository, 3 of them writing both on one line). Same defect shape as
581
606
  // 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)';
607
+ const NO_INTERIOR=new Set(['edge','flowline','transition','threshold','bundle']);
608
+ 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
609
  // `STYLE-KEY-SCOPE`: `style=` left these three directives (it stays live on
585
610
  // node/group/edge/class/bundle/threshold/band). The generic
586
611
  // "<directive> does not take style=" would be true but would not say why, and
@@ -591,8 +616,8 @@ const FILL_NO_INTERIOR=k=>k+' has no interior, so fill= and stroke= name the sam
591
616
  // are closed here the way every other one-channel-two-keys collision in this
592
617
  // language was closed — a named line error, not a precedence rule. A
593
618
  // 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)';
619
+ 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)';
620
+ 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
621
  const NO_ITEM_STYLE=new Set(['field','cell','signal']);
597
622
  const STYLE_NO_ITEM=k=>k+' does not take style= — '+(k==='field'
598
623
  ? '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)'
@@ -604,7 +629,7 @@ const STYLE_NO_ITEM=k=>k+' does not take style= — '+(k==='field'
604
629
  // human, which is the same defect `STYLE-KEY-SCOPE` exists to close. The two channels
605
630
  // that DRAW are the name/label and a `class` meaning (which also earns a
606
631
  // 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)';
632
+ +'. 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
633
  function splitOpts(toks, laneMode){
609
634
  // a repeated option key on one line is a line error, never
610
635
  // silent last-wins. `dup` names the first key that appeared twice.
@@ -654,7 +679,7 @@ const ID_RE=/^[A-Za-z_][A-Za-z0-9_-]*$/;
654
679
  // `a`+`x--b`) and the greedy member regex silently committed to the first,
655
680
  // making the second unreachable and undiagnosed — RULE 6.3 says malformed
656
681
  // 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)';
682
+ 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
683
  // `QUOTED-IDS`: ONE wording for every id position. It covers both
659
684
  // halves of the defect it closes — a needlessly quoted legal id
660
685
  // (`node "a"`, silently accepted before) and a quoted token that is not a
@@ -673,7 +698,7 @@ const DD_ID='"--" is not allowed inside an id — it is the link operator (edge
673
698
  // old failure named the wrong thing (`node a Cache miss` reported
674
699
  // `unexpected argument "miss"` — a surplus argument, when the defect was a
675
700
  // missing quote).
676
- const Q_WHY='whitespace also separates positionals, so a bare token cannot express a phrase (MIGRATIONS)';
701
+ const Q_WHY='whitespace also separates positionals, so a bare token cannot express a phrase (MIGRATIONS 0.1)';
677
702
  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
703
  // isId: the whole id test, used at every id position in the language.
679
704
  const isId=v=>typeof v==='string'&&ID_RE.test(v)&&!v.includes('--');
@@ -861,7 +886,9 @@ function parseIndexRange(v){
861
886
  const CORE_KW=['figdown','title','layout'];
862
887
  const LAYOUT_KW=['pin']; // `LAYOUT-ZONE-NAMESPACE`, NORMATIVE — the whole namespace
863
888
  const GENRE_FREE_KW=CORE_KW.concat(LAYOUT_KW);
864
- const SCENE_KW_TOP=['node','group','external','edge','class','flow','rank'];
889
+ // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: the NODE and CONNECTOR spellings are per genre, so
890
+ // they are NOT in the shared list — every scene genre concats its own two.
891
+ const SCENE_KW_TOP=['group','external','class','flow','rank'];
865
892
  const SCENE_EXP_KW=['threshold','band','bundle','plane'];
866
893
  const SCENE_HOST_KW=GENRE_FREE_KW.concat(SCENE_KW_TOP, SCENE_EXP_KW, ['bitfield','table','timing','chart']);
867
894
  // `FLOWCHART-ROLE-KEYWORDS`: the flowchart ROLE vocabulary — the FIRST exercise of
@@ -877,10 +904,88 @@ const FLOWCHART_ROLE_KW=['process','decision','terminator'];
877
904
  // only its default drawing, and `shape=` on the same line overrides the
878
905
  // drawing without touching the role (§12.7).
879
906
  const ROLE_SHAPE={process:'box',decision:'diamond',terminator:'rounded'};
907
+
908
+ // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: PER-GENRE NODE AND CONNECTOR SPELLINGS.
909
+ // Each scene genre takes the word its own domain uses for the line between
910
+ // two things, and for the thing itself. The criterion is THE TERM THE DOMAIN
911
+ // ACTUALLY USES, not the standard's exact orthography (see the vocabulary
912
+ // rows: `flowline` carries the same ISO-clause verification debt
913
+ // `terminator` does, recorded rather than claimed away).
914
+ //
915
+ // block / topology node edge (DOT, unchanged)
916
+ // flowchart node flowline (ISO 5807)
917
+ // statechart state transition (OMG UML 2.5.1 §14)
918
+ //
919
+ // The ASYMMETRY is deliberate and is the whole of `GENRE-CONNECTOR-SPELLING` vs `GENRE-NODE-SPELLING`: `node`
920
+ // STAYS in `flowchart`, because there a stage can have a role THE SOURCE
921
+ // DOES NOT STATE, and `node` is the only spelling for that — forcing
922
+ // `process` would make the transcriber assert what the source never said.
923
+ // `statechart` has exactly ONE kind of node, so nothing can be left unstated
924
+ // and `state` loses nothing. `BARE-NODE-MEANING` CORRECTS the reason this
925
+ // comment used to give ("ISO has ~ten symbol kinds and we carry three"): a
926
+ // symbol this genre cannot spell is a COVERAGE GAP in FigDown, not a state
927
+ // of the figure, and `node` is not its spelling — see
928
+ // the project’s working record for the coverage ledger.
929
+ const GENRE_NODE_KW={block:'node',topology:'node',flowchart:'node',statechart:'state'};
930
+ const GENRE_CONNECTOR_KW={block:'edge',topology:'edge',flowchart:'flowline',statechart:'transition'};
931
+ const NODE_SPELLINGS=new Set(['node','state']);
932
+ const CONNECTOR_SPELLINGS=new Set(['edge','flowline','transition']);
933
+ // `KEYWORD-RENAME-SCOPE`: the flowchart rename is GATED BY THE DECLARED LANGUAGE
934
+ // VERSION, because `GENRE-CONNECTOR-SPELLING` applied it to `figdown 0.1` and that BROKE documents
935
+ // legal at v0.1.8 — `figdown 0.1 flowchart` + `edge` stopped parsing, with
936
+ // nothing recording the break as a decision. Under core §13.0 only X removes,
937
+ // so 0.1 keeps its spelling:
938
+ //
939
+ // figdown 0.1 flowchart `edge` legal, `flowline` is a VERSION error
940
+ // figdown 0.2 flowchart `flowline` legal, `edge` is the WRONG_WORD error
941
+ //
942
+ // Two spellings inside ONE version is what the no-alias rule (`IDENTITY-ASSERTION`)
943
+ // forbids; two spellings across VERSIONS is ordinary language evolution, and
944
+ // each version accepts exactly one. `statechart` needs no gate of its own —
945
+ // the GENRE requires 0.2 (GENRES_BY_VERSION), so `state`/`transition` cannot
946
+ // be reached from a 0.1 document at all.
947
+ const GENRE_CONNECTOR_KW_AT={
948
+ '0.1':{block:'edge',topology:'edge',flowchart:'edge'},
949
+ '0.2':GENRE_CONNECTOR_KW
950
+ };
951
+ const connectorKwAt=(genre,ver)=>
952
+ ((GENRE_CONNECTOR_KW_AT[ver]||GENRE_CONNECTOR_KW)[genre])||undefined;
953
+ // The version a connector spelling FIRST becomes legal in, so the diagnostic
954
+ // can name it the way the genre gate already names `figdown 0.2`.
955
+ const CONNECTOR_MIN_VERSION={flowline:'0.2',transition:'0.2'};
956
+ const WRONG_VERSION_WORD=(surf,want,genre,need,have)=>
957
+ '"'+surf+'" requires figdown '+need+' (this document declares '+have+'): '+
958
+ 'under figdown '+have+' genre '+genre+' spells this "'+want+'". The rename is '+
959
+ 'gated by the language version — a figdown '+have+' document keeps the spelling it '+
960
+ 'was written with (core §13.0: only a MAJOR version removes) — so raise the header '+
961
+ 'to figdown '+need+' or write "'+want+'" (MIGRATIONS 0.2)';
962
+ // Why each genre's word is its word — quoted in the diagnostic, because
963
+ // "not allowed in genre X" tells an author nothing about what to write.
964
+ const WORD_WHY={
965
+ edge:'a block or topology figure is a graph, and `edge` is the graph word (DOT)',
966
+ flowline:'the connecting line in a flowchart is a FLOWLINE — the term ISO 5807 uses for it',
967
+ transition:'the connecting line in a statechart is a TRANSITION — the term UML 2.5.1 §14 uses for it',
968
+ node:'this genre has more kinds of thing than it has words for, so `node` is the general one',
969
+ state:'a statechart has exactly ONE kind of node and it is a STATE (UML 2.5.1 §14)'
970
+ };
971
+ // The named diagnostic `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING` owe: it says WHICH word this genre uses and
972
+ // WHY, and it names the migration, because every connector line in a
973
+ // reclassified document has to be rewritten (the cost `GENRE-CONNECTOR-SPELLING` accepted).
974
+ const WRONG_WORD=(surf,want,genre)=>
975
+ '"'+surf+'" is not the word genre '+genre+' uses for this — write "'+want+'": '+WORD_WHY[want]+
976
+ '. 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)';
880
977
  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)),
978
+ block:new Set(SCENE_HOST_KW.concat(['node','edge'])),
979
+ topology:new Set(SCENE_HOST_KW.concat(['node','edge'])),
980
+ flowchart:new Set(SCENE_HOST_KW.concat(['node','flowline'], FLOWCHART_ROLE_KW)),
981
+ // `STATECHART-GENRE-SCOPE`: `statechart` added no keyword of its own — it was the
982
+ // scene host set and nothing else. `GENRE-NODE-SPELLING` gives it its two: the
983
+ // scene host set with `state` and `transition` in the slots `node` and
984
+ // `edge` occupy elsewhere. It still does NOT inherit `process`/`decision`/
985
+ // `terminator`: those are flowchart's words (`GENRE-NAMESPACE` `GENRE-VOCABULARY-OBLIGATION`), and a `decision` in a
986
+ // statechart is a category error, not a shorthand. The allowlist is what
987
+ // makes that a line error with no extra code.
988
+ statechart:new Set(SCENE_HOST_KW.concat(['state','transition'])),
884
989
  bitfield:new Set(GENRE_FREE_KW.concat(['class','bitfield'])),
885
990
  // chart is experimental and attaches to a table id in the same document
886
991
  table:new Set(GENRE_FREE_KW.concat(['class','table','chart'])),
@@ -983,8 +1088,13 @@ function parseOne(text){
983
1088
  // operator into halves: -[x]- -[x]-> <-[x]- <-[x]->. Bracket content:
984
1089
  // balanced brackets nest verbatim ([flags[3:0]] just works); ["..."] takes
985
1090
  // the standard quoted-string escapes for unbalanced brackets / \n.
986
- function parseEdgeLine(s,n){
987
- let i=4; // past 'edge'
1091
+ // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: `kw` is the SURFACE spelling the author wrote —
1092
+ // `edge`, `flowline` or `transition`. One scanner, three words: every
1093
+ // message names the word on the line, and nothing downstream of here knows
1094
+ // the difference (the model records a connector, not a spelling).
1095
+ function parseEdgeLine(s,n,kw){
1096
+ kw=kw||'edge';
1097
+ let i=kw.length; // past the connector keyword
988
1098
  const ws=()=>{ while(i<s.length&&/\s/.test(s[i])) i++; };
989
1099
  // `LINK-OPERATOR-IN-IDS`: a hyphen is an id character only when it is NOT
990
1100
  // followed by a second one, because `--` is the link operator. This is
@@ -1032,7 +1142,7 @@ function parseOne(text){
1032
1142
  ws();
1033
1143
  if(s[i]==='"'){ err(n,ID_RULE); return; }
1034
1144
  const a=readId();
1035
- if(!a){ err(n, idHere()?ID_RULE:'edge needs <id> ->|<-|--|<-> <id>'); return; }
1145
+ if(!a){ err(n, idHere()?ID_RULE:kw+' needs <id> ->|<-|--|<-> <id>'); return; }
1036
1146
  if(idHere()){ err(n,ID_RULE); return; }
1037
1147
  ws(); let tail=null;
1038
1148
  if(s[i]==='['){ const r=readLbl(); if(r.error){ err(n,r.error); return; } tail=r.v; }
@@ -1040,7 +1150,7 @@ function parseOne(text){
1040
1150
  let lh=null;
1041
1151
  if(s.startsWith('<-',i)){ lh='<-'; i+=2; }
1042
1152
  else if(s[i]==='-'){ lh='-'; i++; }
1043
- else { err(n,'edge needs an operator: -> <- -- <-> (a [mid] label splits it: -[x]->)'); return; }
1153
+ else { err(n,kw+' needs an operator: -> <- -- <-> (a [mid] label splits it: -[x]->)'); return; }
1044
1154
  let mid=null, op=null;
1045
1155
  if(s[i]==='['){
1046
1156
  const r=readLbl(); if(r.error){ err(n,r.error); return; } mid=r.v;
@@ -1052,14 +1162,14 @@ function parseOne(text){
1052
1162
  } else {
1053
1163
  if(s[i]==='-'){ op='--'; i++; }
1054
1164
  else if(s[i]==='>'){ op='->'; i++; }
1055
- else { err(n,'edge needs an operator: -> <- -- <->'); return; }
1165
+ else { err(n,kw+' needs an operator: -> <- -- <->'); return; }
1056
1166
  }
1057
1167
  ws(); let head=null;
1058
1168
  if(s[i]==='['){ const r=readLbl(); if(r.error){ err(n,r.error); return; } head=r.v; }
1059
1169
  ws();
1060
1170
  if(s[i]==='"'){ err(n,ID_RULE); return; }
1061
1171
  const b=readId();
1062
- if(!b){ err(n, idHere()?ID_RULE:'edge needs a target id after the operator'); return; }
1172
+ if(!b){ err(n, idHere()?ID_RULE:kw+' needs a target id after the operator'); return; }
1063
1173
  if(idHere()){ err(n,ID_RULE); return; }
1064
1174
  const tk2=tokenize(s.slice(i).trim());
1065
1175
  if(tk2.error){ err(n,tk2.error); return; }
@@ -1073,10 +1183,10 @@ function parseOne(text){
1073
1183
  for(const rk in RETIRED_OPT_KEYS)
1074
1184
  if(o2[rk]!==undefined){ err(n,RETIRED_OPT_KEYS[rk]); return; }
1075
1185
  for(const k in o2)
1076
- if(!DIRECTIVE_OPTS.edge.includes(k)){ err(n,'edge does not take '+k+'='); return; }
1186
+ if(!DIRECTIVE_OPTS[kw].includes(k)){ err(n,kw+' does not take '+k+'='); return; }
1077
1187
  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; }
1188
+ if(o2[k]!==undefined){ err(n,k+'= is retired — write the label inline: '+kw+' A [tail] -[mid]-> [head] B (MIGRATIONS 0.1)'); return; }
1189
+ if(o2.fill!==undefined){ err(n,FILL_NO_INTERIOR(kw)); return; }
1080
1190
  for(const k of ['fill','stroke'])
1081
1191
  if(o2[k]!==undefined && !isColor(o2[k])){ err(n,'unknown color "'+o2[k]+'" (#hex or CSS color name)'); return; }
1082
1192
  // `RULE-POSITION-ENUMERATION`: `edge` was the ONE id position in the language that
@@ -1178,17 +1288,41 @@ function parseOne(text){
1178
1288
  if(findReservedSemi(raw)>=0){ err(n,RESERVED_SEMI); continue; }
1179
1289
  // edge lines carry inline [labels] with free text — dedicated scanner,
1180
1290
  // not the generic tokenizer
1181
- if(/^edge(\s|$)/.test(raw.trim())){
1291
+ // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: all THREE connector spellings are scanned here.
1292
+ // The wrong one for the genre must reach the named diagnostic below, so
1293
+ // the dispatch cannot be narrowed to the genre's own word — a `flowline`
1294
+ // under `block` would then fall through to `unrecognized line`, which is
1295
+ // exactly the answer these rulings owe an author better than.
1296
+ const mConn=/^(edge|flowline|transition)(\s|$)/.exec(raw.trim());
1297
+ if(mConn){
1298
+ const ckw=mConn[1];
1182
1299
  if(firstContent){ firstContent=false; err(n,'first line must be "figdown 0.1 <genre>"'); }
1183
1300
  cur=null;
1184
- // `CONTENT-LAYOUT-ZONE-SPLIT`: `edge` is a semantic directive, and it is dispatched here —
1301
+ // `CONTENT-LAYOUT-ZONE-SPLIT`: a connector is a semantic directive, and it is dispatched here —
1185
1302
  // before the generic layout-zone gate below — so it needs its own copy
1186
1303
  // 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);
1304
+ if(sawLayout){ err(n,'"'+ckw+'" is a semantic directive — it must appear before the layout zone (`CONTENT-LAYOUT-ZONE-SPLIT`)'); continue; }
1305
+ // `GENRE-KEYWORD-ALLOWLIST`: a connector is scene vocabulary only — and in a scene genre it is
1306
+ // THAT genre's connector word (`GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`), not any of the three.
1307
+ // `KEYWORD-RENAME-SCOPE`: the word this genre uses is read AT THE DECLARED VERSION, not
1308
+ // at the newest one. A genre with no connector at all (bitfield, table,
1309
+ // timing) still gets the allowlist message.
1310
+ if(sawHeader && doc.genre && GENRE_KW[doc.genre]){
1311
+ const want=connectorKwAt(doc.genre, doc.version);
1312
+ if(!want){ err(n,'"'+ckw+'" is not allowed in genre '+doc.genre); continue; }
1313
+ if(ckw!==want){
1314
+ const need=CONNECTOR_MIN_VERSION[ckw];
1315
+ // The author wrote a word this genre really does use — just not in
1316
+ // the version they declared. That is a different mistake from the
1317
+ // wrong domain's word, and it has a different one-step fix.
1318
+ if(need && doc.version && need>doc.version &&
1319
+ GENRE_CONNECTOR_KW[doc.genre]===ckw)
1320
+ err(n, WRONG_VERSION_WORD(ckw,want,doc.genre,need,doc.version));
1321
+ else
1322
+ err(n, WRONG_WORD(ckw,want,doc.genre));
1323
+ continue; }
1324
+ }
1325
+ parseEdgeLine(raw.trim(),n,ckw);
1192
1326
  continue;
1193
1327
  }
1194
1328
  const tk=tokenize(raw.trim());
@@ -1300,25 +1434,53 @@ function parseOne(text){
1300
1434
  // line. So the quoting check runs first and suppresses the value
1301
1435
  // check for that token, exactly as `badOpts` does for `shape=`.
1302
1436
  const hq1=!!posq[1], hq2=!!posq[2];
1437
+ // `STATECHART-GENRE-SCOPE`: the language version is now a SET, not a constant, so every
1438
+ // message that echoes a corrected header echoes the version the
1439
+ // author actually declared — an author on `0.2` must not be shown a
1440
+ // `0.1` example. An unrecognised version falls back to `0.1` for the
1441
+ // purpose of these examples only; it has already errored on its own.
1442
+ const verOK=!hq1 && LANG_VERSIONS.includes(pos[1]);
1443
+ const ver=verOK?pos[1]:'0.1';
1303
1444
  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'];
1445
+ else if(hq2) err(n,ENUM_BARE('figdown '+ver+' '+pos[2]));
1446
+ if(!hq1 && !LANG_VERSIONS.includes(pos[1])) err(n,'unsupported version "'+(pos[1]||'')+'" (expected '+LANG_VERSIONS.join(' or ')+')');
1307
1447
  // 0.1: the genre token is REQUIRED. `bitfield`/`table`/`timing`
1308
1448
  // documents declare their kind in their content, but `block`,
1309
1449
  // `topology` and `flowchart` share the SAME vocabulary
1310
1450
  // (node/edge/group) and differ only in default flow — so the header
1311
1451
  // is the ONLY place such a document states which kind of figure it
1312
1452
  // 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)');
1453
+ // fallback. The message lists the legal values so an authoring
1454
+ // agent fixes it in one step — the values legal AT THE DECLARED
1455
+ // VERSION, since listing `statechart` to a `figdown 0.1` author would
1456
+ // name a genre that document may not use.
1457
+ const GENRES=GENRES_BY_VERSION[ver];
1458
+ const GLIST='('+GENRES.join('|')+')';
1459
+ if(pos[2]===undefined) err(n,'figdown header requires a genre '+GLIST);
1316
1460
  else if(hq2){ /* the quoting error above is this token's one error */ }
1317
1461
  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)');
1462
+ else if(!GENRES.includes(pos[2])){
1463
+ // A genre that exists but only LATER is its own diagnostic, not
1464
+ // `unknown genre`. core §13.7 forbids guessing a version, and the
1465
+ // author's one-step fix is to raise the header — which the message
1466
+ // SPELLS OUT, because "unknown genre statechart" would send them
1467
+ // looking for a typo that is not there. The trailing `— write: …`
1468
+ // is the same shape WRONG_VERSION_WORD ends with: name the fix, do
1469
+ // not leave the author to derive it.
1470
+ const later=Object.keys(GENRES_BY_VERSION).sort()
1471
+ .find(v=>v>ver && GENRES_BY_VERSION[v].includes(pos[2]));
1472
+ if(later) err(n,'genre "'+pos[2]+'" requires figdown '+later+' (this document declares '+ver+') — write: figdown '+later+' '+pos[2]);
1473
+ else err(n,'unknown genre "'+pos[2]+'" '+GLIST);
1474
+ }
1319
1475
  else{ doc.genre=pos[2];
1476
+ // The DECLARED language version is part of the model (core §12.5
1477
+ // `header.version`): a reader must be able to tell what contract
1478
+ // the author wrote against without re-reading the source.
1479
+ doc.version=ver;
1320
1480
  // genre defaults (`GENRE-NAMESPACE`/`DEFAULT-VALUE-SELECTION`): flowchart figures flow down —
1321
- // the census-dominant direction; an explicit flow line overrides
1481
+ // the census-dominant direction; an explicit `flow` directive overrides.
1482
+ // `statechart` takes the SCENE default (`right`) and adds no rule
1483
+ // of its own — `STATECHART-GENRE-SCOPE` lands a dispatch point, not a layout.
1322
1484
  if(pos[2]==='flowchart') doc.flow='down'; }
1323
1485
  // §1: a directive line carrying positional arguments its grammar does
1324
1486
  // not accept MUST be rejected — the header is a directive like any other
@@ -1362,19 +1524,19 @@ function parseOne(text){
1362
1524
  // the first entries in it that name NO replacement. `route`'s own message
1363
1525
  // has to change with them: it pointed at `path`, which no longer exists,
1364
1526
  // 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; }
1527
+ 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
1528
  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
1529
  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
1530
  if(kw==='path'){ err(n,RETIRED_PATH); continue; }
1369
1531
  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; }
1532
+ if(kw==='render'){ err(n,'render has been renamed: use layout (the zone takes only pin — geometry, not presentation) (MIGRATIONS 0.1)'); continue; }
1371
1533
  if(kw==='wrap'){ err(n,RETIRED_WRAP); continue; }
1372
1534
  if(kw==='boundary'){ err(n,RETIRED_BOUNDARY); continue; }
1373
1535
  if(kw==='layer'){ err(n,RETIRED_LAYER); continue; }
1374
1536
  if(kw==='guide'){ err(n,RETIRED_GUIDE); continue; }
1375
1537
  if(kw==='wave'){ err(n,RETIRED_WAVE); continue; }
1376
1538
  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; }
1539
+ 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
1540
 
1379
1541
  // typed-block children
1380
1542
  if(cur && ['field','break','cell','width','signal','gap'].includes(kw)){
@@ -1452,7 +1614,7 @@ function parseOne(text){
1452
1614
  if(opts.index!==undefined){
1453
1615
  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
1616
  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; }
1617
+ 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
1618
  let bad=null; const parsed=[];
1457
1619
  for(const el of splitList(posT[1],0)){
1458
1620
  const it=el.v; if(!it) continue;
@@ -1517,7 +1679,7 @@ function parseOne(text){
1517
1679
  // `POSITIONAL-LIST-SPELLING`: comma form only; the space form is retired.
1518
1680
  const wtoks=pos.slice(1);
1519
1681
  if(wtoks.length>1){
1520
- err(n,'width takes ONE comma-delimited token: write width '+joinListForm(wtoks)+' — the space form is retired (MIGRATIONS)'); continue; }
1682
+ err(n,'width takes ONE comma-delimited token: write width '+joinListForm(wtoks)+' — the space form is retired (MIGRATIONS 0.1)'); continue; }
1521
1683
  const vals=wtoks.length?splitList(posT[1],0).map(e=>e.v):[];
1522
1684
  if(!vals.length){ err(n,'width needs one value per column (auto | <px> | <n>%)'); continue; }
1523
1685
  let badw=null;
@@ -1546,7 +1708,7 @@ function parseOne(text){
1546
1708
  // stays bare, exactly as `threshold offset=50%` does.
1547
1709
  const cadr=pos[1]||'';
1548
1710
  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; }
1711
+ 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
1712
  const rc=/^\((h?)(\d+),(\d+)\)$/.exec(cadr)||/^(h?)(\d+)$/.exec(cadr);
1551
1713
  // `RULE-POSITION-ENUMERATION`: `highlight` is a bare keyword FLAG
1552
1714
  // (vocabulary-sources.tsv `cell.highlight`), so RULE 2.4 governs it
@@ -1641,7 +1803,16 @@ function parseOne(text){
1641
1803
  // must be in the header genre allowlist. Child keywords still use the
1642
1804
  // "needs a bitfield/table/timing above" path when they appear with no cur.
1643
1805
  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);
1806
+ // `GENRE-NODE-SPELLING`: a word this genre SPELLS DIFFERENTLY is not an
1807
+ // unknown word, and "not allowed in genre statechart" would send an
1808
+ // author looking for a construct they cannot have instead of the one
1809
+ // they already wrote. The named diagnostic says which word and why.
1810
+ // (`edge`/`flowline`/`transition` never reach here — they are
1811
+ // dispatched by their own scanner above.)
1812
+ if(NODE_SPELLINGS.has(kw) && GENRE_NODE_KW[doc.genre])
1813
+ err(n, WRONG_WORD(kw, GENRE_NODE_KW[doc.genre], doc.genre));
1814
+ else
1815
+ err(n,'"'+kw+'" is not allowed in genre '+doc.genre);
1645
1816
  continue;
1646
1817
  }
1647
1818
  if(badOpts(kw)) continue;
@@ -1667,7 +1838,7 @@ function parseOne(text){
1667
1838
  // meaning: the token is a normal quoted string and the generic
1668
1839
  // tokenizer above has already resolved its escapes.
1669
1840
  const t0=tk.toks[1];
1670
- if(!t0||!t0.q){ err(n,'title needs a quoted string: title "<text>" (MIGRATIONS)'); break; }
1841
+ if(!t0||!t0.q){ err(n,'title needs a quoted string: title "<text>" (MIGRATIONS 0.1)'); break; }
1671
1842
  if(tk.toks.length>2){ err(n,'unexpected argument "'+tk.toks[2].v+'"'); break; }
1672
1843
  doc.title=t0.v; sawTitle=true; break;
1673
1844
  }
@@ -1729,9 +1900,15 @@ function parseOne(text){
1729
1900
  // being wrong is harmless, and a flowchart node may be a datastore, an
1730
1901
  // annotation or a state, so `role:"process"` by default would let the
1731
1902
  // model assert a falsehood the figure cannot be inspected to catch.
1903
+ // `GENRE-NODE-SPELLING`: `state` joins the same case. It is `node` renamed
1904
+ // for one genre, not a role — a statechart node has exactly one kind,
1905
+ // so there is no role to record and `role` stays null, exactly as a
1906
+ // bare `node` does. The DRAWING is unchanged too (`shape=box` default),
1907
+ // which is what let the five corpus figures migrate byte-identically.
1732
1908
  case 'process': case 'decision': case 'terminator':
1909
+ case 'state':
1733
1910
  case 'node': {
1734
- const role=kw==='node'?null:kw;
1911
+ const role=(kw==='node'||kw==='state')?null:kw;
1735
1912
  const id=pos[1];
1736
1913
  { const e=idErr(id,posq[1],kw+' needs an id'); if(e){ err(n,e); break; } }
1737
1914
  if(dupId(id)){ err(n,'duplicate id "'+id+'"'); break; }
@@ -1800,7 +1977,7 @@ function parseOne(text){
1800
1977
  break;
1801
1978
  }
1802
1979
  case 'flow': {
1803
- if(sawFlow){ err(n,'duplicate flow line'); break; }
1980
+ if(sawFlow){ err(n,'duplicate flow directive'); break; }
1804
1981
  // `RULE-POSITION-ENUMERATION`: `flow.direction` is an enum position — bare.
1805
1982
  if(posq[1]){ err(n,ENUM_BARE('flow '+pos[1])); break; }
1806
1983
  if(!['right','down','left','up'].includes(pos[1])){ err(n,'flow needs right|down|left|up'); break; }
@@ -1833,7 +2010,7 @@ function parseOne(text){
1833
2010
  // could not even detect a half-converted line.
1834
2011
  const rtoks=pos.slice(1), rT=posT.slice(1);
1835
2012
  if(rtoks.length>1){
1836
- err(n,'rank takes ONE comma-delimited token: write rank '+joinListForm(rtoks)+' — the space form is retired (MIGRATIONS)'); break; }
2013
+ err(n,'rank takes ONE comma-delimited token: write rank '+joinListForm(rtoks)+' — the space form is retired (MIGRATIONS 0.1)'); break; }
1837
2014
  const rels=rtoks.length?splitList(rT[0],0):[];
1838
2015
  let badr=null;
1839
2016
  for(const e of rels){
@@ -1873,7 +2050,7 @@ function parseOne(text){
1873
2050
  // is now detectable — a second positional token after the label is
1874
2051
  // the half-converted line the old tolerance could not report.
1875
2052
  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; }
2053
+ 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
2054
  const pairs=[]; let badp=null;
1878
2055
  outerB:
1879
2056
  for(const t of restT){
@@ -1923,7 +2100,7 @@ function parseOne(text){
1923
2100
  // percentage" `offset` (on <stop>), which is the same source the
1924
2101
  // paint keys come from, so nothing is invented.
1925
2102
  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; }
2103
+ 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
2104
  const m=/^(\d+(?:\.\d+)?)%$/.exec(opts.offset||''); // % is mandatory (`BARE-FRACTION-VALUES`)
1928
2105
  if(!m||+m[1]<0||+m[1]>100){ err(n,'threshold needs offset=<0..100>% (with the % sign)'); break; }
1929
2106
  // §5: `stroke=` is the marker colour, `color=` the label colour,
@@ -1974,7 +2151,7 @@ function parseOne(text){
1974
2151
  // reads the same way. `OPTION-POSITION-PARSING` quotedness is `pos`-aligned, so an option
1975
2152
  // may still precede it.
1976
2153
  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; }
2154
+ 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
2155
  if(!opts['in']){ err(n,'band needs in=<node-or-group-id>'); break; }
1979
2156
  // 0.1: the `%` is MANDATORY, matching `threshold offset=` (`BARE-FRACTION-VALUES`).
1980
2157
  // `band 15`, `band 15-35` and `band 15%-35` all parsed before (the
@@ -1989,7 +2166,7 @@ function parseOne(text){
1989
2166
  // cited by `bitfield`. `band` is EXPERIMENTAL (`CONSTRUCT-STATUS-TIERS`), so no
1990
2167
  // compatibility promise is owed; the diagnostic is owed anyway.
1991
2168
  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; }
2169
+ 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
2170
  const m=/^(\d+(?:\.\d+)?)%$|^(\d+(?:\.\d+)?)\.\.(\d+(?:\.\d+)?)%$/.exec(pos[2]||'');
1994
2171
  if(!m){ err(n,'band needs a range with the % sign: band "<label>" <pct>% or band "<label>" <a>..<b>%'); break; }
1995
2172
  if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
@@ -2022,7 +2199,7 @@ function parseOne(text){
2022
2199
  if(!id){ err(n,PIN_SHAPE); break; }
2023
2200
  // 0.1 (RULE 1.1a): the pair is PARENTHESISED — at=(x,y).
2024
2201
  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; }
2202
+ 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
2203
  const m=at===undefined?null:/^\((-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)\)$/.exec(at);
2027
2204
  // A WRITTEN `at=` that does not parse is its own error, never the
2028
2205
  // whole-line shape message: the author declared a point and got the
@@ -2231,9 +2408,9 @@ function parseOne(text){
2231
2408
  if(!c||c.stroke!==undefined) continue;
2232
2409
  if(c.fill===undefined&&c.style!==undefined) continue;
2233
2410
  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)');
2411
+ 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
2412
  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)');
2413
+ 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
2414
  }
2238
2415
  }
2239
2416
  { // class references must resolve (closed grammar)
@@ -2410,7 +2587,7 @@ function findReservedSemi(s){
2410
2587
  }
2411
2588
  return -1;
2412
2589
  }
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)';
2590
+ 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
2591
 
2415
2592
  function findComment(s){
2416
2593
  // '#' starts a comment only at line start or after whitespace,
@@ -2455,6 +2632,80 @@ function findComment(s){
2455
2632
  // 2. LAYOUT + RENDER (deterministic; no randomness, no Date)
2456
2633
  // ============================================================
2457
2634
  const FONT=13, CH=7.2, PADX=14, NH=36, GAPX=56, GAPY=44;
2635
+
2636
+ // ---- cw(): script-aware advance width --------------------------------
2637
+ // Every px-per-character constant in this file — CH here, and its siblings at
2638
+ // the other font sizes (8.6 title, 6.6 legend, 6.5 edge label, 6.3/6.2 bitfield
2639
+ // caption) — is calibrated on LATIN, where one character advances ~0.554 em.
2640
+ // That is a claim about SCRIPT, not about font: a CJK ideograph advances a full
2641
+ // em, so counting characters undersizes a Chinese label by 45% and the engine
2642
+ // draws the text outside the box it has just sized for it. cw() is the measure
2643
+ // those constants multiply — a string's advance in Latin units. Every codepoint
2644
+ // below U+0300 weighs exactly 1, so an all-ASCII string returns exactly its
2645
+ // .length and every figure that was already right stays byte-identical.
2646
+ //
2647
+ // The classes are Unicode East Asian Width (UAX #11), a fixed character
2648
+ // property: a lookup by codepoint range, deterministic, no font-metrics library
2649
+ // and no canvas measurement at render time. The WEIGHTS are measured, not
2650
+ // chosen — probe strings rendered through librsvg at font-size 13 in the
2651
+ // engine's own stack (font-family="system-ui,sans-serif" → Noto Sans + Noto
2652
+ // Sans CJK), advance recovered as the slope of ink extent over repeat count:
2653
+ // ideograph 封 一 㐀, kana あ ア, bopomofo ㄅ, fullwidth A ,, U+3000 13.000px = 1.000em
2654
+ // emoji U+1F600 U+1F680 (Noto Color Emoji) 16.188px = 1.245em
2655
+ // combining U+0301 standalone, U+200B, U+FEFF 0.000px = 0.000em
2656
+ // Latin — the calibration these constants already carry 7.200px = 0.554em
2657
+ // hence WIDE = 1.000/0.554 = 1.806, rounded UP to 1.81 so a wide glyph is never
2658
+ // UNDER-measured at the node-label site, and EMOJI = 1.245/0.554 = 2.25.
2659
+ // Halfwidth katakana (U+FF61..FF9F) measured 0.500 em: it stays in the default
2660
+ // class, which over-measures it by 11% — the safe direction.
2661
+ //
2662
+ // EAW *Ambiguous* — §, —, –, →, ←, ✓, ≤, •, the only non-ASCII this
2663
+ // repository's figures actually draw — is deliberately NARROW, the
2664
+ // wcwidth/POSIX reading for a non-East-Asian context. Measured, an em dash is
2665
+ // a full em in this font, so a label carrying one is still under-measured by
2666
+ // 0.45 unit; that is a different question from this defect (it needs a
2667
+ // context rule, not a table) and is not decided here.
2668
+ const CW_ZERO=[[0x0300,0x036F],[0x0483,0x0489],[0x0591,0x05BD],[0x0610,0x061A],
2669
+ [0x064B,0x065F],[0x0670,0x0670],[0x06D6,0x06DC],[0x0711,0x0711],[0x0730,0x074A],
2670
+ [0x07A6,0x07B0],[0x0816,0x0819],[0x093C,0x093C],[0x0951,0x0954],[0x0E31,0x0E31],
2671
+ [0x0E34,0x0E3A],[0x0E47,0x0E4E],[0x1AB0,0x1AFF],[0x1DC0,0x1DFF],[0x200B,0x200F],
2672
+ [0x2060,0x2064],[0x20D0,0x20F0],[0xFE00,0xFE0F],[0xFE20,0xFE2F],[0xFEFF,0xFEFF]];
2673
+ const CW_EMOJI=[[0x1F004,0x1F0CF],[0x1F18E,0x1F19A],[0x1F300,0x1F5FF],
2674
+ [0x1F600,0x1F64F],[0x1F680,0x1F6FF],[0x1F7E0,0x1F7EB],[0x1F900,0x1F9FF],
2675
+ [0x1FA70,0x1FAFF]];
2676
+ const CW_WIDE=[[0x1100,0x115F],[0x2E80,0x303E],[0x3041,0x33FF],[0x3400,0x4DBF],
2677
+ [0x4E00,0x9FFF],[0xA000,0xA4CF],[0xA960,0xA97F],[0xAC00,0xD7A3],[0xF900,0xFAFF],
2678
+ [0xFE10,0xFE19],[0xFE30,0xFE6F],[0xFF01,0xFF60],[0xFFE0,0xFFE6],
2679
+ [0x17000,0x18AFF],[0x1B000,0x1B2FF],[0x20000,0x3FFFD]];
2680
+ // ranges are sorted and disjoint, so the scan stops at the first range that
2681
+ // starts above the codepoint
2682
+ function cwIn(cp,rs){
2683
+ for(let i=0;i<rs.length;i++){ if(cp<rs[i][0]) return false; if(cp<=rs[i][1]) return true; }
2684
+ return false;
2685
+ }
2686
+ function cwOf(cp){
2687
+ if(cp<0x0300) return 1; // ASCII and Latin-1: the calibrated case
2688
+ if(cwIn(cp,CW_ZERO)) return 0;
2689
+ if(cwIn(cp,CW_EMOJI)) return 2.25;
2690
+ if(cwIn(cp,CW_WIDE)) return 1.81;
2691
+ return 1;
2692
+ }
2693
+ // Iterate CODEPOINTS, not UTF-16 units: an astral character is one advance,
2694
+ // not two. A ZWJ emoji sequence still counts each codepoint, so it is
2695
+ // over-measured — the safe direction, and the only way to avoid it is a
2696
+ // grapheme segmenter this engine will not carry.
2697
+ function cw(s){
2698
+ s=String(s); let w=0;
2699
+ for(let i=0;i<s.length;i++){
2700
+ const cp=s.codePointAt(i);
2701
+ if(cp>0xFFFF) i++;
2702
+ w+=cwOf(cp);
2703
+ }
2704
+ return w;
2705
+ }
2706
+ // the widest line of a multi-line string, in Latin units
2707
+ function cwMax(s){ return Math.max(...String(s).split('\n').map(cw)); }
2708
+
2458
2709
  // §5 style= → SVG dash pattern. `def` is the construct's conventional
2459
2710
  // default (the bundle ring and the threshold line are dashed by convention);
2460
2711
  // an explicit style= always wins.
@@ -2464,7 +2715,7 @@ function dashOf(style,def){
2464
2715
  }
2465
2716
  function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
2466
2717
  function tw(s){
2467
- const longest=Math.max(...String(s).split('\n').map(l=>l.length));
2718
+ const longest=cwMax(s);
2468
2719
  return Math.max(30,longest*CH+2*PADX);
2469
2720
  }
2470
2721
  // ---- shape geometry (one source of truth for size and for clipping) ----
@@ -2583,7 +2834,7 @@ function inscribedHalfW(g,v){
2583
2834
  const LBLPADX=6, LBLPADY=4;
2584
2835
  function labelBox(label){
2585
2836
  const ls=String(label).split('\n');
2586
- return [Math.max(...ls.map(l=>l.length))*CH+2*LBLPADX,
2837
+ return [Math.max(...ls.map(cw))*CH+2*LBLPADX,
2587
2838
  ((ls.length-1)*1.3+1.2)*FONT+2*LBLPADY];
2588
2839
  }
2589
2840
  // fitOutline: grow the box by the smallest factor that pulls an iw x ih box,
@@ -2675,7 +2926,7 @@ function render(doc,ropts){
2675
2926
  }
2676
2927
  const parts=[]; let y=0, maxW=0;
2677
2928
  if(doc.title && RO.title===true){ parts.push('<text x="0" y="16" font-size="15" font-weight="600">'+esc(doc.title)+'</text>'); y=30;
2678
- maxW=Math.max(maxW, doc.title.length*8.6); } // canvas must fit the title
2929
+ maxW=Math.max(maxW, cw(doc.title)*8.6); } // canvas must fit the title
2679
2930
  let sceneMeta=null;
2680
2931
  if(doc.nodes.length||doc.edges.length||(doc.boundaries||[]).length){
2681
2932
  const s=renderScene(doc,y); parts.push(s.svg); y=s.y; maxW=Math.max(maxW,s.w);
@@ -2713,7 +2964,7 @@ function render(doc,ropts){
2713
2964
  // `style`) is drawn, so "declared but not shown" is now unreachable.
2714
2965
  for(const c of legendCls){
2715
2966
  const paints=c.fill!==undefined||c.stroke!==undefined||c.style!==undefined;
2716
- const tw=String(c.label).length*6.6+(paints?30:9);
2967
+ const tw=cw(c.label)*6.6+(paints?30:9);
2717
2968
  if(lx>0 && lx+tw>wrapW){ lx=0; ly+=rowH; }
2718
2969
  if(paints){
2719
2970
  const dash=c.style==='dashed'?' stroke-dasharray="6 4"':(c.style==='dotted'?' stroke-dasharray="2 4"':'');
@@ -2826,7 +3077,7 @@ function renderScene(doc,y0){
2826
3077
  // ALL nodes participate in auto layout (pinned ones hold their slot so
2827
3078
  // that pinning X never reflows Y); pins override coordinates afterwards.
2828
3079
  const horiz=(doc.flow==='right'||doc.flow==='left');
2829
- const lblPx=s=>Math.max(...String(s).split('\n').map(l=>l.length))*6.5;
3080
+ const lblPx=s=>cwMax(s)*6.5;
2830
3081
  const lay=[...nodes]; // layout participants
2831
3082
  const chains=new Map(); // edge -> [A, ...waypoints, B]
2832
3083
  for(const e of doc.edges){
@@ -3510,7 +3761,7 @@ function renderScene(doc,y0){
3510
3761
  const CLAMP=t=>Math.max(0.06,Math.min(0.94,t));
3511
3762
  const cand=(r,t,side)=>{
3512
3763
  const lines=String(r.text).split('\n'), n=lines.length;
3513
- const w=Math.max(...lines.map(l=>l.length))*6.5*r.fs/11;
3764
+ const w=Math.max(...lines.map(cw))*6.5*r.fs/11;
3514
3765
  const lh=r.fs*1.3, h=(n-1)*lh+r.fs*1.1;
3515
3766
  const up=(n-1)*lh/2+r.fs*0.85; // baseline y = box top + up
3516
3767
  const mx=r.p[0]+(r.q[0]-r.p[0])*t, my=r.p[1]+(r.q[1]-r.p[1])*t;
@@ -3616,7 +3867,7 @@ function renderScene(doc,y0){
3616
3867
  // label with shrink-to-fit when size is rigid (`UNDECLARED-ATTRIBUTE-BEHAVIOUR`); multi-line via "\n"
3617
3868
  let fs=FONT;
3618
3869
  const nl=String(n.label).split('\n');
3619
- const need=Math.max(...nl.map(l=>l.length))*CH;
3870
+ const need=Math.max(...nl.map(cw))*CH;
3620
3871
  // budget = the width the OUTLINE offers at the label's own height, minus
3621
3872
  // 8 px clearance each side. For a box that is n.w-16, exactly as before;
3622
3873
  // for a rhombus or an ellipse it is the inscribed width, so a rigid
@@ -4160,7 +4411,7 @@ function renderBitfield(b,y0){
4160
4411
  const cap=bi===0?String(f.name)+suffix:CONT;
4161
4412
  let lead=bx[0]; for(const s of bx) if(s.w>lead.w) lead=s;
4162
4413
  const ly=flat ? bx[0].y+bx.length*rh/2 : lead.y+rh/2;
4163
- let fs=11; const need=cap.length*6.2;
4414
+ let fs=11; const need=cw(cap)*6.2;
4164
4415
  // The font shrinks to fit the segment that carries it, floor 7px — the
4165
4416
  // rule a too-narrow one-row field has always had. Below the floor the
4166
4417
  // caption overflows its box rather than vanishing: a name half outside
@@ -4254,7 +4505,7 @@ function renderBitfield(b,y0){
4254
4505
  // job.
4255
4506
  svg.push('<line x1="'+e.x+'" y1="'+e.y+'" x2="'+e.x+'" y2="'+(e.y+EL_H)+'" stroke="'+st+'" stroke-dasharray="2 3"/>');
4256
4507
  svg.push('<line x1="'+(e.x+e.w)+'" y1="'+e.y+'" x2="'+(e.x+e.w)+'" y2="'+(e.y+EL_H)+'" stroke="'+st+'" stroke-dasharray="2 3"/>');
4257
- let fs=10.5; const need=e.text.length*6.2;
4508
+ let fs=10.5; const need=cw(e.text)*6.2;
4258
4509
  if(need>e.w-6) fs=Math.max(7,10.5*(e.w-6)/need);
4259
4510
  svg.push('<text x="'+(e.x+e.w/2)+'" y="'+(e.y+EL_H/2+fs*0.35)+'" font-size="'+fs+'" text-anchor="middle" fill="#6f6e69">'+esc(e.text)+'</text>');
4260
4511
  }
@@ -4274,7 +4525,7 @@ function renderBitfield(b,y0){
4274
4525
  for(const f of capt){
4275
4526
  const s=f.name+' — present: '+f.present;
4276
4527
  svg.push('<text x="0" y="'+(yb+9)+'" font-size="10.5" fill="#6f6e69">'+esc(s)+'</text>');
4277
- wb=Math.max(wb, s.length*6.3+2); yb+=14;
4528
+ wb=Math.max(wb, cw(s)*6.3+2); yb+=14;
4278
4529
  }
4279
4530
  yb+=2;
4280
4531
  }
@@ -4512,7 +4763,7 @@ edge l3 -> mmu
4512
4763
  edge acl -> mmu`,
4513
4764
 
4514
4765
  'flowchart roles':
4515
- `figdown 0.1 flowchart
4766
+ `figdown 0.2 flowchart
4516
4767
  title "Packet ingress (roles)"
4517
4768
  terminator rx "Frame received"
4518
4769
  process l2 "Read the L2 header"
@@ -4523,15 +4774,15 @@ process vlan "Pop one VLAN tag"
4523
4774
  terminator drop "Drop"
4524
4775
  terminator fwd "Forward"
4525
4776
  rank ipv4,arp,drop,vlan
4526
- edge rx -> l2
4527
- edge l2 -> et
4528
- edge et -[0x0800 IPv4]-> ipv4
4529
- edge et -[0x0806 ARP]-> arp
4530
- edge et -[other]-> drop
4531
- edge et -[0x8100 VLAN]-> vlan
4532
- edge vlan -[re-read]-> et
4533
- edge ipv4 -> fwd
4534
- edge arp -> fwd`,
4777
+ flowline rx -> l2
4778
+ flowline l2 -> et
4779
+ flowline et -[0x0800 IPv4]-> ipv4
4780
+ flowline et -[0x0806 ARP]-> arp
4781
+ flowline et -[other]-> drop
4782
+ flowline et -[0x8100 VLAN]-> vlan
4783
+ flowline vlan -[re-read]-> et
4784
+ flowline ipv4 -> fwd
4785
+ flowline arp -> fwd`,
4535
4786
 
4536
4787
  'bitfield (census #1)':
4537
4788
  `figdown 0.1 bitfield
@@ -4876,7 +5127,11 @@ let lastSVG='', lastMeta=null, lastPad={x:0,y:0}, lastDoc=null;
4876
5127
  // be treated as "last scene line" (P0: drag then +Node was writing
4877
5128
  // into the layout zone → "node is a semantic directive" white screen).
4878
5129
  const CONTENT_KW=/^(figdown|title|node|group|external|edge|plane|flow|rank|threshold|band|bundle|class)\b/;
4879
- const ID_DECL_KW=/^(node|group|external|bundle|class|plane|bitfield|table|timing)\s+(\S+)/;
5130
+ // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: `state` is a node-declaring keyword under
5131
+ // `statechart`, so an id it declares must count as USED here or the GUI
5132
+ // hands out a colliding id. The list is spellings, not per-genre legality —
5133
+ // the parser owns legality.
5134
+ const ID_DECL_KW=/^(node|state|group|external|bundle|class|plane|bitfield|table|timing)\s+(\S+)/;
4880
5135
  function lastContentLineIdx(lines){
4881
5136
  let last=-1;
4882
5137
  const layoutIdx=lines.findIndex(l=>l.trim()==='layout');
@@ -4916,11 +5171,37 @@ function nextFreeId(lines, prefix){
4916
5171
  // (pins only, no flow) so the strip test left orphan geometry. Genre default flow
4917
5172
  // is content-zone meaning for reading order — write it when the first scene
4918
5173
  // element appears via GUI, not only when the author types it.
4919
- function ensureFlowLine(lines){
5174
+ // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: every GUI action is a TEXT EDIT, so it has to
5175
+ // write the word the document's own genre uses. These read the header the
5176
+ // same way ensureFlowDirective does; the engine tables above are the authority.
5177
+ function guiGenre(lines){
5178
+ for(const l of lines){
5179
+ const m=/^figdown\s+\d+\.\d+\s+(\S+)/.exec(l.trim());
5180
+ if(m) return m[1];
5181
+ }
5182
+ return 'block';
5183
+ }
5184
+ const guiNodeKw=lines=>GENRE_NODE_KW[guiGenre(lines)]||'node';
5185
+ function guiVersion(lines){
5186
+ for(const l of lines){
5187
+ const m=/^figdown\s+(\d+\.\d+)\b/.exec(l.trim());
5188
+ if(m) return m[1];
5189
+ }
5190
+ return '0.1';
5191
+ }
5192
+ // `KEYWORD-RENAME-SCOPE`: the GUI writes the word legal AT THE DECLARED VERSION — inserting
5193
+ // `flowline` into a `figdown 0.1 flowchart` document would make the editor
5194
+ // author a line error.
5195
+ const guiConnKw=lines=>connectorKwAt(guiGenre(lines), guiVersion(lines))||'edge';
5196
+ const NODE_LINE_RE=new RegExp('^\\s*(?:'+[...NODE_SPELLINGS].join('|')+')\\s+');
5197
+ function ensureFlowDirective(lines){
4920
5198
  if(lines.some(l=>/^flow\b/.test(l.trim()))) return lines;
4921
5199
  let genre='block';
4922
5200
  for(const l of lines){
4923
- const m=/^figdown\s+0\.1\s+(\S+)/.exec(l.trim());
5201
+ // `STATECHART-GENRE-SCOPE`: any declared language version, not `0.1` alone — a `figdown 0.2`
5202
+ // document read by a `0\.1`-only regex fell through to the `block`
5203
+ // default and got the wrong axis written into it.
5204
+ const m=/^figdown\s+\d+\.\d+\s+(\S+)/.exec(l.trim());
4924
5205
  if(m){ genre=m[1]; break; }
4925
5206
  }
4926
5207
  // pure typed genres have no scene layout axis
@@ -5020,7 +5301,7 @@ function upsertPin(lines, id, patch){
5020
5301
  }
5021
5302
  function applyPins(pins){ // pins: array of [id, px, py] — one text update, one refresh
5022
5303
  let lines=$('src').value.split('\n');
5023
- lines=ensureFlowLine(lines);
5304
+ lines=ensureFlowDirective(lines);
5024
5305
  for(const [id,px,py] of pins){
5025
5306
  lines=upsertPin(lines, id, {at:'('+px+','+py+')'});
5026
5307
  }
@@ -5268,11 +5549,12 @@ function installDirectEdit(){
5268
5549
  if(linkArm===undefined){ linkArm=id; $('status').textContent='Link mode: now click the target node'; return; }
5269
5550
  if(linkArm!==id){
5270
5551
  let lines=$('src').value.split('\n');
5271
- lines=ensureFlowLine(lines);
5552
+ lines=ensureFlowDirective(lines);
5272
5553
  const last=lastContentLineIdx(lines);
5273
- lines.splice(last+1,0,'edge '+linkArm+' -> '+id);
5554
+ const ck=guiConnKw(lines);
5555
+ lines.splice(last+1,0,ck+' '+linkArm+' -> '+id);
5274
5556
  $('src').value=lines.join('\n'); refresh();
5275
- $('status').textContent='link → wrote "edge '+linkArm+' -> '+id+'" (GUI action = text edit)';
5557
+ $('status').textContent='link → wrote "'+ck+' '+linkArm+' -> '+id+'" (GUI action = text edit)';
5276
5558
  }
5277
5559
  linkArm=null; $('linkmode').style.fontWeight='';
5278
5560
  return;
@@ -5298,7 +5580,7 @@ function installDirectEdit(){
5298
5580
  });
5299
5581
  }
5300
5582
  let selectedId=null, linkArm=null;
5301
- function nodeLineIdx(lines,id){ return lines.findIndex(l=>new RegExp('^\\s*node\\s+'+id+'\\b').test(l)); }
5583
+ function nodeLineIdx(lines,id){ return lines.findIndex(l=>new RegExp('^\\s*(?:node|state)\\s+'+id+'\\b').test(l)); }
5302
5584
  function setNodeOption(id,key,val){
5303
5585
  // Rewrite/append the option on the CODE side of any trailing comment (P0).
5304
5586
  const lines=$('src').value.split('\n');
@@ -5324,7 +5606,7 @@ function deleteNode(id){
5324
5606
  const lines=$('src').value.split('\n').map((l,li)=>{
5325
5607
  if(drop.has(li+1)) return null;
5326
5608
  const t=l.trim();
5327
- if(new RegExp('^(node|pin)\\s+'+id+'\\b').test(t)) return null;
5609
+ if(new RegExp('^(node|state|pin)\\s+'+id+'\\b').test(t)) return null;
5328
5610
  // 0.1: `rank` is ONE comma-delimited token. This rewrite used to
5329
5611
  // both READ and WRITE the retired space form, so deleting a node from a
5330
5612
  // ranked scene produced a document the engine now refuses.
@@ -5355,7 +5637,7 @@ function deleteNode(id){
5355
5637
  function moveNodeLine(id,dir){ // dir +1 = later line = painted on top
5356
5638
  const lines=$('src').value.split('\n');
5357
5639
  const idx=nodeLineIdx(lines,id); if(idx<0) return;
5358
- const isNode=l=>/^\s*node\s+/.test(l);
5640
+ const isNode=l=>NODE_LINE_RE.test(l);
5359
5641
  let j=idx+dir;
5360
5642
  while(j>=0&&j<lines.length&&!isNode(lines[j])) j+=dir;
5361
5643
  if(j<0||j>=lines.length) { $('status').textContent='already at the '+(dir>0?'top':'bottom')+' of paint order'; return; }
@@ -5558,18 +5840,18 @@ $('raise').onclick=()=>{ if(selectedId) moveNodeLine(selectedId,+1); };
5558
5840
  $('lower').onclick=()=>{ if(selectedId) moveNodeLine(selectedId,-1); };
5559
5841
  function insertNodeLine(){
5560
5842
  let lines=$('src').value.split('\n');
5561
- lines=ensureFlowLine(lines);
5843
+ lines=ensureFlowDirective(lines);
5562
5844
  const nid=nextFreeId(lines,'n');
5563
5845
  const nnum=nid.slice(1);
5564
5846
  const last=lastContentLineIdx(lines);
5565
5847
  const kk=$('newkind').value;
5566
- lines.splice(last+1,0,'node '+nid+' "Node '+nnum+'"'+(kk!=='box'?' shape='+kk:''));
5848
+ lines.splice(last+1,0,guiNodeKw(lines)+' '+nid+' "Node '+nnum+'"'+(kk!=='box'?' shape='+kk:''));
5567
5849
  $('src').value=lines.join('\n'); refresh();
5568
5850
  return nid;
5569
5851
  }
5570
5852
  $('addnode').onclick=()=>{
5571
5853
  const id=insertNodeLine(); select(id);
5572
- $('status').textContent='+ Node → wrote "node '+id+'" line — drag it into place';
5854
+ $('status').textContent='+ Node → wrote "'+guiNodeKw($('src').value.split('\n'))+' '+id+'" line — drag it into place';
5573
5855
  };
5574
5856
  function addNodeAt(x,y){
5575
5857
  const id=insertNodeLine();