figdown 0.1.0-rc.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/figdown.js CHANGED
@@ -1,4 +1,4 @@
1
- // figdown.js — FigDown embeddable library (0.1-dev.15)
1
+ // figdown.js — FigDown embeddable library (0.1.0)
2
2
  // GENERATED FILE, DO NOT EDIT. Built from editor/figdown.html.
3
3
  // Regenerate with: node tools/make-lib.js
4
4
  (function (root, factory) {
@@ -10,144 +10,897 @@
10
10
  }
11
11
  }(typeof globalThis !== 'undefined' ? globalThis : this, function () {
12
12
  'use strict';
13
- var VERSION = "0.1-dev.15";
13
+ var VERSION = "0.1.0";
14
14
 
15
15
  // ---- engine (extracted verbatim from editor/figdown.html) ----
16
16
  var __engine = (function () {
17
- const SHAPES = ['box','rounded','circle','ellipse','cloud','diamond','cylinder'];
17
+ const SHAPES = ['box','rounded','circle','ellipse','diamond','cylinder'];
18
+ // The engine's own version string, and the SINGLE source of it: it sits
19
+ // INSIDE the extracted engine region, so tools/make-lib.js (dist/) and
20
+ // tools/build-svg.js (the CLI) both read this line rather than each keeping
21
+ // a copy — one more four-copy drift point removed. Every artifact records it
22
+ // (core §7, `data-engine-version=`): `RENDERING-DETERMINISM` promises byte-identical output for
23
+ // the same source AND THE SAME RENDERER VERSION, so the version is half the
24
+ // input to that promise, and under core §13 a 0.x renderer may differ from
25
+ // the next — which makes the recorded version the only thing that can
26
+ // explain a diff between two renderings of one source.
27
+ const FIGDOWN_VERSION = '0.1.0';
28
+ // Retired shape VALUES keep a named diagnostic (PROCESS §5(d)), the same way
29
+ // retired option keys do: `cloud` was the one value that named a domain
30
+ // (the internet cloud) in an enum the language keeps purely geometric
31
+ // (`SHAPE-ENUM-VOCABULARY`/`EXTERNAL-EDGE-ENDPOINTS`), so it was removed rather than demoted.
32
+ const RETIRED_SHAPES = {
33
+ 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)'
34
+ };
18
35
  // Colors are CSS hex (#rgb / #rrggbb) or CSS named colors (spec §1) — the
19
36
  // 147 CSS/SVG color keywords (lowercase) plus `transparent`. Anything else
20
- // is a line error (closed grammar, 0.1-dev.11).
21
- const CSS_COLORS=new Set(('aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond '+
22
- 'blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan '+
23
- 'darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange '+
24
- 'darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet '+
25
- 'deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite '+
26
- 'gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush '+
27
- 'lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey '+
28
- 'lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow '+
29
- 'lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen '+
30
- 'mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin '+
31
- 'navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise '+
32
- 'palevioletred papayawhip peachpuff peru pink plum powderblue purple red rosybrown royalblue saddlebrown '+
33
- 'salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen '+
34
- 'steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen transparent')
35
- .split(' '));
37
+ // is a line error (closed grammar).
38
+ // 0.1: the same 147 keywords, now as name -> sRGB. The VALUES are new
39
+ // and load-bearing: `color=` is retired (`COLOUR-KEY-STATUS`) and the default label colour is
40
+ // DERIVED from the resolved fill's WCAG relative luminance (`LABEL-COLOUR-SOURCE`), so a fill
41
+ // written as a CSS name has to reach the same arithmetic a `#rrggbb` fill does.
42
+ // Values are CSS Color Module Level 4 §6.1 "Named Colors".
43
+ const CSS_COLOR_HEX=(function(){
44
+ const m={};
45
+ ('aliceblue:f0f8ff antiquewhite:faebd7 aqua:00ffff aquamarine:7fffd4 azure:f0ffff beige:f5f5dc '+
46
+ 'bisque:ffe4c4 black:000000 blanchedalmond:ffebcd blue:0000ff blueviolet:8a2be2 brown:a52a2a '+
47
+ 'burlywood:deb887 cadetblue:5f9ea0 chartreuse:7fff00 chocolate:d2691e coral:ff7f50 cornflowerblue:6495ed '+
48
+ 'cornsilk:fff8dc crimson:dc143c cyan:00ffff darkblue:00008b darkcyan:008b8b darkgoldenrod:b8860b '+
49
+ 'darkgray:a9a9a9 darkgreen:006400 darkgrey:a9a9a9 darkkhaki:bdb76b darkmagenta:8b008b '+
50
+ 'darkolivegreen:556b2f darkorange:ff8c00 darkorchid:9932cc darkred:8b0000 darksalmon:e9967a '+
51
+ 'darkseagreen:8fbc8f darkslateblue:483d8b darkslategray:2f4f4f darkslategrey:2f4f4f darkturquoise:00ced1 '+
52
+ 'darkviolet:9400d3 deeppink:ff1493 deepskyblue:00bfff dimgray:696969 dimgrey:696969 dodgerblue:1e90ff '+
53
+ 'firebrick:b22222 floralwhite:fffaf0 forestgreen:228b22 fuchsia:ff00ff gainsboro:dcdcdc ghostwhite:f8f8ff '+
54
+ 'gold:ffd700 goldenrod:daa520 gray:808080 green:008000 greenyellow:adff2f grey:808080 honeydew:f0fff0 '+
55
+ 'hotpink:ff69b4 indianred:cd5c5c indigo:4b0082 ivory:fffff0 khaki:f0e68c lavender:e6e6fa '+
56
+ 'lavenderblush:fff0f5 lawngreen:7cfc00 lemonchiffon:fffacd lightblue:add8e6 lightcoral:f08080 '+
57
+ 'lightcyan:e0ffff lightgoldenrodyellow:fafad2 lightgray:d3d3d3 lightgreen:90ee90 lightgrey:d3d3d3 '+
58
+ 'lightpink:ffb6c1 lightsalmon:ffa07a lightseagreen:20b2aa lightskyblue:87cefa lightslategray:778899 '+
59
+ 'lightslategrey:778899 lightsteelblue:b0c4de lightyellow:ffffe0 lime:00ff00 limegreen:32cd32 linen:faf0e6 '+
60
+ 'magenta:ff00ff maroon:800000 mediumaquamarine:66cdaa mediumblue:0000cd mediumorchid:ba55d3 '+
61
+ 'mediumpurple:9370db mediumseagreen:3cb371 mediumslateblue:7b68ee mediumspringgreen:00fa9a '+
62
+ 'mediumturquoise:48d1cc mediumvioletred:c71585 midnightblue:191970 mintcream:f5fffa mistyrose:ffe4e1 '+
63
+ 'moccasin:ffe4b5 navajowhite:ffdead navy:000080 oldlace:fdf5e6 olive:808000 olivedrab:6b8e23 '+
64
+ 'orange:ffa500 orangered:ff4500 orchid:da70d6 palegoldenrod:eee8aa palegreen:98fb98 paleturquoise:afeeee '+
65
+ 'palevioletred:db7093 papayawhip:ffefd5 peachpuff:ffdab9 peru:cd853f pink:ffc0cb plum:dda0dd '+
66
+ 'powderblue:b0e0e6 purple:800080 red:ff0000 rosybrown:bc8f8f royalblue:4169e1 saddlebrown:8b4513 '+
67
+ 'salmon:fa8072 sandybrown:f4a460 seagreen:2e8b57 seashell:fff5ee sienna:a0522d silver:c0c0c0 '+
68
+ 'skyblue:87ceeb slateblue:6a5acd slategray:708090 slategrey:708090 snow:fffafa springgreen:00ff7f '+
69
+ 'steelblue:4682b4 tan:d2b48c teal:008080 thistle:d8bfd8 tomato:ff6347 turquoise:40e0d0 violet:ee82ee '+
70
+ 'wheat:f5deb3 white:ffffff whitesmoke:f5f5f5 yellow:ffff00 yellowgreen:9acd32')
71
+ .split(' ').forEach(p=>{const i=p.indexOf(':'); m[p.slice(0,i)]='#'+p.slice(i+1);});
72
+ return m;
73
+ })();
74
+ const CSS_COLORS=new Set(Object.keys(CSS_COLOR_HEX).concat(['transparent']));
36
75
  const isColor=v=>/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v)||CSS_COLORS.has(v);
37
76
 
77
+ // ---- derived label colour (§5, `LABEL-COLOUR-SOURCE`) -------------------------------------
78
+ // v0.1 has NO author-facing label-colour key: `color=` is retired language-wide
79
+ // (`COLOUR-KEY-STATUS`) and nothing replaced it. The DEFAULT is derived from the background
80
+ // the label is drawn on, so it cannot assert a falsehood (`UNSAFE-DEFAULT-ELIMINATION`).
81
+ //
82
+ // The arithmetic is normative and is WCAG 2.1's, unaltered:
83
+ // 1. relative luminance (WCAG 2.1 "relative luminance"):
84
+ // c = channel / 255
85
+ // clin = c/12.92 when c <= 0.03928
86
+ // = ((c + 0.055)/1.055) ** 2.4 otherwise
87
+ // L = 0.2126*Rlin + 0.7152*Glin + 0.0722*Blin
88
+ // 2. contrast ratio (WCAG 2.1 "contrast ratio"): (L1 + 0.05)/(L2 + 0.05).
89
+ // The threshold is not a taste value: it is the L at which white and black
90
+ // contrast EQUALLY against the background, i.e. the solution of
91
+ // 1.05/(L + 0.05) = (L + 0.05)/0.05 => L = sqrt(0.0525) - 0.05
92
+ // = 0.179128784747792
93
+ // Above it the dark ink wins, at or below it the light ink wins. Choosing any
94
+ // other number would knowingly pick the LESS readable of the two.
95
+ const CONTRAST_L = Math.sqrt(0.0525) - 0.05;
96
+ function relLuminance(hex){
97
+ const h=hex.length===4
98
+ ? '#'+hex[1]+hex[1]+hex[2]+hex[2]+hex[3]+hex[3]
99
+ : hex;
100
+ const ch=i=>{
101
+ const c=parseInt(h.slice(1+2*i,3+2*i),16)/255;
102
+ return c<=0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055,2.4);
103
+ };
104
+ return 0.2126*ch(0)+0.7152*ch(1)+0.0722*ch(2);
105
+ }
106
+ // `bg` is the RESOLVED fill the label sits on (never undefined at a call site:
107
+ // every caller passes the same value it painted). `transparent` and an unknown
108
+ // token mean "no fill of its own" — the label is then on the canvas, which is
109
+ // the light case. `dark` is the renderer's ink for that context; the light ink
110
+ // is white everywhere.
111
+ const INK_LIGHT='#ffffff';
112
+ function labelInk(bg, dark){
113
+ if(bg===undefined||bg===null||bg==='transparent') return dark;
114
+ const hex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(bg) ? bg : CSS_COLOR_HEX[bg];
115
+ if(!hex) return dark;
116
+ return relLuminance(hex) > CONTRAST_L ? dark : INK_LIGHT;
117
+ }
118
+ // Inside the typed blocks the dark ink is "the document's own text colour",
119
+ // which is spelled by writing NO fill attribute at all. There the derivation
120
+ // only ever ADDS the light ink, so a figure whose fills are light is byte-for-
121
+ // byte what it was before this release.
122
+ const inkAttr=(bg)=>labelInk(bg,null)===INK_LIGHT?' fill="'+INK_LIGHT+'"':'';
123
+
124
+ // 0.1 (`EDGE-GEOMETRY-CONSTRUCTS`): the waypoint-list and dock parsers were deleted with the
125
+ // `path` directive they served. `points=(x,y),…` was the only value form in
126
+ // the language that needed a point-LIST scanner, and `tailport=`/`headport=`
127
+ // the only one that needed a compass-or-fraction dock. `pin at=` keeps its own
128
+ // single-point grammar inline where it is used, so nothing else in the
129
+ // language lost a parser. See spec/migrations.md 0.1 and core §9
130
+ // `EDGE-IDENTITY-AND-GEOMETRY` for why the constructs went and what a replacement would need.
131
+
132
+ // `LINK-OPERATOR-IN-IDS`: ONE scanner for both the "whole token is a string"
133
+ // case and the inline `key="value with spaces"` case. A token is a maximal
134
+ // run of non-whitespace in which quoted REGIONS may appear anywhere; a
135
+ // quoted region may itself contain whitespace. Two consequences the old
136
+ // two-branch scanner got wrong:
137
+ // - `rank "a",b` used to lex as TWO tokens (`a`, `,b`) because the
138
+ // leading-quote branch stopped at the closing quote, which is why the
139
+ // line reported "do not mix the comma form with the space form" when
140
+ // nothing was mixed;
141
+ // - `field "Long Name":16` used to need a re-gluing hack downstream.
142
+ // QUOTEDNESS IS NO LONGER DISCARDED (`OPTION-POSITION-PARSING`'s principle, extended): every
143
+ // token carries `m`, a mark string index-aligned with `v` ('q' = this
144
+ // character came from inside quotes, '.' = it did not), and `qz`, the
145
+ // offsets in `v` at which a quoted region began (so an EMPTY quoted
146
+ // region — `present=""` — is still observable). Everything that has to know
147
+ // whether a value was quoted (id positions, string-typed option values,
148
+ // comma splitting) reads those two, never the raw line.
38
149
  function tokenize(line){
39
- // split by whitespace, honoring double quotes
40
150
  const toks=[]; let i=0;
41
151
  while(i<line.length){
42
152
  while(i<line.length && /\s/.test(line[i])) i++;
43
153
  if(i>=line.length) break;
44
- if(line[i]==='"'){
45
- let j=i+1, s='';
46
- while(j<line.length && line[j]!=='"'){
47
- if(line[j]==='\\'){
48
- const e=line[j+1];
49
- if(e==='n'){ s+='\n'; j+=2; continue; }
50
- if(e==='"'){ s+='"'; j+=2; continue; }
51
- if(e==='\\'){ s+='\\'; j+=2; continue; }
52
- return {error:'unknown escape "\\'+(e||'')+'" (allowed: \\n \\" \\\\)'};
53
- }
54
- s+=line[j]; j++;
55
- }
56
- if(j>=line.length) return {error:'unterminated string'};
57
- toks.push({v:s,q:true}); i=j+1;
58
- } else {
59
- let j=i, s='';
60
- while(j<line.length && !/\s/.test(line[j])){
61
- if(line[j]==='"'){ // key="value with spaces"
62
- j++;
63
- while(j<line.length && line[j]!=='"'){
64
- if(line[j]==='\\'){
65
- const e=line[j+1];
66
- if(e==='n'){ s+='\n'; j+=2; continue; }
67
- if(e==='"'){ s+='"'; j+=2; continue; }
68
- if(e==='\\'){ s+='\\'; j+=2; continue; }
69
- return {error:'unknown escape "\\'+(e||'')+'" (allowed: \\n \\" \\\\)'};
70
- }
71
- s+=line[j]; j++;
154
+ let s='', m='', qz=[];
155
+ while(i<line.length && !/\s/.test(line[i])){
156
+ if(line[i]==='"'){
157
+ qz.push(s.length); i++;
158
+ while(i<line.length && line[i]!=='"'){
159
+ if(line[i]==='\\'){
160
+ const e=line[i+1];
161
+ if(e==='n'){ s+='\n'; m+='q'; i+=2; continue; }
162
+ if(e==='"'){ s+='"'; m+='q'; i+=2; continue; }
163
+ if(e==='\\'){ s+='\\'; m+='q'; i+=2; continue; }
164
+ return {error:'unknown escape "\\'+(e||'')+'" (allowed: \\n \\" \\\\)'};
72
165
  }
73
- if(j>=line.length) return {error:'unterminated string'};
74
- j++; continue;
166
+ s+=line[i]; m+='q'; i++;
75
167
  }
76
- s+=line[j]; j++;
168
+ if(i>=line.length) return {error:'unterminated string'};
169
+ i++; continue;
77
170
  }
78
- toks.push({v:s,q:false}); i=j;
171
+ s+=line[i]; m+='.'; i++;
79
172
  }
173
+ // `q` keeps its old meaning: the token as a whole is a string literal.
174
+ toks.push({v:s,m,qz,q:qz.length>0 && !m.includes('.')});
80
175
  }
81
176
  return {toks};
82
177
  }
83
- // Option keys are a CLOSED set (two levels, 0.1-dev.11):
178
+ // Quotedness of the slice [a,b) of a token. An empty slice is "quoted" iff
179
+ // a quoted region opened exactly there (`present=""`, `data="",x`).
180
+ function sliceQ(t,a,b){
181
+ if(b>a) return !t.m.slice(a,b).includes('.');
182
+ return t.qz.some(z=>z>=a&&z<=b);
183
+ }
184
+ // Did the slice [a,b) contain ANY quoting? Id positions reject on this, not
185
+ // on sliceQ: `node "a"b` is as much a quoted id as `node "a"` is.
186
+ function sliceHasQ(t,a,b){
187
+ if(t.m.slice(a,b).includes('q')) return true;
188
+ return t.qz.some(z=>z>=a&&z<=b);
189
+ }
190
+ // Split the slice [off,end) of a token into list elements on UNQUOTED
191
+ // commas, so a quoted element protects its own comma (`LINK-OPERATOR-IN-IDS`). Returns
192
+ // {v, q, h} per element: value, fully-quoted, contains-any-quote.
193
+ function splitList(t,off){
194
+ const v=t.v, out=[]; let a=off==null?0:off;
195
+ const start=a;
196
+ for(let i=start;i<=v.length;i++){
197
+ if(i===v.length || (v[i]===','&&t.m[i]==='.')){
198
+ out.push({v:v.slice(a,i), q:sliceQ(t,a,i), h:sliceHasQ(t,a,i)});
199
+ a=i+1;
200
+ }
201
+ }
202
+ return out;
203
+ }
204
+ // Option keys are a CLOSED set (two levels):
84
205
  // - a key listed in OPT_KEYS is parsed as an option; whether it is
85
206
  // *applicable* is checked per directive against DIRECTIVE_OPTS
86
- // (a registered-but-inapplicable key is a line error, D3);
207
+ // (a registered-but-inapplicable key is a line error, `INAPPLICABLE-OPTION-KEYS`);
87
208
  // - a key=value token with an unregistered key is an "unknown option"
88
- // line error (D12) — except inside wave `signal` lanes, where bare
209
+ // line error (`UNKNOWN-OPTION-DEGRADATION`) — except inside timing `signal` lanes, where bare
89
210
  // tokens may contain '=' and stay positional (laneMode).
90
- const OPT_KEYS=new Set(['kind','shape','color','stroke','text','in','layer','label',
91
- 'style','z','at','w','h','unit','note','labels','numbering','fill','from','to','gap','dir','level',
92
- 'taillabel','headlabel','class','via','routing']);
211
+ // `fill` was registered here until this release solely to power a retired
212
+ // migration diagnostic on the old `line` directive; it left the registry with
213
+ // the `fill` → `band` KEYWORD rename, and 0.1 gave the word back to the
214
+ // option-key namespace as the primary presentation key (`color=` → `fill=`).
215
+ // `color` stays registered as a RETIRED key so the rename gets a named
216
+ // diagnostic instead of the generic `unknown option "color="`.
217
+ // `w`/`h` stay registered as RETIRED keys (same device as
218
+ // `color`): the rename to `width=`/`height=` gets a named diagnostic instead
219
+ // of the generic `unknown option "w="`. `dir` is retired the same way in
220
+ // favour of `extend=` on `band`.
221
+ // 0.1: `color` was LIVE again and meant the TEXT colour.
222
+ // 0.1 (`COLOUR-KEY-STATUS`): `color` is RETIRED for the third and last time, and this
223
+ // time the language gains no replacement — v0.1 has no author-facing label
224
+ // colour at all (the default is derived, `LABEL-COLOUR-SOURCE`; the owner-level key that could
225
+ // be added today is the wrong shape, core §9 `ANNOTATION-LOCATOR-SPLIT`). It stays registered so
226
+ // the message can name BOTH eras: a `color=` written before this release meant
227
+ // the FILL, one written meant the LABEL, and only a human
228
+ // knows which document this is. `text` and `z` stay registered
229
+ // as RETIRED keys so each rename gets a named diagnostic. `offset` replaces
230
+ // `threshold at=` (the directive was spelled `guide` until this release);
231
+ // `at` stays live on `pin`.
232
+ // 0.1: `level` stays registered as a RETIRED key — the construct was
233
+ // DELETED (`CHART-LEVEL-KEY`), and a registered-but-retired key is the only way the
234
+ // deletion gets a named message instead of `unknown option "level="`.
235
+ // 0.1 (`EDGE-GEOMETRY-CONSTRUCTS`): `points`, `tailport`, `headport` and `routing` join them,
236
+ // for the same reason and with a message of a NEW shape — the constructs were
237
+ // WITHDRAWN, so there is no replacement spelling to name. Their acceptor
238
+ // directive (`path`) is gone too, so they have no acceptor row at all; they
239
+ // are registered here only so the diagnostic can fire language-wide.
240
+ // 0.1 (`BITFIELD-REPETITION-CONSTRUCT`): `index` joins the registry as `field`'s repetition key.
241
+ // Its value is a RANGE — `index=0..7` — and it is the only option key whose
242
+ // value grammar is a range, so it is also the key that fixes the language's
243
+ // ONE range spelling at `..` (`RANGE-SPELLING` moves `band` off the hyphen in the same
244
+ // release).
245
+ const OPT_KEYS=new Set(['kind','type','shape','fill','color','stroke','text','in','plane','layer','label',
246
+ 'style','z','z-index','at','offset','w','h','width','height','unit','word','note','description','present','index','labels','data','numbering','from','to','gap',
247
+ 'dir','extend','level','taillabel','headlabel','class','via','points','routing','src','dst','tailport','headport']);
93
248
  // Applicable option keys per directive. Keys with dedicated diagnostics
94
- // (node kind/w/h, edge label/taillabel/headlabel, line fill, fill from/to)
95
- // are listed so their specific error messages fire. `edge` is consumed by
96
- // parseEdgeLine; `title` takes its rest-of-line verbatim (no options).
249
+ // (node kind/w/h, edge label/taillabel/headlabel, band from/to, and `color`
250
+ // wherever `fill` replaced it) are listed so their specific error messages
251
+ // fire. `edge` is consumed by
252
+ // parseEdgeLine; `title` takes exactly one quoted string (no options).
253
+ // §5 presentation attributes (`fill`, `stroke`, `text` label colour,
254
+ // `style` solid|dashed|dotted, `plane`) are optional on ANY element that can
255
+ // carry them with a rendering effect — the `GROUP-PRESENTATION-ATTRIBUTES` `group` decision applied to the
256
+ // whole registry. Three families carry a carve-out, because the
257
+ // attribute has nothing to act on there:
258
+ // - `external` is NEVER drawn (`EXTERNAL-EDGE-ENDPOINTS`) — no fill, no border, no dash; only its
259
+ // label exists, so it takes `text=` (plus `plane=`, organizational exactly
260
+ // as on a node);
261
+ // - `band` carried NO label channel at all until this release (`BAND-LABEL-STATUS`); it now
262
+ // takes a mandatory quoted label, so `color=` applies to it like any
263
+ // other labelled element;
264
+ // - typed blocks (`bitfield`/`table`/`timing`) stack in document order OUTSIDE
265
+ // the scene, so nothing can be layered against them (no `plane=`, on the
266
+ // block or its children), and a block-wide `style=` would collide with the
267
+ // per-item dash conventions (an `optional` field is dashed) — per-item
268
+ // `style=` on `field`/`cell`/`signal` covers the real need.
269
+ // 0.1 registry. `text` is listed wherever it was accepted so the
270
+ // RENAME fires with a named diagnostic instead of `unknown option`; `fill`
271
+ // stays listed on `edge`/`threshold`/`bundle` for the same reason (§8.4: those
272
+ // three have no interior, so `fill=` and `stroke=` named one channel with
273
+ // silent `stroke=` precedence).
274
+ // `at` stays listed on `threshold` so its rename to `offset=` is diagnosed.
275
+ // 0.1 (`BAND-LABEL-STATUS`): `band` gained a mandatory quoted label, so it gained the
276
+ // text channel with it and now accepts `color=` — the carve-out that refused
277
+ // the key existed only because there was no label to colour.
97
278
  const DIRECTIVE_OPTS={
98
279
  figdown:[],
99
- node:['shape','color','stroke','text','style','class','in','layer','kind','w','h'],
100
- group:['color','stroke','text','style','gap','class','layer'],
101
- boundary:[],
102
- edge:['style','class','color','layer','label','taillabel','headlabel'],
103
- layer:['z'], flow:[], rank:[],
104
- bundle:['color'],
105
- line:['in','at','color','fill'],
106
- fill:['in','dir','color','from','to'],
107
- pin:['at'], size:['w','h'],
108
- render:[],
109
- routing:[], route:['via','routing'],
110
- 'class':['color','stroke','text','style'],
111
- bitfield:['unit','numbering'], table:[], wave:[],
112
- plot:['kind','level'],
113
- field:['color','class','note'], wrap:[],
114
- cell:['color','class'], colw:[],
115
- signal:['labels'], gap:[]
280
+ node:['shape','fill','stroke','style','class','in','plane','width','height'],
281
+ // `FLOWCHART-ROLE-KEYWORDS`: the three flowchart role keywords take EXACTLY the
282
+ // option keys `node` takes — they ARE nodes, with a role recorded. Listing
283
+ // `width`/`height` mirrors `node` so the same "use a pin line" diagnostic
284
+ // fires rather than a bare `unknown option`.
285
+ process:['shape','fill','stroke','style','class','in','plane','width','height'],
286
+ decision:['shape','fill','stroke','style','class','in','plane','width','height'],
287
+ terminator:['shape','fill','stroke','style','class','in','plane','width','height'],
288
+ group:['fill','stroke','style','gap','class','plane'],
289
+ external:['plane'],
290
+ edge:['style','class','fill','stroke','plane','label','taillabel','headlabel'],
291
+ plane:['z','z-index'], flow:[], rank:[],
292
+ bundle:['fill','stroke','style','plane'],
293
+ threshold:['in','at','offset','fill','stroke','style','plane'],
294
+ band:['in','extend','fill','stroke','style','plane','from','to'],
295
+ // `ELEMENT-GEOMETRY-DIRECTIVE`: `size` merged into `pin`. ONE directive carries an
296
+ // element's whole DECLARED geometry — `at=` places it, `width=`/`height=`
297
+ // extend it — and one model object records it. All three keys are optional
298
+ // and a `pin` with none of them is a line error, because it declares
299
+ // nothing. The domains differ and the parser enforces the difference:
300
+ // `at=` takes nodes, groups and externals; `width=`/`height=` take nodes
301
+ // only (a group sizes to its members, and an external or a typed block
302
+ // derives its geometry from its content).
303
+ pin:['at','width','height'],
304
+ layout:[],
305
+ 'class':['fill','stroke','style','plane'],
306
+ // 0.1: `class=` is NOT accepted on the typed-block OPENERS. The
307
+ // normative registry (core §10) lists its acceptors as node/group/edge/
308
+ // field/cell — the block openers were an engine-only extra with 0 uses in
309
+ // either corpus, and a closed registry the engine widens is not closed.
310
+ bitfield:['word','numbering','fill','stroke'],
311
+ table:['fill','stroke'], timing:['fill','stroke'],
312
+ chart:['type'],
313
+ // `STYLE-KEY-SCOPE`: `style=` LEAVES `field`, `cell` and `signal`. On a
314
+ // `field` it silently erased the dash that is `optional`'s only visual
315
+ // carrier — `field "B" 8 optional style=solid` kept `optional:true` in the
316
+ // model and drew an ordinary field, so a reading agent and a human got
317
+ // different figures from one line. That is `PRESENTATION-AS-MEANING-CARRIER`'s exact prohibition
318
+ // (presentation may RENDER meaning, never be its only carrier). The three
319
+ // move together because they are one minimum set: 11 in-repo uses, 0
320
+ // downstream, and none of the three has a dash convention worth a key.
321
+ // `PRESENCE-CONDITION-EXPRESSION`/`DESCRIPTION-KEY-SPELLING`: `note=` became `description=` (IEEE 1685's own
322
+ // spelling for authored documentation prose) and the positional flag
323
+ // `optional` became the option key `present=`, whose VALUE is the presence
324
+ // condition. Both old spellings fire a named diagnostic.
325
+ // `BITFIELD-REPETITION-CONSTRUCT`: `index=` — the repetition construct. It states that
326
+ // the field is ONE ELEMENT of a repeated run and gives the run's index
327
+ // range; the engine derives the elision row and the index labels from it,
328
+ // exactly as it derives the dash and the caption from `present=`.
329
+ field:['fill','stroke','class','description','present','index'], 'break':[],
330
+ cell:['fill','stroke','class'], width:[],
331
+ signal:['data','fill','stroke'], gap:[]
332
+ };
333
+ const STYLES=['solid','dashed','dotted'];
334
+ // `RULE-POSITION-ENUMERATION`: every LIVE option key whose value grammar is an enum,
335
+ // read off spec/vocabulary-sources.tsv (`shape` column = `enum`, `status`
336
+ // live). These are the option half of RULE 2.4's enum positions; the
337
+ // positional half (`figdown` version and genre, `flow` direction, `cell`
338
+ // `highlight`) is checked at each directive, because each has its own
339
+ // positional layout. `type` is `chart`'s and is experimental; it is listed
340
+ // because the rule is language-wide, not surface-wide.
341
+ const ENUM_OPT_KEYS=['shape','style','numbering','extend','type'];
342
+ // Retired option key. `color=` set the FILL, but CSS and the
343
+ // diagram languages that borrowed from it use `color` for the TEXT colour and
344
+ // `fill` for the fill — so the old spelling read as its own opposite and
345
+ // produced a legal, wrong figure. One message, one lookup (PROCESS §5).
346
+ // 0.1: `color=` is a LIVE key again (it now sets the TEXT colour), so
347
+ // the 0.1 diagnostic is gone. Nothing can replace it: an engine
348
+ // reading `color=#dc2626` cannot tell a pre-0.1 source file —
349
+ // where it meant FILL — from a post-0.1 one, where it means TEXT. The whole burden of that
350
+ // distinction therefore sits in tools/migrate-figdown.js, which refuses to
351
+ // guess: its only auto-rewriting branch is gated on the presence of `text=`,
352
+ // a token the rewrite removes, so it cannot fire twice.
353
+ // Retired option keys. Unlike `color=` — which stayed on the
354
+ // directives that accepted it so the message could be per-directive — these
355
+ // are reported WHEREVER they appear, the `colw` → `width` precedent: the
356
+ // spelling is gone from the language, so no directive can accept it and the
357
+ // generic "<directive> does not take w=" would bury the rename.
358
+ // - `w=`/`h=`: SVG, CSS, Graphviz DOT, mxGraph and D2 all spell the size
359
+ // attributes in full. `fill=` and `stroke=` were borrowed whole from SVG;
360
+ // taking the abbreviation for the size pair while taking the full spelling
361
+ // for the paint pair breaks the single-source vocabulary rule (`UNSAFE-DEFAULT-ELIMINATION`).
362
+ // - `dir=`: HTML's `dir` attribute means TEXT WRITING DIRECTION. FigDown's
363
+ // meant "which way the band extends from its anchor" — the same spelling
364
+ // for a different meaning, which `UNSAFE-DEFAULT-ELIMINATION` treats as a defect in itself.
365
+ // 0.1 retirements. `text=` and `z=` left the LANGUAGE (the channel is
366
+ // now `color=`, the stacking key is now `z-index=`), so under RULE 6.2's
367
+ // placement test they fire wherever they appear, the `w=`/`h=`/`colw`
368
+ // precedent. `text=`'s message must carry the 0.1 warning too: this
369
+ // is the one migration that is NOT a rename — old `color=` set the FILL, new
370
+ // `color=` sets the TEXT — and once `color=` is live again the engine can no
371
+ // longer diagnose a pre-0.1 document at all.
372
+ const RETIRED_OPT_KEYS={
373
+ w:'w= has been renamed: use width= (SVG, CSS, DOT, mxGraph and D2 all spell it in full — no standard abbreviates it) (MIGRATIONS)',
374
+ h:'h= has been renamed: use height= (SVG, CSS, DOT, mxGraph and D2 all spell it in full — no standard abbreviates it) (MIGRATIONS)',
375
+ 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)',
376
+ 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)',
377
+ // `COLOUR-KEY-STATUS`. This is the ONLY key in the language whose diagnostic
378
+ // must name two eras and refuse to choose between them: the same six
379
+ // characters meant the FILL before this release and the LABEL after
380
+ // 0.1, and no engine can tell the two source files apart. Retiring
381
+ // the key is what makes the difference DIAGNOSABLE at all — while it was
382
+ // live, a pre-0.1 document parsed and drew a legal, wrong figure in
383
+ // silence.
384
+ //
385
+ // 0.1: the MESSAGE may not name those release numbers. A `.fd` does
386
+ // not record which pre-release wrote it — downstream every document says
387
+ // `figdown 0.1` — so "which era is this document from?" is a question the
388
+ // reader cannot answer by looking at anything. What they CAN look at is the
389
+ // rest of the file, and the two eras differ in what else it may contain:
390
+ // `fill=` did not exist while `color=` meant the FILL, and the older
391
+ // spellings were gone by the time it meant the LABEL. The message names
392
+ // those observables, says plainly when a file has neither, and cites the
393
+ // release only as a MIGRATIONS lookup. Same rule as the migration tool's
394
+ // `color=` family, which reads the same evidence to decide its refusals.
395
+ 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)',
396
+ 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.',
397
+ 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)',
398
+ 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)',
399
+ // 0.1 (`EDGE-GEOMETRY-CONSTRUCTS`). These six keys end in a WITHDRAWAL, not a rename, so
400
+ // their messages have a shape no earlier retirement in this table has: they
401
+ // name no replacement spelling, because there is none. `via=`/`src=`/`dst=`
402
+ // were renamed to keys that have since been withdrawn, so each
403
+ // states the whole two-hop chain in one lookup (the `line` → `guide` →
404
+ // `threshold` precedent) and ends at the same place.
405
+ via:'via= has been WITHDRAWN: it was renamed points=, and points= was withdrawn with the whole `path` directive. There is no replacement spelling — author waypoints are outside the stable prior-art intersection (only 2 of 4 surveyed systems model them, and those 2 disagree on what happens when an endpoint moves). 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',
406
+ src:'src= has been WITHDRAWN: it was renamed tailport=, and tailport= was withdrawn with the whole `path` directive. There is no replacement spelling — role-addressed attachment IS inside the stable prior-art intersection, but per-edge addressing needs an edge-identity construct FigDown does not have. 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',
407
+ dst:'dst= has been WITHDRAWN: it was renamed headport=, and headport= was withdrawn with the whole `path` directive. There is no replacement spelling — role-addressed attachment IS inside the stable prior-art intersection, but per-edge addressing needs an edge-identity construct FigDown does not have. 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',
408
+ points:'points= 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. Author waypoints are outside the stable prior-art intersection — only 2 of 4 surveyed systems model them, and those 2 disagree on what happens when an endpoint moves (mxGraph leaves them behind, ELK carries them, Visio has no waypoint concept at all). Delete the line; the edge draws under auto layout, and `pin`, `rank`, `flow` and declaration order are the content-zone means of shaping it. The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md',
409
+ 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',
410
+ 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',
411
+ 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',
412
+ 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)',
413
+ 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)',
414
+ // `DESCRIPTION-KEY-SPELLING`. The spelling leaves the LANGUAGE, so the message fires
415
+ // wherever it appears (the `w=`/`h=`/`unit=` placement test, RULE 6.2).
416
+ 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)',
417
+ 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)'
116
418
  };
