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