419
+ // `PLANE-KEYWORD-SPELLING`: the keyword `plane`/`plane=` was spelled `layer`/`layer=`.
420
+ 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)';
421
+ // `THRESHOLD-KEYWORD-SPELLING`: the scene keyword `guide` became `threshold`.
422
+ 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)';
423
+ // `EXTERNAL-ENDPOINT-NAMING`: the scene keyword `boundary` became `external`.
424
+ 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)';
425
+ // `ROW-BREAK-NAMING`: the `bitfield` child keyword `wrap` became `break`.
426
+ 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)';
427
+ // `PRESENCE-FLAG-SPELLING`: the 0.1 rename `optional` -> `conditional` (`PRESENCE-FLAG-SPELLING`)
428
+ // is REVERTED. `conditional` has zero attestation as a wire-format field
429
+ // marker and zero uses in the downstream corpus, while "optional" appears in
430
+ // 34 downstream field LABELS — authors wrote the word in the label precisely
431
+ // because the keyword no longer said it. The MODEL key moves with the surface
432
+ // (`NORMATIVE-SEMANTIC-MODEL`): it is `optional` again on both sides.
433
+ 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)';
434
+ // `PRESENCE-CONDITION-EXPRESSION`: the positional flag `optional` becomes the option key
435
+ // `present=`, whose VALUE is the presence condition. A bare flag could say
436
+ // only THAT a field is conditional; every RFC that draws one also states WHY
437
+ // ("present only if the C bit is set"), and until now that sentence had
438
+ // nowhere to live but `note=` — invisible to the reader and, under `BITFIELD-CONDITIONAL-OFFSETS`,
439
+ // prose the model may not read. `present` is the attested spelling: X.680
440
+ // PRESENT, IP-XACT isPresent, SystemRDL ispresent, RFC 2784 "present only
441
+ // if", draft-mcquistin "present only when".
442
+ 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)';
443
+ // `TIMING-GENRE-NAMING`: the EXPERIMENTAL genre `wave` became `timing`, both as
444
+ // the header genre token and as the block opener. The old name was WaveDrom's
445
+ // MEMBER KEY, not its figure name: in WaveJSON `signal` is the root object and
446
+ // `wave` is a PROPERTY of one signal — the lane activity string — so FigDown
447
+ // named a whole figure kind after a per-lane attribute and left WaveDrom's own
448
+ // figure name ("Digital Timing Diagram") unused. `timing` is the datasheet and
449
+ // JEDEC term for this figure, and UML 2.5.1's Timing Diagram is the same
450
+ // concept (a lifeline's state over time drawn as a waveform), so it is correct
451
+ // reuse under §10, not a collision. The rename also FREES `wave` for the lane,
452
+ // which is where WaveJSON puts it.
453
+ // `ELEMENT-GEOMETRY-DIRECTIVE`: `size` is RETIRED — it did not move to another
454
+ // directive, it MERGED into one. The message therefore names a whole line
455
+ // rather than a word: an author holding `size a width=120 height=60` needs
456
+ // to know that the keys survive unchanged and only their carrier moved.
457
+ // RULE 6.2 placement: the spelling left the LANGUAGE, so this fires wherever
458
+ // it appears at line start, in every genre, ahead of the `GENRE-KEYWORD-ALLOWLIST` allowlist.
459
+ 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)';
460
+ 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)';
461
+ // `EDGE-GEOMETRY-CONSTRUCTS`: `path` and `routing` are WITHDRAWN from the language.
462
+ // These two diagnostics are a NEW SHAPE for this table. Every retirement
463
+ // before them named a replacement spelling — `size` named `pin`, `guide` named
464
+ // `threshold`, `layer` named `plane`, and even `level`, which was DELETED,
465
+ // could say "delete the key" because the key was a modifier on a surviving
466
+ // line. These name none, because there is none: the constructs are gone and
467
+ // nothing in v0.1 does what they did. RULE 6.2 still binds — the spelling must
468
+ // be a line error that names its MIGRATIONS entry, never silently accepted and
469
+ // never silently rejected — so what changes is the sentence, not the contract.
470
+ // Each message therefore does three things: says WITHDRAWN (not "renamed",
471
+ // not "use X"), says what an author should do instead (delete the line and let
472
+ // auto layout draw it, with the content-zone means named), and points at where
473
+ // the decision is RECORDED so the reasoning is one lookup away.
474
+ const WITHDRAWN_WHERE=' The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md';
475
+ 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;
476
+ 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;
477
+ // `TIMING-LANE-ALPHABET`: the timing lane digits `2`-`9` left the closed alphabet.
478
+ 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)';
479
+ // 0.1 (§8.4): `edge`, `threshold` and `bundle` have NO interior, so
480
+ // `fill=` and `stroke=` named the SAME channel and `stroke=` won silently —
481
+ // two keys for one channel, resolved by an undocumented precedence that
482
+ // produced a legal, wrong figure whenever both were written (16 lines in
483
+ // this repository, 3 of them writing both on one line). Same defect shape as
484
+ // the retired `color=`; same cure, a named diagnostic.
485
+ const NO_INTERIOR=new Set(['edge','threshold','bundle']);
486
+ const FILL_NO_INTERIOR=k=>k+' has no interior, so fill= and stroke= name the same channel (stroke= won silently) — write stroke= (MIGRATIONS)';
487
+ // `STYLE-KEY-SCOPE`: `style=` left these three directives (it stays live on
488
+ // node/group/edge/class/bundle/threshold/band). The generic
489
+ // "<directive> does not take style=" would be true but would not say why, and
490
+ // on `field` the removal is a CORRECTNESS fix, not a tidy-up — so these three
491
+ // get a named diagnostic, the FILL_NO_INTERIOR precedent.
492
+ // `ROW-HIGHLIGHT-CELL-FILL-COLLISION`: the row tint and a cell fill are ONE channel. `STYLE-KEY-SCOPE`
493
+ // filed both halves of this as an HONEST NEGATIVE and left them open; they
494
+ // are closed here the way every other one-channel-two-keys collision in this
495
+ // language was closed — a named line error, not a precedence rule. A
496
+ // precedence rule is what `STYLE-KEY-SCOPE` had just finished removing from `field`.
497
+ 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)';
498
+ 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)';
499
+ const NO_ITEM_STYLE=new Set(['field','cell','signal']);
500
+ const STYLE_NO_ITEM=k=>k+' does not take style= — '+(k==='field'
501
+ ? '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)'
502
+ : 'a dash on one '+k+' carried no meaning the block does not already carry, and 0 documents outside this repository wrote it')
503
+ // `DESCRIPTION-KEY-SPELLING` corrected the second half of this message. It used to
504
+ // offer `note=` as a place to put knowledge, which was wrong twice over:
505
+ // the key is now spelled `description=`, and NEITHER spelling draws
506
+ // anything beyond a tooltip — sending knowledge there hides it from the
507
+ // human, which is the same defect `STYLE-KEY-SCOPE` exists to close. The two channels
508
+ // that DRAW are the name/label and a `class` meaning (which also earns a
509
+ // legend entry).
510
+ +'. 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)';
117
511
  function splitOpts(toks, laneMode){
118
- const pos=[], opts={}, unk=[];
512
+ // a repeated option key on one line is a line error, never
513
+ // silent last-wins. `dup` names the first key that appeared twice.
514
+ // `OPTION-POSITION-PARSING`: `posq` carries the quotedness of each POSITIONAL
515
+ // token, index-aligned with `pos`. Before it existed, the two directives
516
+ // that need to know whether their label was quoted (`bundle`, `threshold`)
517
+ // tested `tk.toks[i].q` — the RAW stream — while reading the value from
518
+ // `pos`, the option-stripped one. The indices diverge the moment an
519
+ // option precedes the label (`bundle t1 fill=red "LAG" a--b`), so a legal
520
+ // line was rejected. Option position is free everywhere else; it is free
521
+ // here now too.
522
+ // `LINK-OPERATOR-IN-IDS`: `posT` and `optT` carry the TOKEN behind each value —
523
+ // `OPTION-POSITION-PARSING` built `posq` for positional quotedness; the value layer (comma
524
+ // splitting, id checks, string-typed option values) needs the same
525
+ // information, and it needs it for option VALUES too. `optT[k]` is
526
+ // {t, off}: the token and the offset in `t.v` at which the value starts.
527
+ const pos=[], posq=[], posT=[], opts={}, optT={}, unk=[]; let dup=null;
119
528
  for(const t of toks){
120
529
  const m = !t.q && /^([A-Za-z_][A-Za-z0-9_-]*)=([\s\S]*)$/.exec(t.v);
121
- if(m && OPT_KEYS.has(m[1])) opts[m[1]]=m[2];
530
+ if(m && OPT_KEYS.has(m[1])){
531
+ if(Object.prototype.hasOwnProperty.call(opts,m[1]) && dup===null) dup=m[1];
532
+ opts[m[1]]=m[2];
533
+ optT[m[1]]={t, off:m[1].length+1};
534
+ }
122
535
  else if(m && !laneMode) unk.push(m[1]);
123
- else pos.push(t.v);
536
+ else { pos.push(t.v); posq.push(!!t.q); posT.push(t); }
124
537
  }
125
- return {pos,opts,unk};
538
+ return {pos,posq,posT,opts,optT,unk,dup};
126
539
  }
540
+ // The comma form a retired space form should have been written as. Trailing
541
+ // commas on the individual tokens are the half-converted spelling (`a, b`),
542
+ // so they are dropped before joining — the suggestion must be paste-ready.
543
+ const joinListForm=toks=>toks.map(t=>String(t).replace(/,+$/,'')).filter(Boolean).join(',');
544
+ // Was the VALUE of option `k` written with quotes anywhere / entirely?
545
+ function optHasQ(optT,k){ const e=optT[k]; return !!e && sliceHasQ(e.t,e.off,e.t.v.length); }
546
+ function optQ(optT,k){ const e=optT[k]; return !!e && sliceQ(e.t,e.off,e.t.v.length); }
547
+ // The elements of a comma list held in option `k`, quote-aware.
548
+ function optList(optT,k){ const e=optT[k]; return e?splitList(e.t,e.off):null; }
549
+ // An id is a BARE token matching this pattern. The pattern is a whole-token
550
+ // anchor: `-` and `_` are ordinary interior characters, so a TRAILING `-` or
551
+ // `_` is legal (`node mux_ "MUX"` parses) — a downstream author renamed ids
552
+ // on the mistaken belief that it was not, so core §1 now says so explicitly.
127
553
  const ID_RE=/^[A-Za-z_][A-Za-z0-9_-]*$/;
554
+ // `LINK-OPERATOR-IN-IDS`: `--` is the LINK OPERATOR (`edge a -- b`,
555
+ // `bundle t1 a--b`), so it must not also be able to occur inside an id.
556
+ // While it could, `bundle t1 a-x--b` had two legal readings (`a-x`+`b` and
557
+ // `a`+`x--b`) and the greedy member regex silently committed to the first,
558
+ // making the second unreachable and undiagnosed — RULE 6.3 says malformed
559
+ // input is an error, never a guess. Zero ids in either corpus contain `--`.
560
+ 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)';
561
+ // `QUOTED-IDS`: ONE wording for every id position. It covers both
562
+ // halves of the defect it closes — a needlessly quoted legal id
563
+ // (`node "a"`, silently accepted before) and a quoted token that is not a
564
+ // legal id at all (`node "Router 1"`, which used to report the unhelpful
565
+ // `node needs an id`) — because in both cases the author's next move is the
566
+ // same: put the text in the label slot and leave the id bare.
567
+ // `QUOTING-RULES` — quoting: the TYPE gates eligibility, the DELIMITER
568
+ // gates necessity.
569
+ // whitespace-delimited string position -> quotes MANDATORY
570
+ // comma-delimited list element -> quotes only when the element
571
+ // contains whitespace , " or #
572
+ // (redundant quotes stay legal)
573
+ // [ ] edge label -> quotes only to enable escapes
574
+ // The whitespace row is not a style preference: whitespace is ALSO the
575
+ // positional separator, so a bare token cannot grow into a phrase and the
576
+ // old failure named the wrong thing (`node a Cache miss` reported
577
+ // `unexpected argument "miss"` — a surplus argument, when the defect was a
578
+ // missing quote).
579
+ const Q_WHY='whitespace also separates positionals, so a bare token cannot express a phrase (MIGRATIONS)';
580
+ 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"';
581
+ // isId: the whole id test, used at every id position in the language.
582
+ const isId=v=>typeof v==='string'&&ID_RE.test(v)&&!v.includes('--');
583
+ // idErr(value, quotedFlag, missingMsg) -> the message for an id position, or
584
+ // null when the id is well formed. `missing` fires only when nothing at all
585
+ // was written; a written-but-illegal id always gets ID_RULE or DD_ID, never
586
+ // "<directive> needs an id".
587
+ function idErr(v,quoted,missing){
588
+ if(v===undefined||v===null||v==='') return quoted?ID_RULE:missing;
589
+ if(quoted) return ID_RULE;
590
+ if(ID_RE.test(v)&&v.includes('--')) return DD_ID;
591
+ if(!ID_RE.test(v)) return ID_RULE;
592
+ return null;
593
+ }
594
+ // `RULE-POSITION-ENUMERATION`: RULE 2.4's OTHER HALF, enforced. The rule reads "where
595
+ // the grammar expects an id OR AN ENUM VALUE, the token MUST be bare", and
596
+ // until now only the id half existed — 14 of 14 id positions rejected a
597
+ // quoted token through `idErr`, and 0 of the enum positions rejected
598
+ // anything (`shape="box"`, `style="dashed"`, `flow "right"`,
599
+ // `figdown "0.1" block`, `numbering="msb0"`, `cell 1 "highlight"` all
600
+ // parsed). A rule enforced at half its positions is worse than no rule.
601
+ //
602
+ // THE LINE, and it is derivable rather than a list: a position whose value
603
+ // is drawn from a CLOSED SET OF SPELLINGS is written BARE. That is exactly
604
+ // the id argument (`QUOTED-IDS`) generalised — an id is closed by declaration, an
605
+ // enum and a bare keyword flag are closed by the standard — and quoting one
606
+ // invites the same false belief, that arbitrary text is admissible there
607
+ // (`PRIOR-ART-BORROWING`: two spellings of one meaning feed hallucination).
608
+ //
609
+ // Its complement is stated in the same breath, because leaving it unstated
610
+ // is what made RULE 2.3 and RULE 2.4 disagree at the table `width` position:
611
+ // where the value comes from an OPEN VALUE SPACE — a number, a point, a
612
+ // percentage, a range — redundant quotes stay INERT and legal. `index=`'s
613
+ // inertness is normative and load-bearing there (core §12.7, `INDEX-RANGE-STEP`), and
614
+ // 2.3's own reason applies: a generating agent that quotes uniformly must
615
+ // not be punished, and a validator that rejects redundant quotes makes the
616
+ // write->validate->fix loop oscillate.
617
+ //
618
+ // TWO WORD-SHAPED SPELLINGS SIT INSIDE OTHERWISE-NUMERIC GRAMMARS and stay
619
+ // INERT deliberately — table `width auto,90` and `field "A" *`. They are
620
+ // declared exceptions with a stated reason, SYNTAX-STYLE §8.6, not an
621
+ // oversight: both are ONE alternative inside a numeric value grammar, so the
622
+ // element they sit in is typed by that grammar, and splitting one comma list
623
+ // into bare-word and quotable-number elements would be a worse rule than the
624
+ // exception.
625
+ const ENUM_WHY='quoting a value drawn from a closed set suggests the position accepts arbitrary text, when it accepts only the listed spellings (SYNTAX-STYLE RULE 2.4)';
626
+ const ENUM_BARE=shown=>'this position takes a BARE value: write '+shown+' — '+ENUM_WHY;
627
+ // class= is multi-valued (comma-separated ids). Returns {ok, ids, err} —
628
+ // empty value, empty members, quoted members and illegal id spellings are
629
+ // line errors. `els` is the quote-aware element list (`LINK-OPERATOR-IN-IDS` splitList); when
630
+ // it is absent the caller only wants the ids back and quoting was already
631
+ // checked on the same line.
632
+ function parseClassList(v,els){
633
+ if(v===undefined) return {ok:true, ids:undefined};
634
+ if(v==='') return {ok:false, err:'class= must not be empty'};
635
+ const parts=els?els.map(e=>e.v):String(v).split(',');
636
+ if(parts.some(p=>p==='')) return {ok:false, err:'class= must not contain empty members'};
637
+ for(let i=0;i<parts.length;i++){
638
+ const e=idErr(parts[i], els?els[i].h:false, 'bad class id "'+parts[i]+'"');
639
+ if(e) return {ok:false, err:e};
640
+ }
641
+ return {ok:true, ids:parts};
642
+ }
643
+
644
+ // `BITFIELD-REPETITION-CONSTRUCT`: `index=` on a `field` — the repetition construct.
645
+ //
646
+ // The value is a RANGE, and `..` is the language's ONE range separator
647
+ // (SYNTAX-STYLE §4.8; Ada ISO/IEC 8652 `1 .. 10`, Pascal ISO 7185
648
+ // `array [1..10]`, X.680 `SIZE(1..4)` — all inclusive). `band` moved off the
649
+ // hyphen onto it in the same release (`RANGE-SPELLING`), so the language has one spelling
650
+ // and needs no declared exception for a second.
651
+ //
652
+ // Tri-state, deliberately the same SHAPE as `present=`:
653
+ // key absent no repetition claim — NOT an assertion of one occurrence
654
+ // index="" repeats; nothing about the indices is stated
655
+ // index="0..X" repeats; the last index is opaque prose
656
+ // index=0..7 repeats; the range is DETERMINATE, 8 elements
657
+ //
658
+ // DETERMINACY IS DECIDED BY PARSING BOTH ENDS AS INTEGERS, NEVER BY QUOTING.
659
+ // RULE 2.3 makes redundant quotes inert, so `index="0..7"` is determinate too.
660
+ // This is normative (core §12.7, genres/bitfield.md) rather than an accident
661
+ // of this function, because a future `index="0..7 step 2"` (`INDEX-RANGE-STEP`) rests on
662
+ // it: were quoting made semantic here, that extension would stop being free.
663
+ //
664
+ // `RULE-POSITION-ENUMERATION`: AND THE SPELLING THAT EXTENSION NEEDS IS NOW RESERVED.
665
+ // Inertness alone did not make `step` free. A prose `<last>` accepts ANY
666
+ // non-empty text, so `index="0..7 step 2"` was already a LEGAL document
667
+ // whose model is `{first:0, last:"7 step 2"}` and whose caption reads
668
+ // `[0] … [7 step 2]`. Shipping the step meaning later would therefore change
669
+ // what an existing legal document MEANS, and no engine could separate "prose
670
+ // that happens to read like a step clause" from "the author meant the
671
+ // extension" — the `color=` failure shape, which this project treats as its
672
+ // worst (`COLOUR-KEY-STATUS`). An extension is free only when the syntax carrying it is
673
+ // RESERVED, not merely unused. So `step` is reserved here exactly as `;` was
674
+ // reserved (`SEMICOLON-STATUS`) and for the same reason: a mark or a word that
675
+ // a future release will claim must be an error TODAY, while the claim costs
676
+ // nothing. `INDEX-RANGE-STEP`'s own measurement — 0 of 9 corpus figures with a stepped
677
+ // index — is what makes the price zero.
678
+ //
679
+ // `<first>` is ALWAYS a literal integer. That is what makes the base
680
+ // machine-readable without resolving any name — and 0-based is the canonical
681
+ // form, from IP-XACT's `indices`, which "follows C-semantics for indexing".
682
+ //
683
+ // WHITESPACE AROUND `..` IS NOT SIGNIFICANT (`index="0 .. 7"` is `index=0..7`;
684
+ // Ada writes `1 .. 10`). It is reachable only in the QUOTED form, because an
685
+ // unquoted option value is one whitespace-free token (§1) — normative in
686
+ // core §12.2 and genres/bitfield.md, pinned by golden 420.
687
+ //
688
+ // Returns {ok, val, err}. `val` is the model shape: an object with `first`
689
+ // and `last` being a NUMBER when the run is determinate and a STRING
690
+ // when it is prose. `index=""` yields `{}` — a written value that claims
691
+ // repetition and states no index at all.
692
+ const IDX_INT=/^\d+$/;
693
+ const IDX_SHAPE='index= takes a range written <first>..<last> — one ".." separator, and both ends present: index=0..7, index=53..0 (descending), index="0..Last Entry" when the last index is only named in prose, or index="" to say it repeats without stating any index';
694
+ // 0.1: the LARGEST index either end may state. `<first>` and a literal
695
+ // `<last>` are read as FigDown numbers, and core §12.5 binds a number to an
696
+ // ECMAScript double — so above 2^53-1 the value a reader gets back is NOT the
697
+ // value the author wrote (`9007199254740993` reads back as `…992`, and the
698
+ // derived count |last-first|+1 is then wrong by the rounding). §12.7 requires
699
+ // the model to be recoverable FROM THE SOURCE; a silently rounded index is the
700
+ // one thing it must not do, so the bound is a line error rather than a value.
701
+ const IDX_MAX=Number.MAX_SAFE_INTEGER; // 9007199254740991
702
+ // `RULE-POSITION-ENUMERATION`: the reserved `step` clause. The TRIGGER is a bare
703
+ // lowercase `step` token in the prose `<last>` — whitespace-delimited on both
704
+ // sides (or at either end of the prose), matched case-sensitively.
705
+ // CAUGHT: index="0..7 step 2" index="0..N step 2" index="0..end step"
706
+ // NOT CAUGHT: index="0..7 Step 2" index="0..stepping" index="0..8 steps"
707
+ // index="0..7 in steps of 2"
708
+ // The trigger is deliberately narrow: only the EXACT spelling the extension
709
+ // would use can ever be ambiguous with it, and FigDown's vocabulary is
710
+ // lowercase everywhere (core §1), so `Step` and `steps` can never become the
711
+ // extension's spelling. A wider trigger would reject prose that could never
712
+ // collide, which is a cost with no reservation behind it.
713
+ const IDX_STEP=/(^|\s)step(\s|$)/;
714
+ const IDX_STEP_ERR='"step" is RESERVED inside an index= range and has no meaning in v0.1 — a stepped range (index="0..7 step 2", core §9 `INDEX-RANGE-STEP`) is a v0.2 extension, and the spelling is reserved now so that shipping it cannot change what a document already written means. If the last index really is prose containing this word, spell it differently ("0..7 stepping by 2", "0..7 in steps of 2"); if you meant every other element, v0.1 has no way to say it — write the run you can state and put the rest in description=';
715
+ function parseIndexRange(v){
716
+ if(v===undefined) return {ok:true, val:undefined};
717
+ if(v==='') return {ok:true, val:{}}; // repeats, indices unstated
718
+ const s=String(v);
719
+ // 0.1: a dot RUN longer than two is a mistyped separator, not a
720
+ // separator followed by prose. `'0...7'.split('..')` yields ['0','.7'], two
721
+ // parts, so the letter of "one '..' separator" passed it and the third dot
722
+ // was silently absorbed into an opaque last index ".7" — an accepted figure
723
+ // whose model contradicted the source. Checked on the RAW value, before the
724
+ // split, because the split is exactly what loses the evidence.
725
+ if(/\.{3}/.test(s)) return {ok:false, err:IDX_SHAPE};
726
+ const parts=s.split('..');
727
+ if(parts.length!==2) return {ok:false, err:IDX_SHAPE};
728
+ const a=parts[0].trim(), b=parts[1].trim();
729
+ if(a===''||b==='') return {ok:false, err:IDX_SHAPE};
730
+ if(!IDX_INT.test(a))
731
+ return {ok:false, err:'index= needs a LITERAL first index, and "'+a+'" is not one: a FigDown number is a run of DIGITS — no sign, no decimal point, no separators. Only the LAST index may be prose — the first is machine-readable by construction, which is what lets a reader place the run without resolving any name. Write index=<digits>..<last>: index=0..7, or index="0..'+a+'" if the run really starts at 0 and ends where "'+a+'" says'};
732
+ const first=+a;
733
+ if(first>IDX_MAX)
734
+ return {ok:false, err:'index= first index "'+a+'" is above '+IDX_MAX+', the largest integer a FigDown number carries exactly (core §12.5 binds a number to an ECMAScript double) — a reader would get '+first+' back, not what you wrote'};
735
+ if(IDX_INT.test(b)){
736
+ const last=+b;
737
+ if(last>IDX_MAX)
738
+ return {ok:false, err:'index= last index "'+b+'" is above '+IDX_MAX+', the largest integer a FigDown number carries exactly (core §12.5 binds a number to an ECMAScript double) — a reader would get '+last+' back, not what you wrote, and the element count with it'};
739
+ if(first===last)
740
+ return {ok:false, err:'index='+a+'..'+b+' is a range of ONE element, which is not repetition — drop index= (an absent key claims nothing), or write the range the run really covers'};
741
+ return {ok:true, val:{first,last}};
742
+ }
743
+ // `RULE-POSITION-ENUMERATION`: the prose `<last>` is where the reserved `step` clause would land,
744
+ // and where it is therefore an error. Checked AFTER the integer branch, so
745
+ // a determinate range never pays for it.
746
+ if(IDX_STEP.test(b)) return {ok:false, err:IDX_STEP_ERR};
747
+ return {ok:true, val:{first,last:b}};
748
+ }
749
+
750
+ // `GENRE-KEYWORD-ALLOWLIST`: per-header-genre top-level keyword allowlist.
751
+ // `UNIVERSAL-CORE-KEYWORDS` core is in every set (fixity, not ubiquity). Scene genres keep
752
+ // experimental scene/layout keywords (B2) but they are not the "main
753
+ // standard" taught to agents. Pure bitfield/table/timing reject scene.
754
+ //
755
+ // `LAYOUT-ZONE-NAMESPACE`: the universal core is THREE — the document's structure,
756
+ // readable before the genre is known. `layout` stays here because it is the
757
+ // zone OPENER, a structural marker, not a directive inside the zone. The
758
+ // zone's own members are a SEPARATE namespace (`LAYOUT-ZONE-NAMESPACE`).
759
+ // `EDGE-GEOMETRY-CONSTRUCTS`: that namespace now holds `pin` and NOTHING ELSE.
760
+ // `path` and `routing` were its two experimental members and both were
761
+ // withdrawn, so `LAYOUT_EXP_KW` is gone rather than empty — an empty list
762
+ // would imply the slot is waiting to be refilled, and the two SPELLINGS are
763
+ // released back to the language (core §9 `EDGE-GEOMETRY-CONSTRUCTS` closes on that fact).
764
+ const CORE_KW=['figdown','title','layout'];
765
+ const LAYOUT_KW=['pin']; // `LAYOUT-ZONE-NAMESPACE`, NORMATIVE — the whole namespace
766
+ const GENRE_FREE_KW=CORE_KW.concat(LAYOUT_KW);
767
+ const SCENE_KW_TOP=['node','group','external','edge','class','flow','rank'];
768
+ const SCENE_EXP_KW=['threshold','band','bundle','plane'];
769
+ const SCENE_HOST_KW=GENRE_FREE_KW.concat(SCENE_KW_TOP, SCENE_EXP_KW, ['bitfield','table','timing','chart']);
770
+ // `FLOWCHART-ROLE-KEYWORDS`: the flowchart ROLE vocabulary — the FIRST exercise of
771
+ // `GENRE-NAMESPACE` `GENRE-VOCABULARY-OBLIGATION` ("a genre owns its words"). These three are legal ONLY under
772
+ // `figdown 0.1 flowchart`; `GENRE-NAMESPACE`'s allowlist is what makes `decision x` a line
773
+ // error under `block` with no extra code. Names taken whole from ISO 5807
774
+ // §9.2.1/§9.2.2.4/§9.4.2, whose own definitions are of the REFERENT ("this
775
+ // symbol represents any kind of processing function"), never of the drawn
776
+ // form — which is why `SHAPE-ENUM-VOCABULARY` is satisfied by borrowing them (`FLOWCHART-ROLE-SOURCE`).
777
+ const FLOWCHART_ROLE_KW=['process','decision','terminator'];
778
+ // Geometry DERIVED from role (`SHAPE-ENUM-VOCABULARY`'s own mapping, requirements-notes `SHAPE-ENUM-VOCABULARY`:
779
+ // "decision→diamond, terminator→rounded"). The role is the model; this is
780
+ // only its default drawing, and `shape=` on the same line overrides the
781
+ // drawing without touching the role (§12.7).
782
+ const ROLE_SHAPE={process:'box',decision:'diamond',terminator:'rounded'};
783
+ const GENRE_KW={
784
+ block:new Set(SCENE_HOST_KW),
785
+ topology:new Set(SCENE_HOST_KW),
786
+ flowchart:new Set(SCENE_HOST_KW.concat(FLOWCHART_ROLE_KW)),
787
+ bitfield:new Set(GENRE_FREE_KW.concat(['class','bitfield'])),
788
+ // chart is experimental and attaches to a table id in the same document
789
+ table:new Set(GENRE_FREE_KW.concat(['class','table','chart'])),
790
+ timing:new Set(GENRE_FREE_KW.concat(['class','timing']))
791
+ };
792
+ const CHILD_KW=new Set(['field','break','cell','width','signal','gap']);
128
793
 
794
+ // Multi-section files (`MULTI-FIGURE-DOCUMENTS`): each `figdown 0.1 <genre>` starts a section.
795
+ // Comments/blanks before the first header belong to section 0.
796
+ function splitFigdownSections(text){
797
+ const lines=String(text).split('\n');
798
+ const at=[];
799
+ for(let i=0;i<lines.length;i++){
800
+ let t=lines[i];
801
+ const hi=findComment(t); if(hi>=0) t=t.slice(0,hi);
802
+ if(/^figdown(\s|$)/.test(t.trim())) at.push(i);
803
+ }
804
+ if(at.length<=1) return null;
805
+ const secs=[];
806
+ for(let s=0;s<at.length;s++){
807
+ const from=s===0?0:at[s];
808
+ const to=s+1<at.length?at[s+1]:lines.length;
809
+ secs.push({startLine:from+1, text:lines.slice(from,to).join('\n')});
810
+ }
811
+ return secs;
812
+ }
813
+
814
+ // parse(text) -> {doc, errs, docs}
815
+ // Single-section: docs=[doc] (backward-compatible doc/errs).
816
+ // Multi-section: one doc per figdown header; errs use full-file line numbers;
817
+ // doc is docs[0] for naive callers. Render stacks sections into one SVG (`MULTI-FIGURE-DOCUMENTS`).
129
818
  function parse(text){
819
+ // 0.1: strip a leading U+FEFF BOM EXPLICITLY (core §1). It was
820
+ // already tolerated, but only because JS `\s` happens to include U+FEFF, so
821
+ // `.trim()` ate it — an accident of the host language that a second
822
+ // implementation had no way to know it had to reproduce. Stripping it here
823
+ // makes the normative rule ("a BOM at the very start of the document is
824
+ // ignored") implementable from the spec alone. Behaviour is unchanged.
825
+ text=String(text).replace(/^\uFEFF/,'');
826
+ const secs=splitFigdownSections(text);
827
+ if(!secs){
828
+ const r=parseOne(String(text));
829
+ return {doc:r.doc, errs:r.errs, docs:[r.doc]};
830
+ }
831
+ const docs=[]; const errs=[];
832
+ for(const sec of secs){
833
+ const r=parseOne(sec.text);
834
+ for(const e of r.errs){
835
+ const m=/^Line (\d+): (.*)$/.exec(e);
836
+ if(m) errs.push('Line '+(+m[1]+sec.startLine-1)+': '+m[2]);
837
+ else errs.push(e);
838
+ }
839
+ docs.push(r.doc);
840
+ }
841
+ return {doc:docs[0], errs, docs};
842
+ }
843
+
844
+ function parseOne(text){
130
845
  const errs=[];
131
- const doc={title:'',nodes:[],groups:[],edges:[],layers:[{id:'base',label:'',z:0}],
132
- flow:'right',ranks:[],pins:{},sizes:{},blocks:[],trunks:[],glines:[],fills:[],
133
- classes:[],routes:[],boundaries:[]};
134
- const nodeIds=new Set(), groupIds=new Set(), layerIds=new Set(['base']), classIds=new Set(),
135
- bundleIds=new Set(), boundaryIds=new Set();
136
- let cur=null; // current typed block (bitfield/table/wave)
137
- let sawHeader=false, firstContent=true, sawRender=false;
138
- const RENDER_DIRECTIVES=new Set(['pin','size','route','routing']);
846
+ // `EMPTY-LABEL-STATE`: `title` and a plane label start ABSENT (null), never as an empty
847
+ // string — an author who writes `title ""` has made a distinction the model
848
+ // must keep, and the implicit `base` plane wrote no label at all.
849
+ const doc={title:null,nodes:[],groups:[],edges:[],planes:[{id:'base',label:null,z:0}],
850
+ flow:'right',ranks:[],pins:{},blocks:[],trunks:[],thresholds:[],bands:[],
851
+ classes:[],boundaries:[]};
852
+ const nodeIds=new Set(), groupIds=new Set(), planeIds=new Set(['base']), classIds=new Set(),
853
+ bundleIds=new Set(), boundaryIds=new Set(), blockIds=new Set();
854
+ // §1: "IDs are ... unique per document" — nodes, groups, boundaries AND the
855
+ // typed blocks (bitfield/table/timing) share ONE namespace, so a bare id in
856
+ // `edge`/`pin`/`chart` can never be ambiguous. `plane`, `class` and
857
+ // `bundle` keep their own namespaces: each is referenced through a dedicated
858
+ // option or keyword, never as a bare id.
859
+ const dupId=id=>nodeIds.has(id)||groupIds.has(id)||boundaryIds.has(id)||blockIds.has(id);
860
+ let cur=null; // current typed block (bitfield/table/timing)
861
+ // `REPEATED-DIRECTIVE-HANDLING`: `title`, `flow`, `layout` and a per-id `pin` are SINGLE-VALUED — a
862
+ // repetition is a line error on the second occurrence, never a silent
863
+ // last-one-wins (§8: malformed input is an error, never ignored).
864
+ let sawHeader=false, firstContent=true, sawLayout=false, sawTitle=false, sawFlow=false;
865
+ const LAYOUT_DIRECTIVES=new Set(['pin']); // `EDGE-GEOMETRY-CONSTRUCTS`: `pin` is the whole layout namespace
139
866
  const lines=text.split('\n');
140
867
  const err=(n,m)=>errs.push('Line '+n+': '+m);
868
+ // `RULE-POSITION-ENUMERATION`: the typed-block openers' LABEL is a positional string
869
+ // and takes RULE 2.1's mandatory quotes, exactly as `node`, `group`,
870
+ // `external`, `plane`, `class` and `bundle` already do. Until now
871
+ // `bitfield b Hdr` and `table t Caption` were accepted with the label
872
+ // `Hdr`/`Caption` while `node a Hdr` was a line error, and BOTH genre
873
+ // documents write the form as `<keyword> <id> ["label"]` — so the docs
874
+ // taught a rule the engine did not enforce, at 3 of 15 labelled positions.
875
+ // It is the failure §2.1 says it retired: an unquoted multi-word
876
+ // positional is not a spelling, it is a hole — `table t My Caption`
877
+ // reported `unexpected argument "Caption"`, naming a surplus argument when
878
+ // the defect was a missing quote. Returns true when it reported.
879
+ const BLK_LBL=(n,kw,id,pos,posq)=>{
880
+ if(pos[2]===undefined||posq[2]) return false;
881
+ err(n,kw+' label must be quoted: '+kw+' '+id+' "'+pos[2]+'" — '+Q_WHY);
882
+ return true;
883
+ };
141
884
 
142
- // R34/R35: edge <id> [tail] <op> [head] <id> — a [mid] label splits the
885
+ // `EDGE-LABEL-PLACEMENT`/`REVERSE-ARROW-OPERATOR`: edge <id> [tail] <op> [head] <id> — a [mid] label splits the
143
886
  // operator into halves: -[x]- -[x]-> <-[x]- <-[x]->. Bracket content:
144
887
  // balanced brackets nest verbatim ([flags[3:0]] just works); ["..."] takes
145
888
  // the standard quoted-string escapes for unbalanced brackets / \n.
146
889
  function parseEdgeLine(s,n){
147
890
  let i=4; // past 'edge'
148
891
  const ws=()=>{ while(i<s.length&&/\s/.test(s[i])) i++; };
149
- const readId=()=>{ const m=/^[A-Za-z_][A-Za-z0-9_-]*/.exec(s.slice(i));
892
+ // `LINK-OPERATOR-IN-IDS`: a hyphen is an id character only when it is NOT
893
+ // followed by a second one, because `--` is the link operator. This is
894
+ // what lets `edge a--b` mean the same thing as `bundle t1 a--b`; before
895
+ // the ban the greedy scanner ate `a--b` as one id and then reported
896
+ // "edge needs an operator", so the same token read oppositely two lines
897
+ // apart (SYNTAX-STYLE §6.3).
898
+ const readId=()=>{ const m=/^[A-Za-z_](?:[A-Za-z0-9_]|-(?!-))*/.exec(s.slice(i));
150
899
  if(!m) return null; i+=m[0].length; return m[0]; };
900
+ // `QUOTED-IDS`: an endpoint is an id position. A quoted token or a spelling that
901
+ // is not an id gets the ID RULE — not "edge needs <id> …", which named
902
+ // the wrong thing when the operator was plainly there.
903
+ const idHere=()=>i<s.length&&!/[\s[\-<>]/.test(s[i]);
151
904
  const readLbl=()=>{ // called at '['
152
905
  i++;
153
906
  if(s[i]==='"'){ // ["..."] — quoted content
@@ -179,8 +932,11 @@ function parse(text){
179
932
  }
180
933
  return {error:'unterminated [label] — for unbalanced brackets use ["..."]'};
181
934
  };
182
- ws(); const a=readId();
183
- if(!a){ err(n,'edge needs <id> ->|<-|--|<-> <id>'); return; }
935
+ ws();
936
+ if(s[i]==='"'){ err(n,ID_RULE); return; }
937
+ const a=readId();
938
+ if(!a){ err(n, idHere()?ID_RULE:'edge needs <id> ->|<-|--|<-> <id>'); return; }
939
+ if(idHere()){ err(n,ID_RULE); return; }
184
940
  ws(); let tail=null;
185
941
  if(s[i]==='['){ const r=readLbl(); if(r.error){ err(n,r.error); return; } tail=r.v; }
186
942
  ws();
@@ -203,21 +959,57 @@ function parse(text){
203
959
  }
204
960
  ws(); let head=null;
205
961
  if(s[i]==='['){ const r=readLbl(); if(r.error){ err(n,r.error); return; } head=r.v; }
206
- ws(); const b=readId();
207
- if(!b){ err(n,'edge needs a target id after the operator'); return; }
962
+ ws();
963
+ if(s[i]==='"'){ err(n,ID_RULE); return; }
964
+ const b=readId();
965
+ if(!b){ err(n, idHere()?ID_RULE:'edge needs a target id after the operator'); return; }
966
+ if(idHere()){ err(n,ID_RULE); return; }
208
967
  const tk2=tokenize(s.slice(i).trim());
209
968
  if(tk2.error){ err(n,tk2.error); return; }
210
- const {pos:p2,opts:o2,unk:u2}=splitOpts(tk2.toks);
969
+ const {pos:p2,opts:o2,optT:oT2,unk:u2,dup:d2}=splitOpts(tk2.toks);
970
+ if(d2){ err(n,'duplicate option "'+d2+'=" on one line'); return; }
211
971
  if(u2.length){ err(n,'unknown option "'+u2[0]+'="'); return; }
212
972
  if(p2.length){ err(n,'unexpected argument "'+p2[0]+'"'); return; }
973
+ // 0.1: `edge` has its own scanner, so the language-wide retired
974
+ // keys need their own check here or `edge` would be the one directive
975
+ // that reports the generic message for a retired spelling.
976
+ for(const rk in RETIRED_OPT_KEYS)
977
+ if(o2[rk]!==undefined){ err(n,RETIRED_OPT_KEYS[rk]); return; }
213
978
  for(const k in o2)
214
979
  if(!DIRECTIVE_OPTS.edge.includes(k)){ err(n,'edge does not take '+k+'='); return; }
215
980
  for(const k of ['label','taillabel','headlabel'])
216
- if(o2[k]!==undefined){ err(n,k+'= is retired — write the label inline: edge A [tail] -[mid]-> [head] B (MIGRATIONS 0.1-dev.9)'); return; }
217
- if(o2.color!==undefined && !isColor(o2.color)){ err(n,'unknown color "'+o2.color+'" (#hex or CSS color name)'); return; }
218
- if(o2.style!==undefined && !['solid','dashed','dotted'].includes(o2.style)){ err(n,'style must be solid|dashed|dotted'); return; }
219
- doc.edges.push({a,b,op,tail,mid,head,style:o2.style,cls:o2['class'],
220
- color:o2.color,layer:o2.layer||'base',line:n});
981
+ if(o2[k]!==undefined){ err(n,k+'= is retired — write the label inline: edge A [tail] -[mid]-> [head] B (MIGRATIONS)'); return; }
982
+ if(o2.fill!==undefined){ err(n,FILL_NO_INTERIOR('edge')); return; }
983
+ for(const k of ['fill','stroke'])
984
+ if(o2[k]!==undefined && !isColor(o2[k])){ err(n,'unknown color "'+o2[k]+'" (#hex or CSS color name)'); return; }
985
+ // `RULE-POSITION-ENUMERATION`: `edge` was the ONE id position in the language that
986
+ // tolerated a quoted id. `parseEdgeLine` has its own scanner and
987
+ // therefore its own copies of the genre gate, the layout-zone gate and
988
+ // the retired-key sweep — but it never got a copy of the id-quoting
989
+ // check that `badOpts` applies to `in=`/`plane=` everywhere else, so
990
+ // `node c "C" plane="over"` was a line error and
991
+ // `edge a -> b plane="over"` was accepted. The endpoints were already
992
+ // covered (the scanner rejects a leading `"`); this is the option value.
993
+ if(o2.plane!==undefined){
994
+ const e=idErr(o2.plane, optHasQ(oT2,'plane'), null);
995
+ if(e){ err(n,e); return; }
996
+ }
997
+ // `RULE-POSITION-ENUMERATION`: and the enum half of RULE 2.4, for the one enum key `edge` takes.
998
+ // Checked before the value, exactly as `badOpts` does it.
999
+ if(o2.style!==undefined && optHasQ(oT2,'style')){ err(n,ENUM_BARE('style='+o2.style)); return; }
1000
+ if(o2.style!==undefined && !STYLES.includes(o2.style)){ err(n,'style must be solid|dashed|dotted'); return; }
1001
+ let ecls;
1002
+ if(o2['class']!==undefined){
1003
+ const pc=parseClassList(o2['class'],optList(oT2,'class'));
1004
+ if(!pc.ok){ err(n,pc.err); return; }
1005
+ ecls=pc.ids;
1006
+ }
1007
+ // §5 on an edge: the line IS a stroke and has no interior, so `stroke=`
1008
+ // and `fill=` name the same channel (`stroke=` wins when both are
1009
+ // written); `text=` colours the [tail]/[mid]/[head] labels.
1010
+ doc.edges.push({a,b,op,tail,mid,head,style:o2.style,cls:ecls,
1011
+ stroke:o2.stroke,
1012
+ plane:o2.plane||'base',line:n});
221
1013
  }
222
1014
 
223
1015
  for(let li=0; li<lines.length; li++){
@@ -244,9 +1036,9 @@ function parse(text){
244
1036
  segs.push(acc);
245
1037
  const isSep=segs.every(x=>/^\s*:?-+:?\s*$/.test(x)) && segs.length>0; // GFM: 1+ hyphens
246
1038
  if(isSep){
247
- if(cur.sep){ err(n,'duplicate separator row'); continue; }
248
- if(!cur.heads.length){ err(n,'separator row before any header row'); continue; }
249
- if(segs.length!==cur.cols.length){ err(n,'separator has '+segs.length+' columns, expected '+cur.cols.length); continue; }
1039
+ if(cur.sep){ err(n,'duplicate delimiter row'); continue; }
1040
+ if(!cur.heads.length){ err(n,'delimiter row before any header row'); continue; }
1041
+ if(segs.length!==cur.cols.length){ err(n,'delimiter row has '+segs.length+' columns, expected '+cur.cols.length); continue; }
250
1042
  cur.sep=true;
251
1043
  cur.aligns=segs.map(x=>{ x=x.trim();
252
1044
  const l=x.startsWith(':'), r=x.endsWith(':');
@@ -256,12 +1048,17 @@ function parse(text){
256
1048
  // content row: raw empty segment = colspan-left (multimd "||");
257
1049
  // cell exactly ^^ = rowspan-up (multimd). The only cell escapes are
258
1050
  // \| (handled during segmentation) and \^^ (literal caret pair) —
259
- // any other leading backslash is literal cell text (D10).
1051
+ // any other backslash (including \n) is literal cell text (`TABLE-CELL-BACKSLASH`).
1052
+ // Multi-line cells (Br2): GFM-style HTML line breaks
1053
+ // <br> / <br/> / <br /> only — normalized to U+000A in the model;
1054
+ // other HTML is literal text, never markup.
260
1055
  const cells=segs.map(x=>{
261
1056
  if(x.length===0) return {v:'', m:'left'};
262
1057
  const t=x.trim();
263
1058
  if(t==='^^') return {v:'', m:'up'};
264
- return {v:t.startsWith('\\^^')?t.slice(1):t, m:null};
1059
+ let v=t.startsWith('\\^^')?t.slice(1):t;
1060
+ v=v.replace(/<br\s*\/?>/gi,'\n');
1061
+ return {v, m:null};
265
1062
  });
266
1063
  if(cells[0].m==='left'){ err(n,'colspan cannot start in the first column'); continue; }
267
1064
  if(cur.heads.length===0 && cells.some(c=>c.m==='up')){ err(n,'"^^" cannot appear in the first row'); continue; }
@@ -272,43 +1069,120 @@ function parse(text){
272
1069
  } else {
273
1070
  if(cells.length!==cur.cols.length){ err(n,'row has '+cells.length+' cells, expected '+cur.cols.length); continue; }
274
1071
  // rowspan cannot cross the thead/tbody boundary (multimd prior
275
- // art): "^^" in the first data row is a line error (D9).
276
- if(!cur.rows.length && cells.some(c=>c.m==='up')){ err(n,'"^^" cannot appear in the first data row (rowspan does not cross the header separator)'); continue; }
1072
+ // art): "^^" in the first data row is a line error (`MERGE-ACROSS-DELIMITER-ROW`).
1073
+ if(!cur.rows.length && cells.some(c=>c.m==='up')){ err(n,'"^^" cannot appear in the first data row (rowspan does not cross the header delimiter row)'); continue; }
277
1074
  cur.rows.push({cells,hl:false,line:n});
278
1075
  }
279
1076
  continue;
280
1077
  }
1078
+ // `SEMICOLON-STATUS`: the `;` reservation, enforced. Runs after the pipe-row
1079
+ // block (a table cell is raw GFM content) and before every other line
1080
+ // form, so no directive can quietly give `;` a meaning.
1081
+ if(findReservedSemi(raw)>=0){ err(n,RESERVED_SEMI); continue; }
281
1082
  // edge lines carry inline [labels] with free text — dedicated scanner,
282
1083
  // not the generic tokenizer
283
1084
  if(/^edge(\s|$)/.test(raw.trim())){
284
- if(firstContent){ firstContent=false; err(n,'first line must be "figdown 0.1 [template]"'); }
1085
+ if(firstContent){ firstContent=false; err(n,'first line must be "figdown 0.1 <genre>"'); }
285
1086
  cur=null;
1087
+ // `CONTENT-LAYOUT-ZONE-SPLIT`: `edge` is a semantic directive, and it is dispatched here —
1088
+ // before the generic layout-zone gate below — so it needs its own copy
1089
+ // of that gate, or it is the one semantic line that escapes the zone.
1090
+ if(sawLayout){ err(n,'"edge" is a semantic directive — it must appear before the layout zone (`CONTENT-LAYOUT-ZONE-SPLIT`)'); continue; }
1091
+ // `GENRE-KEYWORD-ALLOWLIST`: edge is scene vocabulary only
1092
+ if(sawHeader && doc.genre && GENRE_KW[doc.genre] && !GENRE_KW[doc.genre].has('edge')){
1093
+ err(n,'"edge" is not allowed in genre '+doc.genre); continue; }
286
1094
  parseEdgeLine(raw.trim(),n);
287
1095
  continue;
288
1096
  }
289
1097
  const tk=tokenize(raw.trim());
290
1098
  if(tk.error){ err(n,tk.error); continue; }
291
1099
  const lead=(tk.toks.length&&!tk.toks[0].q)?tk.toks[0].v:'';
292
- const {pos,opts,unk}=splitOpts(tk.toks, lead==='signal');
1100
+ const {pos,posq,posT,opts,optT,unk,dup}=splitOpts(tk.toks, lead==='signal');
293
1101
  const kw=pos[0];
294
- // D3/D12/D14 (0.1-dev.11): uniform per-directive option checks —
1102
+ // `INAPPLICABLE-OPTION-KEYS`/`UNKNOWN-OPTION-DEGRADATION`/`COLOUR-VALUE-VALIDATION`: uniform per-directive option checks —
295
1103
  // unknown keys, registered-but-inapplicable keys, invalid colors.
296
- // Directives not in DIRECTIVE_OPTS (title's rest-of-line, unknown
1104
+ // Directives not in DIRECTIVE_OPTS (title's single quoted string, unknown
297
1105
  // keywords) are handled by their own paths.
298
1106
  const badOpts=(k)=>{
299
1107
  const allowed=DIRECTIVE_OPTS[k];
300
1108
  if(!allowed) return false;
301
1109
  let bad=false;
1110
+ // same-line repeated option key (last-wins was silent data loss)
1111
+ if(dup){ err(n,'duplicate option "'+dup+'=" on one line'); bad=true; }
302
1112
  for(const u of unk){ err(n,'unknown option "'+u+'="'); bad=true; }
1113
+ // Retired spelling: `color=` → `fill=`. Fires only where
1114
+ // the key was accepted; on a directive that never took it the existing
1115
+ // `<directive> does not take color=` is still the right answer.
1116
+ if(opts.fill!==undefined && NO_INTERIOR.has(k)){ err(n,FILL_NO_INTERIOR(k)); bad=true; }
1117
+ if(opts.style!==undefined && NO_ITEM_STYLE.has(k)){ err(n,STYLE_NO_ITEM(k)); bad=true; }
1118
+ // 0.1 retirements: reported on every directive, because the
1119
+ // spelling left the language rather than moving between directives.
1120
+ for(const rk in RETIRED_OPT_KEYS)
1121
+ if(opts[rk]!==undefined){ err(n,RETIRED_OPT_KEYS[rk]); bad=true; }
1122
+ // `RULE-POSITION-ENUMERATION`: RULE 2.4's enum half on the OPTION keys. One loop
1123
+ // for every enum-valued key, the same device the id-valued keys below
1124
+ // use — so a key that gains an enum grammar later is covered by
1125
+ // listing it in ENUM_OPT_KEYS, not by remembering to write a check.
1126
+ // It runs BEFORE the value checks and suppresses them for that key
1127
+ // (`enumQ`): ONE TOKEN, ONE ERROR. A quoted enum has not been
1128
+ // established to be IN the position at all, so reporting what its
1129
+ // CONTENT would have meant is premature — `shape="bkx"` says the
1130
+ // spelling is wrong, once, rather than the spelling and `unknown
1131
+ // shape` on one line. This is the convention `idErr` has followed at
1132
+ // every id position (a quoted id gets the ID RULE and
1133
+ // nothing else), `parseClassList` follows, and the `edge` scanner
1134
+ // follows by returning; the `figdown` header was made to follow it
1135
+ // here too, so all four sites agree.
1136
+ const enumQ=new Set();
1137
+ for(const k of ENUM_OPT_KEYS)
1138
+ if(opts[k]!==undefined && allowed.includes(k) && optHasQ(optT,k)){
1139
+ err(n,ENUM_BARE(k+'='+opts[k])); enumQ.add(k); bad=true; }
303
1140
  for(const o in opts){
1141
+ if(RETIRED_OPT_KEYS[o]) continue; // already reported above
1142
+ if(o==='style'&&NO_ITEM_STYLE.has(k)) continue; // `STYLE-KEY-SCOPE`: named above
304
1143
  if(allowed.includes(o)) continue;
305
1144
  if(k==='group'&&o==='in') err(n,'group does not take in= — nesting is one level (node in=group) in v0.1');
306
1145
  else err(n,k+' does not take '+o+'=');
307
1146
  bad=true;
308
1147
  }
309
- for(const o of ['color','stroke','text'])
1148
+ for(const o of ['fill','stroke'])
310
1149
  if(opts[o]!==undefined && allowed.includes(o) && !isColor(opts[o])){
311
1150
  err(n,'unknown color "'+opts[o]+'" (#hex or CSS color name)'); bad=true; }
1151
+ // §5 style enum — checked once, for every directive that takes style=
1152
+ if(opts.style!==undefined && allowed.includes('style') && !enumQ.has('style') && !STYLES.includes(opts.style)){
1153
+ err(n,'style must be solid|dashed|dotted'); bad=true; }
1154
+ // class= multi-value form: validate spelling here so every
1155
+ // directive that accepts class= shares one check; the parsed list is
1156
+ // re-derived at the push site via parseClassList.
1157
+ if(opts['class']!==undefined && allowed.includes('class')){
1158
+ const pc=parseClassList(opts['class'],optList(optT,'class'));
1159
+ if(!pc.ok){ err(n,pc.err); bad=true; }
1160
+ }
1161
+ // `QUOTING-RULES`: a STRING-typed option value in a whitespace-
1162
+ // delimited position is quoted — the same rule that governs a
1163
+ // positional string. Without the quotes `description=a b` silently
1164
+ // keeps only `a` and turns `b` into a surplus positional on a
1165
+ // different line of the diagnostic. There are TWO string-typed option
1166
+ // values: `description=` (`DESCRIPTION-KEY-SPELLING`, spelled `note=`
1167
+ // until then) and `present=` (`PRESENCE-CONDITION-EXPRESSION`). `present=""` is legal and is the
1168
+ // "conditional, condition not stated" form — an EMPTY quoted value,
1169
+ // not an unquoted one, so the same rule admits it.
1170
+ for(const sk of ['description','present'])
1171
+ if(opts[sk]!==undefined && allowed.includes(sk) && !optQ(optT,sk)){
1172
+ err(n,sk+'= must be quoted: '+sk+'="'+opts[sk]+'" — '+Q_WHY); bad=true; }
1173
+ // `QUOTED-IDS`: `in=` and `plane=` are ID-VALUED options, so the
1174
+ // same rule that governs a positional id governs them. An empty value
1175
+ // keeps its directive-specific message (`threshold needs in=…`); a written
1176
+ // one that is quoted or not a legal id gets the ID RULE.
1177
+ for(const k of ['in','plane'])
1178
+ if(opts[k]!==undefined && allowed.includes(k)){
1179
+ const e=idErr(opts[k], optHasQ(optT,k), null);
1180
+ if(e){ err(n,e); bad=true; }
1181
+ }
1182
+ // `RULE-POSITION-ENUMERATION`: RULE 2.4's enum half on the OPTION keys. One loop
1183
+ // for every enum-valued key, the same device the id-valued keys above
1184
+ // use — so a key that gains an enum grammar later is covered by
1185
+ // listing it once, not by remembering to write a check.
312
1186
  return bad;
313
1187
  };
314
1188
 
@@ -316,216 +1190,561 @@ function parse(text){
316
1190
  firstContent=false;
317
1191
  if(kw==='figdown'){
318
1192
  sawHeader=true;
319
- if(pos[1]!=='0.1') err(n,'unsupported version "'+(pos[1]||'')+'" (expected 0.1)');
320
- if(pos[2]!==undefined){
321
- const TEMPLATES=['block','topology','flowchart','bitfield','table','wave'];
322
- if(!TEMPLATES.includes(pos[2])) err(n,'unknown template "'+pos[2]+'" (block|topology|flowchart|bitfield|table|wave)');
323
- else{ doc.template=pos[2];
324
- // template defaults (D8/R13): flowchart figures flow down —
325
- // the census-dominant direction; an explicit flow line overrides
326
- if(pos[2]==='flowchart') doc.flow='down'; }
327
- }
1193
+ // `RULE-POSITION-ENUMERATION`: the version and the genre are both enum
1194
+ // positions (vocabulary-sources.tsv `figdown.version`,
1195
+ // `figdown.genre`), so both are BARE.
1196
+ //
1197
+ // ONE TOKEN, ONE ERROR — the convention `idErr` has followed at
1198
+ // every id position, and `parseClassList` and the
1199
+ // `edge` scanner follow too. A quoted token has not been established
1200
+ // to be IN the position at all, so reporting what its CONTENT would
1201
+ // have meant is premature: `figdown 0.1 "blok"` says the spelling is
1202
+ // wrong, once, rather than the spelling and `unknown genre` on one
1203
+ // line. So the quoting check runs first and suppresses the value
1204
+ // check for that token, exactly as `badOpts` does for `shape=`.
1205
+ const hq1=!!posq[1], hq2=!!posq[2];
1206
+ if(hq1) err(n,ENUM_BARE('figdown '+pos[1]+' <genre>'));
1207
+ else if(hq2) err(n,ENUM_BARE('figdown 0.1 '+pos[2]));
1208
+ if(!hq1 && pos[1]!=='0.1') err(n,'unsupported version "'+(pos[1]||'')+'" (expected 0.1)');
1209
+ const GENRES=['block','topology','flowchart','bitfield','table','timing'];
1210
+ // 0.1: the genre token is REQUIRED. `bitfield`/`table`/`timing`
1211
+ // documents declare their kind in their content, but `block`,
1212
+ // `topology` and `flowchart` share the SAME vocabulary
1213
+ // (node/edge/group) and differ only in default flow — so the header
1214
+ // is the ONLY place such a document states which kind of figure it
1215
+ // is, and omitting it destroys the distinction with no recoverable
1216
+ // fallback. The message lists the six legal values so an authoring
1217
+ // agent fixes it in one step.
1218
+ if(pos[2]===undefined) err(n,'figdown header requires a genre (block|topology|flowchart|bitfield|table|timing)');
1219
+ else if(hq2){ /* the quoting error above is this token's one error */ }
1220
+ else if(pos[2]==='wave') err(n,RETIRED_WAVE);
1221
+ else if(!GENRES.includes(pos[2])) err(n,'unknown genre "'+pos[2]+'" (block|topology|flowchart|bitfield|table|timing)');
1222
+ else{ doc.genre=pos[2];
1223
+ // genre defaults (`GENRE-NAMESPACE`/`DEFAULT-VALUE-SELECTION`): flowchart figures flow down —
1224
+ // the census-dominant direction; an explicit flow line overrides
1225
+ if(pos[2]==='flowchart') doc.flow='down'; }
1226
+ // §1: a directive line carrying positional arguments its grammar does
1227
+ // not accept MUST be rejected — the header is a directive like any other
1228
+ if(pos.length>3) err(n,'unexpected argument "'+pos[3]+'"');
328
1229
  badOpts('figdown');
329
1230
  continue;
330
- } else { err(n,'first line must be "figdown 0.1 [template]"'); }
1231
+ } else { err(n,'first line must be "figdown 0.1 <genre>"'); }
331
1232
  } else if(kw==='figdown'){ err(n,'duplicate version header'); continue; }
332
1233
 
1234
+ // Retired spelling: `colw` → `width`. Keyword naming
1235
+ // discipline — one lowercase word, borrowed standard terminology; `colw`
1236
+ // was the only invented abbreviation in the registry. Reported wherever
1237
+ // it appears (inside a block or not) so the migration is unmissable.
1238
+ if(kw==='colw'){ err(n,'colw has been renamed: use width (one lowercase word; the table block scopes it to columns)'); continue; }
1239
+
1240
+ // Retired spellings: four keywords whose names collided with
1241
+ // something else in the language.
1242
+ // - `line` collided with the model's own `line` field (the source line
1243
+ // number every element carries) and read as "draw a line", which it is
1244
+ // not; §2.6's prose already called it a guide. 0.1 (`THRESHOLD-KEYWORD-SPELLING`) renamed
1245
+ // that replacement again, to `threshold`, so this message names the
1246
+ // CURRENT spelling — a two-hop migration is one lookup, not two;
1247
+ // - `fill` read as "fill colour" (which was `color=` then) and was also an
1248
+ // option key, so one word lived in two namespaces; §2.6's prose already
1249
+ // called it a range band. 0.1 gave the word back to the OPTION-KEY
1250
+ // namespace only (`color=` → `fill=`); the KEYWORD stays retired, so
1251
+ // `fill` at line start is still this error. §10 records the declared
1252
+ // exception to `UNSAFE-DEFAULT-ELIMINATION` that this one-directional reuse costs;
1253
+ // - `route` sat three characters from the document-level `routing`, which
1254
+ // is ALSO an option key on the same directive, so a typo produced
1255
+ // another legal directive instead of an error, and `route` is a domain
1256
+ // noun in a topology figure (`SHAPE-ENUM-VOCABULARY`: no domain nouns in the presentation
1257
+ // vocabulary) while the directive means geometric waypoints;
1258
+ // - `render` was a verb naming a zone that admits only geometry
1259
+ // (`pin` — and, until this release, `path` and `routing`), and it
1260
+ // collided with the renderer
1261
+ // and the render options of §7. `layout` is the cross-tool word for
1262
+ // this half of a diagram language, and the zone it opens carried the
1263
+ // `# --- layout` comment convention before it became a keyword.
1264
+ // 0.1 (`EDGE-GEOMETRY-CONSTRUCTS`) added `path` and `routing` to this block, and they are
1265
+ // the first entries in it that name NO replacement. `route`'s own message
1266
+ // has to change with them: it pointed at `path`, which no longer exists,
1267
+ // so it now states the whole chain and ends where the others end.
1268
+ 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; }
1269
+ 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; }
1270
+ 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; }
1271
+ if(kw==='path'){ err(n,RETIRED_PATH); continue; }
1272
+ if(kw==='routing'){ err(n,RETIRED_ROUTING); continue; }
1273
+ if(kw==='render'){ err(n,'render has been renamed: use layout (the zone takes only pin — geometry, not presentation) (MIGRATIONS)'); continue; }
1274
+ if(kw==='wrap'){ err(n,RETIRED_WRAP); continue; }
1275
+ if(kw==='boundary'){ err(n,RETIRED_BOUNDARY); continue; }
1276
+ if(kw==='layer'){ err(n,RETIRED_LAYER); continue; }
1277
+ if(kw==='guide'){ err(n,RETIRED_GUIDE); continue; }
1278
+ if(kw==='wave'){ err(n,RETIRED_WAVE); continue; }
1279
+ if(kw==='size'){ err(n,RETIRED_SIZE); continue; }
1280
+ 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; }
1281
+
333
1282
  // typed-block children
334
- if(cur && ['field','wrap','cell','colw','signal','gap'].includes(kw)){
1283
+ if(cur && ['field','break','cell','width','signal','gap'].includes(kw)){
335
1284
  if(badOpts(kw)) continue;
336
1285
  if(cur.type==='bitfield' && kw==='field'){
337
1286
  // Compact form (C bit-field convention): field F1:16,F2:8 SYN:1 ...
338
1287
  // — bare name:width items separated by commas and/or spaces, no
339
1288
  // per-field options. Classic form: field <name> <width> [options].
340
- // Classic form: field <name> <width-in-bits|*> [optional] [color=] [note=]
341
- // '*' = variable-length field: fills the remainder of the current row
1289
+ // Classic form: field <name> <width-in-bits|*> [fill=] [description=]
1290
+ // [present=]
1291
+ // Conditional presence was a POSITIONAL FLAG until this release:
1292
+ // `optional` (…0.1), `conditional` (0.1…0.1),
1293
+ // `optional` again (`PRESENCE-FLAG-SPELLING`). `PRESENCE-CONDITION-EXPRESSION` replaces the flag with
1294
+ // `present=`, an option key whose value is the presence CONDITION as
1295
+ // authored prose. Tri-state: key absent = no presence claim;
1296
+ // present="" = conditional, condition not stated; present="C = 1" =
1297
+ // conditional, condition stated.
1298
+ // '*' = variable-length field: fills the remainder of the current row.
1299
+ // At most one '*' per bitfield block (maintainer A):
1300
+ // '*' is "row remainder / trailing blob", not present-only-if-and-fixed
1301
+ // (use `present=`) and not multi-segment TLV (use table / description=
1302
+ // / v0.2).
342
1303
  const cname=pos[1], cw=pos[2];
343
1304
  if(cname!==undefined && (/^\d+$/.test(cw||'')&&+cw>=1 || cw==='*')){
344
- const fextra=pos.slice(3).find(x=>x!=='optional');
1305
+ // `QUOTING-RULES`: the CLASSIC form's name is a whitespace-
1306
+ // delimited string position, so it is quoted. The compact form's
1307
+ // names are comma-delimited and stay bare (see below).
1308
+ if(!posq[1]){ err(n,'field name must be quoted in the classic form: field "'+cname+'" '+cw+' — '+Q_WHY); continue; }
1309
+ const fextra=pos[3];
1310
+ if(fextra==='optional'){ err(n,RETIRED_FIELD_OPTIONAL); continue; }
1311
+ if(fextra==='conditional'){ err(n,RETIRED_FIELD_CONDITIONAL); continue; }
345
1312
  if(fextra!==undefined){ err(n,'unexpected argument "'+fextra+'"'); continue; }
346
- cur.fields.push({name:cname,w:cw==='*'?'*':+cw,optional:pos.includes('optional'),color:opts.color,cls:opts['class'],note:opts.note,line:n});
1313
+ // `BITFIELD-REPETITION-CONSTRUCT`: `index=` states that this field is one element
1314
+ // of a repeated run. It is refused on a `*` field: `*` already means
1315
+ // "the length is not in the document", so "an element of unstated
1316
+ // width, n times" states nothing at all — two indeterminacies
1317
+ // multiplied, with a picture that would look like knowledge.
1318
+ if(cw==='*' && opts.index!==undefined){
1319
+ err(n,'index= does not apply to a * field: * means the length is NOT in the document, so "an element of unstated width, repeated" asserts nothing a reader could use. Give the repeated element its real width and put the range on it (field "Segment List" 128 index="0..Last Entry"), or keep * and drop index='); continue; }
1320
+ const irange=parseIndexRange(opts.index);
1321
+ if(!irange.ok){ err(n,irange.err); continue; }
1322
+ if(cw==='*'){
1323
+ if(cur.starCount){ err(n,'only one * field is allowed per bitfield (* fills the remainder of the row)'); continue; }
1324
+ cur.starCount=1;
1325
+ }
1326
+ cur.fields.push({name:cname,w:cw==='*'?'*':+cw,present:opts.present,
1327
+ index:irange.val,
1328
+ fill:opts.fill,stroke:opts.stroke,
1329
+ cls:parseClassList(opts['class'],optList(optT,'class')).ids,
1330
+ description:opts.description,line:n});
1331
+ cur.rowFields=true;
347
1332
  continue;
348
1333
  }
349
- // Compact form (C bit-field convention): field a:1, b:2, Long Name:16
350
- // Commas separate items; the name is everything before the last colon
351
- // (spaces allowed, no quotes needed); no per-field options here.
352
- const rest=raw.trim().replace(/^field\s+/,'');
353
- if(!rest.includes(':')){ err(n,'field needs <name> <width-in-bits>, or a name:width list'); continue; }
354
- const items=rest.split(',').map(s=>s.trim()).filter(Boolean);
1334
+ // Compact form (C bit-field convention): field a:1,b:2,"Long Name":16
1335
+ //
1336
+ // 0.1: the list is read from `pos` — the OPTION-STRIPPED token
1337
+ // stream — not from the raw source line, so line-wide options apply
1338
+ // to every item.
1339
+ //
1340
+ // `COMMA-LIST-WHITESPACE`: the list is ONE whitespace-free token (RULE 3.1 —
1341
+ // a positional list must terminate, and whitespace is the only
1342
+ // terminator available). `field a:1, b:2` used to be accepted and is
1343
+ // now an error: one policy for every comma list in the language.
1344
+ // `QUOTING-RULES`: an ELEMENT is comma-delimited, so it is bare unless it
1345
+ // contains whitespace, a comma, a quote, a paren or `#` — and the
1346
+ // quotes now protect a comma inside the element too (`LINK-OPERATOR-IN-IDS` splitList).
1347
+ const items0=pos.slice(1);
1348
+ if(!items0.length||!items0.some(t=>t.includes(':'))){ err(n,'field needs <name> <width-in-bits>, or a name:width list'); continue; }
1349
+ // `BITFIELD-REPETITION-CONSTRUCT`: `index=` is CLASSIC-FORM ONLY in v0.1. Every
1350
+ // other `field` option is LINE-wide on the compact form, which is
1351
+ // right for presentation and for a shared condition — and wrong here:
1352
+ // one index range applied to every item in the list would say that
1353
+ // each of them repeats over the SAME indices, which is a claim no
1354
+ // author has yet wanted to make and which the drawing cannot show.
1355
+ if(opts.index!==undefined){
1356
+ 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; }
1357
+ if(items0.length>1){
1358
+ 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; }
355
1359
  let bad=null; const parsed=[];
356
- for(const it of items){
1360
+ for(const el of splitList(posT[1],0)){
1361
+ const it=el.v; if(!it) continue;
357
1362
  const m=/^(.+):(\d+|\*)$/.exec(it);
358
- if(!m||(m[2]!=='*'&&+m[2]<1)){ bad='bad item "'+it+'" (expected name:width)'; break; }
359
- let nm=m[1].trim();
360
- if(/^".*"$/.test(nm)) nm=nm.slice(1,-1); // quotes tolerated
361
- if(/:(\d+|\*)(\s|$)/.test(nm)){ bad='"'+it+'" looks like two fields — missing comma?'; break; }
1363
+ if(!m||(m[2]!=='*'&&+m[2]<1)){
1364
+ bad=it.includes(':')
1365
+ ? 'bad item "'+it+'" (expected name:width)'
1366
+ : 'bad item "'+it+'" (expected name:width; quote a name that contains spaces: "Long Name":16)';
1367
+ break; }
1368
+ const nm=m[1];
1369
+ // Bit-width overflow (genre doc: "beyond `word` on a NON-SPANNING
1370
+ // field"). A classic `field X 128` wider than the row is the
1371
+ // documented spanning case and stays legal; the compact form is the
1372
+ // C bit-field convention, where an item must fit its storage unit,
1373
+ // and its options are LINE-wide, so no single item in the list can
1374
+ // say "this one is meant to span". So an over-word compact item is
1375
+ // the overflow.
1376
+ if(m[2]!=='*' && +m[2]>cur.word){
1377
+ // The suggestion spells the CLASSIC form, and the classic name is
1378
+ // QUOTED (`QUOTING-RULES`). Until this release this string said
1379
+ // `write "field P 64"` — a second line error, so a user who
1380
+ // followed the diagnostic was told off twice.
1381
+ 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; }
1382
+ if(m[2]==='*'){
1383
+ if(cur.starCount){ bad='only one * field is allowed per bitfield (* fills the remainder of the row)'; break; }
1384
+ cur.starCount=1;
1385
+ }
362
1386
  parsed.push({name:nm,w:m[2]==='*'?'*':+m[2]});
363
1387
  }
1388
+ // On error, still keep any items parsed before the bad one so a
1389
+ // second `*` does not also cascade into "bitfield has no fields".
1390
+ if(parsed.length){
1391
+ // 0.1: line-wide options apply to every item in the list.
1392
+ for(const it of parsed) cur.fields.push({name:it.name,w:it.w,present:opts.present,
1393
+ fill:opts.fill,stroke:opts.stroke,
1394
+ cls:parseClassList(opts['class'],optList(optT,'class')).ids,
1395
+ description:opts.description,line:n});
1396
+ cur.rowFields=true;
1397
+ }
364
1398
  if(bad){ err(n,bad); continue; }
365
- for(const it of parsed) cur.fields.push({name:it.name,w:it.w,optional:false,line:n});
366
- } else if(cur.type==='bitfield' && kw==='wrap'){
1399
+ } else if(cur.type==='bitfield' && kw==='break'){
1400
+ if(pos.length>1){ err(n,'unexpected argument "'+pos[1]+'"'); continue; }
1401
+ // `break` ends the row after the fields declared since the block
1402
+ // opened (or since the previous break). With none there is nothing to
1403
+ // break — genre doc: "break with no preceding field in the current row".
1404
+ // Spelled `wrap` until this release (`ROW-BREAK-NAMING`): in CSS/typography `wrap` is
1405
+ // AUTOMATIC reflow — a mode — while this is an EXPLICIT break, an
1406
+ // event; CSS Fragmentation calls exactly this "a forced break …
1407
+ // explicitly indicated by the … author" and HTML spells it `br`.
1408
+ // is a line error. A field that ends exactly on a row boundary still
1409
+ // counts: it IS the preceding field of the row being closed.
1410
+ if(!cur.rowFields){ err(n,'break has no preceding field in the current row'); continue; }
367
1411
  cur.fields.push({wrap:true,line:n});
368
- } else if(cur.type==='table' && kw==='colw'){
369
- if(cur.colw){ err(n,'duplicate colw'); continue; }
370
- const vals=pos.slice(1);
371
- if(!vals.length){ err(n,'colw needs one width per column (auto | <px> | <n>%)'); continue; }
1412
+ cur.rowFields=false;
1413
+ } else if(cur.type==='table' && kw==='width'){
1414
+ if(cur.width){ err(n,'duplicate width'); continue; }
1415
+ // 0.1: `width auto,90,auto,25%` — ONE comma-delimited token —
1416
+ // is the canonical spelling; it terminates at the first whitespace
1417
+ // instead of consuming the rest of the line. The space form
1418
+ // `width auto 90 auto 25%` stays accepted (same reasoning as `rank`);
1419
+ // mixing the two on one line is an error.
1420
+ // `POSITIONAL-LIST-SPELLING`: comma form only; the space form is retired.
1421
+ const wtoks=pos.slice(1);
1422
+ if(wtoks.length>1){
1423
+ err(n,'width takes ONE comma-delimited token: write width '+joinListForm(wtoks)+' — the space form is retired (MIGRATIONS)'); continue; }
1424
+ const vals=wtoks.length?splitList(posT[1],0).map(e=>e.v):[];
1425
+ if(!vals.length){ err(n,'width needs one value per column (auto | <px> | <n>%)'); continue; }
372
1426
  let badw=null;
373
1427
  const parsed=vals.map(v=>{
374
1428
  if(v==='auto') return {t:'auto'};
375
- let m=/^(\d+(?:\.\d+)?)px$/.exec(v)||/^(\d+(?:\.\d+)?)$/.exec(v);
1429
+ // 0.1: a `px` SUFFIX is rejected. `pin width=90px` was
1430
+ // already an error, and RULE 4.4 says two constructs that share a
1431
+ // spelling on purpose must share the value grammar too.
1432
+ let m=/^(\d+(?:\.\d+)?)$/.exec(v);
376
1433
  if(m) return {t:'px',v:+m[1]};
377
1434
  m=/^(\d+(?:\.\d+)?)%$/.exec(v);
378
1435
  if(m) return {t:'pct',v:+m[1]};
379
- badw='bad width "'+v+'" (auto | <px> | <n>%)'; return null;
1436
+ badw=/^\d+(?:\.\d+)?px$/.test(v)
1437
+ ? 'bad width "'+v+'" — write the number without a unit: '+v.replace(/px$/,'')+' (auto | <px> | <n>%)'
1438
+ : 'bad width "'+v+'" (auto | <px> | <n>%)';
1439
+ return null;
380
1440
  });
381
1441
  if(badw){ err(n,badw); continue; }
382
- cur.colw={vals:parsed,line:n};
1442
+ cur.width={vals:parsed,line:n};
383
1443
  } else if(cur.type==='table' && kw==='cell'){
384
- // cell h<k>,<c> | <r>,<c> color=… (h1..hN = header tiers top-down;
385
- // data rows 1-based below the separator); cell <r> highlight
386
- const rc=/^(h?)(\d+)(?:,(\d+))?$/.exec(pos[1]||'');
387
- const hl=pos.includes('highlight');
1444
+ // cell h<k>,<c> | <r>,<c> fill=… (h1..hN = header tiers top-down;
1445
+ // data rows 1-based below the delimiter row); cell <r> highlight
1446
+ // 0.1 (RULE 1.1a): a grid ADDRESS is a point, so the two-part
1447
+ // form is parenthesised — `cell (h1,2)`, `cell (3,4)`. The
1448
+ // single-valued row form (`cell 3 highlight`) is not a point and
1449
+ // stays bare, exactly as `threshold offset=50%` does.
1450
+ const cadr=pos[1]||'';
1451
+ if(/^h?\d+,\d+$/.test(cadr)){
1452
+ 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; }
1453
+ const rc=/^\((h?)(\d+),(\d+)\)$/.exec(cadr)||/^(h?)(\d+)$/.exec(cadr);
1454
+ // `RULE-POSITION-ENUMERATION`: `highlight` is a bare keyword FLAG
1455
+ // (vocabulary-sources.tsv `cell.highlight`), so RULE 2.4 governs it
1456
+ // exactly as it governs an enum value: `cell 1 "highlight"` used to
1457
+ // be accepted and set the row mark. The index is found first so the
1458
+ // quotedness can be read off `posq` at the SAME index.
1459
+ const hli=pos.indexOf('highlight');
1460
+ if(hli>0&&posq[hli]){ err(n,ENUM_BARE('cell '+pos[1]+' highlight')); continue; }
1461
+ const hl=hli>0;
388
1462
  const cextra=pos.slice(2).find(x=>x!=='highlight');
389
1463
  if(cextra!==undefined){ err(n,'unexpected argument "'+cextra+'"'); continue; }
390
- if(!rc||(!opts.color&&!opts['class']&&!hl)){ err(n,'cell needs [h]<row>[,<col>] with color=…/class=… or highlight'); continue; }
1464
+ // any §5 presentation attribute is annotation enough to address a cell
1465
+ const cpres=opts.fill!==undefined||opts.stroke!==undefined;
1466
+ if(!rc||(!cpres&&!opts['class']&&!hl)){ err(n,'cell needs [h]<row>[,<col>] with fill=…/class=… or highlight'); continue; }
391
1467
  if(!rc[1]&&+rc[2]===0){ err(n,'row 0 is retired — address header tiers as h1..hN (top-down)'); continue; }
1468
+ // `ROW-HIGHLIGHT-CELL-FILL-COLLISION`: `highlight` is a ROW mark, `fill=` is a CELL
1469
+ // mark, and both paint the SAME channel — the cell interior. Written
1470
+ // for one cell they used to resolve silently, and the resolution ran
1471
+ // the wrong way: the flag was DISCARDED and never reached the model.
1472
+ // Two keys for one channel with a silent precedence is the `color=`
1473
+ // and the §8.4 `fill=`/`stroke=` defect, and this project's answer to
1474
+ // it has always been a named line error, never a precedence rule.
1475
+ if(hl&&rc[3]!==undefined){ err(n,CELL_HL_ON_CELL); continue; }
392
1476
  if(hl&&rc[3]===undefined){
393
1477
  if(rc[1]){ err(n,'highlight applies to data rows only'); continue; }
394
1478
  cur.rowmarks=cur.rowmarks||[];
395
1479
  cur.rowmarks.push({r:+rc[2],line:n});
396
- } else if(rc[3]!==undefined&&(opts.color||opts['class'])){
1480
+ } else if(rc[3]!==undefined&&(cpres||opts['class'])){
397
1481
  cur.marks=cur.marks||[];
398
- cur.marks.push({hdr:!!rc[1],r:+rc[2],c:+rc[3],color:opts.color,cls:opts['class'],line:n});
399
- } else { err(n,'cell needs [h]<row>,<col> color=…/class=… or <row> highlight'); }
400
- } else if(cur.type==='wave' && kw==='signal'){
1482
+ cur.marks.push({hdr:!!rc[1],r:+rc[2],c:+rc[3],fill:opts.fill,stroke:opts.stroke,
1483
+ cls:parseClassList(opts['class'],optList(optT,'class')).ids,line:n});
1484
+ } else { err(n,'cell needs [h]<row>,<col> fill=…/class=… or <row> highlight'); }
1485
+ } else if(cur.type==='timing' && kw==='signal'){
401
1486
  const name=pos[1], lane=pos[2];
402
1487
  if(!name||!lane){ err(n,'signal needs <name> <lane>'); continue; }
403
- if(!/^[01pnx=.\d]+$/.test(lane)){ err(n,'lane may contain only 0 1 p n x = . digits'); continue; }
404
- cur.signals.push({name,lane,labels:(opts.labels||'').split(',').filter(Boolean)});
405
- } else if(cur.type==='wave' && kw==='gap'){
406
- const t=parseInt(pos[1],10);
407
- if(!isFinite(t)||t<0){ err(n,'gap needs a tick number'); continue; }
408
- cur.gaps.push(t);
1488
+ if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); continue; }
1489
+ // `TIMING-LANE-ALPHABET`: `2`-`9` LEFT the alphabet. WaveDrom's schema gives
1490
+ // them "value with color 2".."value with color 9" and `=` "value
1491
+ // (default color 2)", so `=` and `2` are the same brick and a
1492
+ // WaveDrom-literate reader counts every contiguous value cell as
1493
+ // needing a data entry. FigDown instead drew the digit CHARACTER as
1494
+ // the box label and consumed no entry, so `x..=3=5x data=cfg,val`
1495
+ // read as four data cells to them and as boxes cfg,3,val,5 here —
1496
+ // legal, different, and silent. Named diagnostic, not `unknown
1497
+ // character`, because the author's mental model is the thing to fix.
1498
+ if(/[2-9]/.test(lane)){ err(n,RETIRED_LANE_DIGIT); continue; }
1499
+ if(!/^[01pnx=.]+$/.test(lane)){ err(n,'lane may contain only 0 1 p n x = .'); continue; }
1500
+ // / `TYPED-BLOCK-SILENT-FALLBACK`: data= is ABSENCE vs presence. An empty value, empty
1501
+ // members (a,b), or a count that does not match the lane's `=`
1502
+ // cells are all line errors — never silent drop or shift.
1503
+ // Spelled `labels=` until this release (`SIGNAL-DATA-KEY-SPELLING`): WaveDrom's own key is
1504
+ // `data`, "an array of signal labels" naming every value cell, and
1505
+ // after the `2`-`9` retirement (`TIMING-LANE-ALPHABET`) the two scopes coincide exactly.
1506
+ let labels;
1507
+ if(opts.data!==undefined){
1508
+ if(opts.data===''){ err(n,'data= must not be empty'); continue; }
1509
+ // `LINK-OPERATOR-IN-IDS`/2g: split on UNQUOTED commas, so a quoted
1510
+ // element protects its own comma (`data="a,b"` is ONE label).
1511
+ // Before this the tokenizer discarded quotedness before the split
1512
+ // and the language had no spelling at all for such an element.
1513
+ // Whether a data= element SHOULD be allowed to contain a comma is
1514
+ // deliberately left open (202 elements observed, 0 needing one, and
1515
+ // the construct is experimental) — this only makes it expressible.
1516
+ const parts=optList(optT,'data').map(e=>e.v);
1517
+ if(parts.some(p=>p==='')){ err(n,'data= must not contain empty members'); continue; }
1518
+ const eqN=(lane.match(/=/g)||[]).length;
1519
+ if(parts.length!==eqN){
1520
+ err(n,'data= has '+parts.length+' label'+(parts.length===1?'':'s')+', expected '+eqN+' (one per = in the lane)');
1521
+ continue;
1522
+ }
1523
+ labels=parts;
1524
+ }
1525
+ cur.signals.push({name,lane,labels,
1526
+ fill:opts.fill,stroke:opts.stroke});
1527
+ } else if(cur.type==='timing' && kw==='gap'){
1528
+ // `TYPED-BLOCK-SILENT-FALLBACK`: integer only (no parseInt truncation of 1.5→1), no silent
1529
+ // duplicates, range checked after the block closes (needs all lanes).
1530
+ const raw=pos[1];
1531
+ if(raw===undefined||!/^\d+$/.test(raw)){ err(n,'gap needs a non-negative integer cycle'); continue; }
1532
+ if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); continue; }
1533
+ const t=+raw;
1534
+ if(cur.gaps.some(g=>g.t===t)){ err(n,'duplicate gap at cycle '+t); continue; }
1535
+ cur.gaps.push({t,line:n});
409
1536
  } else { err(n,'"'+kw+'" not valid inside '+cur.type); }
410
1537
  continue;
411
1538
  }
412
1539
  cur=null; // any other keyword closes the block
1540
+ // Dynamic-profile reserved words keep their dedicated message (before `GENRE-KEYWORD-ALLOWLIST`).
1541
+ if(kw==='page'||kw==='set'||kw==='pulse'){
1542
+ err(n,'"'+kw+'" is reserved for the dynamic profile (not in v0.1)'); continue; }
1543
+ // `GENRE-KEYWORD-ALLOWLIST`: after closing a typed region, top-level keywords
1544
+ // must be in the header genre allowlist. Child keywords still use the
1545
+ // "needs a bitfield/table/timing above" path when they appear with no cur.
1546
+ if(sawHeader && doc.genre && GENRE_KW[doc.genre] && !CHILD_KW.has(kw) && !GENRE_KW[doc.genre].has(kw)){
1547
+ err(n,'"'+kw+'" is not allowed in genre '+doc.genre);
1548
+ continue;
1549
+ }
413
1550
  if(badOpts(kw)) continue;
414
1551
 
415
- // R43: render zone — after `render`, only rendering directives are legal
416
- if(sawRender && !RENDER_DIRECTIVES.has(kw) && kw!=='render'){
417
- err(n,'"'+kw+'" is a semantic directive — it must appear before the render zone (R43)');
1552
+ // `CONTENT-LAYOUT-ZONE-SPLIT`: layout zone — after `layout`, only layout directives are legal
1553
+ if(sawLayout && !LAYOUT_DIRECTIVES.has(kw) && kw!=='layout'){
1554
+ err(n,'"'+kw+'" is a semantic directive — it must appear before the layout zone (`CONTENT-LAYOUT-ZONE-SPLIT`)');
418
1555
  continue;
419
1556
  }
420
1557
 
421
1558
  switch(kw){
422
1559
  case 'title': {
423
- // rest-of-line: `title TCP Header` == `title "TCP Header"` (A-1)
424
- let t=raw.trim().replace(/^title\s*/,'');
425
- const qm=/^"([\s\S]*)"$/.exec(t);
426
- if(qm){
427
- // decode escapes left-to-right in one pass, exactly like the
428
- // generic tokenizer: \\ consumes first, so "a\\nb" is a\nb
429
- // (backslash + letter n), never a line break (D2, 0.1-dev.11).
430
- // Unknown escapes were already rejected by tokenize() above.
431
- const s0=qm[1]; t='';
432
- for(let ii=0;ii<s0.length;ii++){
433
- if(s0[ii]==='\\'){
434
- const e=s0[ii+1];
435
- if(e==='n'){ t+='\n'; ii++; continue; }
436
- if(e==='"'){ t+='"'; ii++; continue; }
437
- if(e==='\\'){ t+='\\'; ii++; continue; }
438
- }
439
- t+=s0[ii];
440
- }
441
- }
442
- doc.title=t; break;
1560
+ if(sawTitle){ err(n,'duplicate title line'); break; }
1561
+ // 0.1: the string MUST be quoted. `title` was the only
1562
+ // directive that accepted an unquoted string containing spaces —
1563
+ // `node`, `class`, `group` and `external` all report
1564
+ // `unexpected argument "…"` for the second word — and the
1565
+ // rest-of-line form carried three defects of its own: a title
1566
+ // could never contain a `#` (the comment scanner cut it), inner
1567
+ // quotes ended up inside the value, and escapes resolved ONLY in
1568
+ // the quoted form, so `title A\nB` and `title "A\nB"` produced
1569
+ // different values for the same visible text. One form, one
1570
+ // meaning: the token is a normal quoted string and the generic
1571
+ // tokenizer above has already resolved its escapes.
1572
+ const t0=tk.toks[1];
1573
+ if(!t0||!t0.q){ err(n,'title needs a quoted string: title "<text>" (MIGRATIONS)'); break; }
1574
+ if(tk.toks.length>2){ err(n,'unexpected argument "'+tk.toks[2].v+'"'); break; }
1575
+ doc.title=t0.v; sawTitle=true; break;
443
1576
  }
444
1577
  case 'class': {
445
- // semantic class (D9): meaning + presentation defaults declared
1578
+ // semantic class (`CATEGORICAL-MEANING-MAPPING`): meaning + presentation defaults declared
446
1579
  // once; elements join via class=<id>; the legend strip derives
447
1580
  const id=pos[1];
448
- if(!id||!ID_RE.test(id)){ err(n,'class needs an id'); break; }
1581
+ { const e=idErr(id,posq[1],'class needs an id'); if(e){ err(n,e); break; } }
449
1582
  if(classIds.has(id)){ err(n,'duplicate class "'+id+'"'); break; }
450
- if(!pos[2]){ err(n,'class needs a meaning: class '+id+' "<meaning>"'); break; }
1583
+ // 0.1 (`CLASS-EMPTY-MEANING`): the meaning field is syntactically MANDATORY and
1584
+ // its value may be EMPTY — the language's own absent/""/"text"
1585
+ // tri-state (`EMPTY-LABEL-STATE`, `EMPTY-LABEL-DIRECTIVE-COVERAGE`), the same rule labels already follow.
1586
+ // class x -> line error (the field is missing)
1587
+ // class x "" -> legal; the author claims NO meaning, so the
1588
+ // class is pure attribute grouping and draws
1589
+ // no legend entry
1590
+ // class x "Hot path" -> legal; the entry is drawn
1591
+ // The test is ABSENCE, never truthiness: `!pos[2]` treated `""` as
1592
+ // missing, which is exactly the collapse `EMPTY-LABEL-STATE` exists to prevent.
1593
+ if(pos[2]===undefined){ err(n,'class needs a meaning: class '+id+' "<meaning>" (write "" to declare no meaning: the class then groups attributes only and draws no legend entry)'); break; }
1594
+ if(!posq[2]){ err(n,'class meaning must be quoted: class '+id+' "'+pos[2]+'" — '+Q_WHY); break; }
451
1595
  if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
452
- const cstyle=opts.style;
453
- if(cstyle!==undefined && !['solid','dashed','dotted'].includes(cstyle)){ err(n,'style must be solid|dashed|dotted'); break; }
454
1596
  classIds.add(id);
455
- doc.classes.push({id,label:pos[2],color:opts.color,stroke:opts.stroke,
456
- text:opts.text,style:cstyle,line:n});
1597
+ // `plane=` on a class is the members' default plane (an element's own
1598
+ // plane= wins, rigidity `LAYOUT-STABILITY`) — the class carries all five §5 attributes.
1599
+ doc.classes.push({id,label:pos[2],fill:opts.fill,stroke:opts.stroke,
1600
+ style:opts.style,plane:opts.plane,line:n});
457
1601
  break;
458
1602
  }
459
- case 'layer': {
1603
+ case 'plane': {
460
1604
  const id=pos[1];
461
- if(!id||!ID_RE.test(id)){ err(n,'layer needs an id'); break; }
462
- if(layerIds.has(id)){ err(n,'duplicate layer id "'+id+'"'); break; }
1605
+ { const e=idErr(id,posq[1],'plane needs an id'); if(e){ err(n,e); break; } }
1606
+ if(planeIds.has(id)){ err(n,'duplicate plane id "'+id+'"'); break; }
1607
+ if(pos[2]!==undefined&&!posq[2]){ err(n,'plane label must be quoted: plane '+id+' "'+pos[2]+'" — '+Q_WHY); break; }
463
1608
  if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
464
- let z=doc.layers.length;
465
- if(opts.z!==undefined){
466
- if(!/^-?\d+$/.test(opts.z)){ err(n,'z must be a number'); break; }
467
- z=parseInt(opts.z,10);
1609
+ // 0.1: `z=` -> `z-index=` (CSS's own spelling for the
1610
+ // stacking concept, taken in full per RULE 4.2). The retired `z=`
1611
+ // is caught language-wide in RETIRED_OPT_KEYS before this runs.
1612
+ let z=doc.planes.length;
1613
+ const zi=opts['z-index'];
1614
+ if(zi!==undefined){
1615
+ if(!/^-?\d+$/.test(zi)){ err(n,'z-index must be a number'); break; }
1616
+ z=parseInt(zi,10);
468
1617
  }
469
- layerIds.add(id);
470
- doc.layers.push({id,label:pos[2]||'',z});
1618
+ planeIds.add(id);
1619
+ // `EMPTY-LABEL-STATE`: absent is absent, `""` is a written value — same
1620
+ // non-collapsing form as node/group/bundle and the typed blocks.
1621
+ doc.planes.push({id,label:pos[2]!==undefined?pos[2]:null,z});
471
1622
  break;
472
1623
  }
1624
+ // `FLOWCHART-ROLE-KEYWORDS`: `process` / `decision` / `terminator` DESUGAR to
1625
+ // `node` plus a `role` field. One case, four spellings, so there is
1626
+ // exactly one place where a node is built and no second id namespace:
1627
+ // a role line shares the node/group/external/typed-block id space and
1628
+ // is addressable by `edge`, `pin` and `rank` like any node.
1629
+ //
1630
+ // A bare `node` under `flowchart` stays ROLE-UNSTATED — it does NOT
1631
+ // default to `process`. `UNSAFE-DEFAULT-ELIMINATION` §3: a default is legitimate only when
1632
+ // being wrong is harmless, and a flowchart node may be a datastore, an
1633
+ // annotation or a state, so `role:"process"` by default would let the
1634
+ // model assert a falsehood the figure cannot be inspected to catch.
1635
+ case 'process': case 'decision': case 'terminator':
473
1636
  case 'node': {
1637
+ const role=kw==='node'?null:kw;
474
1638
  const id=pos[1];
475
- if(!id||!ID_RE.test(id)){ err(n,'node needs an id'); break; }
476
- if(nodeIds.has(id)||groupIds.has(id)||boundaryIds.has(id)){ err(n,'duplicate id "'+id+'"'); break; }
477
- if(opts.kind!==undefined){ err(n,'kind= has been renamed: use shape= (geometric; the label text carries the device semantics)'); break; }
478
- if(opts.w!==undefined||opts.h!==undefined){ err(n,'node does not take w=/h= — use a size line'); break; }
479
- const shape=opts.shape||'box';
1639
+ { const e=idErr(id,posq[1],kw+' needs an id'); if(e){ err(n,e); break; } }
1640
+ if(dupId(id)){ err(n,'duplicate id "'+id+'"'); break; }
1641
+ if(opts.width!==undefined||opts.height!==undefined){ err(n,kw+' does not take width=/height= — use a pin line'); break; }
1642
+ // Geometry is DERIVED from the role, and `shape=` overrides the
1643
+ // DRAWING only. §12.7: presentation is not meaning on its own, so
1644
+ // `decision q "…" shape=box` is still a decision in the model — the
1645
+ // role is the model, the shape is how it happens to be drawn.
1646
+ const shape=opts.shape||(role?ROLE_SHAPE[role]:'box');
1647
+ if(RETIRED_SHAPES[shape]){ err(n,RETIRED_SHAPES[shape]); break; }
480
1648
  if(!SHAPES.includes(shape)){ err(n,'unknown shape "'+shape+'" ('+SHAPES.join('|')+')'); break; }
1649
+ if(pos[2]!==undefined&&!posq[2]){ err(n,kw+' label must be quoted: '+kw+' '+id+' "'+pos[2]+'" — '+Q_WHY); break; }
481
1650
  nodeIds.add(id);
482
1651
  if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
483
- const nstyle=opts.style;
484
- if(nstyle!==undefined && !['solid','dashed','dotted'].includes(nstyle)){ err(n,'style must be solid|dashed|dotted'); break; }
485
- doc.nodes.push({id,label:pos[2]||id,shape,color:opts.color,stroke:opts.stroke,
486
- style:nstyle,text:opts.text,cls:opts['class'],
487
- group:opts['in']||null,layer:opts.layer||'base',line:n});
1652
+ // `OMITTED-LABEL-RECORDING`: an omitted label is recorded as ABSENT (null), never silently
1653
+ // replaced by the id — a reading agent must be able to tell "the author
1654
+ // wrote no label" from "the author's label happens to equal the id".
1655
+ // Display falls back to the id in render(), so the figure is unchanged.
1656
+ doc.nodes.push({id,label:pos[2]!==undefined?pos[2]:null,shape,role,fill:opts.fill,stroke:opts.stroke,
1657
+ style:opts.style,cls:parseClassList(opts['class'],optList(optT,'class')).ids,
1658
+ group:opts['in']||null,plane:opts.plane||'base',line:n});
488
1659
  break;
489
1660
  }
490
1661
  case 'group': {
491
1662
  const id=pos[1];
492
- if(!id||!ID_RE.test(id)){ err(n,'group needs an id'); break; }
493
- if(nodeIds.has(id)||groupIds.has(id)||boundaryIds.has(id)){ err(n,'duplicate id "'+id+'"'); break; }
1663
+ { const e=idErr(id,posq[1],'group needs an id'); if(e){ err(n,e); break; } }
1664
+ if(dupId(id)){ err(n,'duplicate id "'+id+'"'); break; }
1665
+ if(pos[2]!==undefined&&!posq[2]){ err(n,'group label must be quoted: group '+id+' "'+pos[2]+'" — '+Q_WHY); break; }
494
1666
  if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
495
1667
  groupIds.add(id);
496
1668
  let ggap;
497
1669
  if(opts.gap!==undefined){
1670
+ // the numeric-value rule: exact number grammar (non-negative, including 0 for flush)
1671
+ if(!/^\d+(\.\d+)?$/.test(opts.gap)){ err(n,'gap must be a non-negative number'); break; }
498
1672
  ggap=parseFloat(opts.gap);
499
1673
  if(!isFinite(ggap)||ggap<0){ err(n,'gap must be a non-negative number'); break; }
500
1674
  }
501
- const gstyle=opts.style;
502
- if(gstyle!==undefined && !['solid','dashed','dotted'].includes(gstyle)){ err(n,'style must be solid|dashed|dotted'); break; }
503
- doc.groups.push({id,label:pos[2]||id,color:opts.color,stroke:opts.stroke,
504
- text:opts.text,style:gstyle,gap:ggap,cls:opts['class'],
505
- layer:opts.layer||null,line:n});
1675
+ doc.groups.push({id,label:pos[2]!==undefined?pos[2]:null,fill:opts.fill,stroke:opts.stroke,
1676
+ style:opts.style,gap:ggap,cls:parseClassList(opts['class'],optList(optT,'class')).ids,
1677
+ plane:opts.plane||null,line:n});
506
1678
  break;
507
1679
  }
508
- case 'boundary': {
509
- // external I/O endpoint (R44): "the outside world", not a
1680
+ case 'external': {
1681
+ // external I/O endpoint (`EXTERNAL-EDGE-ENDPOINTS`): "the outside world", not a
510
1682
  // participant node. Referenced by edges like a node, pinnable for
511
1683
  // layout, NEVER drawn as a shape — the edge simply ends open at an
512
1684
  // invisible anchor, optionally with the small label beyond the end.
513
- // Shares the node/group id namespace; takes no options.
1685
+ // Shares the node/group/block id namespace. Of the §5 attributes it
1686
+ // can carry only the two that need no drawn shape: `text=` (the label
1687
+ // colour) and `plane=` (organizational, exactly as on a node).
1688
+ // Spelled `boundary` until this release (`EXTERNAL-ENDPOINT-NAMING`): three standards claim that
1689
+ // word for the OPPOSITE meaning (UML ECB «boundary» is an internal
1690
+ // interface object, C4 System_Boundary is a dashed grouping container,
1691
+ // BPMN's is an event), and this spec's own prose had already stopped
1692
+ // using it — §2.8 says "declares an external I/O endpoint".
514
1693
  const id=pos[1];
515
- if(!id||!ID_RE.test(id)){ err(n,'boundary needs an id'); break; }
516
- if(nodeIds.has(id)||groupIds.has(id)||boundaryIds.has(id)){ err(n,'duplicate id "'+id+'"'); break; }
1694
+ { const e=idErr(id,posq[1],'external needs an id'); if(e){ err(n,e); break; } }
1695
+ if(dupId(id)){ err(n,'duplicate id "'+id+'"'); break; }
1696
+ if(pos[2]!==undefined&&!posq[2]){ err(n,'external label must be quoted: external '+id+' "'+pos[2]+'" — '+Q_WHY); break; }
517
1697
  if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
518
1698
  boundaryIds.add(id);
519
- doc.boundaries.push({id,label:pos[2]||undefined,line:n});
1699
+ // `EMPTY-LABEL-STATE`: the `OMITTED-LABEL-RECORDING` batch never converted this line; `pos[2]||undefined`
1700
+ // collapsed an explicitly empty label into absence. Same form as the
1701
+ // other eight label-bearing directives now.
1702
+ doc.boundaries.push({id,label:pos[2]!==undefined?pos[2]:null,plane:opts.plane,line:n});
520
1703
  break;
521
1704
  }
522
1705
  case 'flow': {
1706
+ if(sawFlow){ err(n,'duplicate flow line'); break; }
1707
+ // `RULE-POSITION-ENUMERATION`: `flow.direction` is an enum position — bare.
1708
+ if(posq[1]){ err(n,ENUM_BARE('flow '+pos[1])); break; }
523
1709
  if(!['right','down','left','up'].includes(pos[1])){ err(n,'flow needs right|down|left|up'); break; }
524
1710
  if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
525
- doc.flow=pos[1]; break;
1711
+ doc.flow=pos[1]; sawFlow=true; break;
526
1712
  }
527
1713
  case 'rank': {
528
- const ids=pos.slice(1);
1714
+ // 0.1: `rank a,b,c` — ONE comma-delimited token — is the
1715
+ // canonical spelling. It terminates at the first whitespace, so the
1716
+ // rest of the line stays free for future `key=` options, and it reads
1717
+ // as one list rather than as a directive whose arity is "the rest of
1718
+ // the line". The space form `rank a b c` is NOT deprecated and stays
1719
+ // accepted: options are extracted by shape (`key=`) from anywhere on
1720
+ // the line, so the space form blocks only a trailing bare positional.
1721
+ // Migration cost of the comma form is zero — it was unreachable
1722
+ // before (a comma-bearing id is not a legal id), so no document can
1723
+ // have used it. Mixing the two spellings on one line is an error.
1724
+ // `QUOTED-IDS`: the elements are read quote-aware, so
1725
+ // `rank "a",b` reports the ID RULE instead of the old — and simply
1726
+ // wrong — "do not mix the comma form with the space form", which
1727
+ // fired because the lexer split `"a",b` into two tokens.
1728
+ // `POSITIONAL-LIST-SPELLING` REVERSES `POSITIONAL-LIST-SPELLING`'s disposition: the SPACE form is
1729
+ // retired and `rank a,b,c` is the only spelling. `POSITIONAL-LIST-SPELLING` kept both on a
1730
+ // measured migration cost (360 downstream lines, 25 local); the
1731
+ // maintainer has since ruled that cost is not a language argument
1732
+ // before the freeze, and `POSITIONAL-LIST-SPELLING`'s own words concede the change is "a
1733
+ // notation-only improvement" — while the notation IS the language.
1734
+ // Two spellings of one meaning give a generating model two attractors
1735
+ // (`PRIOR-ART-BORROWING`), and this pair is the worst of the three because `bundle`
1736
+ // could not even detect a half-converted line.
1737
+ const rtoks=pos.slice(1), rT=posT.slice(1);
1738
+ if(rtoks.length>1){
1739
+ err(n,'rank takes ONE comma-delimited token: write rank '+joinListForm(rtoks)+' — the space form is retired (MIGRATIONS)'); break; }
1740
+ const rels=rtoks.length?splitList(rT[0],0):[];
1741
+ let badr=null;
1742
+ for(const e of rels){
1743
+ const m2=idErr(e.v,e.h,'rank ids must not be empty (write rank a,b,c)');
1744
+ if(m2){ badr=m2; break; }
1745
+ }
1746
+ if(badr){ err(n,badr); break; }
1747
+ const ids=rels.map(e=>e.v);
529
1748
  if(ids.length<2){ err(n,'rank needs two or more node ids'); break; }
530
1749
  doc.ranks.push({ids,line:n}); break;
531
1750
  }
@@ -533,156 +1752,287 @@ function parse(text){
533
1752
  // semantic link bundle (LAG / Ethernet Segment): the renderer draws
534
1753
  // the dashed ellipse around the member links automatically
535
1754
  const id=pos[1];
536
- if(!id||!ID_RE.test(id)){ err(n,'bundle needs an id'); break; }
1755
+ { const e=idErr(id,posq[1],'bundle needs an id'); if(e){ err(n,e); break; } }
537
1756
  if(bundleIds.has(id)){ err(n,'duplicate bundle id "'+id+'"'); break; }
538
- let rest=pos.slice(2), tlabel=id;
539
- if(tk.toks[2]&&tk.toks[2].q){ tlabel=pos[2]; rest=pos.slice(3); }
1757
+ // `OPTION-POSITION-PARSING`: quotedness is tested `pos`-aligned (posq), so an option may
1758
+ // precede the label exactly as it may on node/group/class/plane.
1759
+ let restT=posT.slice(2), tlabel=null; // `OMITTED-LABEL-RECORDING`: absent label stays absent
1760
+ if(posq[2]){ tlabel=pos[2]; restT=posT.slice(3); }
1761
+ // 0.1 (EXPERIMENTAL, `bundle` is a demoted construct):
1762
+ // the member list may be ONE comma-delimited token —
1763
+ // `bundle t1 "LAG" a--b,c--d` — so it terminates at whitespace
1764
+ // instead of resting entirely on the `A--B` shape test. The
1765
+ // whitespace-separated form stays accepted, including the trailing
1766
+ // comma per token (`a--b, c--d`) that the old member regex already
1767
+ // tolerated and that local documents already use; that tolerance is
1768
+ // why `bundle` — unlike `rank` and `width` — cannot reject "mixing":
1769
+ // the two spellings were never distinguishable here.
1770
+ //
1771
+ // `LINK-OPERATOR-IN-IDS`: a member is SPLIT on `--`, not matched by a
1772
+ // greedy two-id regex. With `--` banned inside an id the split is
1773
+ // total: exactly two parts, each a legal id, or the line is an
1774
+ // error. `a-x--b` no longer silently picks one of two readings.
1775
+ // `POSITIONAL-LIST-SPELLING`: comma form only; the space form is retired. It
1776
+ // is now detectable — a second positional token after the label is
1777
+ // the half-converted line the old tolerance could not report.
1778
+ if(restT.length>1){
1779
+ 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; }
540
1780
  const pairs=[]; let badp=null;
541
- for(const t of rest){
542
- const m=/^([A-Za-z_][A-Za-z0-9_-]*)--([A-Za-z_][A-Za-z0-9_-]*),?$/.exec(t);
543
- if(!m){ badp='bad member "'+t+'" (expected A--B)'; break; }
544
- pairs.push([m[1],m[2]]);
1781
+ outerB:
1782
+ for(const t of restT){
1783
+ for(const s of splitList(t,0)){
1784
+ const mem=s.v.trim(); if(!mem) continue;
1785
+ if(s.h){ badp=ID_RULE; break outerB; }
1786
+ const parts=mem.split('--');
1787
+ if(parts.length!==2||!isId(parts[0])||!isId(parts[1])){
1788
+ badp='bad member "'+mem+'" (expected A--B)'; break outerB; }
1789
+ pairs.push([parts[0],parts[1]]);
1790
+ }
545
1791
  }
546
1792
  if(badp){ err(n,badp); break; }
547
1793
  if(!pairs.length){ err(n,'bundle needs at least one member link A--B'); break; }
548
1794
  bundleIds.add(id);
549
- doc.trunks.push({id,label:tlabel,pairs,color:opts.color,line:n});
1795
+ // §5 on the derived ring: `fill=` stays the ring colour (stroke +
1796
+ // label) as before, `stroke=`/`text=` split it, `style=` picks the
1797
+ // dash (the conventional default is dashed), `plane=` orders this
1798
+ // ring against the other rings.
1799
+ doc.trunks.push({id,label:tlabel,pairs,stroke:opts.stroke,
1800
+ style:opts.style,plane:opts.plane,line:n});
550
1801
  break;
551
1802
  }
552
- case 'line': {
553
- // generic horizontal guide/threshold line across a group's box:
554
- // line "<label>" in=<group> at=<0..100>% [color=] [fill=below|above]
555
- const glabel=(tk.toks[1]&&tk.toks[1].q)?pos[1]:null;
556
- if(glabel===null){ err(n,'line needs a quoted "<label>" first'); break; }
557
- if(!opts['in']){ err(n,'line needs in=<group-id>'); break; }
558
- const m=/^(\d+(?:\.\d+)?)%$/.exec(opts.at||''); // % is mandatory (D8)
559
- if(!m||+m[1]<0||+m[1]>100){ err(n,'line needs at=<0..100>% (with the % sign)'); break; }
560
- if(opts.fill!==undefined){ err(n,'line is a pure marker — use the fill directive for zones'); break; }
561
- doc.glines.push({label:glabel,group:opts['in'],pct:+m[1],color:opts.color,line:n});
1803
+ case 'threshold': {
1804
+ // horizontal threshold marker across the target's box:
1805
+ // threshold "<label>" in=<group|node> offset=<0..100>%
1806
+ // Spelled `guide` in an earlier release (`THRESHOLD-KEYWORD-SPELLING`): in every
1807
+ // vector editor a "guide" is an author-only construction line that is
1808
+ // never rendered, so the name was inverted; `threshold` is Grafana's,
1809
+ // with IETF RED/AQM min_th/max_th as the secondary source.
1810
+ // The target may be a GROUP or a single NODE, exactly like `band`
1811
+ // (§2.6: scope follows the meaning, `AUTHORING-INTENT-OVER-RENDERING`) — the pair is symmetric.
1812
+ // `OPTION-POSITION-PARSING`: `pos`-aligned quotedness — `threshold in=a "Thr" offset=50%`
1813
+ // is legal.
1814
+ // 0.1 (`THRESHOLD-VALUE-SCOPE`): NO `value=` and NO `ref=`. Zero corpus figures
1815
+ // carry a literal numeric value — every reference is a named
1816
+ // software-configurable register, and the name already lives in the
1817
+ // mandatory label. A second carrier has no demonstrated need.
1818
+ const glabel=posq[1]?pos[1]:null;
1819
+ if(glabel===null){ err(n,'threshold needs a quoted "<label>" first'); break; }
1820
+ if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
1821
+ if(!opts['in']){ err(n,'threshold needs in=<node-or-group-id>'); break; }
1822
+ // 0.1: `at=` -> `offset=`. `at=` carried TWO value shapes —
1823
+ // a point in canvas px on `pin`, a percentage here — and no reader
1824
+ // can predict which applies without knowing the directive (RULE
1825
+ // 4.3). SVG already spells "a position along an extent, as a
1826
+ // percentage" `offset` (on <stop>), which is the same source the
1827
+ // paint keys come from, so nothing is invented.
1828
+ if(opts.at!==undefined){
1829
+ 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; }
1830
+ const m=/^(\d+(?:\.\d+)?)%$/.exec(opts.offset||''); // % is mandatory (`BARE-FRACTION-VALUES`)
1831
+ if(!m||+m[1]<0||+m[1]>100){ err(n,'threshold needs offset=<0..100>% (with the % sign)'); break; }
1832
+ // §5: `stroke=` is the marker colour, `color=` the label colour,
1833
+ // `style=` picks the dash (default dashed), `plane=` orders this
1834
+ // marker against the other markers.
1835
+ doc.thresholds.push({label:glabel,target:opts['in'],pct:+m[1],
1836
+ stroke:opts.stroke,style:opts.style,
1837
+ plane:opts.plane,line:n});
562
1838
  break;
563
1839
  }
564
- case 'plot': {
565
- // chart family: plot <table-id> [kind=bars3d] [level=<value>]
1840
+ case 'chart': {
1841
+ // chart family: chart <table-id> [type=bar3d]
566
1842
  // rows -> X, columns -> Y, numeric cells -> Z (the table IS the data)
1843
+ // Spelled `plot` with `kind=bars3d` until this release (`CHART-BLOCK-NAMING`).
1844
+ // 0.1 (`CHART-LEVEL-KEY`): `level=` is DELETED. Zero uses corpus-wide, zero
1845
+ // 3-D bar charts, zero requests; one in-repo example and two fixtures.
1846
+ // It was the only construct whose caption the ENGINE wrote rather than
1847
+ // the author, and its parseFloat grammar uniquely accepted `1e3`,
1848
+ // breaking the otherwise-uniform \d+(\.\d+)? numeric grammar. The
1849
+ // retirement message lives in RETIRED_OPT_KEYS.
567
1850
  const tid=pos[1];
568
- if(!tid||!ID_RE.test(tid)){ err(n,'plot needs a table id'); break; }
1851
+ { const e=idErr(tid,posq[1],'chart needs a table id'); if(e){ err(n,e); break; } }
569
1852
  if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
570
- const pkind=opts.kind||'bars3d';
571
- if(pkind!=='bars3d'){ err(n,'unknown plot kind "'+pkind+'" (bars3d)'); break; }
572
- let plevel=null;
573
- if(opts.level!==undefined){
574
- plevel=parseFloat(opts.level);
575
- if(!isFinite(plevel)||plevel<0){ err(n,'level must be a non-negative number'); break; }
576
- }
577
- doc.blocks.push({type:'plot',id:'plot_'+doc.blocks.length,tid,kind:pkind,level:plevel,line:n});
1853
+ const ptype=opts.type||'bar3d';
1854
+ if(ptype!=='bar3d'){ err(n,'unknown chart type "'+ptype+'" (bar3d)'); break; }
1855
+ // NB: the block discriminator is already `type`, so the chart-type
1856
+ // value is stored as `ctype` and surfaced in the model as `type`.
1857
+ doc.blocks.push({type:'chart',id:'chart_'+doc.blocks.length,tid,ctype:ptype,line:n});
578
1858
  break;
579
1859
  }
580
- case 'fill': {
581
- // zone band: fill <pct>% | <a>-<b>% in=<node|group> [dir=] [color=]
582
- // "fill 15%" = 0..15; "fill 15-35%" = the range in one token
583
- if(opts.from!==undefined||opts.to!==undefined){ err(n,'from=/to= retired — write the range positionally: fill 15% or fill 15-35%'); break; }
584
- if(!opts['in']){ err(n,'fill needs in=<node-or-group-id>'); break; }
585
- const m=/^(\d+(?:\.\d+)?)%?(?:-(\d+(?:\.\d+)?)%?)?$/.exec(pos[1]||'');
586
- if(!m){ err(n,'fill needs a range: <pct>% or <a>-<b>%'); break; }
587
- const from=m[2]!==undefined?+m[1]:0;
588
- const to=m[2]!==undefined?+m[2]:+m[1];
589
- if(from<0||to>100||from>=to){ err(n,'fill range needs 0 <= from < to <= 100'); break; }
590
- const fdir=opts.dir||'up';
591
- if(!['up','down','left','right'].includes(fdir)){ err(n,'dir must be up|down|left|right'); break; }
592
- doc.fills.push({target:opts['in'],from,to,dir:fdir,color:opts.color||'#e5e7eb',line:n});
1860
+ case 'band': {
1861
+ // zone band: band "<label>" <pct>% | <a>..<b>% in=<node|group> [extend=]
1862
+ // "band 15%" = 0..15; "band 15..35%" = the range in one token
1863
+ if(opts.from!==undefined||opts.to!==undefined){ err(n,'from=/to= retired — write the range positionally: band "<label>" 15% or band "<label>" 15..35%'); break; }
1864
+ // 0.1 (`BAND-LABEL-STATUS`): the label is MANDATORY and QUOTED. Until now
1865
+ // `band` had no label slot at ALL, so its complete model was
1866
+ // {target, from, to, extend, fill, line}; strip `fill=`, which §5/`PRESENTATION-AS-MEANING-CARRIER`
1867
+ // entitle a reader to discard, and a band asserted NOTHING — its
1868
+ // meaning rode on colour alone, which §5 declares must never happen.
1869
+ // Every interval region in the measured corpus is a NAMED one
1870
+ // (Headroom/Share/Guarantee, Latency/Block/Transmit, G/Y/R), and the
1871
+ // buffer-region figure was forced to carry those names in three
1872
+ // `class` declarations all literally spelled "region".
1873
+ // The label comes FIRST — the position every other labelled directive
1874
+ // in the language puts it in (`node`, `group`, `external`, `bundle`,
1875
+ // `threshold`), so one reader rule covers the whole vocabulary and
1876
+ // the sibling pair `threshold "X" in=… ` / `band "X" 15..35% in=…`
1877
+ // reads the same way. `OPTION-POSITION-PARSING` quotedness is `pos`-aligned, so an option
1878
+ // may still precede it.
1879
+ const flabel=posq[1]?pos[1]:null;
1880
+ 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; }
1881
+ if(!opts['in']){ err(n,'band needs in=<node-or-group-id>'); break; }
1882
+ // 0.1: the `%` is MANDATORY, matching `threshold offset=` (`BARE-FRACTION-VALUES`).
1883
+ // `band 15`, `band 15-35` and `band 15%-35` all parsed before (the
1884
+ // separator was a hyphen until this release); one concept in one
1885
+ // document must not have two value grammars (RULE 4.4).
1886
+ // `RANGE-SPELLING`: the separator is `..`, and the HYPHEN form it
1887
+ // replaces gets its own named diagnostic. `15-35%` reads as
1888
+ // subtraction, and it was the language's SECOND range spelling — the
1889
+ // first being `index=` (`BITFIELD-REPETITION-CONSTRUCT`, same release), which is single-sourced
1890
+ // from Ada (ISO/IEC 8652 `1 .. 10`) and Pascal (ISO 7185
1891
+ // `array [1..10]`), both inclusive, with X.680's `SIZE(1..4)` already
1892
+ // cited by `bitfield`. `band` is EXPERIMENTAL (`CONSTRUCT-STATUS-TIERS`), so no
1893
+ // compatibility promise is owed; the diagnostic is owed anyway.
1894
+ if(/^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)%$/.test(pos[2]||'')){
1895
+ 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; }
1896
+ const m=/^(\d+(?:\.\d+)?)%$|^(\d+(?:\.\d+)?)\.\.(\d+(?:\.\d+)?)%$/.exec(pos[2]||'');
1897
+ if(!m){ err(n,'band needs a range with the % sign: band "<label>" <pct>% or band "<label>" <a>..<b>%'); break; }
1898
+ if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
1899
+ const from=m[1]!==undefined?0:+m[2];
1900
+ const to=m[1]!==undefined?+m[1]:+m[3];
1901
+ if(from<0||to>100||from>=to){ err(n,'band range needs 0 <= from < to <= 100'); break; }
1902
+ // 0.1: `dir=` -> `extend=` (`UNSAFE-DEFAULT-ELIMINATION`: `dir` is HTML's text writing
1903
+ // direction; this says which way the band extends from its anchor).
1904
+ const fdir=opts.extend||'up';
1905
+ if(!['up','down','left','right'].includes(fdir)){ err(n,'extend must be up|down|left|right'); break; }
1906
+ // §5: `fill=` is the band fill; `stroke=` and/or `style=` draw the
1907
+ // band outline; `color=` is the label colour (with the
1908
+ // label); `plane=` orders this band against the other bands.
1909
+ doc.bands.push({label:flabel,target:opts['in'],from,to,dir:fdir,fill:opts.fill||'#e5e7eb',
1910
+ stroke:opts.stroke,style:opts.style,plane:opts.plane,line:n});
593
1911
  break;
594
1912
  }
1913
+ // `ELEMENT-GEOMETRY-DIRECTIVE`: ONE directive for an element's declared geometry.
1914
+ // `at=` places it, `width=`/`height=` extend it; all three are optional
1915
+ // and at least one is required, because a `pin` line that declares
1916
+ // nothing is not a weaker constraint — it is a typo.
595
1917
  case 'pin': {
596
1918
  const id=pos[1], at=opts.at;
1919
+ const PIN_SHAPE='pin needs <id> and at=(<x>,<y>) and/or width=<px> and/or height=<px>';
597
1920
  if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
598
- const m=at&&/^(-?[\d.]+),(-?[\d.]+)$/.exec(at);
599
- if(!id||!m){ err(n,'pin needs <id> at=<x>,<y>'); break; }
600
- doc.pins[id]={fx:parseFloat(m[1]),fy:parseFloat(m[2]),line:n};
601
- break;
602
- }
603
- case 'size': {
604
- const id=pos[1];
605
- if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
606
- if(!id||(!opts.w&&!opts.h)){ err(n,'size needs <id> w=<px> and/or h=<px>'); break; }
1921
+ // the numeric-value rule: value grammar is exactly -?\d+(\.\d+)? per component —
1922
+ // no scientific notation, no trailing junk, no multi-dot.
1923
+ // Negative values stay legal (canvas can place off-origin).
1924
+ { const e=idErr(id,posq[1],null); if(e){ err(n,e); break; } }
1925
+ if(!id){ err(n,PIN_SHAPE); break; }
1926
+ // 0.1 (RULE 1.1a): the pair is PARENTHESISED — at=(x,y).
1927
+ if(at!==undefined&&/^-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?$/.test(at)){
1928
+ 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; }
1929
+ const m=at===undefined?null:/^\((-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)\)$/.exec(at);
1930
+ // A WRITTEN `at=` that does not parse is its own error, never the
1931
+ // whole-line shape message: the author declared a point and got the
1932
+ // point wrong, and telling them the line "needs an id" hides that.
1933
+ if(at!==undefined&&!m){ err(n,'pin at= needs a paren point: at=(<x>,<y>)'); break; }
607
1934
  let badsz=false;
608
- const dim=(k)=>{ // px only (D7/D13)
609
- const v=opts[k]; if(v===undefined||v==='') return null;
1935
+ const dim=(k)=>{ // px only (`NON-NUMERIC-EXTENT`/`PERCENT-VALUES-LOST`)
1936
+ const v=opts[k]; if(v===undefined) return null;
1937
+ if(v===''){ err(n,k+' must be a positive number (px)'); badsz=true; return null; }
610
1938
  if(/%$/.test(v)){ err(n,'percentage sizes are not in v0.1 — use px'); badsz=true; return null; }
1939
+ // 0.1 (RULE 4.4): the `width` KEYWORD and the `width=`
1940
+ // OPTION share one unit grammar — a bare number in px, `<n>%`, or
1941
+ // `auto`. `%` and `auto` are outside v0.1 SCOPE on `pin` (a node
1942
+ // has no containing extent for a percentage to resolve against,
1943
+ // and `auto` is what an absent extent already means), so each gets
1944
+ // its own named diagnostic instead of the generic number message.
1945
+ if(v==='auto'){ err(n,k+'=auto is the default — omit the key (a node with no width=/height= is already auto-sized)'); badsz=true; return null; }
1946
+ // the numeric-value rule: exact grammar \d+(\.\d+)? — rejects em/px suffixes,
1947
+ // hex (0x10), scientific notation, negatives, and zero. `at=`
1948
+ // keeps negatives; an extent is a box dimension and must be positive.
1949
+ if(!/^\d+(\.\d+)?$/.test(v)){ err(n,k+' must be a positive number (px)'); badsz=true; return null; }
611
1950
  const f=parseFloat(v);
612
- if(!isFinite(f)){ err(n,k+' must be a number'); badsz=true; return null; }
1951
+ if(!isFinite(f)||f<=0){ err(n,k+' must be a positive number (px)'); badsz=true; return null; }
613
1952
  return f;
614
1953
  };
615
- const w=dim('w'), h=dim('h');
1954
+ const w=dim('width'), h=dim('height');
616
1955
  if(badsz) break;
617
- doc.sizes[id]={w,h,line:n};
1956
+ // Declares nothing: no position and no extent (§3, `ELEMENT-GEOMETRY-DIRECTIVE`).
1957
+ if(!m&&w===null&&h===null){ err(n,PIN_SHAPE); break; }
1958
+ // single-valued PER ID (`REPEATED-DIRECTIVE-HANDLING` / ): `pin a` + `pin b` is fine,
1959
+ // `pin a` twice is a line error — one element cannot sit at two
1960
+ // places or carry two extents, and last-wins would silently delete
1961
+ // whichever keys the second line omitted.
1962
+ if(doc.pins[id]!==undefined){ err(n,'duplicate pin for "'+id+'"'); break; }
1963
+ doc.pins[id]={fx:m?parseFloat(m[1]):null,fy:m?parseFloat(m[2]):null,w,h,line:n};
618
1964
  break;
619
1965
  }
620
- case 'routing': {
621
- // document-level presentation directive (0.1-dev.13): how straight
622
- // scene edges are drawn. straight (default) = today's direct lines;
623
- // orthogonal = deterministic manhattan elbows. Rendering parameter
624
- // only — no semantics; belongs in the trailing layout section.
625
- if(!['orthogonal','straight'].includes(pos[1]||'')){ err(n,'routing needs orthogonal|straight'); break; }
626
- if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
627
- doc.routing=pos[1]; break;
628
- }
629
- case 'route': {
630
- // per-edge declared waypoints (0.1-dev.13): route <a> <op> <b>
631
- // via=x,y;x,y;… [routing=orthogonal|straight]. References ONE
632
- // existing edge exactly as written (a, operator, b — endpoint
633
- // order matters, unlike bundle members); waypoints are rigid
634
- // canvas px, the same space as ungrouped pin. Layout section only.
635
- const a=pos[1], op=pos[2], b=pos[3];
636
- if(!a||!ID_RE.test(a)||!['->','<-','--','<->'].includes(op||'')||!b||!ID_RE.test(b)){
637
- err(n,'route needs <a> ->|<-|--|<-> <b> via=x,y;x,y;…'); break; }
638
- if(pos.length>4){ err(n,'unexpected argument "'+pos[4]+'"'); break; }
639
- if(opts.via===undefined){ err(n,'route needs via=x,y;x,y;… (canvas px waypoints)'); break; }
640
- const pairs=[]; let badv=null;
641
- for(const t of String(opts.via).split(';')){
642
- const m=/^(-?[\d.]+),(-?[\d.]+)$/.exec(t.trim());
643
- const px=m&&parseFloat(m[1]), py=m&&parseFloat(m[2]);
644
- if(!m||!isFinite(px)||!isFinite(py)){ badv='bad via point "'+t.trim()+'" (expected x,y)'; break; }
645
- pairs.push([px,py]);
646
- }
647
- if(badv){ err(n,badv); break; }
648
- if(opts.routing!==undefined && !['orthogonal','straight'].includes(opts.routing)){
649
- err(n,'routing must be orthogonal|straight'); break; }
650
- doc.routes.push({a,op,b,via:pairs,routing:opts.routing,line:n});
651
- break;
652
- }
653
- case 'render': {
654
- if(sawRender){ err(n,'duplicate render line'); break; }
655
- if(pos.length>1){ err(n,'render takes no arguments'); break; }
656
- sawRender=true; break;
1966
+ case 'layout': {
1967
+ if(sawLayout){ err(n,'duplicate layout line'); break; }
1968
+ if(pos.length>1){ err(n,'layout takes no arguments'); break; }
1969
+ sawLayout=true; break;
657
1970
  }
658
1971
  case 'bitfield': {
659
1972
  const id=pos[1];
660
- if(!id||!ID_RE.test(id)){ err(n,'bitfield needs an id'); break; }
1973
+ { const e=idErr(id,posq[1],'bitfield needs an id'); if(e){ err(n,e); break; } }
1974
+ if(dupId(id)){ err(n,'duplicate id "'+id+'"'); break; }
1975
+ if(BLK_LBL(n,kw,id,pos,posq)) break;
661
1976
  if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
662
- cur={type:'bitfield',id,label:pos[2]||id,unit:opts.unit?parseInt(opts.unit,10):32,
663
- numbering:opts.numbering||'lsb0',fields:[],line:n};
664
- if(!isFinite(cur.unit)||cur.unit<1){ err(n,'bad unit'); cur.unit=32; }
665
- if(!['lsb0','msb0'].includes(cur.numbering)) err(n,'numbering must be lsb0 or msb0');
1977
+ blockIds.add(id);
1978
+ // §5 on a typed block: `fill=` is the default cell fill, `stroke=`
1979
+ // the cell border colour, `text=` the block caption colour.
1980
+ // `TYPED-BLOCK-SILENT-FALLBACK`: word= empty or non-integer was a silent fallback/truncation
1981
+ // (word= → 32, word=8.5 → 8). Positive integer only; absence → 32.
1982
+ // Spelled `unit=` until this release (`BITS-PER-ROW-KEY-NAMING`).
1983
+ let word=32;
1984
+ if(opts.word!==undefined){
1985
+ if(opts.word===''||!/^\d+$/.test(opts.word)||+opts.word<1){
1986
+ err(n,'word must be a positive integer');
1987
+ word=32; // keep a usable value so field lines still check
1988
+ } else word=+opts.word;
1989
+ }
1990
+ cur={type:'bitfield',id,label:pos[2]!==undefined?pos[2]:null,word,
1991
+ numbering:opts.numbering||'lsb0',fields:[],starCount:0,
1992
+ fill:opts.fill,stroke:opts.stroke,
1993
+ cls:parseClassList(opts['class'],optList(optT,'class')).ids,line:n};
1994
+ // 0.1: `numbering=` is REQUIRED — there is no default. Unlike an
1995
+ // absent label (which has a harmless rendering fallback), bit-numbering
1996
+ // direction has no harmless fallback: whichever value were defaulted,
1997
+ // the other half of the corpus would silently render a ruler that
1998
+ // asserts something false. Bit numbering is meaning, and under `MEANING-RECOVERY-SOURCE`
1999
+ // meaning comes from syntax, never from a silent default. The block
2000
+ // still opens (with the old lsb0 behaviour) so the `field` lines below
2001
+ // report their own problems in the same pass.
2002
+ if(opts.numbering===undefined) err(n,'bitfield requires numbering= (lsb0 or msb0)');
2003
+ else if(!['lsb0','msb0'].includes(cur.numbering)) err(n,'numbering must be lsb0 or msb0');
666
2004
  doc.blocks.push(cur); break;
667
2005
  }
668
2006
  case 'table': {
669
2007
  const id=pos[1];
670
- if(!id||!ID_RE.test(id)){ err(n,'table needs an id'); break; }
2008
+ { const e=idErr(id,posq[1],'table needs an id'); if(e){ err(n,e); break; } }
2009
+ if(dupId(id)){ err(n,'duplicate id "'+id+'"'); break; }
2010
+ if(BLK_LBL(n,kw,id,pos,posq)) break;
671
2011
  if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
672
- cur={type:'table',id,label:pos[2]||id,cols:[],heads:[],rows:[],line:n};
2012
+ blockIds.add(id);
2013
+ cur={type:'table',id,label:pos[2]!==undefined?pos[2]:null,cols:[],heads:[],rows:[],
2014
+ fill:opts.fill,stroke:opts.stroke,
2015
+ cls:parseClassList(opts['class'],optList(optT,'class')).ids,line:n};
673
2016
  doc.blocks.push(cur); break;
674
2017
  }
675
- case 'wave': {
2018
+ case 'timing': {
676
2019
  const id=pos[1];
677
- if(!id||!ID_RE.test(id)){ err(n,'wave needs an id'); break; }
2020
+ { const e=idErr(id,posq[1],'timing needs an id'); if(e){ err(n,e); break; } }
2021
+ if(dupId(id)){ err(n,'duplicate id "'+id+'"'); break; }
2022
+ if(BLK_LBL(n,kw,id,pos,posq)) break;
678
2023
  if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
679
- cur={type:'wave',id,label:pos[2]||id,signals:[],gaps:[],line:n};
2024
+ blockIds.add(id);
2025
+ cur={type:'timing',id,label:pos[2]!==undefined?pos[2]:null,signals:[],gaps:[],
2026
+ fill:opts.fill,stroke:opts.stroke,
2027
+ cls:parseClassList(opts['class'],optList(optT,'class')).ids,line:n};
680
2028
  doc.blocks.push(cur); break;
681
2029
  }
682
- case 'page': case 'step': case 'set': case 'pulse':
2030
+ // `step` was released: the §6 dynamic sketch uses
2031
+ // `page`/`set`/`pulse` only, and no genre claimed the word.
2032
+ case 'page': case 'set': case 'pulse':
683
2033
  err(n,'"'+kw+'" is reserved for the dynamic profile (not in v0.1)'); break;
684
- case 'field': case 'wrap': case 'cell': case 'colw': case 'signal': case 'gap':
685
- err(n,'"'+kw+'" is a typed-block child — it needs a bitfield/table/wave block above it'); break;
2034
+ case 'field': case 'break': case 'cell': case 'width': case 'signal': case 'gap':
2035
+ err(n,'"'+kw+'" is a typed-block child — it needs a bitfield/table/timing block above it'); break;
686
2036
  default:
687
2037
  err(n,'unrecognized line (unknown keyword "'+kw+'")');
688
2038
  }
@@ -692,25 +2042,26 @@ function parse(text){
692
2042
  // semantic checks
693
2043
  for(const nd of doc.nodes){
694
2044
  if(nd.group && !groupIds.has(nd.group)) errs.push('Line '+nd.line+': unknown group "'+nd.group+'"');
695
- if(nd.layer && !layerIds.has(nd.layer)) errs.push('Line '+nd.line+': unknown layer "'+nd.layer+'"');
2045
+ if(nd.plane && !planeIds.has(nd.plane)) errs.push('Line '+nd.line+': unknown plane "'+nd.plane+'"');
696
2046
  }
697
2047
  for(const g of doc.groups){
698
- if(g.layer && !layerIds.has(g.layer)) errs.push('Line '+g.line+': unknown layer "'+g.layer+'"');
2048
+ if(g.plane && !planeIds.has(g.plane)) errs.push('Line '+g.line+': unknown plane "'+g.plane+'"');
699
2049
  }
700
2050
  for(const e of doc.edges){
701
2051
  if(!nodeIds.has(e.a)&&!groupIds.has(e.a)&&!boundaryIds.has(e.a)) errs.push('Line '+e.line+': unknown endpoint "'+e.a+'"');
702
2052
  else if(groupIds.has(e.a)) errs.push('Line '+e.line+': edge endpoint "'+e.a+'" is a group — connect to a member node (group edges are not in v0.1)');
703
2053
  if(!nodeIds.has(e.b)&&!groupIds.has(e.b)&&!boundaryIds.has(e.b)) errs.push('Line '+e.line+': unknown endpoint "'+e.b+'"');
704
2054
  else if(groupIds.has(e.b)) errs.push('Line '+e.line+': edge endpoint "'+e.b+'" is a group — connect to a member node (group edges are not in v0.1)');
705
- if(e.layer && !layerIds.has(e.layer)) errs.push('Line '+e.line+': unknown layer "'+e.layer+'"');
2055
+ if(e.plane && !planeIds.has(e.plane)) errs.push('Line '+e.line+': unknown plane "'+e.plane+'"');
706
2056
  }
707
2057
  for(const r of doc.ranks) for(const id of r.ids)
708
2058
  if(!nodeIds.has(id)) errs.push('Line '+r.line+': unknown node "'+id+'" in rank');
709
- for(const gl of doc.glines)
710
- if(!groupIds.has(gl.group)) errs.push('Line '+gl.line+': unknown group "'+gl.group+'" for line');
711
- for(const f of doc.fills)
2059
+ for(const gl of doc.thresholds)
2060
+ if(!groupIds.has(gl.target)&&!nodeIds.has(gl.target))
2061
+ errs.push('Line '+gl.line+': unknown target "'+gl.target+'" for threshold');
2062
+ for(const f of doc.bands)
712
2063
  if(!groupIds.has(f.target)&&!nodeIds.has(f.target))
713
- errs.push('Line '+f.line+': unknown target "'+f.target+'" for fill');
2064
+ errs.push('Line '+f.line+': unknown target "'+f.target+'" for band');
714
2065
  for(const t of doc.trunks) for(const [a,b] of t.pairs){
715
2066
  if((!nodeIds.has(a)&&!boundaryIds.has(a))||(!nodeIds.has(b)&&!boundaryIds.has(b))){ errs.push('Line '+t.line+': unknown endpoint in "'+a+'--'+b+'"'); continue; }
716
2067
  const matches=doc.edges.filter(e=>(e.a===a&&e.b===b)||(e.a===b&&e.b===a)).length;
@@ -719,51 +2070,156 @@ function parse(text){
719
2070
  else if(matches>1)
720
2071
  errs.push('Line '+t.line+': "'+a+'--'+b+'" is ambiguous ('+matches+' parallel edges); parallel edges are out of scope for v0.1');
721
2072
  }
722
- { // route lines reference ONE existing edge exactly as written (a-op-b;
723
- // endpoint order matters — unlike bundle members); parallel edges are
724
- // ambiguous; one route per edge (closed grammar, 0.1-dev.13)
725
- const seenRt=new Set();
726
- for(const r of doc.routes){
727
- const ref=r.a+' '+r.op+' '+r.b;
728
- const matches=doc.edges.filter(e=>e.a===r.a&&e.op===r.op&&e.b===r.b);
729
- if(!matches.length){ errs.push('Line '+r.line+': no edge "'+ref+'" for route (must match the edge as written)'); continue; }
730
- if(matches.length>1){ errs.push('Line '+r.line+': "'+ref+'" is ambiguous ('+matches.length+' parallel edges); parallel edges are out of scope for v0.1'); continue; }
731
- if(seenRt.has(ref)){ errs.push('Line '+r.line+': duplicate route for "'+ref+'"'); continue; }
732
- seenRt.add(ref);
733
- matches[0].route=r; // renderer convenience; the model keeps doc.routes
2073
+ // `ELEMENT-GEOMETRY-DIRECTIVE`: `pin` has a SPLIT DOMAIN, and both halves are checked
2074
+ // here because both need the finished id sets.
2075
+ // at= nodes, groups and external endpoints;
2076
+ // width=/height= nodes ONLY — a group sizes to its members (D2 reached
2077
+ // the same conclusion independently), and an external
2078
+ // endpoint or a typed block derives its geometry from
2079
+ // its content, so an extent there is a claim the
2080
+ // renderer cannot honour. Silently ignoring it was the
2081
+ // 0.1 defect; each now names its own subject.
2082
+ for(const id in doc.pins){
2083
+ const p=doc.pins[id];
2084
+ if(p.w!==null||p.h!==null){
2085
+ if(groupIds.has(id)){
2086
+ errs.push('Line '+p.line+': pin width=/height= do not apply to group "'+id+'" — groups size to their members'); continue; }
2087
+ if(boundaryIds.has(id)){
2088
+ errs.push('Line '+p.line+': pin width=/height= do not apply to external endpoint "'+id+'" — its geometry derives from its content'); continue; }
2089
+ const blk=doc.blocks.find(b=>b.id===id);
2090
+ if(blk){
2091
+ errs.push('Line '+p.line+': pin width=/height= do not apply to the '+blk.type+' block "'+id+'" — its geometry derives from its content'); continue; }
2092
+ }
2093
+ if(!nodeIds.has(id)&&!groupIds.has(id)&&!boundaryIds.has(id))
2094
+ errs.push('Line '+p.line+': pin of unknown id "'+id+'"');
2095
+ }
2096
+ { // 0.1 (§8.4), the class-mediated half. Rejecting `fill=` on the
2097
+ // `edge` LINE is not enough on its own: before this release the edge
2098
+ // renderer read `e.stroke||e.fill`, so a class carrying only `fill=`
2099
+ // painted edges through the fill channel, and `fill=` would have gone on
2100
+ // meaning "an edge's colour" by the back door.
2101
+ //
2102
+ // A class is a bundle of channel defaults for HETEROGENEOUS members, so
2103
+ // the rule is per channel, not per class: `fill` applies to members that
2104
+ // HAVE an interior and is inapplicable to those that do not. One class
2105
+ // can therefore still carry one meaning for both — `class hot "…"
2106
+ // fill=#fee2e2 stroke=#dc2626` paints the box of a node member and the
2107
+ // line of an edge member, with no key meaning two things and no second
2108
+ // legend entry for one meaning (`CATEGORICAL-MEANING-MAPPING`).
2109
+ //
2110
+ // What is forbidden is the case that would be SILENT: a class that sets
2111
+ // `fill=` and no `stroke=`, used by an edge. Ignoring it would drop the
2112
+ // edge's colour with nothing to warn on; honouring it would make `fill`
2113
+ // mean "stroke" for that member. So it is a line error that names the
2114
+ // key to add. `edge` is the only interior-less construct taking `class=`.
2115
+ //
2116
+ // 0.1 (`CLASS-PAINT-REQUIREMENT`): the SAME hole sat one key over and was left open.
2117
+ // 0.1 rejected `fill=`-only and said nothing about a class that
2118
+ // paints NEITHER channel — `class p "Path" color=#dc2626` plus `edge a ->
2119
+ // b class=p` was accepted, drew a #555 line, and rendered a legend swatch
2120
+ // that showed nothing, so the class's meaning was invisible in its own
2121
+ // derived legend. With `color=` retired (`COLOUR-KEY-STATUS`) the remaining shape of the
2122
+ // hole is a class carrying only `style=` and/or `plane=`, or nothing at
2123
+ // all: the edge silently takes the default colour and the author who
2124
+ // declared a class to CLASSIFY the edge gets no colour and no warning.
2125
+ // Both halves are the same rule — a class an edge joins must declare at
2126
+ // least one channel an edge HAS — so they share one diagnostic shape.
2127
+ // An edge has exactly two: `stroke` (its colour) and `style` (its dash).
2128
+ // `style=`-only is therefore FINE and must stay fine: the dash reaches the
2129
+ // edge, nothing is lost, and it is how a multi-class cascade splits one
2130
+ // meaning across two declarations (`class=hot,deprecated`). The test is
2131
+ // per class, per channel — the same shape `INTERIOR-LESS-ELEMENT-PAINT` chose, for the same reason.
2132
+ for(const e of doc.edges) for(const cid of (e.cls||[])){
2133
+ const c=doc.classes.find(x=>x.id===cid);
2134
+ if(!c||c.stroke!==undefined) continue;
2135
+ if(c.fill===undefined&&c.style!==undefined) continue;
2136
+ if(c.fill!==undefined)
2137
+ 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)');
2138
+ else
2139
+ 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)');
734
2140
  }
735
2141
  }
736
- for(const id in doc.pins) if(!nodeIds.has(id)&&!groupIds.has(id)&&!boundaryIds.has(id))
737
- errs.push('Line '+doc.pins[id].line+': pin of unknown id "'+id+'"');
738
2142
  { // class references must resolve (closed grammar)
739
- const chk=(cls,line)=>{ if(cls!==undefined && !classIds.has(cls))
740
- errs.push('Line '+line+': unknown class "'+cls+'"'); };
2143
+ const chk=(cls,line)=>{
2144
+ if(cls===undefined||cls===null) return;
2145
+ const ids=Array.isArray(cls)?cls:[cls];
2146
+ for(const id of ids) if(!classIds.has(id))
2147
+ errs.push('Line '+line+': unknown class "'+id+'"');
2148
+ };
741
2149
  for(const x of doc.nodes) chk(x.cls,x.line);
742
2150
  for(const x of doc.groups) chk(x.cls,x.line);
743
2151
  for(const x of doc.edges) chk(x.cls,x.line);
744
2152
  for(const b of doc.blocks){
745
2153
  if(b.fields) for(const f of b.fields) chk(f.cls,f.line);
746
2154
  if(b.marks) for(const mk of b.marks) chk(mk.cls,mk.line);
2155
+ chk(b.cls,b.line);
747
2156
  }
748
2157
  }
749
- for(const id in doc.sizes) if(!nodeIds.has(id)&&!groupIds.has(id))
750
- errs.push('Line '+doc.sizes[id].line+': size of unknown id "'+id+'"');
751
2158
  for(const b of doc.blocks){
752
2159
  if(b.type==='table'&&!b.heads.length) errs.push('Line '+b.line+': table has no header row');
753
- if(b.type==='table'&&b.heads.length&&!b.sep) errs.push('Line '+b.line+': table has no |---| separator row');
2160
+ if(b.type==='table'&&b.heads.length&&!b.sep) errs.push('Line '+b.line+': table has no |---| delimiter row');
754
2161
  if(b.type==='bitfield'&&!b.fields.some(f=>!f.wrap)) errs.push('Line '+b.line+': bitfield has no fields');
755
- if(b.type==='wave'&&!b.signals.length) errs.push('Line '+b.line+': wave has no signals');
756
- if(b.type==='plot'){
2162
+ if(b.type==='timing'&&!b.signals.length) errs.push('Line '+b.line+': timing has no signals');
2163
+ // `TYPED-BLOCK-SILENT-FALLBACK`: gap cycle must be an integer in [0, max lane length]; duplicates
2164
+ // already rejected at parse. Range needs all signals, so it runs here.
2165
+ if(b.type==='timing'&&b.gaps&&b.gaps.length){
2166
+ const maxLen=b.signals.reduce((m,s)=>Math.max(m,s.lane.length),0);
2167
+ for(const g of b.gaps){
2168
+ if(g.t>maxLen)
2169
+ errs.push('Line '+g.line+': gap cycle '+g.t+' is past the end of the timing lane (length '+maxLen+')');
2170
+ }
2171
+ }
2172
+ // `TYPED-BLOCK-SILENT-FALLBACK`: table merges must form rectangles. An L-shape (^^ next to ||
2173
+ // in a way the model cannot describe) is a line error. Prior art
2174
+ // (markdown-it-multimd-table) rejects the same class of input.
2175
+ if(b.type==='table'&&(b.heads.length||b.rows.length)){
2176
+ const grid=b.heads.map(hr=>hr.map(c=>({m:c.m,line:b.line})))
2177
+ .concat(b.rows.map(r=>r.cells.map(c=>({m:c.m,line:r.line}))));
2178
+ const covered=grid.map(row=>row.map(()=>false));
2179
+ let mergeErr=null;
2180
+ for(let r=0;r<grid.length&&!mergeErr;r++){
2181
+ for(let c=0;c<grid[r].length&&!mergeErr;c++){
2182
+ if(grid[r][c].m) continue;
2183
+ let cs=1; while(c+cs<grid[r].length&&grid[r][c+cs].m==='left') cs++;
2184
+ let rs=1; while(r+rs<grid.length&&grid[r+rs][c].m==='up') rs++;
2185
+ for(let i=0;i<rs&&!mergeErr;i++){
2186
+ for(let j=0;j<cs&&!mergeErr;j++){
2187
+ if(i===0&&j===0){ covered[r][c]=true; continue; }
2188
+ const cell=grid[r+i][c+j];
2189
+ if(!cell.m){
2190
+ mergeErr={line:cell.line,msg:'merge region is not rectangular (L-shaped or overlapping spans)'};
2191
+ } else {
2192
+ if(i===0&&cell.m!=='left')
2193
+ mergeErr={line:cell.line,msg:'merge region is not rectangular (L-shaped or overlapping spans)'};
2194
+ else if(j===0&&cell.m!=='up')
2195
+ mergeErr={line:cell.line,msg:'merge region is not rectangular (L-shaped or overlapping spans)'};
2196
+ else covered[r+i][c+j]=true;
2197
+ }
2198
+ }
2199
+ }
2200
+ }
2201
+ }
2202
+ if(!mergeErr){
2203
+ for(let r=0;r<grid.length&&!mergeErr;r++){
2204
+ for(let c=0;c<grid[r].length&&!mergeErr;c++){
2205
+ if(grid[r][c].m&&!covered[r][c])
2206
+ mergeErr={line:grid[r][c].line,msg:'merge marker does not attach to a rectangular span'};
2207
+ }
2208
+ }
2209
+ }
2210
+ if(mergeErr) errs.push('Line '+mergeErr.line+': '+mergeErr.msg);
2211
+ }
2212
+ if(b.type==='chart'){
757
2213
  const t=doc.blocks.find(x=>x.type==='table'&&x.id===b.tid);
758
- if(!t) errs.push('Line '+b.line+': plot references unknown table "'+b.tid+'"');
2214
+ if(!t) errs.push('Line '+b.line+': chart references unknown table "'+b.tid+'"');
759
2215
  else{
760
2216
  for(const r of t.rows) for(let c=1;c<r.cells.length;c++)
761
2217
  if(!r.cells[c].m && !/^-?\d+(?:\.\d+)?$/.test(r.cells[c].v))
762
- { errs.push('Line '+b.line+': plot data must be numeric (row value "'+r.cells[c].v+'")'); break; }
2218
+ { errs.push('Line '+b.line+': chart data must be numeric (row value "'+r.cells[c].v+'")'); break; }
763
2219
  }
764
2220
  }
765
- if(b.type==='table'&&b.colw&&b.colw.vals.length!==b.cols.length)
766
- errs.push('Line '+b.colw.line+': colw has '+b.colw.vals.length+' widths, expected '+b.cols.length);
2221
+ if(b.type==='table'&&b.width&&b.width.vals.length!==b.cols.length)
2222
+ errs.push('Line '+b.width.line+': width has '+b.width.vals.length+' values, expected '+b.cols.length);
767
2223
  if(b.type==='table'&&b.marks) for(const mk of b.marks){
768
2224
  const H=b.heads.length;
769
2225
  const inRange = mk.hdr ? (mk.r>=1&&mk.r<=H) : (mk.r>=1&&mk.r<=b.rows.length);
@@ -779,16 +2235,121 @@ function parse(text){
779
2235
  if(mk.r<1||mk.r>b.rows.length)
780
2236
  errs.push('Line '+mk.line+': row '+mk.r+' out of range');
781
2237
  }
2238
+ // `ROW-HIGHLIGHT-CELL-FILL-COLLISION`, the SECOND half of the collision: the two marks
2239
+ // written on two lines. `cell 1 highlight` + `cell (1,2) fill=#ffffff`
2240
+ // both parsed, and the per-cell fill won at paint time, so the model
2241
+ // asserted "row 1 is highlighted" while the drawing tinted only part of
2242
+ // the row — presentation deleting the visual carrier of meaning, which is
2243
+ // exactly the `field … style=solid` shape `STYLE-KEY-SCOPE` removed `style=` for. Only
2244
+ // a mark that RESOLVES to a fill collides: `stroke=`/`style=` paint a
2245
+ // different channel and stay legal on a highlighted row.
2246
+ if(b.type==='table'&&b.marks&&b.rowmarks&&b.rowmarks.length) for(const mk of b.marks){
2247
+ if(mk.hdr||!b.rowmarks.some(r=>r.r===mk.r)) continue;
2248
+ const fills=mk.fill!==undefined
2249
+ || (mk.cls||[]).some(id=>{const c=doc.classes.find(x=>x.id===id); return c&&c.fill!==undefined;});
2250
+ if(fills) errs.push('Line '+mk.line+': '+CELL_HL_ROW_CONFLICT(mk.r,mk.c));
2251
+ }
782
2252
  }
2253
+ // §5 `plane=` resolves against the declared planes wherever it is written —
2254
+ // the rule nodes/groups/edges have always had, now applied uniformly.
2255
+ // Declaration order does not matter (the check runs after the whole pass).
2256
+ for(const x of (doc.boundaries||[]).concat(doc.trunks||[], doc.thresholds||[],
2257
+ doc.bands||[], doc.classes||[]))
2258
+ if(x.plane && !planeIds.has(x.plane)) errs.push('Line '+x.line+': unknown plane "'+x.plane+'"');
783
2259
  return {doc,errs};
784
2260
  }
2261
+
2262
+ // Stack several single-section render results into ONE svg (multi-section `MULTI-FIGURE-DOCUMENTS`).
2263
+ // Each section is an independent figure; no shared ids or cross edges.
2264
+ // Marker/pattern ids are prefixed so defs do not collide.
2265
+ function stackSectionSvgs(results){
2266
+ const GAP=28;
2267
+ let y=0, maxW=0;
2268
+ const chunks=[];
2269
+ for(let i=0;i<results.length;i++){
2270
+ const svg=results[i].svg;
2271
+ const vb=/viewBox="0 0 ([0-9.]+) ([0-9.]+)"/.exec(svg);
2272
+ const W=vb?+vb[1]:400, H=vb?+vb[2]:200;
2273
+ maxW=Math.max(maxW,W);
2274
+ let inner=svg.replace(/^[\s\S]*?<svg[^>]*>/,'').replace(/<\/svg>\s*$/,'');
2275
+ const pfx='s'+i+'_';
2276
+ inner=inner.replace(/\bid="([^"]+)"/g,'id="'+pfx+'$1"')
2277
+ .replace(/url\(#([^)]+)\)/g,'url(#'+pfx+'$1)');
2278
+ chunks.push('<g transform="translate(0,'+y+')">'+inner+'</g>');
2279
+ y+=H+(i+1<results.length?GAP:0);
2280
+ }
2281
+ if(!chunks.length) y=1;
2282
+ return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 '+Math.ceil(maxW)+' '+Math.ceil(y)+'" width="'+Math.ceil(maxW)+'" height="'+Math.ceil(y)+'" font-family="system-ui,sans-serif">'
2283
+ +chunks.join('')+'</svg>';
2284
+ }
2285
+
2286
+ // `SEMICOLON-STATUS`: `;` is RESERVED for a future statement separator, and
2287
+ // RULE 1.3 says a reserved mark MUST NOT be given any other meaning. Until
2288
+ // this release it was an ordinary character: `node a ;` parsed and `;`
2289
+ // became the node's LABEL. It is now a line error wherever it is part of the
2290
+ // GRAMMAR — that is, everywhere except the FOUR verbatim regions (this
2291
+ // function handles three of them; the pipe row is the caller's, see below).
2292
+ // The count read "three" until this release, listing four:
2293
+ // - inside a quoted string ("…;…"),
2294
+ // - inside a comment (already stripped before this runs),
2295
+ // - inside an edge label (edge a -[packet arrives; TMR != 0]-> b),
2296
+ // - inside a GFM pipe row (raw table content; checked by the caller).
2297
+ // The bracket exemption is NOT optional: `[ ]` is the language's verbatim
2298
+ // text region, and three downstream figures write a `;` inside an edge label
2299
+ // as ordinary prose. Returns the index of the offending `;`, or -1.
2300
+ function findReservedSemi(s){
2301
+ let inq=false, depth=0;
2302
+ for(let i=0;i<s.length;i++){
2303
+ const c=s[i];
2304
+ if(inq){
2305
+ if(c==='\\'&&i+1<s.length){ i++; continue; }
2306
+ if(c==='"') inq=false;
2307
+ continue;
2308
+ }
2309
+ if(c==='"'){ inq=true; continue; }
2310
+ if(c==='['){ depth++; continue; }
2311
+ if(c===']'){ if(depth) depth--; continue; }
2312
+ if(c===';'&&!depth) return i;
2313
+ }
2314
+ return -1;
2315
+ }
2316
+ 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)';
2317
+
785
2318
  function findComment(s){
786
2319
  // '#' starts a comment only at line start or after whitespace,
787
- // so hex colors like color=#0d9488 survive.
788
- let inq=false;
2320
+ // so hex colors like fill=#0d9488 survive.
2321
+ // 0.1: the in-string state honours the SAME escapes the tokenizer
2322
+ // does (\n \" \\). Without this, `title "a \" b # c"` toggled `inq` on the
2323
+ // escaped quote, read the `#` as a comment introducer, cut the line there
2324
+ // and reported `unterminated string` for a perfectly legal input line.
2325
+ //
2326
+ // `VERBATIM-REGION-SCOPE`: `[ ]` IS A VERBATIM REGION FOR `#` TOO. SYNTAX-STYLE
2327
+ // §1 names four verbatim regions — a quoted string, an `[edge label]`, a
2328
+ // comment and a GFM pipe row — and `;` was honoured in all four from
2329
+ // 0.1 (`SEMICOLON-STATUS`, findReservedSemi below carries the same `depth`
2330
+ // counter). `#` was honoured in three: comment stripping ran before edge
2331
+ // dispatch and knew nothing about brackets, so `edge a -[hop #1]-> b` was
2332
+ // cut at the `#` and reported `unterminated [label]`. §1's own
2333
+ // justification for giving `[ ]` a row is this exact bug class, met and
2334
+ // fixed once for `;`; the lesson was simply not carried to the other mark,
2335
+ // and `#1`/`#2` is the natural spelling for hop and sequence numbers in
2336
+ // the protocol figures this language targets.
2337
+ //
2338
+ // The counter is safe to run on EVERY line, not just `edge` lines: it can
2339
+ // only affect a `#` that appears AFTER an unclosed `[`, and this function
2340
+ // returns at the first `#` outside brackets — so a `[` inside a comment is
2341
+ // always reached too late to matter, and `[` occurs nowhere else in the
2342
+ // grammar outside a quoted string. A stray unbalanced `[` before a `#` on
2343
+ // a non-edge line therefore swallows the comment marker and the line fails
2344
+ // as the malformed line it is, rather than half-parsing.
2345
+ let inq=false, depth=0;
789
2346
  for(let i=0;i<s.length;i++){
2347
+ if(inq&&s[i]==='\\'&&i+1<s.length){ i++; continue; }
790
2348
  if(s[i]==='"') inq=!inq;
791
- else if(s[i]==='#'&&!inq&&(i===0||/\s/.test(s[i-1]))) return i;
2349
+ else if(inq) continue;
2350
+ else if(s[i]==='[') depth++;
2351
+ else if(s[i]===']'){ if(depth) depth--; }
2352
+ else if(s[i]==='#'&&!depth&&(i===0||/\s/.test(s[i-1]))) return i;
792
2353
  }
793
2354
  return -1;
794
2355
  }
@@ -797,6 +2358,13 @@ function findComment(s){
797
2358
  // 2. LAYOUT + RENDER (deterministic; no randomness, no Date)
798
2359
  // ============================================================
799
2360
  const FONT=13, CH=7.2, PADX=14, NH=36, GAPX=56, GAPY=44;
2361
+ // §5 style= → SVG dash pattern. `def` is the construct's conventional
2362
+ // default (the bundle ring and the threshold line are dashed by convention);
2363
+ // an explicit style= always wins.
2364
+ function dashOf(style,def){
2365
+ const p = style==='dashed'?'6 4' : style==='dotted'?'2 4' : style==='solid'?'' : def;
2366
+ return p?' stroke-dasharray="'+p+'"':'';
2367
+ }
800
2368
  function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
801
2369
  function tw(s){
802
2370
  const longest=Math.max(...String(s).split('\n').map(l=>l.length));
@@ -808,19 +2376,20 @@ function tw(s){
808
2376
  // half-extents (a,b) about the box centre and the exponent p of the curve
809
2377
  // through them,
810
2378
  // (|dx|/a)^p + (|dy|/b)^p = 1 p=1 rhombus, 2 ellipse, Inf rectangle
811
- // with (ox,oy) = how far that outline is drawn OUTSIDE the box (cloud only),
812
- // so the box can be recovered from the axes. Node sizing (how large must the
813
- // box be for the label to sit inside the outline) and borderPoint() (where
814
- // does a ray from the centre leave the outline) both read this, so the two
815
- // can never disagree about where a shape ends. Keep it in step with the
816
- // shape drawing in renderScene.
2379
+ // Every shape is drawn INSIDE its box, so the box is recoverable from the
2380
+ // axes with no overhang term. (`cloud` was the one exception — it drew an
2381
+ // ellipse 10/8 px outside the box, and carried an (ox,oy) overhang through
2382
+ // this helper, outSide() and fitOutline(); it was retired.)
2383
+ // Node sizing (how large must the box be for the label to sit inside the
2384
+ // outline) and borderPoint() (where does a ray from the centre leave the
2385
+ // outline) both read this, so the two can never disagree about where a shape
2386
+ // ends. Keep it in step with the shape drawing in renderScene.
817
2387
  function shapeAxes(n){
818
2388
  const s=n.shape;
819
- if(s==='diamond') return {a:n.w/2, b:n.h/2, p:1, ox:0, oy:0};
820
- if(s==='cloud') return {a:n.w/2+10,b:n.h/2+8, p:2, ox:10,oy:8};
821
- if(s==='circle') return {a:n.w/2, b:n.w/2, p:2, ox:0, oy:0}; // rx=ry=w/2
822
- if(s==='ellipse') return {a:n.w/2, b:n.h/2, p:2, ox:0, oy:0};
823
- return {a:n.w/2, b:n.h/2, p:Infinity, ox:0, oy:0}; // box, rounded, cylinder
2389
+ if(s==='diamond') return {a:n.w/2, b:n.h/2, p:1};
2390
+ if(s==='circle') return {a:n.w/2, b:n.w/2, p:2}; // rx=ry=w/2
2391
+ if(s==='ellipse') return {a:n.w/2, b:n.h/2, p:2};
2392
+ return {a:n.w/2, b:n.h/2, p:Infinity}; // box, rounded, cylinder
824
2393
  }
825
2394
  // outlineNorm: <1 inside the outline, =1 on it, >1 outside. Homogeneous in
826
2395
  // (dx,dy) — scaling the offset scales the norm — which is what lets the same
@@ -832,11 +2401,12 @@ function outlineNorm(g,dx,dy){
832
2401
  }
833
2402
  // outSide: the coordinate an orthogonal run has to reach to touch the DRAWN
834
2403
  // outline on one side ('l','r','t','b') — what a channel route needs when it
835
- // arrives square-on instead of along a ray. Every shape but `cloud` is drawn
836
- // inside its box, so this returns the box edge unchanged, bit for bit.
2404
+ // arrives square-on instead of along a ray. Every registered shape is drawn
2405
+ // inside its box, so this is the box edge; the call site is kept so a future
2406
+ // outside-the-box outline has one place to answer for (`cloud`, the only one
2407
+ // that ever was, is retired — 0.1).
837
2408
  function outSide(n,k){
838
- const g=shapeAxes(n);
839
- return k==='l'?n.x-g.ox : k==='r'?n.x+n.w+g.ox : k==='t'?n.y-g.oy : n.y+n.h+g.oy;
2409
+ return k==='l'?n.x : k==='r'?n.x+n.w : k==='t'?n.y : n.y+n.h;
840
2410
  }
841
2411
  // simplifyPts: Douglas–Peucker simplification in place. The waypoint chain
842
2412
  // emits an entry+exit port per crossed rank; home-anchoring keeps those ports
@@ -927,8 +2497,8 @@ function labelBox(label){
927
2497
  function fitOutline(n,box){
928
2498
  const g=shapeAxes(n), k=outlineNorm(g,box[0]/2,box[1]/2);
929
2499
  if(k<=1) return;
930
- n.w=Math.max(n.w,2*(k*g.a-g.ox));
931
- n.h=Math.max(n.h,2*(k*g.b-g.oy));
2500
+ n.w=Math.max(n.w,2*k*g.a);
2501
+ n.h=Math.max(n.h,2*k*g.b);
932
2502
  if(n.shape==='circle') n.w=n.h=Math.max(n.w,n.h); // rx=ry=w/2: box stays square
933
2503
  }
934
2504
  // multi-line <text>: "\n" in labels becomes centered tspans
@@ -951,19 +2521,60 @@ function textEl(x,y,fs,anchor,fill,content,extraAttrs){
951
2521
 
952
2522
  function render(doc,ropts){
953
2523
  // presentation options (renderer tier, not language): {title:true}
954
- // draws the title. Default is NOT drawn (R13: embedded figures almost
2524
+ // draws the title. Default is NOT drawn (`DEFAULT-VALUE-SELECTION`: embedded figures almost
955
2525
  // always sit under a caption in the host document; the title text
956
2526
  // stays semantic in the source either way).
957
2527
  const RO=ropts||{};
958
- // resolve class defaults (explicit element attributes win — rigidity, R8)
2528
+ // `OMITTED-LABEL-RECORDING` display fallback: the semantic model records an omitted label as
2529
+ // absent (null); the RENDERER — never the model — substitutes the id, so a
2530
+ // label-less `node a` still draws "a". This is the whole of the difference
2531
+ // between the model and the figure, and it lives here, in one place.
2532
+ // The element is COPIED rather than patched: a caller that parses, renders
2533
+ // and then reads its own `doc` must still see the absence it was given.
2534
+ // `EMPTY-LABEL-STATE`: the test is ABSENCE, not truthiness. An explicitly empty label
2535
+ // (`node a ""`) is an authorial act — the figure draws no text at all. The
2536
+ // id is an internal handle, never authored display text, so substituting it
2537
+ // would put words into a figure whose source has none.
2538
+ const lbl=(x)=>(x.label===null||x.label===undefined)?Object.assign({},x,{label:x.id}):x;
2539
+ doc=Object.assign({},doc,{
2540
+ nodes : doc.nodes.map(lbl),
2541
+ groups: doc.groups.map(lbl),
2542
+ trunks: (doc.trunks||[]).map(lbl),
2543
+ blocks: doc.blocks.map(b=>b.type==='chart'?b:lbl(b)),
2544
+ });
2545
+ // resolve class defaults (explicit element attributes win — rigidity, `LAYOUT-STABILITY`).
2546
+ // class= is multi-valued: ids apply left-to-right, later wins per channel
2547
+ // (same cascade as CSS multi-class). Single-id documents are unchanged.
959
2548
  const C={}; for(const c of doc.classes||[]) C[c.id]=c;
960
- const rs=(x,k)=>{ if(x[k]===undefined && x.cls && C[x.cls] && C[x.cls][k]!==undefined) x[k]=C[x.cls][k]; };
961
- for(const n of doc.nodes){ rs(n,'color'); rs(n,'stroke'); rs(n,'text'); rs(n,'style'); if(n.style===undefined) n.style='solid'; }
962
- for(const g of doc.groups){ rs(g,'color'); rs(g,'stroke'); rs(g,'text'); rs(g,'style'); }
963
- for(const e of doc.edges){ rs(e,'color'); rs(e,'style'); if(e.style===undefined) e.style='solid'; }
2549
+ const classIdsOf=(x)=>{
2550
+ if(x.cls===undefined||x.cls===null) return [];
2551
+ return Array.isArray(x.cls)?x.cls:[x.cls];
2552
+ };
2553
+ const rs=(x,k)=>{
2554
+ if(x[k]!==undefined) return;
2555
+ for(const id of classIdsOf(x)){
2556
+ if(C[id] && C[id][k]!==undefined) x[k]=C[id][k];
2557
+ }
2558
+ };
2559
+ // a class carries all five §5 attributes; the element's own value wins (`LAYOUT-STABILITY`).
2560
+ // `plane` is materialized to 'base' at parse time, so "unset" is 'base' here.
2561
+ const rsl=(x)=>{
2562
+ if(!(x.plane===undefined||x.plane===null||x.plane==='base')) return;
2563
+ for(const id of classIdsOf(x)){
2564
+ if(C[id] && C[id].plane!==undefined) x.plane=C[id].plane;
2565
+ }
2566
+ };
2567
+ const rsAll=(x)=>{ rs(x,'fill'); rs(x,'stroke'); rs(x,'style'); rsl(x); };
2568
+ for(const n of doc.nodes){ rsAll(n); if(n.style===undefined) n.style='solid'; }
2569
+ for(const g of doc.groups){ rsAll(g); }
2570
+ // 0.1 (§8.4): an edge has no interior, so it takes every class
2571
+ // channel EXCEPT `fill` — which the parser has already guaranteed is
2572
+ // accompanied by a `stroke` on any class an edge joins.
2573
+ for(const e of doc.edges){ rs(e,'stroke'); rs(e,'style'); rsl(e); if(e.style===undefined) e.style='solid'; }
964
2574
  for(const b of doc.blocks){
965
- if(b.fields) for(const f of b.fields) rs(f,'color');
966
- if(b.marks) for(const mk of b.marks) rs(mk,'color');
2575
+ rsAll(b);
2576
+ if(b.fields) for(const f of b.fields) rsAll(f);
2577
+ if(b.marks) for(const mk of b.marks) rsAll(mk);
967
2578
  }
968
2579
  const parts=[]; let y=0, maxW=0;
969
2580
  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;
@@ -977,19 +2588,41 @@ function render(doc,ropts){
977
2588
  let s;
978
2589
  if(b.type==='bitfield') s=renderBitfield(b,y);
979
2590
  else if(b.type==='table') s=renderTable(b,y);
980
- else if(b.type==='plot') s=renderPlot(b,y,doc);
981
- else s=renderWave(b,y);
2591
+ else if(b.type==='chart') s=renderChart(b,y,doc);
2592
+ else s=renderTiming(b,y);
982
2593
  parts.push(s.svg); y=s.y+24; maxW=Math.max(maxW,s.w);
983
2594
  }
984
- if((doc.classes||[]).length){
985
- // derived legend strip (D9): declaration order, swatch + meaning
2595
+ // 0.1 (`CLASS-EMPTY-MEANING`): a class whose meaning is the EMPTY string claims no
2596
+ // meaning, so it has nothing to explain and draws NO legend entry — it is
2597
+ // pure attribute grouping. The filter runs before the `.length` test so a
2598
+ // document whose classes are all meaning-less adds no blank strip and no
2599
+ // vertical space. (Rejected alternative: a `legend=hide` option, which
2600
+ // would have let an author claim a meaning and then hide it — the reader
2601
+ // gets the category from the model while the human sees an unexplained
2602
+ // colour. See `CLASS-EMPTY-MEANING`.)
2603
+ const legendCls=(doc.classes||[]).filter(c=>c.label!=='');
2604
+ if(legendCls.length){
2605
+ // derived legend strip (`CATEGORICAL-MEANING-MAPPING`): declaration order, swatch + meaning
986
2606
  const es=[]; let lx=0, ly=y+8; const rowH=20, wrapW=Math.max(maxW,420);
987
- for(const c of doc.classes){
988
- const tw=String(c.label).length*6.6+30;
2607
+ // 0.1 (`CLASS-PAINT-REQUIREMENT`): the swatch shows the class's DECLARED paint, and a
2608
+ // class that declares none gets no swatch. Before this release the swatch
2609
+ // was drawn unconditionally from `fill`/`stroke` alone, so a class whose
2610
+ // only channel was the (now retired, `COLOUR-KEY-STATUS`) label colour rendered an empty
2611
+ // box — its meaning invisible in the legend the language derives FOR that
2612
+ // meaning — and a class with no paint at all rendered a box
2613
+ // indistinguishable from `fill=white stroke=#555`, which it never wrote.
2614
+ // Retiring `color=` removes the first case; omitting the swatch removes
2615
+ // the second. Every channel a class can still declare (`fill`, `stroke`,
2616
+ // `style`) is drawn, so "declared but not shown" is now unreachable.
2617
+ for(const c of legendCls){
2618
+ const paints=c.fill!==undefined||c.stroke!==undefined||c.style!==undefined;
2619
+ const tw=String(c.label).length*6.6+(paints?30:9);
989
2620
  if(lx>0 && lx+tw>wrapW){ lx=0; ly+=rowH; }
990
- const dash=c.style==='dashed'?' stroke-dasharray="6 4"':(c.style==='dotted'?' stroke-dasharray="2 4"':'');
991
- es.push('<rect x="'+lx+'" y="'+(ly+3)+'" width="16" height="11" fill="'+(c.color||'#fff')+'" stroke="'+(c.stroke||'#555')+'"'+dash+'/>');
992
- es.push('<text x="'+(lx+21)+'" y="'+(ly+12.5)+'" font-size="11" fill="#1d1d1b">'+esc(c.label)+'</text>');
2621
+ if(paints){
2622
+ const dash=c.style==='dashed'?' stroke-dasharray="6 4"':(c.style==='dotted'?' stroke-dasharray="2 4"':'');
2623
+ es.push('<rect x="'+lx+'" y="'+(ly+3)+'" width="16" height="11" fill="'+(c.fill||'#fff')+'" stroke="'+(c.stroke||'#555')+'"'+dash+'/>');
2624
+ }
2625
+ es.push('<text x="'+(lx+(paints?21:0))+'" y="'+(ly+12.5)+'" font-size="11" fill="#1d1d1b">'+esc(c.label)+'</text>');
993
2626
  lx+=tw+14; maxW=Math.max(maxW,lx);
994
2627
  }
995
2628
  parts.push(es.join(''));
@@ -1009,18 +2642,29 @@ function render(doc,ropts){
1009
2642
  // ---- scene ----
1010
2643
  function renderScene(doc,y0){
1011
2644
  const nodes=doc.nodes.map(n=>({...n}));
1012
- // boundary anchors (R44): external I/O endpoints join the layout as
2645
+ // §2.4 plane z = paint order, applied by every pass that stacks annotations
2646
+ // (edges, bundle rings, threshold lines, zone bands). The sort is stable, so
2647
+ // same-plane items keep document order — "a later line paints on top".
2648
+ const zOf=l=>{const L=doc.planes.find(x=>x.id===l);return L?L.z:0;};
2649
+ const zsort=a=>[...(a||[])].sort((p,q)=>zOf(p.plane)-zOf(q.plane));
2650
+ // a zone band draws an outline when stroke= and/or style= is written
2651
+ const bandEdge=f=>(f.stroke||f.style)?' stroke="'+(f.stroke||'#8a8880')+'"'+dashOf(f.style,''):'';
2652
+ // boundary anchors (`EXTERNAL-EDGE-ENDPOINTS`): external I/O endpoints join the layout as
1013
2653
  // small invisible ~12x12 extents so externals land at natural margins;
1014
2654
  // they are never drawn as shapes (the edge ends open at the anchor).
1015
2655
  for(const b of doc.boundaries||[])
1016
- nodes.push({id:b.id,label:b.label||'',boundary:true,layer:'base',group:null,line:b.line});
2656
+ nodes.push({id:b.id,label:b.label!==null&&b.label!==undefined?b.label:'',boundary:true,plane:b.plane||'base',group:null,
2657
+ line:b.line});
1017
2658
  const byId={}; nodes.forEach(n=>byId[n.id]=n);
1018
2659
  // sizes
1019
2660
  for(const n of nodes){
1020
2661
  if(n.boundary){ n.w=n.h=12; continue; }
1021
2662
  const nLines=String(n.label).split('\n').length;
1022
2663
  n.w=tw(n.label); n.h=NH+(nLines-1)*16;
1023
- const s=doc.sizes[n.id]; if(s){ if(s.w)n.w=s.w; if(s.h)n.h=s.h; n.rigid=true; }
2664
+ // `ELEMENT-GEOMETRY-DIRECTIVE`: the declared extent now rides on the `pin` entry.
2665
+ // A pin that declares only `at=` leaves w/h null and the node auto-sizes,
2666
+ // exactly as an id with no `size` line did before the merge.
2667
+ const s=doc.pins[n.id]; if(s&&(s.w!==null||s.h!==null)){ if(s.w)n.w=s.w; if(s.h)n.h=s.h; n.rigid=true; }
1024
2668
  if(n.shape==='diamond'){ n.w=Math.max(n.w+20,70); n.h=Math.max(n.h,48); }
1025
2669
  if(n.shape==='circle'){ n.w=n.h=Math.max(n.w,n.h); }
1026
2670
  // The box is what the layout reserves; the OUTLINE is what the reader
@@ -1028,15 +2672,18 @@ function renderScene(doc,y0){
1028
2672
  // sized as if it were a rectangle lets the label cross the drawn outline
1029
2673
  // (a rhombus offers only its inscribed rectangle, ~half the box in each
1030
2674
  // direction). Grow the box until the label's text box is inside the
1031
- // outline. An explicit `size` is the author's word: a rigid node keeps
1032
- // its box and the label shrinks to the inscribed width instead (R10).
2675
+ // outline. An explicit extent is the author's word: a rigid node keeps
2676
+ // its box and the label shrinks to the inscribed width instead (`UNDECLARED-ATTRIBUTE-BEHAVIOUR`).
1033
2677
  if(!n.rigid) fitOutline(n,labelBox(n.label));
1034
2678
  }
1035
2679
  // ranks: DFS from document-order sources classifies back-edges; a
1036
2680
  // longest-path layering runs on the remaining DAG so sources sit at
1037
2681
  // layer 0. `rank a b c` merges its nodes into ONE vertex for the
1038
2682
  // layering (same layer) without dragging unrelated branches along.
1039
- const pinned=id=>doc.pins[id]!==undefined;
2683
+ // "Pinned" means POSITIONED. Since `ELEMENT-GEOMETRY-DIRECTIVE` a `pin` entry may carry an extent
2684
+ // and no `at=`, and such an element still takes its auto-layout slot — so
2685
+ // the test is on the point, not on the entry's existence.
2686
+ const pinned=id=>doc.pins[id]!==undefined&&doc.pins[id].fx!==null;
1040
2687
  nodes.forEach((n,i)=>n.di=i); // document order (sort tiebreak)
1041
2688
  const rep={}; nodes.forEach(n=>rep[n.id]=n.id); // rank-group union-find
1042
2689
  const find=u=>{ while(rep[u]!==u) u=rep[u]=rep[rep[u]]; return u; };
@@ -1087,7 +2734,7 @@ function renderScene(doc,y0){
1087
2734
  const chains=new Map(); // edge -> [A, ...waypoints, B]
1088
2735
  for(const e of doc.edges){
1089
2736
  const A=byId[e.a], B=byId[e.b];
1090
- if(!A||!B||isBack.has(e)||e.route) continue; // routed edges draw their own waypoints
2737
+ if(!A||!B||isBack.has(e)) continue;
1091
2738
  if(B.rank-A.rank<=1 || pinned(e.a) || pinned(e.b)) continue;
1092
2739
  const chain=[A];
1093
2740
  const span=B.rank-A.rank;
@@ -1215,20 +2862,21 @@ function renderScene(doc,y0){
1215
2862
  const M=main; for(const n of lay){
1216
2863
  if(horiz) n.x=M-n.x-n.w; else n.y=y0+20+(M-(n.y-y0-20))-n.h; }
1217
2864
  }
1218
- // Two-level coordinates (D6): a pinned GROUP anchors its local origin in
2865
+ // Two-level coordinates (`PIN-COORDINATE-SCOPE`): a pinned GROUP anchors its local origin in
1219
2866
  // canvas px; a pinned MEMBER is group-local (relative to that origin);
1220
2867
  // ungrouped pins are canvas px. Moving a group = editing one pin line.
1221
2868
  const gOrigin={};
1222
2869
  for(const g of doc.groups){
1223
2870
  const p=doc.pins[g.id];
1224
- if(p){ gOrigin[g.id]={x:p.fx, y:y0+20+p.fy}; }
2871
+ // `ELEMENT-GEOMETRY-DIRECTIVE`: only a pin that carries `at=` anchors an origin.
2872
+ if(p&&p.fx!==null){ gOrigin[g.id]={x:p.fx, y:y0+20+p.fy}; }
1225
2873
  else{
1226
2874
  const mem=nodes.filter(n=>n.group===g.id);
1227
2875
  if(mem.length) gOrigin[g.id]={x:Math.min(...mem.map(n=>n.x)),
1228
2876
  y:Math.min(...mem.map(n=>n.y))};
1229
2877
  }
1230
2878
  }
1231
- for(const n of nodes){ const p=doc.pins[n.id]; if(!p) continue;
2879
+ for(const n of nodes){ const p=doc.pins[n.id]; if(!p||p.fx===null) continue;
1232
2880
  const o=n.group?gOrigin[n.group]:null;
1233
2881
  if(o){ n.x=o.x+p.fx; n.y=o.y+p.fy; }
1234
2882
  else { n.x=p.fx; n.y=y0+20+p.fy; }
@@ -1240,8 +2888,8 @@ function renderScene(doc,y0){
1240
2888
  // (or the scene is substantially pinned), place the boundary just outside
1241
2889
  // that node's border in the flow direction — upstream for `boundary -> node`,
1242
2890
  // downstream for `node -> boundary` — aligned on the node's cross-axis, at a
1243
- // small fixed gap. Explicitly pinned or explicitly routed boundaries keep
1244
- // their author-given placement. Strip pin/size/route and the AST is unchanged.
2891
+ // small fixed gap. Explicitly pinned boundaries keep
2892
+ // their author-given placement. Strip every `pin` and the AST is unchanged.
1245
2893
  {
1246
2894
  const real=nodes.filter(n=>!n.boundary);
1247
2895
  const pinnedReal=real.filter(n=>pinned(n.id)).length;
@@ -1249,10 +2897,9 @@ function renderScene(doc,y0){
1249
2897
  const BGAP=30; // border-to-anchor gap (spec range 24–40)
1250
2898
  for(const b of nodes){
1251
2899
  if(!b.boundary||pinned(b.id)) continue;
1252
- // sole incident edge (degree-1); skip routed edges and self-refs
2900
+ // sole incident edge (degree-1); skip self-refs
1253
2901
  let node=null, downstream=true;
1254
2902
  for(const e of doc.edges){
1255
- if(e.route) continue;
1256
2903
  if(e.a===b.id&&byId[e.b]&&byId[e.b]!==b){ node=byId[e.b]; downstream=false; break; } // boundary -> node
1257
2904
  if(e.b===b.id&&byId[e.a]&&byId[e.a]!==b){ node=byId[e.a]; downstream=true; break; } // node -> boundary
1258
2905
  }
@@ -1270,7 +2917,7 @@ function renderScene(doc,y0){
1270
2917
  }
1271
2918
  }
1272
2919
  }
1273
- // Boundary labels sit beyond the open end, away from the figure (R44).
2920
+ // Boundary labels sit beyond the open end, away from the figure (`EXTERNAL-EDGE-ENDPOINTS`).
1274
2921
  // bDir = outward direction of an anchor: away from its first incident
1275
2922
  // edge's other endpoint (document order; [0,1] = below when unwired).
1276
2923
  const bDir=n=>{
@@ -1308,7 +2955,7 @@ function renderScene(doc,y0){
1308
2955
  // channel side — so fan-in hubs (N states -> IDLE reset) have no crossings.
1309
2956
  const chPlan=new Map(); let chTop=0, chShift=0;
1310
2957
  {
1311
- const chList=doc.edges.filter(e=>byId[e.a]&&byId[e.b]&&isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)&&!e.route);
2958
+ const chList=doc.edges.filter(e=>byId[e.a]&&byId[e.b]&&isBack.has(e)&&!pinned(e.a)&&!pinned(e.b));
1312
2959
  const near=e=>{ const A=byId[e.a]; return horiz?A.y+A.h/2:A.x+A.w/2; };
1313
2960
  const order=chList.map((e,i)=>({e,i}));
1314
2961
  order.sort((p,q)=>near(q.e)-near(p.e)||p.i-q.i);
@@ -1356,7 +3003,7 @@ function renderScene(doc,y0){
1356
3003
  // opposite sides; endpoints and labels shift together.
1357
3004
  const apOff=new Map();
1358
3005
  {
1359
- const straight=e=>byId[e.a]&&byId[e.b]&&!(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b))&&!chains.get(e)&&!e.route;
3006
+ const straight=e=>byId[e.a]&&byId[e.b]&&!(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b))&&!chains.get(e);
1360
3007
  const pk=e=>e.a<e.b?e.a+'\t'+e.b:e.b+'\t'+e.a;
1361
3008
  const pairN={}, seen={};
1362
3009
  for(const e of doc.edges) if(straight(e)){ const k=pk(e); pairN[k]=(pairN[k]||0)+1; }
@@ -1385,8 +3032,8 @@ function renderScene(doc,y0){
1385
3032
  gBox[g.id]={x0,x1,yA,yB};
1386
3033
  const gdash=g.style==='dashed'?' stroke-dasharray="6 4"':(g.style==='dotted'?' stroke-dasharray="2 4"':'');
1387
3034
  gsvg.push('<g data-group="'+g.id+'" data-gx="'+o.x+'" data-gy="'+o.y+'" style="cursor:move">'
1388
- +'<rect x="'+x0+'" y="'+yA+'" width="'+(x1-x0)+'" height="'+(yB-yA)+'" rx="10" fill="'+(g.color||'#f6f5ef')+'" stroke="'+(g.stroke||'#d6d4cc')+'"'+gdash+'/>'
1389
- +'<text x="'+(x0+10)+'" y="'+(yA+16)+'" font-size="11.5" fill="'+(g.text||'#6f6e69')+'">'+esc(g.label)+'</text></g>');
3035
+ +'<rect x="'+x0+'" y="'+yA+'" width="'+(x1-x0)+'" height="'+(yB-yA)+'" rx="10" fill="'+(g.fill||'#f6f5ef')+'" stroke="'+(g.stroke||'#d6d4cc')+'"'+gdash+'/>'
3036
+ +'<text x="'+(x0+10)+'" y="'+(yA+16)+'" font-size="11.5" fill="'+labelInk(g.fill||'#f6f5ef','#6f6e69')+'">'+esc(g.label)+'</text></g>');
1390
3037
  W=Math.max(W,x1); Hh=Math.max(Hh,yB-y0-20);
1391
3038
  }
1392
3039
  // zone bands on groups: above the group background, below the nodes.
@@ -1399,14 +3046,13 @@ function renderScene(doc,y0){
1399
3046
  if(f.dir==='right') return [x0+w*f.from/100, yA, w*(f.to-f.from)/100, h];
1400
3047
  return [x1-w*f.to/100, yA, w*(f.to-f.from)/100, h]; // left
1401
3048
  };
1402
- for(const f of doc.fills){
3049
+ for(const f of zsort(doc.bands)){
1403
3050
  const B=gBox[f.target]; if(!B) continue;
1404
3051
  const [bx,by,bw,bh]=bandRect(f,B.x0,B.yA,B.x1,B.yB);
1405
- gsvg.push('<rect x="'+bx+'" y="'+by+'" width="'+bw+'" height="'+bh+'" fill="'+f.color+'" opacity="0.9"/>');
3052
+ gsvg.push('<rect x="'+bx+'" y="'+by+'" width="'+bw+'" height="'+bh+'" fill="'+f.fill+'" opacity="0.9"'+bandEdge(f)+'/>');
1406
3053
  }
1407
- // edges (sorted by layer z, then doc order)
1408
- const zOf=l=>{const L=doc.layers.find(x=>x.id===l);return L?L.z:0;};
1409
- const edges=[...doc.edges].sort((p,q)=>zOf(p.layer)-zOf(q.layer));
3054
+ // edges (sorted by plane z, then doc order)
3055
+ const edges=zsort(doc.edges);
1410
3056
  const esvg=[], lblsvg=[]; // labels paint last = closest to the viewer
1411
3057
  // ── deferred edge-label placement ───────────────────────────────────────
1412
3058
  // An edge label is not written where it is emitted. Each emission reserves
@@ -1448,54 +3094,23 @@ function renderScene(doc,y0){
1448
3094
  });
1449
3095
  for(const e of edges){
1450
3096
  const A=byId[e.a], B=byId[e.b]; if(!A||!B) continue;
1451
- const col=e.color||'#555';
3097
+ // an edge is pure stroke: `stroke=` and `fill=` name the same channel
3098
+ // (stroke= wins), `text=` colours the [tail]/[mid]/[head] labels.
3099
+ // 0.1 (§8.4): `fill=` on an edge is a line error, so the silent
3100
+ // precedence this line encoded no longer has anything to resolve.
3101
+ const col=e.stroke||'#555';
3102
+ // 0.1 (`LABEL-COLOUR-SOURCE`): an edge has no interior, so its labels cannot be
3103
+ // derived from a fill — they follow their OWNER, the line. Before this
3104
+ // release the mid label did (`e.color||col`) and the [tail]/[head] labels
3105
+ // did NOT (`e.color||'#555'`): one construct, two undocumented defaults,
3106
+ // so a stroke=#0f766e edge drew a teal mid label and two grey endpoint
3107
+ // labels. All three now take the line's colour.
3108
+ const lcol=col, ecol=col;
1452
3109
  const dash=e.style==='dashed'?' stroke-dasharray="6 4"':(e.style==='dotted'?' stroke-dasharray="2 4"':'');
1453
3110
  const wantsStart=e.op==='<->'||e.op==='<-', wantsEnd=e.op==='<->'||e.op==='->';
1454
3111
  const m1='', m2=''; // markers removed — arrowTri() paints triangles above nodes in lblsvg
1455
3112
  const halo=' paint-order="stroke" stroke="#fff" stroke-width="3"';
1456
- const seg=(p,q,t,lbl,fs)=>reqLabel({p,q,t0:t,text:lbl,fs,col:'#555',halo,e,A,B,kind:'end'});
1457
- if(e.route){
1458
- // declared waypoints (route … via=, 0.1-dev.13) are RIGID: the edge
1459
- // draws source → via1 → … → viaN → target and bypasses the automatic
1460
- // machinery (chains, channels, obstacle detours). Waypoints are
1461
- // canvas px in the same space as ungrouped pins (and follow the
1462
- // ring-channel scene shift like pins do). Under orthogonal routing
1463
- // (route-level routing= wins over the document directive) consecutive
1464
- // points are joined by deterministic manhattan elbows — horizontal-
1465
- // then-vertical for flow right|left, vertical-then-horizontal for
1466
- // down|up — reusing the standard borderPoint anchoring at both ends.
1467
- const orth=(e.route.routing||doc.routing||'straight')==='orthogonal';
1468
- const via=e.route.via.map(p=>[p[0], y0+20+chShift+p[1]]);
1469
- let pts=[borderPoint(A,via[0][0],via[0][1])]
1470
- .concat(via,[borderPoint(B,via[via.length-1][0],via[via.length-1][1])]);
1471
- if(orth){
1472
- const out=[pts[0]];
1473
- for(let i=1;i<pts.length;i++){
1474
- const p=out[out.length-1], q=pts[i];
1475
- if(p[0]!==q[0]&&p[1]!==q[1]) out.push(horiz?[q[0],p[1]]:[p[0],q[1]]);
1476
- out.push(q);
1477
- }
1478
- pts=out;
1479
- }
1480
- for(let i=1;i+1<pts.length;i++) // drop zero-length interior steps
1481
- if(Math.hypot(pts[i][0]-pts[i-1][0],pts[i][1]-pts[i-1][1])<0.5){ pts.splice(i,1); i--; }
1482
- esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
1483
- noteSegs(e,pts);
1484
- if(e.mid){ // the longest segment carries the mid label
1485
- let bi=0,bl=-1;
1486
- for(let i=0;i+1<pts.length;i++){
1487
- const l=Math.hypot(pts[i+1][0]-pts[i][0],pts[i+1][1]-pts[i][1]);
1488
- if(l>bl){ bl=l; bi=i; }
1489
- }
1490
- reqLabel({p:pts[bi],q:pts[bi+1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:bi===0});
1491
- }
1492
- if(e.tail) seg(pts[0],pts[1],0.25,e.tail,10);
1493
- if(e.head) seg(pts[pts.length-1],pts[pts.length-2],0.25,e.head,10);
1494
- if(wantsStart) arrowTri(pts[0],pts[1],col);
1495
- if(wantsEnd) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
1496
- for(const pP of pts){ W=Math.max(W,pP[0]+4); Hh=Math.max(Hh,pP[1]+16-y0-20); }
1497
- continue;
1498
- }
3113
+ const seg=(p,q,t,lbl,fs)=>reqLabel({p,q,t0:t,text:lbl,fs,col:ecol,halo,e,A,B,kind:'end'});
1499
3114
  if(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)){
1500
3115
  // back-edge (retry loop): polyline through a side channel beyond the
1501
3116
  // occupied lanes instead of a straight line hidden under the spine,
@@ -1521,7 +3136,7 @@ function renderScene(doc,y0){
1521
3136
  } else pts.push([tx,chY],[tx,outSide(B,'b')]);
1522
3137
  if(e.mid){
1523
3138
  const c1=pts.findIndex(p=>p[1]===chY);
1524
- reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:false});
3139
+ reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
1525
3140
  }
1526
3141
  } else if(P.ringOK){ // concentric ring: under, around, over, in
1527
3142
  const sx=A.x+A.w/2;
@@ -1529,7 +3144,7 @@ function renderScene(doc,y0){
1529
3144
  pts.push([sx,outSide(A,'b')],[sx,gy],[chX,gy],[chX,topY],[P.ex,topY],[P.ex,outSide(B,'t')]);
1530
3145
  if(e.mid){
1531
3146
  const c1=pts.findIndex(p=>p[0]===chX);
1532
- reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:false});
3147
+ reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
1533
3148
  }
1534
3149
  } else { // channel runs right of the lanes
1535
3150
  const chX=occR+28+P.slot;
@@ -1546,7 +3161,7 @@ function renderScene(doc,y0){
1546
3161
  } else pts.push([chX,ty],[outSide(B,'r'),ty]);
1547
3162
  if(e.mid){
1548
3163
  const c1=pts.findIndex(p=>p[0]===chX);
1549
- reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:false});
3164
+ reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
1550
3165
  }
1551
3166
  }
1552
3167
  // non-incident nodes are obstacles for the channel runs too: a run
@@ -1625,7 +3240,7 @@ function renderScene(doc,y0){
1625
3240
  simplifyPts(pts);
1626
3241
  esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
1627
3242
  noteSegs(e,pts);
1628
- if(midSeg) reqLabel({p:midSeg[0],q:midSeg[1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:false});
3243
+ if(midSeg) reqLabel({p:midSeg[0],q:midSeg[1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
1629
3244
  if(e.tail) seg(p0,pts[1],0.4,e.tail,10);
1630
3245
  if(e.head) seg(p1,pts[pts.length-2],0.4,e.head,10);
1631
3246
  if(wantsStart) arrowTri(pts[0],pts[1],col);
@@ -1655,21 +3270,6 @@ function renderScene(doc,y0){
1655
3270
  route[route.length-1]=borderPoint(B,route[route.length-2][0],route[route.length-2][1]);
1656
3271
  }
1657
3272
  }
1658
- if(!route && A!==B && doc.routing==='orthogonal' && ax!==bx && ay!==by){
1659
- // document-level orthogonal routing (0.1-dev.13): plain straight
1660
- // edges become deterministic manhattan elbows — horizontal-then-
1661
- // vertical under flow right|left, vertical-then-horizontal under
1662
- // down|up — anchored by the same borderPoint rule (the corner sits
1663
- // on the far node's main-axis line, so exits are clean right-angle
1664
- // stubs). Obstacle detours and channel/chain polylines keep their
1665
- // existing shapes; axis-aligned pairs stay plain straight lines.
1666
- const corner=horiz?[bx,ay]:[ax,by];
1667
- let sP=borderPoint(A,corner[0],corner[1]), tP=borderPoint(B,corner[0],corner[1]);
1668
- if(ap){ sP=[sP[0]+ap[0],sP[1]+ap[1]]; tP=[tP[0]+ap[0],tP[1]+ap[1]]; }
1669
- const mid=horiz?[tP[0],sP[1]]:[sP[0],tP[1]];
1670
- const deg=(pp,qq)=>Math.hypot(pp[0]-qq[0],pp[1]-qq[1])<0.5;
1671
- if(!deg(mid,sP)&&!deg(mid,tP)) route=[sP,mid,tP];
1672
- }
1673
3273
  if(route){
1674
3274
  esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(route)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
1675
3275
  noteSegs(e,route);
@@ -1679,7 +3279,7 @@ function renderScene(doc,y0){
1679
3279
  const l=Math.hypot(route[i+1][0]-route[i][0],route[i+1][1]-route[i][1]);
1680
3280
  if(l>bl){ bl=l; bi=i; }
1681
3281
  }
1682
- reqLabel({p:route[bi],q:route[bi+1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:bi===0});
3282
+ reqLabel({p:route[bi],q:route[bi+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:bi===0});
1683
3283
  }
1684
3284
  if(e.tail) seg(route[0],route[1],0.25,e.tail,10);
1685
3285
  if(e.head) seg(route[route.length-1],route[route.length-2],0.25,e.head,10);
@@ -1691,8 +3291,8 @@ function renderScene(doc,y0){
1691
3291
  esvg.push('<line data-edge="'+e.line+'" x1="'+x1+'" y1="'+yy1+'" x2="'+x2+'" y2="'+yy2+'" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
1692
3292
  noteSegs(e,[[x1,yy1],[x2,yy2]]);
1693
3293
  if(e.mid)
1694
- reqLabel({p:[x1,yy1],q:[x2,yy2],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:true});
1695
- // endpoint labels at the tail/head positions (three-position model, R34)
3294
+ reqLabel({p:[x1,yy1],q:[x2,yy2],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:true});
3295
+ // endpoint labels at the tail/head positions (three-position model, `EDGE-LABEL-PLACEMENT`)
1696
3296
  if(e.tail) seg([x1,yy1],[x2,yy2],0.18,e.tail,10);
1697
3297
  if(e.head) seg([x1,yy1],[x2,yy2],0.82,e.head,10);
1698
3298
  if(wantsStart) arrowTri([x1,yy1],[x2,yy2],col);
@@ -1756,8 +3356,13 @@ function renderScene(doc,y0){
1756
3356
  } else {
1757
3357
  sides=across?['above','below']:['right','left'];
1758
3358
  // flowchart convention: a short branch marker leaving a decision node
1759
- // reads as that branch's name only if it sits next to the decision
1760
- const branch=r.first && r.A && r.A.shape==='diamond' &&
3359
+ // reads as that branch's name only if it sits next to the decision.
3360
+ // `FLOWCHART-ROLE-KEYWORDS`: the test is the ROLE, not the geometry. Until
3361
+ // this release it read `r.A.shape==='diamond'`, which is the renderer
3362
+ // consuming a `shape=` value AS a role — exactly what `SHAPE-ENUM-VOCABULARY`/`EXTERNAL-EDGE-ENDPOINTS` forbids,
3363
+ // and a live violation the backlog had recorded (entry 12). Now that
3364
+ // `decision` exists as a word, the heuristic can ask the model.
3365
+ const branch=r.first && r.A && r.A.role==='decision' &&
1761
3366
  String(r.text).length<=3 && !String(r.text).includes('\n');
1762
3367
  tPref=branch?0.22:0.5;
1763
3368
  ts=branch?[0.22,0.3,0.16,0.4,0.5,0.62]:[0.5,0.38,0.62,0.28,0.72];
@@ -1784,32 +3389,34 @@ function renderScene(doc,y0){
1784
3389
  const nsvg=[];
1785
3390
  for(const n of nodes){
1786
3391
  if(n.boundary){
1787
- // never drawn as a shape (R44): the edge already ended open at the
3392
+ // never drawn as a shape (`EXTERNAL-EDGE-ENDPOINTS`): the edge already ended open at the
1788
3393
  // anchor; a declared label sits just beyond the open end, on the
1789
3394
  // side away from the figure — small muted text with a white halo
1790
3395
  if(!n.label) continue;
1791
3396
  const cx=n.x+n.w/2, cy=n.y+n.h/2, [bdx,bdy]=bDir(n);
1792
3397
  const bhalo=' paint-order="stroke" stroke="#fff" stroke-width="3"';
3398
+ // an `external` is never drawn (`EXTERNAL-EDGE-ENDPOINTS`): no fill, no line of its own, so
3399
+ // there is nothing to derive from. Its label keeps the muted canvas ink
3400
+ // (`LABEL-COLOUR-SOURCE` — stated in §5 rather than left to the engine).
3401
+ const bcol='#555';
1793
3402
  if(Math.abs(bdx)>=Math.abs(bdy)){
1794
- if(bdx>=0){ lblsvg.push(textEl(cx+10,cy+3.5,10,'start','#555',n.label,bhalo));
3403
+ if(bdx>=0){ lblsvg.push(textEl(cx+10,cy+3.5,10,'start',bcol,n.label,bhalo));
1795
3404
  W=Math.max(W,cx+12+lblPx(n.label)); }
1796
- else lblsvg.push(textEl(cx-10,cy+3.5,10,'end','#555',n.label,bhalo));
3405
+ else lblsvg.push(textEl(cx-10,cy+3.5,10,'end',bcol,n.label,bhalo));
1797
3406
  } else if(bdy>=0){
1798
- lblsvg.push(textEl(cx,cy+17,10,'middle','#555',n.label,bhalo));
3407
+ lblsvg.push(textEl(cx,cy+17,10,'middle',bcol,n.label,bhalo));
1799
3408
  Hh=Math.max(Hh,cy+21-y0-20);
1800
- } else lblsvg.push(textEl(cx,cy-10,10,'middle','#555',n.label,bhalo));
3409
+ } else lblsvg.push(textEl(cx,cy-10,10,'middle',bcol,n.label,bhalo));
1801
3410
  continue;
1802
3411
  }
1803
3412
  nsvg.push('<g data-node="'+n.id+'" data-x="'+n.x+'" data-y="'+n.y+'" style="cursor:move">');
1804
- const fill=n.color||'#fff', stroke=n.stroke||'#8a8880', txt=n.text||'#1d1d1b';
3413
+ const fill=n.fill||'#fff', stroke=n.stroke||'#8a8880', txt=labelInk(fill,'#1d1d1b');
1805
3414
  const ndash=n.style==='dashed'?' stroke-dasharray="6 4"':(n.style==='dotted'?' stroke-dasharray="2 4"':'');
1806
3415
  if(n.shape==='diamond'){
1807
3416
  const cx=n.x+n.w/2, cy=n.y+n.h/2;
1808
3417
  nsvg.push('<polygon points="'+cx+','+n.y+' '+(n.x+n.w)+','+cy+' '+cx+','+(n.y+n.h)+' '+n.x+','+cy+'" fill="'+fill+'" stroke="'+stroke+'"'+ndash+'/>');
1809
3418
  } else if(n.shape==='rounded'){
1810
3419
  nsvg.push('<rect x="'+n.x+'" y="'+n.y+'" width="'+n.w+'" height="'+n.h+'" rx="'+Math.min(14,n.h/2)+'" fill="'+fill+'" stroke="'+stroke+'"'+ndash+' stroke-width="1.8"/>');
1811
- } else if(n.shape==='cloud'){
1812
- nsvg.push('<ellipse cx="'+(n.x+n.w/2)+'" cy="'+(n.y+n.h/2)+'" rx="'+(n.w/2+10)+'" ry="'+(n.h/2+8)+'" fill="'+fill+'" stroke="'+stroke+'"'+ndash+'/>');
1813
3420
  } else if(n.shape==='ellipse'||n.shape==='circle'){
1814
3421
  nsvg.push('<ellipse cx="'+(n.x+n.w/2)+'" cy="'+(n.y+n.h/2)+'" rx="'+(n.w/2)+'" ry="'+(n.shape==='circle'?n.w/2:n.h/2)+'" fill="'+fill+'" stroke="'+stroke+'"'+ndash+'/>');
1815
3422
  } else if(n.shape==='cylinder'){
@@ -1821,12 +3428,12 @@ function renderScene(doc,y0){
1821
3428
  nsvg.push('<rect x="'+n.x+'" y="'+n.y+'" width="'+n.w+'" height="'+n.h+'" fill="'+fill+'" stroke="'+stroke+'"'+ndash+'/>');
1822
3429
  }
1823
3430
  // zone bands on this node (dir: up=bottom-based, down, left, right)
1824
- for(const f of doc.fills){
3431
+ for(const f of zsort(doc.bands)){
1825
3432
  if(f.target!==n.id) continue;
1826
3433
  const [bx,by,bw,bh]=bandRect(f,n.x,n.y,n.x+n.w,n.y+n.h);
1827
- nsvg.push('<rect x="'+bx+'" y="'+by+'" width="'+bw+'" height="'+bh+'" fill="'+f.color+'" opacity="0.9"/>');
3434
+ nsvg.push('<rect x="'+bx+'" y="'+by+'" width="'+bw+'" height="'+bh+'" fill="'+f.fill+'" opacity="0.9"'+bandEdge(f)+'/>');
1828
3435
  }
1829
- // label with shrink-to-fit when size is rigid (R10); multi-line via "\n"
3436
+ // label with shrink-to-fit when size is rigid (`UNDECLARED-ATTRIBUTE-BEHAVIOUR`); multi-line via "\n"
1830
3437
  let fs=FONT;
1831
3438
  const nl=String(n.label).split('\n');
1832
3439
  const need=Math.max(...nl.map(l=>l.length))*CH;
@@ -1842,7 +3449,7 @@ function renderScene(doc,y0){
1842
3449
  // trunk rings (semantic LAG/ES bundles): the ellipse is DERIVED from the
1843
3450
  // member links' midpoints — drag a node and the ring follows
1844
3451
  const tsvg=[];
1845
- for(const t of doc.trunks){
3452
+ for(const t of zsort(doc.trunks)){
1846
3453
  const mids=[];
1847
3454
  for(const [a,b] of t.pairs){
1848
3455
  const A=byId[a], B=byId[b]; if(!A||!B) continue;
@@ -1855,22 +3462,46 @@ function renderScene(doc,y0){
1855
3462
  const cy=mids.reduce((s,m)=>s+m[1],0)/mids.length;
1856
3463
  const rx=Math.max(46, Math.max(...mids.map(m=>Math.abs(m[0]-cx)))+38);
1857
3464
  const ry=Math.max(26, Math.max(...mids.map(m=>Math.abs(m[1]-cy)))+22);
1858
- const col=t.color||'#64748b';
1859
- tsvg.push('<ellipse cx="'+cx+'" cy="'+cy+'" rx="'+rx+'" ry="'+ry+'" fill="transparent" stroke="'+col+'" stroke-dasharray="6 4" stroke-width="1.6"/>');
3465
+ // 0.1 (§8.4): the ring has no interior — `stroke=` is the one
3466
+ // name for its channel; `fill=` on a bundle is now a line error, so the
3467
+ // model field is always absent and the old `t.fill` fallback is gone.
3468
+ const col=t.stroke||'#64748b';
3469
+ tsvg.push('<ellipse cx="'+cx+'" cy="'+cy+'" rx="'+rx+'" ry="'+ry+'" fill="transparent" stroke="'+(t.stroke||col)+'"'+dashOf(t.style,'6 4')+' stroke-width="1.6"/>');
1860
3470
  tsvg.push(textEl(cx, cy+4, 11.5, 'middle', col, t.label,' paint-order="stroke" stroke="#fff" stroke-width="3"'));
1861
3471
  W=Math.max(W,cx+rx); Hh=Math.max(Hh,cy+ry-y0-20);
1862
3472
  }
1863
- // guide lines + labels (top layer)
1864
- for(const gl of doc.glines){
1865
- const B=gBox[gl.group]; if(!B) continue;
3473
+ // threshold markers + labels (top layer). The target is a GROUP box or,
3474
+ // a single NODE box — the same two scopes `band` has always
3475
+ // taken (§2.6, `AUTHORING-INTENT-OVER-RENDERING`); a node's box is spelled in the gBox coordinate shape.
3476
+ for(const gl of zsort(doc.thresholds)){
3477
+ const gn=byId[gl.target];
3478
+ const B=gBox[gl.target] || (gn ? {x0:gn.x, x1:gn.x+gn.w, yA:gn.y, yB:gn.y+gn.h} : null);
3479
+ if(!B) continue;
1866
3480
  const ly=B.yB-(B.yB-B.yA)*gl.pct/100;
1867
- const col=gl.color||'#ef4444';
3481
+ // 0.1 (§8.4): same as the bundle ring — `stroke=` only.
3482
+ const col=gl.stroke||'#ef4444';
1868
3483
  tsvg.push('<g data-gline="'+gl.line+'" data-gtop="'+B.yA+'" data-gbot="'+B.yB+'" style="cursor:ns-resize">'
1869
- +'<line x1="'+B.x0+'" y1="'+ly+'" x2="'+B.x1+'" y2="'+ly+'" stroke="'+col+'" stroke-width="'+(gl.pct>=100?4:2)+'" stroke-dasharray="7 4"/>'
3484
+ +'<line x1="'+B.x0+'" y1="'+ly+'" x2="'+B.x1+'" y2="'+ly+'" stroke="'+(gl.stroke||col)+'" stroke-width="'+(gl.pct>=100?4:2)+'"'+dashOf(gl.style,'7 4')+'/>'
1870
3485
  +'<line x1="'+B.x0+'" y1="'+ly+'" x2="'+B.x1+'" y2="'+ly+'" stroke="transparent" stroke-width="12"/>'
1871
3486
  +textEl(B.x1+8, ly+4, 11, 'start', col, gl.label,' paint-order="stroke" stroke="#fff" stroke-width="3"')+'</g>');
1872
3487
  W=Math.max(W, B.x1+8+tw(gl.label)); Hh=Math.max(Hh, B.yB-y0-20);
1873
3488
  }
3489
+ // band labels (`BAND-LABEL-STATUS`). The RECT is painted with its target — under
3490
+ // the nodes for a group-scope band, with the node for a node-scope one — so
3491
+ // the label cannot ride along there: it would be occluded by whatever sits on
3492
+ // top. It is written here instead, in the same top layer as the threshold
3493
+ // labels, CENTRED INSIDE the band's own rectangle. Inside, not beside: the
3494
+ // band already owns an area, so the name needs no gutter and the canvas box
3495
+ // does not grow — W and Hh are untouched, which is why adding the label moves
3496
+ // no existing geometry in any figure.
3497
+ for(const f of zsort(doc.bands)){
3498
+ const bn=byId[f.target];
3499
+ const B=gBox[f.target] || (bn ? {x0:bn.x, x1:bn.x+bn.w, yA:bn.y, yB:bn.y+bn.h} : null);
3500
+ if(!B) continue;
3501
+ const [bx,by,bw,bh]=bandRect(f,B.x0,B.yA,B.x1,B.yB);
3502
+ tsvg.push(textEl(bx+bw/2, by+bh/2+4, 11, 'middle', labelInk(f.fill,'#334155'), f.label,
3503
+ ' paint-order="stroke" stroke="#fff" stroke-width="3"'));
3504
+ }
1874
3505
  const yEnd=y0+20+Hh+10;
1875
3506
  return {svg:gsvg.join('')+esvg.join('')+nsvg.join('')+tsvg.join('')+lblsvg.join(''), y:yEnd, w:W+2,
1876
3507
  meta:{W:W, top:y0+20+chShift, Hh:Hh, left:bShift}};
@@ -1878,8 +3509,7 @@ function renderScene(doc,y0){
1878
3509
  // borderPoint: where the ray from n's centre toward (tx,ty) leaves the shape.
1879
3510
  // It must leave the DRAWN outline: a rectangle clip on a diamond or an ellipse
1880
3511
  // stops on the bounding box, which for a rhombus can be a corner where the
1881
- // shape is not — the endpoint then floats in empty space (or, on a cloud whose
1882
- // ellipse is drawn outside the box, hides under the fill).
3512
+ // shape is not — the endpoint then floats in empty space next to the shape.
1883
3513
  function borderPoint(n,tx,ty){
1884
3514
  const cx=n.x+n.w/2, cy=n.y+n.h/2, dx=tx-cx, dy=ty-cy;
1885
3515
  if(dx===0&&dy===0) return [cx,cy];
@@ -1957,44 +3587,391 @@ function routeAround(p,q,obs){
1957
3587
 
1958
3588
  // ---- bitfield ----
1959
3589
  function renderBitfield(b,y0){
1960
- const cell=Math.max(18,Math.min(28,Math.floor(760/b.unit))), rh=30, ruler=16;
3590
+ const cell=Math.max(18,Math.min(28,Math.floor(760/b.word))), rh=30, ruler=16;
1961
3591
  const svg=[]; let y=y0+18;
3592
+ // §5 on the block: text= is the caption colour, fill=/stroke= are the
3593
+ // per-cell defaults a field can override.
1962
3594
  svg.push('<text x="0" y="'+(y-4)+'" font-size="13" font-weight="600">'+esc(b.label)+'</text>');
1963
3595
  // ruler: lsb0 (register style, default) = N-1..0; msb0 (RFC style) = 0..N-1
1964
- for(let i=0;i<b.unit;i++){
1965
- const bit=(b.numbering==='msb0') ? i : b.unit-1-i;
3596
+ for(let i=0;i<b.word;i++){
3597
+ const bit=(b.numbering==='msb0') ? i : b.word-1-i;
1966
3598
  svg.push('<text x="'+(i*cell+cell/2)+'" y="'+(y+11)+'" font-size="8.5" text-anchor="middle" fill="#6f6e69">'+bit+'</text>');
1967
3599
  }
1968
3600
  y+=ruler;
3601
+ // `BITFIELD-REPETITION-CONSTRUCT`: the DERIVED elision row. A field carrying `index=` is
3602
+ // ONE ELEMENT of a repeated run, and the engine draws what that means. Same
3603
+ // division of labour as `present=`: the author names the meaning, the engine
3604
+ // owns the convention (`DOMAIN-CONVENTION-DIRECTIVES`), and there is no option key for any of it.
3605
+ //
3606
+ // `REPEATED-RUN-DRAWING`: WHAT that means is the RFC's own drawing — FIRST
3607
+ // element, elision, LAST element. RFC 8754 §2, the document examples/srh.fd
3608
+ // transcribes, prints
3609
+ //
3610
+ // | Segment List[0] (128-bit IPv6 address) |
3611
+ // ...
3612
+ // | Segment List[n] (128-bit IPv6 address) |
3613
+ //
3614
+ // and 0.1 already ruled that a spanning field follows the RFC's
3615
+ // drawing rather than a FigDown one; this is the same ruling applied to the
3616
+ // other construct in the same figure. Until this release the engine drew ONE
3617
+ // occurrence and hung `[first] … [last]` on the strip — a FigDown invention
3618
+ // where a convention already existed.
3619
+ //
3620
+ // THE INDEX IS APPENDED TO THE AUTHOR'S LABEL, never inserted into it:
3621
+ // `Segment List (128-bit IPv6 address) [0]`, not the RFC's
3622
+ // `Segment List[0] (128-bit IPv6 address)`. Placing it the RFC's way would
3623
+ // mean PARSING the label to find where a bracket belongs, and a label is
3624
+ // opaque text (§12.7) — the engine may not read it. This is a deliberate
3625
+ // departure from the RFC's typography and the trade is the right one: the
3626
+ // reader still learns which occurrence is which, and the cost is zero.
3627
+ //
3628
+ // THE ELISION MARK IS DERIVED, never special-cased. The elements not drawn
3629
+ // number `|last - first| - 1`, and the mark is drawn when that is not ZERO:
3630
+ // index=0..7 6 undrawn [0], elision, [7]
3631
+ // index=0..n indeterminate (a prose last index) [0], elision, [n]
3632
+ // index=0..1 0 undrawn [0], [1], and NO elision mark — the mark means
3633
+ // "there are more, not drawn", and drawing it over nothing
3634
+ // would be a false statement. It falls out of the arithmetic.
3635
+ // index=0..0 a line error before it reaches here (not repetition)
3636
+ // index=53..0 descending, 52 undrawn — the absolute value is why
3637
+ //
3638
+ // `index=""` (the model records `{}`) keeps the ONE-occurrence drawing plus
3639
+ // a bare strip: the author claimed repetition and declined to state any
3640
+ // index, so there is no `[first]` and no `[last]` to write, and two boxes
3641
+ // would assert a first and a last the author did not state. Same ruling
3642
+ // `present=""` gets — the author does not say, the engine does not speak for
3643
+ // them.
3644
+ //
3645
+ // The strip itself carries NO text but the elision mark. With the indices on
3646
+ // the occurrences, `[first] … [last]` on the strip would repeat them.
3647
+ //
3648
+ // THE STRIP OCCUPIES NO BIT POSITIONS. The bit cursor is untouched by it —
3649
+ // it is vertical space only, in the same family as the blank cells a
3650
+ // `break` leaves behind — so the ruler keeps meaning what it means and a
3651
+ // reader that counts drawn rows is reading the drawing (`MEANING-RECOVERY-SOURCE`), which it must
3652
+ // not do. Its width is the element's OWN columns, never the whole word: a
3653
+ // full-width strip would claim columns the element does not occupy.
3654
+ const EL_H=16;
3655
+ const elis=[]; // {row, x, w, text} — one per repeated element, in row order
3656
+ // Two repeated elements ending on the SAME drawn row share one strip BAND,
3657
+ // so the shift counts distinct rows, never elements.
3658
+ const elisBands=(below)=>{ const s=new Set();
3659
+ for(const e of elis) if(below===null||e.row<below) s.add(e.row); return s.size; };
3660
+ const shiftFor=(row)=>elisBands(row)*EL_H;
3661
+ // `FIELD-WIDER-THAN-WORD`: ONE FIELD IS ONE BOX.
3662
+ //
3663
+ // A field wider than `word=` occupies several rows. Until this release each
3664
+ // row was a separate fully-bordered <rect> carrying the full label, so a
3665
+ // 128-bit address at word=32 drew as FOUR captioned boxes and a reader saw
3666
+ // four fields where the model has one. In examples/srh.fd it was worse: the
3667
+ // four rows sat directly above that element's elision strip, so the figure
3668
+ // read as "elements 0,1,2,3 are drawn, more are elided" —
3669
+ // asserting a count of 4 that is 128/32, an artifact of the word width,
3670
+ // while the document records that run as INDETERMINATE. The drawing stated
3671
+ // a number the source refuses to state. `MEANING-RECOVERY-SOURCE` forbids a READER to take a
3672
+ // quantity off the drawing; this is the other half of that duty, which is
3673
+ // the engine's: do not draw a quantity the model does not carry.
3674
+ //
3675
+ // The convention is the RFCs' own. RFC 8200 §3 and RFC 8754 §2 draw a
3676
+ // multi-row field with `+ +` at its internal boundaries — the side
3677
+ // verticals continue, the horizontal rule is ABSENT — and write the name
3678
+ // once, centred on the whole extent.
3679
+ //
3680
+ // But a spanning field is not always a rectangle: a 48-bit MAC at word=32
3681
+ // fills all of row N and columns 0..15 of row N+1, an L (the case that was
3682
+ // examples/ethernet-ii.fd, cut — conformance case 424 now
3683
+ // carries all three geometries). So the rule is not "drop the horizontal
3684
+ // line". It is:
3685
+ //
3686
+ // the boundary between two consecutive segments of the SAME field is
3687
+ // unstroked over the horizontal extent the two segments SHARE, and
3688
+ // stroked everywhere else.
3689
+ //
3690
+ // That is the RFCs' too, and not a generalisation of theirs: RFC 2892
3691
+ // §4.5/§4.6 draws exactly this L for a 48-bit MAC — the rule absent across
3692
+ // the field's own columns, a bare `+` at each end, the name once in the
3693
+ // wider piece. LaTeX `bytefield` §2.4 spells the same drawing as
3694
+ // `\bitbox[lrt]` over `\wordbox[lrb]`.
3695
+ //
3696
+ // Where that shared extent is EMPTY nothing can be suppressed and the field
3697
+ // has to draw as two boxes: `field "Sender Protocol Address" 32` in what
3698
+ // was examples/arp.fd starts at column 16 and ends at column 15 of the next
3699
+ // row, so its halves meet at one CORNER and share no column, and a corner
3700
+ // is not an edge a renderer can leave out. That is the ONLY geometry with
3701
+ // two boxes — every middle segment of a spanning field is a full row, so it
3702
+ // overlaps both neighbours, and disjointness can only ever happen between
3703
+ // the first segment and the second.
3704
+ //
3705
+ // The first cut of this rule captioned once per DRAWN BOX, which put the
3706
+ // name on both halves and reproduced the very misreading the rest of the
3707
+ // change removes — two captioned boxes read as two fields, and same-name
3708
+ // does not disambiguate in a language where eight fields may all be named
3709
+ // `U` (examples/srh.fd). So: THE NAME APPEARS ONCE, on the first box in
3710
+ // reading order, and every later box of the same field carries the
3711
+ // CONTINUATION MARK below instead. No box is left anonymous and no box
3712
+ // claims to be a field of its own.
3713
+ //
3714
+ // CONT — the continuation mark. RULE 4.1 says a vocabulary is borrowed
3715
+ // whole from ONE source and that invention is the last resort, so the
3716
+ // survey came first and it came back EMPTY: no packet- or register-diagram
3717
+ // tradition has a mark for this geometry. What was checked, and what each
3718
+ // does, so the next reader does not repeat it:
3719
+ //
3720
+ // RFC ASCII art — RFC 2892 §4.5/§4.6 is the one place the IETF
3721
+ // draws a 48-bit field wrapping MID-row: the
3722
+ // horizontal rule is absent across the field's
3723
+ // columns with a bare `+` at each end, and the
3724
+ // name is written ONCE, in the WIDER piece, the
3725
+ // narrow piece left blank. That is this engine's
3726
+ // L, and it corroborates the placement below —
3727
+ // but its pieces SHARE columns, so an unstroked
3728
+ // edge is available. RFC 826 never draws ARP's
3729
+ // addresses at all (a prose byte list), and RFC
3730
+ // 903 defers to it, so the corner-touching case
3731
+ // has no IETF drawing anywhere. LaTeX
3732
+ // `bytefield` §2.4 encodes the same habit
3733
+ // (`\bitbox[lrt]` over `\wordbox[lrb]`).
3734
+ // WaveDrom `bitfield` — clips the field per lane and writes the FULL
3735
+ // name into every lane, each fully boxed. That
3736
+ // is precisely the drawing this release removes.
3737
+ // IP-XACT / IEEE 1685 — a metadata schema with no rendering at all;
3738
+ // `fieldSegment` (1685-2022) slices a PORT onto
3739
+ // a field, not a field across a row. Nothing.
3740
+ // Kaitai / DFDL — neither draws a bit grid (Kaitai's graphviz
3741
+ // target is a pos/size/type table), so the case
3742
+ // cannot arise. Nothing.
3743
+ // ISO 5807 / ISO 128 — the off-page connector is control flow, and a
3744
+ // break line means material REMOVED, the
3745
+ // opposite claim. Wrong semantics, not adopted.
3746
+ //
3747
+ // So this is a coinage, and it is the minimal one: the ordinary
3748
+ // typographic abbreviation for a thing continued below, in italic to mark
3749
+ // it as the ENGINE's word and not the author's — the same division of
3750
+ // labour as the derived `present=` caption and the derived index suffix
3751
+ // (`DOMAIN-CONVENTION-DIRECTIVES`), and like those it has no option key and cannot be turned off.
3752
+ // It is deliberately TEXT. The two graphical marks this genre already
3753
+ // spends are the dashed stroke, which IS `present=` and which nothing else
3754
+ // may set or clear (`STYLE-KEY-SCOPE`), and the dotted elision strip `index=` draws
3755
+ // (whose `…` is why the mark is not an ellipsis) — a third stroke pattern
3756
+ // could not be read unambiguously against those two.
3757
+ //
3758
+ // REJECTED, so it is not re-litigated blind: the hardware-datasheet
3759
+ // convention, which is the one real prior art for the geometry — repeat
3760
+ // the name on both pieces with a bit-range suffix, `Base 31:24` /
3761
+ // `Base Address 15:00` (Intel SDM Vol. 3A Fig. 3-8), `COUNT[15:8]` /
3762
+ // `COUNT[7:0]` (Microchip DS40001882). It loses twice. It repeats the
3763
+ // name, which is the misreading being removed; and the suffix is a
3764
+ // COMPUTED bit range, which core §12.7 keeps out of the model on purpose
3765
+ // and `MEANING-RECOVERY-SOURCE` forbids a reader to take off the drawing — drawing one would
3766
+ // publish the number the model declines to hold. WHAT WOULD REOPEN IT: a
3767
+ // decision to materialize bit ranges in the model, which would make the
3768
+ // suffix a fact the drawing merely displays rather than one it invents.
3769
+ const CONT='(cont.)';
3770
+ //
3771
+ // boxOutline: the true outline of a box that is not a rectangle. Merge
3772
+ // vertically adjacent segments of equal extent into BANDS, then walk the
3773
+ // right side down and the left side back up. Every horizontal edge the walk
3774
+ // emits is a real boundary of the field; the shared part of an internal
3775
+ // boundary is never reached, which is the `+ +` above. A <path> is used
3776
+ // rather than rects-plus-edge-suppression because the shared edge is the
3777
+ // one edge a per-row rect cannot leave out: a rect strokes four sides or
3778
+ // none, and "none" would also lose the two verticals that must continue.
3779
+ // Fill stays a single closed region, so `fill=` paints the field once with
3780
+ // no seam, and the stroke carries `present=`'s dash around the whole
3781
+ // outline instead of around each row (`STYLE-KEY-SCOPE`: the dash IS conditional
3782
+ // presence, and nothing else may set or clear it).
3783
+ const boxOutline=(bx)=>{
3784
+ const bands=[];
3785
+ for(const s of bx){
3786
+ const t=bands[bands.length-1];
3787
+ if(t && t.x===s.x && t.w===s.w) t.y1=s.y+rh;
3788
+ else bands.push({x:s.x, w:s.w, y0:s.y, y1:s.y+rh});
3789
+ }
3790
+ const pts=[], add=(px,py)=>{ const t=pts[pts.length-1];
3791
+ if(!t || t[0]!==px || t[1]!==py) pts.push([px,py]); };
3792
+ add(bands[0].x+bands[0].w, bands[0].y0);
3793
+ for(let j=0;j<bands.length;j++){ // right side, downward
3794
+ add(bands[j].x+bands[j].w, bands[j].y1);
3795
+ if(j+1<bands.length) add(bands[j+1].x+bands[j+1].w, bands[j].y1);
3796
+ }
3797
+ add(bands[bands.length-1].x, bands[bands.length-1].y1); // the bottom
3798
+ for(let j=bands.length-1;j>=0;j--){ // left side, upward
3799
+ add(bands[j].x, bands[j].y0);
3800
+ if(j>0) add(bands[j-1].x, bands[j].y0);
3801
+ }
3802
+ return 'M'+pts.map(p=>p[0]+' '+p[1]).join(' L')+' Z';
3803
+ };
1969
3804
  let pos=0; // bit cursor
1970
3805
  for(const f of b.fields){
1971
- if(f.wrap){ pos=Math.ceil((pos||1)/b.unit)*b.unit; continue; }
3806
+ if(f.wrap){ pos=Math.ceil((pos||1)/b.word)*b.word; continue; }
1972
3807
  // '*' = variable-length: fill the remainder of the current row
1973
- const w0 = f.w==='*' ? (b.unit - (pos % b.unit)) : f.w;
1974
- let rem = w0;
1975
- while(rem>0){
1976
- const row=Math.floor(pos/b.unit), col=pos%b.unit;
1977
- const span=Math.min(rem, b.unit-col);
1978
- const x=col*cell, yy=y+row*rh;
1979
- const dash=f.optional?' stroke-dasharray="5 3"':'';
1980
- svg.push('<rect x="'+x+'" y="'+yy+'" width="'+(span*cell)+'" height="'+rh+'" fill="'+(f.color||'#fff')+'" stroke="#555"'+dash+'/>');
1981
- if(span*cell>String(f.name).length*6 || rem===w0){
1982
- let fs=11; const need=String(f.name).length*6.2;
1983
- if(need>span*cell-6) fs=Math.max(7,11*(span*cell-6)/need);
1984
- svg.push('<text x="'+(x+span*cell/2)+'" y="'+(yy+rh/2+fs*0.35)+'" font-size="'+fs+'" text-anchor="middle">'+esc(f.name)+'</text>');
3808
+ const w0 = f.w==='*' ? (b.word - (pos % b.word)) : f.w;
3809
+ // The row segments ONE OCCURRENCE of this field occupies, in order — one
3810
+ // row's worth each. The bit cursor advances here and nowhere else, so
3811
+ // `break` and the elision strip keep meaning exactly what they meant.
3812
+ const cut=()=>{
3813
+ const segs=[]; let rem=w0;
3814
+ while(rem>0){
3815
+ const row=Math.floor(pos/b.word), col=pos%b.word;
3816
+ const span=Math.min(rem, b.word-col);
3817
+ segs.push({row:row, x:col*cell, w:span*cell, y:y+row*rh+shiftFor(row)});
3818
+ pos+=span; rem-=span;
1985
3819
  }
1986
- if(f.note && rem===w0)
1987
- svg.push('<title>'+esc(f.note)+'</title>');
1988
- pos+=span; rem-=span;
3820
+ return segs;
3821
+ };
3822
+ // `STYLE-KEY-SCOPE`: the dash IS conditional presence, and nothing else
3823
+ // can set or clear it. `style=` used to win here under `LAYOUT-STABILITY` (the explicit
3824
+ // beats the convention), which meant `style=solid` erased the only mark
3825
+ // that carried the flag while the model kept it — `PRESENTATION-AS-MEANING-CARRIER`'s prohibition.
3826
+ // `PRESENCE-CONDITION-EXPRESSION`: the carrier is now `present=`, and BOTH of its
3827
+ // written forms dash — `present=""` claims conditional presence just as
3828
+ // `present="C = 1"` does; only the caption below distinguishes them.
3829
+ const dash=f.present!==undefined?' stroke-dasharray="5 3"':'';
3830
+ const cfill=f.fill||b.fill||'#fff';
3831
+ const paint=' fill="'+cfill+'" stroke="'+(f.stroke||b.stroke||'#555')+'"'+dash;
3832
+ // Draw one occurrence. `suffix` is the derived index label — '' for a
3833
+ // field that is not one element of a run, ' [0]' / ' [n]' for the two
3834
+ // occurrences `REPEATED-RUN-DRAWING` draws. It is APPENDED to the author's label, and the
3835
+ // label is never read (see the `REPEATED-RUN-DRAWING` note above).
3836
+ const drawOcc=(segs,suffix)=>{
3837
+ // Group the segments into DRAWN BOXES. Two consecutive segments join when
3838
+ // they are vertically adjacent AND share a positive horizontal extent.
3839
+ // The vertical test is not decoration: an elision strip can open a 16px
3840
+ // band between two rows, and a box must never be drawn across one.
3841
+ const boxes=[[segs[0]]];
3842
+ for(let i=1;i<segs.length;i++){
3843
+ const p=segs[i-1], q=segs[i];
3844
+ if(q.y===p.y+rh && Math.min(p.x+p.w,q.x+q.w) > Math.max(p.x,q.x))
3845
+ boxes[boxes.length-1].push(q);
3846
+ else boxes.push([q]);
1989
3847
  }
3848
+ boxes.forEach(function(bx,bi){
3849
+ // `DESCRIPTION-KEY-SPELLING`: the `<title>` is a CHILD of the shape it names, not
3850
+ // a sibling. Until this release it was pushed into the block's stream
3851
+ // after the rect and the label, so it landed under the figure's single
3852
+ // <g> — and SVG says a <title> names its PARENT, so every description in
3853
+ // a figure named the same <g> and a conforming UA showed one arbitrary
3854
+ // tooltip for the whole figure. Written on the FIRST box of a spanning
3855
+ // field only: one field, one description.
3856
+ const desc=(f.description!==undefined && bi===0)?'<title>'+esc(f.description)+'</title>':'';
3857
+ // A box whose segments all have the same horizontal extent is a plain
3858
+ // rectangle — the RFC's `+ +` case, and the only shape a
3859
+ // single-segment field can have, which is why an unspanned field's
3860
+ // bytes are unchanged by all of this.
3861
+ const flat=bx.every(s=>s.x===bx[0].x&&s.w===bx[0].w);
3862
+ const shape=flat
3863
+ ? '<rect x="'+bx[0].x+'" y="'+bx[0].y+'" width="'+bx[0].w+'" height="'+(bx.length*rh)+'"'+paint
3864
+ : '<path d="'+boxOutline(bx)+'"'+paint;
3865
+ const tag=flat?'rect':'path';
3866
+ svg.push(desc?shape+'>'+desc+'</'+tag+'>':shape+'/>');
3867
+ // ONE caption per box: the NAME on the first box, CONT on every later
3868
+ // one. The first box, not the widest — reading order is the order the
3869
+ // bits are in, so the name sits where the field starts, and a reader who
3870
+ // meets `(cont.)` has already met the name. Choosing the widest box
3871
+ // instead would put the name after the mark whenever the tail is the
3872
+ // roomier half, which is the ARP case (16 columns against 16 — a tie
3873
+ // that only reading order breaks anyway).
3874
+ //
3875
+ // A flat box is one open area, so it takes the RFC's placement: centred
3876
+ // on the box's whole vertical extent. A non-flat box has no row spanning
3877
+ // that extent, and centring there would put ink across a stroked
3878
+ // boundary or across columns that belong to the next field — so the
3879
+ // caption goes in the box's WIDEST segment, its largest contiguous area,
3880
+ // ties keeping the topmost.
3881
+ const cap=bi===0?String(f.name)+suffix:CONT;
3882
+ let lead=bx[0]; for(const s of bx) if(s.w>lead.w) lead=s;
3883
+ const ly=flat ? bx[0].y+bx.length*rh/2 : lead.y+rh/2;
3884
+ let fs=11; const need=cap.length*6.2;
3885
+ // The font shrinks to fit the segment that carries it, floor 7px — the
3886
+ // rule a too-narrow one-row field has always had. Below the floor the
3887
+ // caption overflows its box rather than vanishing: a name half outside
3888
+ // its box is legible, an undrawn one leaves a box no reader can
3889
+ // identify — and that applies to `(cont.)` for the same reason, since a
3890
+ // one-column continuation (`field "x" 2` starting at the last column)
3891
+ // is narrower than the mark at any size.
3892
+ if(need>lead.w-6) fs=Math.max(7,11*(lead.w-6)/need);
3893
+ svg.push('<text x="'+(lead.x+lead.w/2)+'" y="'+(ly+fs*0.35)+'" font-size="'+fs+'" text-anchor="middle"'+(bi?' font-style="italic"':'')+inkAttr(cfill)+'>'+esc(cap)+'</text>');
3894
+ });
3895
+ };
3896
+ const ix=f.index;
3897
+ // `REPEATED-RUN-DRAWING`: the FIRST element carries `[first]` — but only when the author
3898
+ // stated one. `index=""` names no index, so nothing is appended and only
3899
+ // this one occurrence is drawn.
3900
+ const stated = ix!==undefined && ix.first!==undefined;
3901
+ const first=cut();
3902
+ if(!first.length) continue; // a zero-width field draws nothing (and cannot parse)
3903
+ drawOcc(first, stated?' ['+ix.first+']':'');
3904
+ if(ix!==undefined){
3905
+ // The elements between the two drawn ones. `null` = indeterminate: a
3906
+ // prose last index, or `index=""`, which states no index to subtract.
3907
+ const undrawn = (typeof ix.last==='number') ? Math.abs(ix.last-ix.first)-1 : null;
3908
+ // The elision strip, directly under the FIRST element's last drawn row —
3909
+ // between the two occurrences, which is where RFC 8754 §2 prints its
3910
+ // `...`. Not drawn when nothing is elided; see the `REPEATED-RUN-DRAWING` note above.
3911
+ if(undrawn!==0){
3912
+ const ls=first[first.length-1];
3913
+ elis.push({row:ls.row, x:ls.x, w:ls.w, y:ls.y+rh, text:'…'});
3914
+ }
3915
+ if(stated){
3916
+ // The LAST element is the first one translated straight down by its
3917
+ // own height, so the two occurrences stand in the same columns and
3918
+ // read as the same shape repeated. The cursor moves by whole ROWS to
3919
+ // get there — the skipped cells are blank drawing, in the same family
3920
+ // as the ones a `break` leaves, and are NOT bits.
3921
+ pos += first.length*b.word - w0;
3922
+ drawOcc(cut(), ' ['+ix.last+']');
3923
+ }
3924
+ }
3925
+ }
3926
+ for(const e of elis){
3927
+ const st=(b.stroke||'#555');
3928
+ // `ELISION-MARK-EXTENT`: THE SIDE DOTS SAY WHICH COLUMNS ARE ELIDED, so a
3929
+ // strip that already spans the whole word does not draw them. RFC 8754 §2
3930
+ // prints its `...` row with NO `|` at either end — the box is simply open
3931
+ // there — and an element occupying the full word needs no mark to say
3932
+ // which columns it occupied, because it occupied all of them. The dots
3933
+ // still carry information for a narrower element (reference/bitfield.fd's
3934
+ // `Queue Depth` runs columns 16-31), where without them the strip's extent
3935
+ // is guesswork. Derived from the geometry, never an option key: the same
3936
+ // shape as `REPEATED-RUN-DRAWING`'s rule for the mark itself, which draws iff something is
3937
+ // in fact undrawn.
3938
+ if(e.w < b.word*cell){
3939
+ svg.push('<line x1="'+e.x+'" y1="'+e.y+'" x2="'+e.x+'" y2="'+(e.y+EL_H)+'" stroke="'+st+'" stroke-dasharray="2 3"/>');
3940
+ 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"/>');
3941
+ }
3942
+ let fs=10.5; const need=e.text.length*6.2;
3943
+ if(need>e.w-6) fs=Math.max(7,10.5*(e.w-6)/need);
3944
+ 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>');
3945
+ }
3946
+ const rows=Math.max(1,Math.ceil(pos/b.word));
3947
+ let yb=y+rows*rh+elisBands(null)*EL_H+6, wb=b.word*cell+2;
3948
+ // `PRESENCE-CONDITION-EXPRESSION`: the DERIVED presence caption. `present=` must DRAW, or
3949
+ // the condition would be visible to a reading agent and invisible to the
3950
+ // human looking at the same figure — half an inversion, and the shape `CLASS-EMPTY-MEANING`
3951
+ // rejected `legend=hide` for. The author names the meaning; the engine owns
3952
+ // the drawing convention (`DOMAIN-CONVENTION-DIRECTIVES`), exactly as with the derived legend and the
3953
+ // derived bundle ring — there is no option key for this.
3954
+ // `present=""` draws NO caption line: the author claimed nothing, and
3955
+ // "condition unknown" would be the engine speaking for the author.
3956
+ const capt=b.fields.filter(f=>f.present);
3957
+ if(capt.length){
3958
+ yb+=4;
3959
+ for(const f of capt){
3960
+ const s=f.name+' — present: '+f.present;
3961
+ svg.push('<text x="0" y="'+(yb+9)+'" font-size="10.5" fill="#6f6e69">'+esc(s)+'</text>');
3962
+ wb=Math.max(wb, s.length*6.3+2); yb+=14;
3963
+ }
3964
+ yb+=2;
1990
3965
  }
1991
- const rows=Math.max(1,Math.ceil(pos/b.unit));
1992
- return {svg:svg.join(''), y:y+rows*rh+6, w:b.unit*cell+2};
3966
+ return {svg:svg.join(''), y:yb, w:wb};
1993
3967
  }
1994
3968
 
1995
3969
  // ---- table (with ^ rowspan / < colspan merging and per-cell marks) ----
1996
3970
  function renderTable(t,y0){
1997
- const rh=26; const svg=[]; let y=y0+18;
3971
+ // Per-row height grows with multi-line cells (<br> → U+000A at parse).
3972
+ // Single-line rows keep the historical 26px so existing figures stay put.
3973
+ const CFS=12, CLH=CFS*1.3, CMIN=26;
3974
+ const svg=[]; let y=y0+18;
1998
3975
  svg.push('<text x="0" y="'+(y-4)+'" font-size="13" font-weight="600">'+esc(t.label)+'</text>');
1999
3976
  // grid: header tiers (from `head`/`cols` lines) then data rows
2000
3977
  const H=t.heads.length;
@@ -2002,20 +3979,29 @@ function renderTable(t,y0){
2002
3979
  .concat(t.rows.map(r=>r.cells.map(c=>({v:c.v,m:c.m}))));
2003
3980
  const hlRow=r=>r>=H&&(t.rowmarks||[]).some(mk=>mk.r===r-H+1);
2004
3981
  const alignOf=c=>(t.aligns&&t.aligns[c])||null;
3982
+ const nLines=v=>Math.max(1,String(v==null?'':v).split('\n').length);
2005
3983
  const widths=t.cols.map((c,i)=>{
2006
3984
  let w=30;
2007
3985
  for(const hr of t.heads) if(!hr[i].m) w=Math.max(w,tw(hr[i].v));
2008
3986
  for(const r of t.rows) if(!r.cells[i].m) w=Math.max(w,tw(r.cells[i].v));
2009
3987
  return w;
2010
3988
  });
2011
- if(t.colw){ // colw: auto = natural, px fixed, % of natural total
3989
+ if(t.width){ // width: auto = natural, px fixed, % of natural total
2012
3990
  const base=widths.reduce((a,b2)=>a+b2,0);
2013
- t.colw.vals.forEach((v,i)=>{
3991
+ t.width.vals.forEach((v,i)=>{
2014
3992
  if(v.t==='px') widths[i]=v.v;
2015
3993
  else if(v.t==='pct') widths[i]=v.v/100*base;
2016
3994
  });
2017
3995
  }
2018
3996
  const totalW=widths.reduce((a,b)=>a+b,0);
3997
+ // row heights from max line-count among anchors that start on that row
3998
+ const rowH=grid.map(row=>{
3999
+ let ml=1;
4000
+ for(let c=0;c<row.length;c++) if(!row[c].m) ml=Math.max(ml,nLines(row[c].v));
4001
+ return Math.max(CMIN, Math.round(8+ml*CLH));
4002
+ });
4003
+ const yAt=[0];
4004
+ for(let r=0;r<grid.length;r++) yAt.push(yAt[r]+rowH[r]);
2019
4005
  // cell marks: h1..hN address header tiers top-down, r>=1 the data rows
2020
4006
  const markOf=(r,c)=>(t.marks||[]).find(mk=>(mk.hdr?mk.r-1:H+mk.r-1)===r&&mk.c===c+1);
2021
4007
  const yTop=y+4;
@@ -2027,42 +4013,51 @@ function renderTable(t,y0){
2027
4013
  let rs=1; while(r+rs<grid.length && grid[r+rs][c].m==='up') rs++;
2028
4014
  const x=widths.slice(0,c).reduce((a,b)=>a+b,0);
2029
4015
  const wsum=widths.slice(c,c+cs).reduce((a,b)=>a+b,0);
2030
- const yy=yTop+r*rh, h=rs*rh;
4016
+ const yy=yTop+yAt[r], h=yAt[r+rs]-yAt[r];
2031
4017
  const mk=markOf(r,c);
2032
- const fill=mk?mk.color:(cell.hdr?'#eeede6':(hlRow(r)?'#fef3c7':'#fff'));
4018
+ // block fill= is the default DATA-cell fill (header tint is structural)
4019
+ const fill=(mk&&mk.fill)||(cell.hdr?'#eeede6':(hlRow(r)?'#fef3c7':(t.fill||'#fff')));
2033
4020
  // addressable cells carry table-id:row:col (row 0 = bottom header tier)
2034
4021
  const addrR = r>=H ? (r-H+1) : (r===H-1 ? 0 : null);
2035
4022
  const addr = addrR===null ? '' : ' data-cell="'+t.id+':'+addrR+':'+(c+1)+'" style="cursor:pointer"';
2036
- svg.push('<rect x="'+x+'" y="'+yy+'" width="'+wsum+'" height="'+h+'" fill="'+fill+'" stroke="#c9c7bf"'+addr+'/>');
4023
+ svg.push('<rect x="'+x+'" y="'+yy+'" width="'+wsum+'" height="'+h+'" fill="'+fill+'" stroke="'+((mk&&mk.stroke)||t.stroke||'#c9c7bf')+'"'+addr+'/>');
2037
4024
  // alignment: headers centered; data follows GFM colon alignment (default left)
2038
4025
  const al=cell.hdr?'center':(alignOf(c)||'left');
2039
4026
  const tx=al==='center'?x+wsum/2:(al==='right'?x+wsum-7:x+7);
2040
4027
  const anchor=al==='center'?'middle':(al==='right'?'end':'start');
2041
- svg.push('<text x="'+tx+'" y="'+(yy+h/2+4.3)+'" font-size="12" text-anchor="'+anchor+'"'+(cell.hdr?' font-weight="600"':'')+'>'+esc(cell.v)+'</text>');
4028
+ // multi-line via U+000A (from <br> at parse). Single-line path keeps
4029
+ // the historical <text> shape (no default fill=) so figures without
4030
+ // <br> stay byte-stable; multi-line uses textEl like node labels.
4031
+ const cv=cell.v==null?'':String(cell.v);
4032
+ if(cv.indexOf('\n')<0){
4033
+ svg.push('<text x="'+tx+'" y="'+(yy+h/2+4.3)+'" font-size="12" text-anchor="'+anchor+'"'+(cell.hdr?' font-weight="600"':'')+inkAttr(fill)+'>'+esc(cv)+'</text>');
4034
+ } else {
4035
+ svg.push(textEl(tx, yy+h/2+4, CFS, anchor, labelInk(fill,'#1d1d1b'), cv, cell.hdr?' font-weight="600"':''));
4036
+ }
2042
4037
  }
2043
4038
  }
2044
- const yEnd=yTop+grid.length*rh;
4039
+ const yEnd=yTop+yAt[grid.length];
2045
4040
  return {svg:svg.join(''), y:yEnd+6, w:totalW+2};
2046
4041
  }
2047
4042
 
2048
- // ---- plot bars3d: deterministic isometric projection of a table ----
4043
+ // ---- chart bar3d: deterministic isometric projection of a table ----
2049
4044
  function shade(hex,f){
2050
4045
  const v=parseInt(hex.slice(1),16);
2051
4046
  const c=x=>Math.round(Math.max(0,Math.min(255,x))).toString(16).padStart(2,'0');
2052
4047
  return '#'+c(((v>>16)&255)*f)+c(((v>>8)&255)*f)+c((v&255)*f);
2053
4048
  }
2054
- const PLOT_PALETTE=['#3b82f6','#22c55e','#f59e0b','#ef4444','#a855f7','#14b8a6','#eab308','#64748b'];
2055
- function renderPlot(b,y0,doc){
4049
+ const CHART_PALETTE=['#3b82f6','#22c55e','#f59e0b','#ef4444','#a855f7','#14b8a6','#eab308','#64748b'];
4050
+ function renderChart(b,y0,doc){
2056
4051
  const t=doc.blocks.find(x=>x.type==='table'&&x.id===b.tid);
2057
4052
  const rows=t.rows.map(r=>r.cells.slice(1).map(c=>parseFloat(c.v)||0));
2058
4053
  const rLab=t.rows.map(r=>r.cells[0].v), cLab=t.cols.slice(1);
2059
4054
  const R=rows.length, C=cLab.length;
2060
- const zmax=Math.max(b.level||0, ...rows.flat(), 1);
4055
+ const zmax=Math.max(...rows.flat(), 1);
2061
4056
  const W2=20,H2=10,ZS=130/zmax,BAR=0.72;
2062
4057
  const ox=R*W2+8, oy=y0+18+ZS*zmax+6;
2063
4058
  const P=(r,c,z)=>[ox+(c-r)*W2, oy+(c+r)*H2-z*ZS];
2064
4059
  const svg=[];
2065
- svg.push('<text x="0" y="'+(y0+14)+'" font-size="13" font-weight="600">'+esc(t.label)+' — bars3d</text>');
4060
+ svg.push('<text x="0" y="'+(y0+14)+'" font-size="13" font-weight="600">'+esc(t.label)+' — bar3d</text>');
2066
4061
  // floor grid edges
2067
4062
  const F=[P(0,0,0),P(R,0,0),P(R,C,0),P(0,C,0)];
2068
4063
  svg.push('<polygon points="'+F.map(p=>p.join(',')).join(' ')+'" fill="#f6f5ef" stroke="#d6d4cc"/>');
@@ -2072,7 +4067,7 @@ function renderPlot(b,y0,doc){
2072
4067
  order.sort((a,b2)=>(a[0]+a[1])-(b2[0]+b2[1]));
2073
4068
  for(const [r,c] of order){
2074
4069
  const h=rows[r][c]; if(h<=0) continue;
2075
- const col=PLOT_PALETTE[c%PLOT_PALETTE.length];
4070
+ const col=CHART_PALETTE[c%CHART_PALETTE.length];
2076
4071
  const i0=r+(1-BAR)/2, i1=r+(1+BAR)/2, j0=c+(1-BAR)/2, j1=c+(1+BAR)/2;
2077
4072
  const A=P(i0,j0,h),Bp=P(i1,j0,h),Cp=P(i1,j1,h),D=P(i0,j1,h);
2078
4073
  const B0=P(i1,j0,0),C0=P(i1,j1,0),D0=P(i0,j1,0);
@@ -2080,12 +4075,6 @@ function renderPlot(b,y0,doc){
2080
4075
  svg.push('<polygon points="'+[Cp,D,D0,C0].map(p=>p.join(',')).join(' ')+'" fill="'+shade(col,0.55)+'"/>');
2081
4076
  svg.push('<polygon points="'+[A,Bp,Cp,D].map(p=>p.join(',')).join(' ')+'" fill="'+col+'"/>');
2082
4077
  }
2083
- // threshold plane (translucent, drawn over bars like the convention)
2084
- if(b.level!==null&&b.level!==undefined){
2085
- const L=[P(0,0,b.level),P(R,0,b.level),P(R,C,b.level),P(0,C,b.level)];
2086
- svg.push('<polygon points="'+L.map(p=>p.join(',')).join(' ')+'" fill="#ef4444" opacity="0.18" stroke="#ef4444" stroke-dasharray="6 4"/>');
2087
- svg.push(textEl(L[3][0]+8, L[3][1]+4, 11, 'start', '#ef4444', 'level '+b.level,' paint-order="stroke" stroke="#fff" stroke-width="3"'));
2088
- }
2089
4078
  // axis labels
2090
4079
  rLab.forEach((l,r)=>{ const p=P(r+0.5,-0.15,0); svg.push(textEl(p[0]-4,p[1]+10,10,'end','#6f6e69',l)); });
2091
4080
  cLab.forEach((l,c)=>{ const p=P(R+0.15,c+0.5,0); svg.push(textEl(p[0]+4,p[1]+10,10,'start','#6f6e69',l)); });
@@ -2100,57 +4089,63 @@ function renderPlot(b,y0,doc){
2100
4089
  return {svg:svg.join(''), y:y0+hgt, w:w};
2101
4090
  }
2102
4091
 
2103
- // ---- wave ----
2104
- function renderWave(w,y0){
2105
- const tickW=26, laneH=30, laneGap=12, nameW=Math.max(...w.signals.map(s=>tw(s.name)),60);
4092
+ // ---- timing ----
4093
+ function renderTiming(w,y0){
4094
+ const cycleW=26, laneH=30, laneGap=12, nameW=Math.max(...w.signals.map(s=>tw(s.name)),60);
2106
4095
  const svg=[]; let y=y0+18;
2107
4096
  svg.push('<text x="0" y="'+(y-4)+'" font-size="13" font-weight="600">'+esc(w.label)+'</text>');
2108
- const ticks=Math.max(...w.signals.map(s=>s.lane.length));
4097
+ const cycles=Math.max(...w.signals.map(s=>s.lane.length));
2109
4098
  w.signals.forEach((s,si)=>{
2110
4099
  const top=y+8+si*(laneH+laneGap), bot=top+laneH-8;
4100
+ // §5 per signal: fill= data-box fill, stroke= the waveform colour,
4101
+ // style= the path dash. 0.1 (`LABEL-COLOUR-SOURCE`): the signal NAME sits in the
4102
+ // left gutter, on the canvas, so it takes the canvas ink; only the data
4103
+ // labels sit on `sbox` and derive from it.
4104
+ const sbox=s.fill||w.fill||'#eef6ff', spath=s.stroke||w.stroke||'#1d4ed8';
2111
4105
  svg.push('<text x="'+(nameW-10)+'" y="'+(top+(laneH-8)/2+4)+'" font-size="11.5" text-anchor="end" font-family="monospace">'+esc(s.name)+'</text>');
2112
4106
  let d='', prev=null, li=0, dataIdx=0;
2113
4107
  for(let i=0;i<s.lane.length;i++){
2114
4108
  let ch=s.lane[i];
2115
4109
  if(ch==='.') ch=prev||'0';
2116
- const x=nameW+i*tickW;
4110
+ const x=nameW+i*cycleW;
2117
4111
  if(ch==='p'||ch==='n'){
2118
4112
  const hiFirst=(ch==='p');
2119
4113
  const a=hiFirst?top:bot, b=hiFirst?bot:top;
2120
- d+='M'+x+','+a+' L'+(x+tickW/2)+','+a+' L'+(x+tickW/2)+','+b+' L'+(x+tickW)+','+b+' ';
2121
- // draw transition edge at tick start
4114
+ d+='M'+x+','+a+' L'+(x+cycleW/2)+','+a+' L'+(x+cycleW/2)+','+b+' L'+(x+cycleW)+','+b+' ';
4115
+ // draw transition edge at cycle start
2122
4116
  if(prev) d+='M'+x+','+top+' L'+x+','+bot+' ';
2123
4117
  } else if(ch==='0'||ch==='1'){
2124
4118
  const yy=(ch==='1')?top:bot;
2125
4119
  const pv=(prev==='1')?top:(prev==='0'?bot:null);
2126
4120
  if(pv!==null&&pv!==yy) d+='M'+x+','+pv+' L'+x+','+yy+' ';
2127
- d+='M'+x+','+yy+' L'+(x+tickW)+','+yy+' ';
4121
+ d+='M'+x+','+yy+' L'+(x+cycleW)+','+yy+' ';
2128
4122
  } else if(ch==='x'){
2129
- svg.push('<rect x="'+x+'" y="'+top+'" width="'+tickW+'" height="'+(bot-top)+'" fill="url(#hatch)" stroke="#999"/>');
2130
- } else if(/[=\d]/.test(ch)){
4123
+ svg.push('<rect x="'+x+'" y="'+top+'" width="'+cycleW+'" height="'+(bot-top)+'" fill="url(#hatch)" stroke="#999"/>');
4124
+ } else if(ch==='='){
2131
4125
  // merge consecutive identical data chars
2132
4126
  let j=i; while(j+1<s.lane.length && s.lane[j+1]==='.') j++;
2133
4127
  const span=j-i+1;
2134
- svg.push('<rect x="'+x+'" y="'+top+'" width="'+(tickW*span)+'" height="'+(bot-top)+'" fill="#eef6ff" stroke="#7aa7d9"/>');
2135
- const lbl = ch==='='? (s.labels[dataIdx++]||'') : ch;
2136
- if(lbl) svg.push('<text x="'+(x+tickW*span/2)+'" y="'+((top+bot)/2+4)+'" font-size="10.5" text-anchor="middle">'+esc(lbl)+'</text>');
4128
+ svg.push('<rect x="'+x+'" y="'+top+'" width="'+(cycleW*span)+'" height="'+(bot-top)+'" fill="'+sbox+'" stroke="'+(s.stroke||w.stroke||'#7aa7d9')+'"/>');
4129
+ const lbl = (s.labels&&s.labels[dataIdx++])||'';
4130
+ if(lbl) svg.push('<text x="'+(x+cycleW*span/2)+'" y="'+((top+bot)/2+4)+'" font-size="10.5" text-anchor="middle"'+inkAttr(sbox)+'>'+esc(lbl)+'</text>');
2137
4131
  i=j;
2138
4132
  }
2139
4133
  prev=ch;
2140
4134
  }
2141
- if(d) svg.push('<path d="'+d+'" fill="none" stroke="#1d4ed8" stroke-width="1.6"/>');
4135
+ if(d) svg.push('<path d="'+d+'" fill="none" stroke="'+spath+'" stroke-width="1.6"/>');
2142
4136
  });
2143
4137
  for(const g of w.gaps){
2144
- const x=nameW+g*tickW;
4138
+ const cycle=(g&&typeof g==='object')?g.t:g;
4139
+ const x=nameW+cycle*cycleW;
2145
4140
  const hTotal=w.signals.length*(laneH+laneGap);
2146
4141
  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"/>');
2147
4142
  }
2148
4143
  const H=y+8+w.signals.length*(laneH+laneGap);
2149
- return {svg:svg.join(''), y:H, w:nameW+ticks*tickW+2};
4144
+ return {svg:svg.join(''), y:H, w:nameW+cycles*cycleW+2};
2150
4145
  }
2151
4146
 
2152
4147
  // ============================================================
2153
- return { parse: parse, render: render };
4148
+ return { parse: parse, render: render, stackSectionSvgs: stackSectionSvgs };
2154
4149
  })();
2155
4150
 
2156
4151
  // ---- minimal synchronous SHA-256 (FIPS 180-4), hex output ----
@@ -2201,39 +4196,65 @@ function __sha256hex(text) {
2201
4196
  }
2202
4197
 
2203
4198
  // ---- public API ----
2204
- // parse(text) -> { doc, errors } errors: array of "Line N: message"
4199
+ // parse(text) -> { doc, errors, docs }
4200
+ // errors: "Line N: message"
4201
+ // docs: one entry per figdown section (length 1 for ordinary files)
4202
+ // doc: docs[0] (compat for single-section callers)
2205
4203
  function parse(text) {
2206
4204
  var p = __engine.parse(String(text));
2207
- return { doc: p.doc, errors: p.errs };
4205
+ var docs = p.docs && p.docs.length ? p.docs : (p.doc ? [p.doc] : []);
4206
+ return { doc: p.doc, errors: p.errs, docs: docs };
4207
+ }
4208
+ // Stack multi-section SVGs (MULTI-FIGURE-DOCUMENTS): one .fd → one .svg, sections top-to-bottom.
4209
+ function __stackSectionSvgs(results) {
4210
+ if (typeof __engine.stackSectionSvgs === 'function')
4211
+ return __engine.stackSectionSvgs(results);
4212
+ // fallback if engine build is older
4213
+ return results[0] && results[0].svg;
2208
4214
  }
2209
4215
  // render(text, opts) -> { svg, errors } svg is null when there are errors
2210
4216
  // (determinism over convenience: no partial renders of invalid input).
2211
4217
  // opts (presentation, renderer tier): { title: true } draws the title;
2212
4218
  // the default does NOT (embedded figures almost always sit under the
2213
4219
  // host document's caption — the majority case).
4220
+ // Multi-section sources are stacked vertically into a single SVG (MULTI-FIGURE-DOCUMENTS).
2214
4221
  function render(text, opts) {
2215
4222
  var p = parse(text);
2216
4223
  if (p.errors.length) return { svg: null, errors: p.errors };
4224
+ if (p.docs.length > 1) {
4225
+ var rs = p.docs.map(function (d) { return __engine.render(d, opts); });
4226
+ return { svg: __engine.stackSectionSvgs(rs), errors: [] };
4227
+ }
2217
4228
  return { svg: __engine.render(p.doc, opts).svg, errors: [] };
2218
4229
  }
2219
4230
  // renderDoc(doc, opts) -> svg string, for an already-validated doc from parse().
4231
+ // For multi-section, pass parse().docs to renderDocs instead.
2220
4232
  function renderDoc(doc, opts) {
2221
4233
  return __engine.render(doc, opts).svg;
2222
4234
  }
4235
+ function renderDocs(docs, opts) {
4236
+ if (!docs || !docs.length) return '';
4237
+ if (docs.length === 1) return __engine.render(docs[0], opts).svg;
4238
+ return __engine.stackSectionSvgs(docs.map(function (d) { return __engine.render(d, opts); }));
4239
+ }
2223
4240
  // artifact(text) -> { svg, errors } svg is the full self-carrying SVG:
2224
4241
  // the render plus a <metadata id="figdown-source"> block embedding the
2225
- // source text and its SHA-256 (same convention as tools/build-svg.js).
4242
+ // source text, the SHA-256 OF THAT SOURCE, and the engine version that
4243
+ // rendered it (same convention as tools/build-svg.js; spec core §7).
2226
4244
  // svg is null when there are errors.
2227
4245
  function artifact(text, opts) {
2228
4246
  var src = String(text);
2229
4247
  var p = render(src, opts);
2230
4248
  if (p.errors.length) return { svg: null, errors: p.errors };
2231
- // recorded render options keep third-party rebuilds bit-identical
4249
+ // The artifact records the SHA-256 OF THE SOURCE, the ENGINE VERSION that
4250
+ // rendered it, and any non-default render option (core §7) — together they
4251
+ // keep third-party rebuilds bit-identical and give a diff somewhere to point
2232
4252
  var optAttr = (opts && opts.title === true) ? ' data-render-options="with-title"' : '';
2233
- var meta = '<metadata id="figdown-source" data-sha256="' + __sha256hex(src) + '"' + optAttr + '><![CDATA[\n'
4253
+ var meta = '<metadata id="figdown-source" data-sha256="' + __sha256hex(src) + '"'
4254
+ + ' data-engine-version="' + VERSION + '"' + optAttr + '><![CDATA[\n'
2234
4255
  + src.replace(/]]>/g, ']]]]><![CDATA[>') + '\n]]></metadata>';
2235
4256
  return { svg: p.svg.replace(/<\/svg>$/, meta + '</svg>'), errors: [] };
2236
4257
  }
2237
4258
 
2238
- return { parse: parse, render: render, renderDoc: renderDoc, artifact: artifact, version: VERSION };
4259
+ return { parse: parse, render: render, renderDoc: renderDoc, renderDocs: renderDocs, artifact: artifact, version: VERSION };
2239
4260
  }));