figdown 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/figdown.js CHANGED
@@ -1,4 +1,4 @@
1
- // figdown.js — FigDown embeddable library (0.2.0)
1
+ // figdown.js — FigDown embeddable library (0.3.0)
2
2
  // GENERATED FILE, DO NOT EDIT. Built from editor/figdown.html.
3
3
  // Regenerate with: node tools/make-lib.js
4
4
  (function (root, factory) {
@@ -10,7 +10,7 @@
10
10
  }
11
11
  }(typeof globalThis !== 'undefined' ? globalThis : this, function () {
12
12
  'use strict';
13
- var VERSION = "0.2.0";
13
+ var VERSION = "0.3.0";
14
14
 
15
15
  // ---- engine (extracted verbatim from editor/figdown.html) ----
16
16
  var __engine = (function () {
@@ -24,7 +24,7 @@ const SHAPES = ['box','rounded','circle','ellipse','diamond','cylinder'];
24
24
  // input to that promise, and under core §13 a 0.x renderer may differ from
25
25
  // the next — which makes the recorded version the only thing that can
26
26
  // explain a diff between two renderings of one source.
27
- const FIGDOWN_VERSION = '0.2.0';
27
+ const FIGDOWN_VERSION = '0.3.0';
28
28
  // `STATECHART-GENRE-SCOPE`: the language number moved for the first time. The dev
29
29
  // counter does NOT reset (core §13.0.4 — `N` counts source states of the
30
30
  // engine and only ever increases), so 0.1 is followed by
@@ -34,13 +34,39 @@ const FIGDOWN_VERSION = '0.2.0';
34
34
  // MUST state this, and stating the release version alone does not satisfy
35
35
  // it). Declared here, in ONE place, so the header check and the documented
36
36
  // interface cannot drift:
37
- const LANG_VERSIONS = ['0.1', '0.2'];
37
+ // `DRAWN-ANNOTATION-FORM`: `figdown 0.3` joins the set. `note=` is a NEW OPTION KEY,
38
+ // and core §13.0 makes a new key a `Y` change and not a `Z` one — "`Z`: Bug
39
+ // fixes only. No new features. The language does not move." Shipping `note=`
40
+ // under `v0.2.z` would make `figdown 0.2` name two different languages: the one
41
+ // `v0.2.0` published and the one with `note=`. So the language number moves.
42
+ const LANG_VERSIONS = ['0.1', '0.2', '0.3'];
38
43
  // Genres per declared language version. `Y` never removes (core §13.0), so
39
44
  // each row is a superset of the one above it, and `figdown 0.1 <anything>`
40
45
  // resolves against exactly the list it resolved against before `STATECHART-GENRE-SCOPE`.
41
46
  const GENRES_BY_VERSION = {
42
47
  '0.1': ['block','topology','flowchart','bitfield','table','timing'],
43
- '0.2': ['block','topology','flowchart','bitfield','table','timing','statechart']
48
+ '0.2': ['block','topology','flowchart','bitfield','table','timing','statechart'],
49
+ '0.3': ['block','topology','flowchart','bitfield','table','timing','statechart']
50
+ };
51
+ // The version an OPTION KEY first becomes legal in — the `CONNECTOR_MIN_VERSION`
52
+ // device, applied to the option namespace. `DRAWN-ANNOTATION-FORM`: `note=` is gated on the
53
+ // declared version, and the gate's reason is specific to THIS key rather than
54
+ // generic to new keys. `note=` has a PRIOR MEANING on the record: it was the
55
+ // retired spelling of `description=` (`DESCRIPTION-KEY-SPELLING`) and its retirement
56
+ // diagnostic actively told authors to write `description=` for a tooltip.
57
+ // Accepting it silently under a `figdown 0.2` header would repaint a document
58
+ // whose author meant a never-drawn tooltip as one that puts ink on the page —
59
+ // core §13.0.1's named hazard, "a figure that looks right and means something
60
+ // else". A key that had never been spelled before would carry no such risk.
61
+ const OPT_MIN_VERSION={note:'0.3'};
62
+ // True when the document's declared version is older than the key's own.
63
+ // A document with no parsable header has already been diagnosed on line 1, so
64
+ // an absent version never gates a second time.
65
+ const belowOptVersion=(key,ver)=>{
66
+ const need=OPT_MIN_VERSION[key];
67
+ if(!need||!ver) return false;
68
+ const i=LANG_VERSIONS.indexOf(ver), j=LANG_VERSIONS.indexOf(need);
69
+ return i>=0 && j>=0 && i<j;
44
70
  };
45
71
  // Retired shape VALUES keep a named diagnostic (PROCESS §5(d)), the same way
46
72
  // retired option keys do: `cloud` was the one value that named a domain
@@ -225,7 +251,7 @@ function splitList(t,off){
225
251
  // - a key=value token with an unregistered key is an "unknown option"
226
252
  // line error (`UNKNOWN-OPTION-DEGRADATION`) — except inside timing `signal` lanes, where bare
227
253
  // tokens may contain '=' and stay positional (laneMode).
228
- // `fill` was registered here until this release solely to power a retired
254
+ // `fill` was registered here until 0.1 solely to power a retired
229
255
  // migration diagnostic on the old `line` directive; it left the registry with
230
256
  // the `fill` → `band` KEYWORD rename, and 0.1 gave the word back to the
231
257
  // option-key namespace as the primary presentation key (`color=` → `fill=`).
@@ -240,11 +266,11 @@ function splitList(t,off){
240
266
  // time the language gains no replacement — v0.1 has no author-facing label
241
267
  // colour at all (the default is derived, `LABEL-COLOUR-SOURCE`; the owner-level key that could
242
268
  // be added today is the wrong shape, core §9 `ANNOTATION-LOCATOR-SPLIT`). It stays registered so
243
- // the message can name BOTH eras: a `color=` written before this release meant
244
- // the FILL, one written meant the LABEL, and only a human
269
+ // the message can name BOTH eras: a `color=` written in one era meant
270
+ // the FILL, one written in another meant the LABEL, and only a human
245
271
  // knows which document this is. `text` and `z` stay registered
246
272
  // as RETIRED keys so each rename gets a named diagnostic. `offset` replaces
247
- // `threshold at=` (the directive was spelled `guide` until this release);
273
+ // `threshold at=` (the directive was spelled `guide` until 0.1);
248
274
  // `at` stays live on `pin`.
249
275
  // 0.1: `level` stays registered as a RETIRED key — the construct was
250
276
  // DELETED (`CHART-LEVEL-KEY`), and a registered-but-retired key is the only way the
@@ -275,7 +301,7 @@ const OPT_KEYS=new Set(['kind','type','shape','fill','color','stroke','text','in
275
301
  // - `external` is NEVER drawn (`EXTERNAL-EDGE-ENDPOINTS`) — no fill, no border, no dash; only its
276
302
  // label exists, so it takes `text=` (plus `plane=`, organizational exactly
277
303
  // as on a node);
278
- // - `band` carried NO label channel at all until this release (`BAND-LABEL-STATUS`); it now
304
+ // - `band` carried NO label channel at all until 0.1 (`BAND-LABEL-STATUS`); it now
279
305
  // takes a mandatory quoted label, so `color=` applies to it like any
280
306
  // other labelled element;
281
307
  // - typed blocks (`bitfield`/`table`/`timing`) stack in document order OUTSIDE
@@ -294,29 +320,48 @@ const OPT_KEYS=new Set(['kind','type','shape','fill','color','stroke','text','in
294
320
  // the key existed only because there was no label to colour.
295
321
  const DIRECTIVE_OPTS={
296
322
  figdown:[],
297
- node:['shape','fill','stroke','style','class','in','plane','width','height'],
323
+ // `DRAWN-ANNOTATION-FORM`: `title` gains its FIRST option key. It took one
324
+ // positional string and nothing else until now, and the key
325
+ // it gains carries the figure-level annotation — 14% of the measured demand,
326
+ // 10 instances that name no single element ("Total: 2 blocks × 8 ways × 1k
327
+ // sets = 16,384 entries", a four-signal legend, a TODO about the figure).
328
+ // The figure HAS a declaration line, so attachment-by-position reaches it and
329
+ // no standalone keyword is needed. `UNIVERSAL-CORE-KEYWORDS` fixes what `title` MEANS across genres;
330
+ // it is not a bar on the directive taking options, so the key exists in every
331
+ // genre at once, which is correct — every genre has figures.
332
+ title:['note'],
333
+ node:['shape','fill','stroke','style','class','in','width','height','note'],
298
334
  // `FLOWCHART-ROLE-KEYWORDS`: the three flowchart role keywords take EXACTLY the
299
335
  // option keys `node` takes — they ARE nodes, with a role recorded. Listing
300
336
  // `width`/`height` mirrors `node` so the same "use a pin line" diagnostic
301
337
  // fires rather than a bare `unknown option`.
302
- process:['shape','fill','stroke','style','class','in','plane','width','height'],
303
- decision:['shape','fill','stroke','style','class','in','plane','width','height'],
304
- terminator:['shape','fill','stroke','style','class','in','plane','width','height'],
338
+ process:['shape','fill','stroke','style','class','in','width','height','note'],
339
+ decision:['shape','fill','stroke','style','class','in','width','height','note'],
340
+ terminator:['shape','fill','stroke','style','class','in','width','height','note'],
305
341
  // `GENRE-NODE-SPELLING`: `state` IS `node` under `statechart` — a rename, not a
306
342
  // new directive, so it takes `node`'s keys exactly and nothing more.
307
- state:['shape','fill','stroke','style','class','in','plane','width','height'],
308
- group:['fill','stroke','style','gap','class','plane'],
309
- external:['plane'],
310
- edge:['style','class','fill','stroke','plane','label','taillabel','headlabel'],
343
+ state:['shape','fill','stroke','style','class','in','width','height','note'],
344
+ group:['fill','stroke','style','gap','class','note'],
345
+ // `PAINT-ORDER-CONSTRUCT`: `external` now takes NO option key at all. `plane=`
346
+ // was its only one — it is never drawn (`EXTERNAL-EDGE-ENDPOINTS`), so it has no fill, no border
347
+ // and no dash to set — and the withdrawal of `plane=` empties the row. An
348
+ // empty array is the declaration: every key falls through to the generic
349
+ // `external does not take <k>=`.
350
+ external:[],
351
+ edge:['style','class','fill','stroke','label','taillabel','headlabel','note'],
311
352
  // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: same rename argument — the connector's option set is one set
312
353
  // under three spellings, listed three times only because the tables are
313
354
  // keyed by the surface word an author actually wrote.
314
- flowline:['style','class','fill','stroke','plane','label','taillabel','headlabel'],
315
- transition:['style','class','fill','stroke','plane','label','taillabel','headlabel'],
316
- plane:['z','z-index'], flow:[], rank:[],
317
- bundle:['fill','stroke','style','plane'],
318
- threshold:['in','at','offset','fill','stroke','style','plane'],
319
- band:['in','extend','fill','stroke','style','plane','from','to'],
355
+ flowline:['style','class','fill','stroke','label','taillabel','headlabel','note'],
356
+ transition:['style','class','fill','stroke','label','taillabel','headlabel','note'],
357
+ // `PAINT-ORDER-CONSTRUCT`: the `plane` row is GONE, not emptied — the keyword is
358
+ // withdrawn from the language, so it has no acceptor row at all, the shape
359
+ // `path`/`routing` left behind. `z-index=` goes with it: it
360
+ // was legal on `plane` and on nothing else.
361
+ flow:[], rank:[],
362
+ bundle:['fill','stroke','style'],
363
+ threshold:['in','at','offset','fill','stroke','style'],
364
+ band:['in','extend','fill','stroke','style','from','to'],
320
365
  // `ELEMENT-GEOMETRY-DIRECTIVE`: `size` merged into `pin`. ONE directive carries an
321
366
  // element's whole DECLARED geometry — `at=` places it, `width=`/`height=`
322
367
  // extend it — and one model object records it. All three keys are optional
@@ -327,7 +372,7 @@ const DIRECTIVE_OPTS={
327
372
  // derives its geometry from its content).
328
373
  pin:['at','width','height'],
329
374
  layout:[],
330
- 'class':['fill','stroke','style','plane'],
375
+ 'class':['fill','stroke','style'],
331
376
  // 0.1: `class=` is NOT accepted on the typed-block OPENERS. The
332
377
  // normative registry (core §10) lists its acceptors as node/group/edge/
333
378
  // field/cell — the block openers were an engine-only extra with 0 uses in
@@ -351,7 +396,7 @@ const DIRECTIVE_OPTS={
351
396
  // the field is ONE ELEMENT of a repeated run and gives the run's index
352
397
  // range; the engine derives the elision row and the index labels from it,
353
398
  // exactly as it derives the dash and the caption from `present=`.
354
- field:['fill','stroke','class','description','present','index'], 'break':[],
399
+ field:['fill','stroke','class','description','present','index','note'], 'break':[],
355
400
  cell:['fill','stroke','class'], width:[],
356
401
  signal:['data','fill','stroke'], gap:[]
357
402
  };
@@ -401,8 +446,8 @@ const RETIRED_OPT_KEYS={
401
446
  text:'text= has been retired: v0.1 has NO label-colour key — the label colour is DERIVED from the fill it sits on (core §5), and the owner-level key that could replace it would colour an edge\'s [tail]/[mid]/[head] labels identically, which is the wrong shape (core §9 `ANNOTATION-LOCATOR-SPLIT`). Delete the key; if the distinction was knowledge, write it in the label or a class= meaning (§5, `PRESENTATION-AS-MEANING-CARRIER`) (MIGRATIONS 0.1)',
402
447
  // `COLOUR-KEY-STATUS`. This is the ONLY key in the language whose diagnostic
403
448
  // must name two eras and refuse to choose between them: the same six
404
- // characters meant the FILL before this release and the LABEL after
405
- // 0.1, and no engine can tell the two source files apart. Retiring
449
+ // characters meant the FILL in one era and the LABEL
450
+ // in another, and no engine can tell the two source files apart. Retiring
406
451
  // the key is what makes the difference DIAGNOSABLE at all — while it was
407
452
  // live, a pre-0.1 document parsed and drew a legal, wrong figure in
408
453
  // silence.
@@ -419,8 +464,17 @@ const RETIRED_OPT_KEYS={
419
464
  // `color=` family, which reads the same evidence to decide its refusals.
420
465
  color:'color= has been retired: the same six characters set the box FILL in one era of this language and the LABEL colour in another, and this line does not say which — which is why the key is gone rather than renamed. READ IT OFF THE REST OF THE DOCUMENT. A file that also writes fill= cannot be from the FILL era (the two keys never coexisted), so its color= was a LABEL colour: delete it and let the derived default apply (core §5). A file still writing the spellings that were retired before the LABEL era (w= h= unit= via= dir= kind= layer= boundary wrap optional) cannot be from that era, so its color= was a FILL: write fill= instead. A file with NEITHER carries no evidence at all, and the two readings then differ only in what was DRAWN — as a FILL the value painted the box interior, as a LABEL colour it painted only the text. If the colour carried meaning, put that meaning in the label or a class= (§5, `PRESENTATION-AS-MEANING-CARRIER`). tools/migrate-figdown.js reads this evidence for you and REFUSES the wrong --color-means=fill|text (MIGRATIONS 0.1)',
421
466
  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.',
422
- layer:'layer= has been renamed: use plane= (mxGraph makes a layer a containment parent that establishes coordinates; Inkscape layers can carry a transform; OGC WMS layers carry an SRS; CSS @layer is cascade priority. None of those is what this key does, and SVG has no layer at all) (MIGRATIONS 0.1)',
467
+ // `PAINT-ORDER-CONSTRUCT`: `layer=` was renamed `plane=`, and
468
+ // `plane=` has since been WITHDRAWN, so this message can no longer end at
469
+ // the rename — the `route`→`path` precedent, where a message
470
+ // pointing at a spelling that no longer exists had to state the whole chain.
471
+ layer:'layer= has been WITHDRAWN: it was renamed plane=, and plane= was withdrawn with the `plane` keyword (`PAINT-ORDER-CONSTRUCT`). There is no replacement spelling. Delete the key: everything paints in one plane, in document order. If the element is on a distinct logical layer of the SUBJECT — an overlay, a control plane — say so with a class= whose label states it, which is where that meaning belongs (core §5, `PRESENTATION-AS-MEANING-CARRIER`) (MIGRATIONS 0.3)',
423
472
  labels:'labels= has been renamed: use data= (WaveDrom\'s own key for exactly this is `data`, "an array of signal labels" — one per value cell of the lane) (MIGRATIONS 0.1)',
473
+ // `PAINT-ORDER-CONSTRUCT`. `plane=` referenced a declared `plane`; with the
474
+ // keyword withdrawn from every genre the key would keep exactly ONE legal
475
+ // value — the implicit `base` — so it is withdrawn with it rather than left
476
+ // as a key that can only ever restate the default.
477
+ plane:'plane= has been WITHDRAWN with the `plane` keyword (`PAINT-ORDER-CONSTRUCT`): the construct is removed from the language, not renamed, so there is no spelling to migrate to. `plane=` named a declared plane, and with no way to declare one the key had a single legal value — `base`, the implicit plane every element is already on. Delete the key. What it did was PAINT ORDER, and paint order is document order: a later line paints on top. The measurement: stripping `plane` and `plane=` from examples/evpn-fabric.fd left the drawn SVG byte-identical but for one `data-edge` index, because the overlay meaning was carried by `class=overlay` throughout — which is where a logical layer of the SUBJECT belongs (core §5, `PRESENTATION-AS-MEANING-CARRIER`) (MIGRATIONS 0.3)',
424
478
  // 0.1 (`EDGE-GEOMETRY-CONSTRUCTS`). These six keys end in a WITHDRAWAL, not a rename, so
425
479
  // their messages have a shape no earlier retirement in this table has: they
426
480
  // name no replacement spelling, because there is none. `via=`/`src=`/`dst=`
@@ -435,14 +489,80 @@ const RETIRED_OPT_KEYS={
435
489
  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',
436
490
  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',
437
491
  unit:'unit= has been renamed: use word= (RFC 2360 §3.1: "a sequence of long words in network byte order, with each word horizontal on the page"; RFC 791 §3.1 measures the header in "32 bit words". Mermaid names the identical setting bitsPerRow — semantically right, camelCase barred. `unit=32` also inverts count-vs-unit, reading as "the unit is 32", and C\'s "unit" is the addressable storage unit, not the row width) (MIGRATIONS 0.1)',
438
- z:'z= has been renamed: use z-index= (CSS spells the stacking concept z-index, and RULE 4.2 takes the standard spelling in full; a single-letter key is structurally risky next to the closed timing lane alphabet, `LANE-ALPHABET-KEY-RESERVATION`) (MIGRATIONS 0.1)',
439
- // `DESCRIPTION-KEY-SPELLING`. The spelling leaves the LANGUAGE, so the message fires
440
- // wherever it appears (the `w=`/`h=`/`unit=` placement test, RULE 6.2).
441
- note:'note= has been renamed: use description= (IEEE 1685-2022 spells this channel `description`; SystemRDL\'s `desc` is barred by RULE 4.2 as an abbreviation. The rename is defensive: `ANNOTATION-LOCATOR-SPLIT` files `note` as the highest-demand v0.2 annotation construct ~66 figure-identities, 20 independent reinventions and that one will be a DRAWN callout, so a never-drawing `note=` beside an always-drawing `note` would be one spelling with two opposite behaviours) (MIGRATIONS 0.1)',
492
+ // `PAINT-ORDER-CONSTRUCT`: `z-index=` itself. Its ONLY acceptor was `plane`, so
493
+ // with the keyword withdrawn the key has no directive left to sit on. Left
494
+ // in OPT_KEYS with no acceptor row it would have produced `<directive> does
495
+ // not take z-index=`true, but it tells an author holding a 0.2 document
496
+ // that they picked the wrong host, when in fact there is no host. RULE 6.2
497
+ // placement: the spelling left the LANGUAGE, so it is reported wherever it
498
+ // appears.
499
+ 'z-index':'z-index= has been WITHDRAWN with the `plane` keyword (`PAINT-ORDER-CONSTRUCT`): it was legal on `plane` and on nothing else, so it left with its only acceptor. There is no replacement spelling and no other directive to move it to. Delete the key: paint order is document order, and a later line paints on top (MIGRATIONS 0.3)',
500
+ z:'z= has been WITHDRAWN: it was renamed z-index=, and z-index= was withdrawn with the `plane` keyword (`PAINT-ORDER-CONSTRUCT`) — it was legal on `plane` and on nothing else. There is no replacement spelling. Delete the key: paint order is document order, a later line paints on top (MIGRATIONS 0.3)',
501
+ // `note` was HERE (`DESCRIPTION-KEY-SPELLING`) until this release (`DRAWN-ANNOTATION-FORM`), and its
502
+ // row is gone because the key is LIVE again — SYNTAX-STYLE RULE 4.9
503
+ // obligation 3 forbids leaving the retirement message standing past the
504
+ // revival, on the ground that a message telling an author to write
505
+ // `description=` where `note=` is now the correct key is worse than no
506
+ // message: it is the language actively misinforming its user. What replaces
507
+ // it is not silence but two NARROWER messages — NOTE_VERSION for a document
508
+ // that declares a language version older than the key, and NOTE_ON_FIELD for
509
+ // the one directive that keeps refusing it. See OPT_MIN_VERSION above.
442
510
  level:'level= has been DELETED, not renamed: it drew a reference plane through a 3-D bar chart, has zero uses in either downstream corpus and zero 3-D bar charts to draw it on, was the only construct whose caption the ENGINE wrote rather than the author, and its parseFloat grammar uniquely accepted 1e3 where every other number in the language is \\d+(\\.\\d+)? — delete the key (MIGRATIONS 0.1)'
443
511
  };
512
+ // `DRAWN-ANNOTATION-FORM`. The two messages that REPLACE the `note=` retirement
513
+ // diagnostic. SYNTAX-STYLE RULE 4.9 obligation 3 requires the retirement
514
+ // message to be reversed in the same release that revives the spelling, and
515
+ // "reversed" does not mean "deleted": each of the two situations the old
516
+ // message used to cover keeps a named diagnostic of its own.
517
+ //
518
+ // (a) The document declares a language version older than the key. The gate is
519
+ // `KEYWORD-RENAME-SCOPE`'s device — name the version, offer the one-step fix — and its
520
+ // reason is stated in the message because `note=`'s prior meaning is what
521
+ // makes the gate necessary rather than merely tidy.
522
+ const NOTE_VERSION=(have)=>
523
+ 'note= requires figdown 0.3 (this document declares '+have+'): under figdown '+
524
+ have+' the spelling is still the RETIRED one that meant description=, and an '+
525
+ 'engine that accepted it here would repaint a tooltip as ink — a figure that '+
526
+ 'looks right and means something else (core §13.0.1). note= is the DRAWN '+
527
+ 'annotation: an explanation the human reader must SEE. Raise the header to '+
528
+ 'figdown 0.3, or write description= if you meant prose only a machine reads '+
529
+ '(MIGRATIONS 0.3)';
530
+ // (b) The directive is `field`, which refuses the key at EVERY version. The
531
+ // bitfield genre already has `description=` for machine-facing prose, and
532
+ // no measured figure needs a DRAWN per-field aside — granting a directive
533
+ // both keys with no evidence spends the distinction before anyone needs it.
534
+ // The message states the distinction rather than naming a replacement,
535
+ // because `description=` is not a replacement: it reaches a different
536
+ // reader.
537
+ const NOTE_ON_FIELD=
538
+ 'note= draws and is not accepted on field; use description= for prose a '+
539
+ 'machine reads. The two keys divide by AUDIENCE, not by length: description= '+
540
+ 'reaches the reading agent as an SVG <title> and puts no ink on the page, '+
541
+ 'while note= is an explanation the human must see. A field\'s presence '+
542
+ 'condition is present=, not either of them (MIGRATIONS 0.3)';
444
543
  // `PLANE-KEYWORD-SPELLING`: the keyword `plane`/`plane=` was spelled `layer`/`layer=`.
445
- const RETIRED_LAYER='layer has been renamed: use plane (in mxGraph — the geometry model FigDown adopted — a layer is a CONTAINMENT PARENT that establishes coordinates, so layer=overlay reads as "reparent and re-origin this element", which FigDown does not do; Inkscape layers are <g> and may carry a transform, OGC WMS layers each carry an SRS, and CSS @layer is cascade priority with no visual meaning. SVG has no layer concept at all. `plane` is claimed by no standard for a conflicting meaning and removes the layout/layer near-miss) (MIGRATIONS 0.1)';
544
+ // `PAINT-ORDER-CONSTRUCT`: `plane` is WITHDRAWN, so `layer`'s message states the
545
+ // whole chain and ends where `route`'s does — the precedent,
546
+ // when `path` was withdrawn out from under the spelling `route` pointed at.
547
+ // `PAINT-ORDER-CONSTRUCT`: `plane` is WITHDRAWN from the language. The construct
548
+ // left because every genre that could write it lost it at once, and for two
549
+ // different reasons that happen to converge:
550
+ // - `block` and `flowchart` had ZERO authored uses. Every authored use in
551
+ // the tree was a `topology` document.
552
+ // - `topology` had two, and they are the worst domain collision measured in
553
+ // the language: in networking a PLANE is the control / data / management
554
+ // partition of a device — one of the first distinctions the field teaches
555
+ // — and `topology` is precisely the genre network engineers author in.
556
+ // `examples/evpn-fabric.fd` showed the trap already closed: it wrote
557
+ // `plane overlay "VXLAN tunnels" z-index=2`, where `overlay` is itself a
558
+ // networking term, so the line read as a network-architectural assertion
559
+ // and was in fact a paint order.
560
+ // What replaced it was already there. Stripping both writings from that file
561
+ // left the drawn SVG byte-identical apart from one `data-edge` index, because
562
+ // `class=overlay` carried the meaning the whole time.
563
+ const WITHDRAWN_PLANE_WHERE=' The decision and its evidence: MIGRATIONS 0.3, decisions/registry.md.';
564
+ const RETIRED_PLANE='plane has been WITHDRAWN from the language (`PAINT-ORDER-CONSTRUCT`) — removed, not renamed, so there is no replacement spelling. It declared a DRAWING LAYER (a z-order), and in the genre that actually used it "plane" means the control / data / management partition of a network device, so the one word said the wrong thing to exactly the readers who write the figure. Delete the line and delete every plane= that referenced it: paint order is document order, a later line paints on top. If the elements form a logical layer of the SUBJECT, that is a class= whose label states it (core §5, `PRESENTATION-AS-MEANING-CARRIER`) — which is what the two authored uses were already doing alongside it.'+WITHDRAWN_PLANE_WHERE;
565
+ const RETIRED_LAYER='layer has been WITHDRAWN: it was renamed plane, and plane was withdrawn from the language (`PAINT-ORDER-CONSTRUCT`). There is no replacement spelling. Delete the line: everything paints in one plane and paint order is document order (a later line paints on top). A logical layer of the SUBJECT — an overlay, a control plane — is a class= whose label says so (core §5, `PRESENTATION-AS-MEANING-CARRIER`).'+WITHDRAWN_PLANE_WHERE;
446
566
  // `THRESHOLD-KEYWORD-SPELLING`: the scene keyword `guide` became `threshold`.
447
567
  const RETIRED_GUIDE='guide has been renamed: use threshold (in Illustrator, Inkscape, Figma and draw.io a "guide" is an author-only construction line that is NEVER rendered, while FigDown\'s is drawn output — an INVERTED name, which `UNSAFE-DEFAULT-ELIMINATION` rates worse than an unfamiliar one, and no counter-example was found where "guide" names rendered output. `guide` was also a FigDown coinage, and `SIZE-AND-DIRECTION-KEY-NAMING` makes coining a last resort; `threshold` comes whole from Grafana, whose "Show thresholds" render option offers "As lines", "As filled regions" and "As filled regions and lines" — FigDown\'s marker + region pair, split the same way — with IETF RED/AQM as the secondary source (RFC 2309: "Two RED parameters, minth (minimum threshold) and maxth (maximum threshold)"; RFC 7567: "an AQM algorithm configured with a threshold"). 78% of the measured corpus marks are thresholds; target/mean/reference marks: 0) (MIGRATIONS 0.1)';
448
568
  // `EXTERNAL-ENDPOINT-NAMING`: the scene keyword `boundary` became `external`.
@@ -464,7 +584,7 @@ const RETIRED_FIELD_CONDITIONAL='the field flag "conditional" has been retired:
464
584
  // prose the model may not read. `present` is the attested spelling: X.680
465
585
  // PRESENT, IP-XACT isPresent, SystemRDL ispresent, RFC 2784 "present only
466
586
  // if", draft-mcquistin "present only when".
467
- const RETIRED_FIELD_OPTIONAL='the field flag "optional" has been retired and replaced by an option key that carries the CONDITION: write present="<the condition>" (e.g. field "Checksum" 16 present="C = 1"), or present="" when the condition is not stated. The bare flag could say only THAT the field was conditional, so the condition had to live in note= — invisible to the human reading the figure, and prose the model may not parse (`BITFIELD-CONDITIONAL-OFFSETS`). present= DRAWS: the field stays dashed and a stated condition becomes a caption under the block (MIGRATIONS 0.1)';
587
+ const RETIRED_FIELD_OPTIONAL='the field flag "optional" has been retired and replaced by an option key that carries the CONDITION: write present="<the condition>" (e.g. field "Checksum" 16 present="C = 1"), or present="" when the condition is not stated. The bare flag could say only THAT the field was conditional, so the condition had to live in the field\'s documentation prose — the key spelled note= at the time and description= where it was invisible to the human reading the figure, and prose the model may not parse (`BITFIELD-CONDITIONAL-OFFSETS`). (Today\'s note= is a different key: it is the DRAWN annotation revived, it is refused on field, and it is not where a presence condition belongs either.) present= DRAWS: the field stays dashed and a stated condition becomes a caption under the block (MIGRATIONS 0.1)';
468
588
  // `TIMING-GENRE-NAMING`: the EXPERIMENTAL genre `wave` became `timing`, both as
469
589
  // the header genre token and as the block opener. The old name was WaveDrom's
470
590
  // MEMBER KEY, not its figure name: in WaveJSON `signal` is the root object and
@@ -496,7 +616,7 @@ const RETIRED_WAVE='wave has been renamed: use timing (in WaveJSON `signal` is t
496
616
  // not "use X"), says what an author should do instead (delete the line and let
497
617
  // auto layout draw it, with the content-zone means named), and points at where
498
618
  // the decision is RECORDED so the reasoning is one lookup away.
499
- const WITHDRAWN_WHERE=' The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md';
619
+ const WITHDRAWN_WHERE=' The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md.';
500
620
  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;
501
621
  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;
502
622
  // `TIMING-LANE-ALPHABET`: the timing lane digits `2`-`9` left the closed alphabet.
@@ -523,7 +643,7 @@ const CELL_HL_ON_CELL='highlight is a ROW mark and takes the single-valued row f
523
643
  const CELL_HL_ROW_CONFLICT=(r,c)=>'cell ('+r+','+c+') resolves to a fill on row '+r+', which is highlighted — the cell fill overrides the row tint, so the model says "row '+r+' is highlighted" while the drawing shows only part of the row tinted (`PRESENTATION-AS-MEANING-CARRIER`: presentation may render meaning, never delete it). Drop the row highlight, or move the cell fill to a row that carries none (MIGRATIONS 0.1)';
524
644
  const NO_ITEM_STYLE=new Set(['field','cell','signal']);
525
645
  const STYLE_NO_ITEM=k=>k+' does not take style= — '+(k==='field'
526
- ? '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)'
646
+ ? 'on a field the dash IS conditional presence (`present=`, spelled `optional` until 0.1), and style=solid erased it while the model still recorded the field as conditionally present (`PRESENTATION-AS-MEANING-CARRIER`: presentation may render meaning, never be its only carrier)'
527
647
  : 'a dash on one '+k+' carried no meaning the block does not already carry, and 0 documents outside this repository wrote it')
528
648
  // `DESCRIPTION-KEY-SPELLING` corrected the second half of this message. It used to
529
649
  // offer `note=` as a place to put knowledge, which was wrong twice over:
@@ -791,9 +911,53 @@ const LAYOUT_KW=['pin']; // `LAYOUT-ZONE-NAMESPACE`, NORMA
791
911
  const GENRE_FREE_KW=CORE_KW.concat(LAYOUT_KW);
792
912
  // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: the NODE and CONNECTOR spellings are per genre, so
793
913
  // they are NOT in the shared list — every scene genre concats its own two.
794
- const SCENE_KW_TOP=['group','external','class','flow','rank'];
795
- const SCENE_EXP_KW=['threshold','band','bundle','plane'];
796
- const SCENE_HOST_KW=GENRE_FREE_KW.concat(SCENE_KW_TOP, SCENE_EXP_KW, ['bitfield','table','timing','chart']);
914
+ //
915
+ // `SUBJECT-VOCABULARY-SCOPE`: SUBJECT VOCABULARY IS PER GENRE, AND THERE IS NO SHARED
916
+ // LIST OF IT. `SCENE_KW_TOP` and `SCENE_EXP_KW` are gone. They held the words
917
+ // that say what a figure is OF — `group`, `external`, `threshold`, `band`,
918
+ // `bundle`, `plane` — in one array concatenated into four genres, which is
919
+ // the same defect core §3's "scene keywords" sentence recorded: an
920
+ // INTERSECTION written down as if it were a namespace. Under `GENRE-VOCABULARY-OBLIGATION` a genre owns
921
+ // its words, so each scene genre now names its own subject vocabulary in its
922
+ // own array below. Two arrays agreeing is TWO DECLARATIONS that agree today,
923
+ // never one declaration inherited, and either may be withdrawn, renamed or
924
+ // constrained without touching the other.
925
+ //
926
+ // What stays shared, and why that is not a contradiction:
927
+ // - `class` is STYLING declaration and `flow`/`rank` are LAYOUT INTENT.
928
+ // None of the three describes a referent, so no genre's domain holds a
929
+ // competing meaning for them and no genre can independently earn or lose
930
+ // one. They are nearer `LAYOUT-ZONE-NAMESPACE`'s genre-independent layout namespace than `GENRE-VOCABULARY-OBLIGATION`.
931
+ // - `bitfield`/`table`/`timing`/`chart` are `GENRE-COMPOSITION` REGION OPENERS: composition,
932
+ // not subject vocabulary. The region's own namespace is the nested
933
+ // genre's.
934
+ const SCENE_STYLE_KW=['class','flow','rank'];
935
+ const SCENE_REGION_KW=['bitfield','table','timing','chart'];
936
+ const SCENE_HOST_KW=GENRE_FREE_KW.concat(SCENE_STYLE_KW, SCENE_REGION_KW);
937
+ // --- Each scene genre's OWN subject vocabulary. One array per genre. ---
938
+ // `block` (NORMATIVE): `group` and `external` are normative; `threshold` and
939
+ // `band` are EXPERIMENTAL and are the `GENRE-EARNING-THRESHOLD` INTERIM scalar-marker pair, held here
940
+ // deliberately unfrozen so the future scalar-marker genre can name them once
941
+ // WITH a scale. They are not renamed now — a rename would hand that genre a
942
+ // retired word.
943
+ const BLOCK_SUBJECT_KW=['group','external','threshold','band'];
944
+ // `topology` (EXPERIMENTAL): `bundle` is the one construct whose domain
945
+ // reading and drawn reading are the same reading — a LAG (IEEE 802.1AX), an
946
+ // ECMP set, an EVPN Ethernet Segment. `group`/`external` keep their block
947
+ // spellings because every networking synonym is more taken (`zone` DNS and
948
+ // firewall, `cluster` RFC 4456, `domain` RFC 7926, `area` OSPF, `site` EVPN)
949
+ // and their collisions are SOFT — the picture contradicts the wrong reading.
950
+ const TOPOLOGY_SUBJECT_KW=['group','external','bundle'];
951
+ // `flowchart` (EXPERIMENTAL): `external` only — the off-page terminus, ISO
952
+ // 5807 §9.4.2 *Terminator* being ISO's word for the concept and already this
953
+ // genre's live keyword, so the spelling stays. `group` had one occurrence in
954
+ // the whole tree and it was this genre's own reference figure.
955
+ const FLOWCHART_SUBJECT_KW=['external'];
956
+ // `statechart` (EXPERIMENTAL): NONE, and the empty array is the declaration.
957
+ // Three authored statechart figures, all transcribed from RFCs, reach for
958
+ // none of the six; `external` is additionally UML 2.5.1 §14's own
959
+ // `TransitionKind` literal and is reserved for it (`RESERVED-SPELLINGS`).
960
+ const STATECHART_SUBJECT_KW=[];
797
961
  // `FLOWCHART-ROLE-KEYWORDS`: the flowchart ROLE vocabulary — the FIRST exercise of
798
962
  // `GENRE-NAMESPACE` `GENRE-VOCABULARY-OBLIGATION` ("a genre owns its words"). These three are legal ONLY under
799
963
  // `figdown 0.1 flowchart`; `GENRE-NAMESPACE`'s allowlist is what makes `decision x` a line
@@ -866,7 +1030,7 @@ const WRONG_VERSION_WORD=(surf,want,genre,need,have)=>
866
1030
  // "not allowed in genre X" tells an author nothing about what to write.
867
1031
  const WORD_WHY={
868
1032
  edge:'a block or topology figure is a graph, and `edge` is the graph word (DOT)',
869
- flowline:'the connecting line in a flowchart is a FLOWLINE — the term ISO 5807 uses for it',
1033
+ flowline:'the connecting line in a flowchart is a FLOWLINE — the term the flowchart domain commonly uses for the symbol ISO 5807 §9.3.1 names "Line"',
870
1034
  transition:'the connecting line in a statechart is a TRANSITION — the term UML 2.5.1 §14 uses for it',
871
1035
  node:'this genre has more kinds of thing than it has words for, so `node` is the general one',
872
1036
  state:'a statechart has exactly ONE kind of node and it is a STATE (UML 2.5.1 §14)'
@@ -877,10 +1041,82 @@ const WORD_WHY={
877
1041
  const WRONG_WORD=(surf,want,genre)=>
878
1042
  '"'+surf+'" is not the word genre '+genre+' uses for this — write "'+want+'": '+WORD_WHY[want]+
879
1043
  '. Each scene genre takes the term its own domain uses (block/topology `node` `edge`, flowchart `node` `flowline`, statechart `state` `transition`) — run tools/migrate-figdown.js to rewrite it (MIGRATIONS 0.2)';
1044
+ // `SCENE-KEYWORD-MEMBERSHIP`: a word WITHDRAWN FROM ONE GENRE is not an unknown word,
1045
+ // and `"threshold" is not allowed in genre topology` would send an author
1046
+ // looking for a typo. Each cell below was legal until this release and states
1047
+ // WHY that genre no longer declares it — the ruling's own ground, per cell,
1048
+ // because the grounds differ and a single sentence could not carry them.
1049
+ // Every one of these withdrawals was FREE: `topology`, `flowchart` and
1050
+ // `statechart` are EXPERIMENTAL genres outside the compatibility promise, and
1051
+ // in `block` the two withdrawn words were EXPERIMENTAL keywords (`EDGE-GEOMETRY-CONSTRUCTS` precedent:
1052
+ // experimental withdrawal, no gate, no rewrite owed).
1053
+ const WITHDREW_AT=' (withdrawn, `SCENE-KEYWORD-MEMBERSHIP`; MIGRATIONS 0.3)';
1054
+ const GENRE_WITHDRAWN={
1055
+ block:{
1056
+ bundle:'`bundle` is now declared by `topology` only. It had ZERO authored uses under `block` — every authored link bundle in the corpus is a topology document — and the construct is defined by a REFERENT that only that genre has: a LAG (IEEE 802.1AX), an ECMP set, an EVPN Ethernet Segment. Under `block` it was a ring around parallel edges with nothing to name.'
1057
+ },
1058
+ topology:{
1059
+ threshold:'`threshold` is now declared by `block` only. It had ZERO occurrences under `topology` in the whole corpus, and in this genre\'s domain a threshold is a QUEUE DEPTH WITH A NUMERIC VALUE (RFC 2309 minth/maxth, RFC 7567) — while FigDown\'s takes no value= and its offset= is a fraction of the target\'s rendered extent, not a quantity. Author the figure as `block`, where the `GENRE-EARNING-THRESHOLD` scalar-marker evidence lives.',
1060
+ band:'`band` is now declared by `block` only. Its two occurrences under `topology` were both conformance fixtures, never a figure anyone needed, and in this genre a BAND is a frequency band — radio, wireless, microwave, optical transport — which is exactly the kind of figure a topology document draws. Author the figure as `block`.'
1061
+ },
1062
+ flowchart:{
1063
+ group:'`flowchart` no longer declares `group`. Its one occurrence in the corpus was this genre\'s own reference figure, which exists to demonstrate every form of every keyword — so citing it as evidence of need is circular. There were no authored uses.',
1064
+ threshold:'`flowchart` no longer declares `threshold`. Zero occurrences, and the construct does not apply: a threshold is a labelled reference value drawn at a percentage of the target\'s RENDERED EXTENT, and a process box\'s extent is an artifact of its label length, so the line asserts nothing a reader can read.',
1065
+ band:'`flowchart` no longer declares `band`. Zero occurrences, and a band is a RANGE over that same meaningless extent.',
1066
+ bundle:'`flowchart` no longer declares `bundle`. Zero occurrences, and parallel flowlines between the same two stages are different CONDITIONS; drawing a ring round them hides what the figure is for.'
1067
+ },
1068
+ statechart:{
1069
+ group:'`statechart` declares NO subject vocabulary at all. UML\'s grouping construct is the COMPOSITE STATE and its REGIONS, and under the single-source-vocabulary rule a statechart that needed grouping should take UML 2.5.1 §14\'s word for it — declared in this genre\'s own document — rather than inherit another genre\'s.',
1070
+ external:'`statechart` declares NO subject vocabulary at all, and `external` is additionally RESERVED here: UML 2.5.1 §14 defines TransitionKind as `external | internal | local`, so in this genre\'s own source standard "external" already names A TRANSITION THAT EXITS AND RE-ENTERS ITS SOURCE STATE. FigDown\'s `external` means an endpoint outside the figure that is never drawn — same word, same genre, same standard, unrelated meanings.',
1071
+ threshold:'`statechart` declares NO subject vocabulary at all. A state has no extent that means anything — its box is sized by its label — so a reference value drawn 60% down it asserts nothing.',
1072
+ band:'`statechart` declares NO subject vocabulary at all, and a band is a range over that same meaningless extent.',
1073
+ bundle:'`statechart` declares NO subject vocabulary at all, and here `bundle` is an ANTI-FEATURE: two transitions between the same pair of states are two different TRIGGERS, and the trigger is the whole content of the arc. Bundling them draws away exactly what the figure is for.'
1074
+ }
1075
+ };
1076
+ const WITHDRAWN_FROM_GENRE=(kw,genre)=>
1077
+ '"'+kw+'" is not allowed in genre '+genre+' — it was WITHDRAWN from this genre, not misspelled: '+
1078
+ GENRE_WITHDRAWN[genre][kw]+
1079
+ ' Subject vocabulary is per genre (core §3, `GENRE-VOCABULARY-OBLIGATION`): a spelling accepted by several genres is several '+
1080
+ 'independent declarations, and this genre\'s was withdrawn without touching any other\'s.'+WITHDREW_AT;
1081
+ // `MEMBERSHIP-KEY-ACCEPTANCE`: THE OPTION-KEY HALF OF `SCENE-KEYWORD-MEMBERSHIP`. A per-genre withdrawal can
1082
+ // strand an option KEY as easily as it strands a keyword: `in=` states
1083
+ // membership and its ONLY value domain is the id of a containing `group`, so
1084
+ // once `SCENE-KEYWORD-MEMBERSHIP` stopped `flowchart` and `statechart` from declaring a `group` the
1085
+ // key stayed accepted with nothing it could name. The measured symptom was
1086
+ // `process a "A" in=g` answering `unknown group "g"` with NO spelling that
1087
+ // succeeds — a dangling reference every author reaches by writing the key at
1088
+ // all. The other acceptors of `in=` in the language are `threshold` and
1089
+ // `band`, whose domain `MARKER-TARGET-KINDS` widened to REGION ids; neither is a keyword of
1090
+ // either genre since `SCENE-KEYWORD-MEMBERSHIP`, and the widening never reached `node`, so a
1091
+ // `flowchart` document that declares `table q` still answers `unknown group
1092
+ // "q"` for `node a "A" in=q`. Nothing in either genre was left un-stranded,
1093
+ // which is why the withdrawal is by KEY here and not directive by directive.
1094
+ //
1095
+ // The grounds differ per genre and are stated per cell, as `SCENE-KEYWORD-MEMBERSHIP`'s are:
1096
+ // `flowchart`'s is that every value is a dead end, `statechart`'s is that the
1097
+ // spelling is RESERVED for a different domain. Both genres are EXPERIMENTAL,
1098
+ // so both withdrawals are free — the `EDGE-GEOMETRY-CONSTRUCTS` precedent, no gate and no rewrite
1099
+ // owed — which is also what makes re-adding `in=` to `statechart` later with
1100
+ // a `state`-id domain cost nothing.
1101
+ const WITHDREW_OPT_AT=' (withdrawn, `MEMBERSHIP-KEY-ACCEPTANCE`; MIGRATIONS 0.3)';
1102
+ const GENRE_WITHDRAWN_OPT={
1103
+ flowchart:{
1104
+ in:'`flowchart` no longer accepts `in=`. Its only value domain was the id of a containing `group`, and this genre has not declared `group` (`SCENE-KEYWORD-MEMBERSHIP`) — so EVERY value was a dead end: `in=x` answered `unknown group "x"` and no spelling succeeded. An unknown-option error that names the reason beats a dangling reference no author can satisfy. What expresses membership TODAY is `class=`: declare `class ingress "Ingress phase"` and write `class=ingress` on each stage — it earns a legend entry and applies to every member at once. Containment in a flowchart is an OPEN question and the construct the need is waiting on is a swimlane, not a box (spec/genres/experimental/flowchart.md, What is excluded).'
1105
+ },
1106
+ statechart:{
1107
+ in:'`statechart` no longer accepts `in=`, and the spelling is RESERVED rather than merely dropped. This genre declares NO subject vocabulary (`SUBJECT-VOCABULARY-SCOPE`), so the `group` id that was `in=`\'s only value domain cannot exist here and every value was a dead end. The reason for withdrawing rather than leaving it is the RESERVATION: `in=`\'s future domain in this genre is a STATE id — UML 2.5.1 §14.2.3.4 composite states would arrive as nesting on `state`, through this exact key — so a live key with a group-id domain taught the WRONG model using the very spelling reserved for the right one. Delete the key: a category shared by several states is a `class=` meaning. `in=` is expected back with a state-id domain when composite-state nesting is earned (`MEMBERSHIP-KEY-ACCEPTANCE`).'
1108
+ }
1109
+ };
1110
+ const WITHDRAWN_OPT_FROM_GENRE=(key,genre)=>
1111
+ key+'= is not allowed in genre '+genre+' — it was WITHDRAWN from this genre, not misspelled: '+
1112
+ GENRE_WITHDRAWN_OPT[genre][key]+
1113
+ ' An option key is per genre for the same reason a keyword is (core §3, `GENRE-VOCABULARY-OBLIGATION`): the key is accepted '+
1114
+ 'by the directive AND by the genre, and this genre\'s acceptance was withdrawn without touching any other\'s.'+
1115
+ WITHDREW_OPT_AT;
880
1116
  const GENRE_KW={
881
- block:new Set(SCENE_HOST_KW.concat(['node','edge'])),
882
- topology:new Set(SCENE_HOST_KW.concat(['node','edge'])),
883
- flowchart:new Set(SCENE_HOST_KW.concat(['node','flowline'], FLOWCHART_ROLE_KW)),
1117
+ block:new Set(SCENE_HOST_KW.concat(BLOCK_SUBJECT_KW, ['node','edge'])),
1118
+ topology:new Set(SCENE_HOST_KW.concat(TOPOLOGY_SUBJECT_KW, ['node','edge'])),
1119
+ flowchart:new Set(SCENE_HOST_KW.concat(FLOWCHART_SUBJECT_KW, ['node','flowline'], FLOWCHART_ROLE_KW)),
884
1120
  // `STATECHART-GENRE-SCOPE`: `statechart` added no keyword of its own — it was the
885
1121
  // scene host set and nothing else. `GENRE-NODE-SPELLING` gives it its two: the
886
1122
  // scene host set with `state` and `transition` in the slots `node` and
@@ -888,7 +1124,10 @@ const GENRE_KW={
888
1124
  // `terminator`: those are flowchart's words (`GENRE-NAMESPACE` `GENRE-VOCABULARY-OBLIGATION`), and a `decision` in a
889
1125
  // statechart is a category error, not a shorthand. The allowlist is what
890
1126
  // makes that a line error with no extra code.
891
- statechart:new Set(SCENE_HOST_KW.concat(['state','transition'])),
1127
+ // `SUBJECT-VOCABULARY-SCOPE`: its subject vocabulary is the empty array above, so
1128
+ // `state` + `transition` + core + layout + styling + region openers is now
1129
+ // the WHOLE of what a statechart document may write at top level.
1130
+ statechart:new Set(SCENE_HOST_KW.concat(STATECHART_SUBJECT_KW, ['state','transition'])),
892
1131
  bitfield:new Set(GENRE_FREE_KW.concat(['class','bitfield'])),
893
1132
  // chart is experimental and attaches to a table id in the same document
894
1133
  table:new Set(GENRE_FREE_KW.concat(['class','table','chart'])),
@@ -951,7 +1190,10 @@ function parseOne(text){
951
1190
  // `EMPTY-LABEL-STATE`: `title` and a plane label start ABSENT (null), never as an empty
952
1191
  // string — an author who writes `title ""` has made a distinction the model
953
1192
  // must keep, and the implicit `base` plane wrote no label at all.
954
- const doc={title:null,nodes:[],groups:[],edges:[],planes:[{id:'base',label:null,z:0}],
1193
+ // `DRAWN-ANNOTATION-FORM`: `note` sits beside `title` and starts ABSENT (null), on
1194
+ // `EMPTY-LABEL-STATE`'s rule for `title` itself — an author who writes `note=""` has made
1195
+ // a distinction the model must keep.
1196
+ const doc={title:null,note:null,nodes:[],groups:[],edges:[],planes:[{id:'base',label:null,z:0}],
955
1197
  flow:'right',ranks:[],pins:{},blocks:[],trunks:[],thresholds:[],bands:[],
956
1198
  classes:[],boundaries:[]};
957
1199
  const nodeIds=new Set(), groupIds=new Set(), planeIds=new Set(['base']), classIds=new Set(),
@@ -1104,6 +1346,16 @@ function parseOne(text){
1104
1346
  const e=idErr(o2.plane, optHasQ(oT2,'plane'), null);
1105
1347
  if(e){ err(n,e); return; }
1106
1348
  }
1349
+ // `DRAWN-ANNOTATION-FORM`: the connector's copy of the `note=` version gate and
1350
+ // of the `QUOTING-RULES` quoted-prose rule. A connector is the acceptor the ruling
1351
+ // called decisive — an edge has no id, so an attribute is the ONLY form
1352
+ // that can reach it — and this scanner has to carry every language-wide
1353
+ // check itself or the one construct that most needs the key is the one
1354
+ // construct where the key is unchecked.
1355
+ if(o2.note!==undefined){
1356
+ if(belowOptVersion('note',doc.version)){ err(n,NOTE_VERSION(doc.version)); return; }
1357
+ if(!optQ(oT2,'note')){ err(n,'note= must be quoted: note="'+o2.note+'" — '+Q_WHY); return; }
1358
+ }
1107
1359
  // `RULE-POSITION-ENUMERATION`: and the enum half of RULE 2.4, for the one enum key `edge` takes.
1108
1360
  // Checked before the value, exactly as `badOpts` does it.
1109
1361
  if(o2.style!==undefined && optHasQ(oT2,'style')){ err(n,ENUM_BARE('style='+o2.style)); return; }
@@ -1118,7 +1370,7 @@ function parseOne(text){
1118
1370
  // and `fill=` name the same channel (`stroke=` wins when both are
1119
1371
  // written); `text=` colours the [tail]/[mid]/[head] labels.
1120
1372
  doc.edges.push({a,b,op,tail,mid,head,style:o2.style,cls:ecls,
1121
- stroke:o2.stroke,
1373
+ stroke:o2.stroke,note:o2.note,
1122
1374
  plane:o2.plane||'base',line:n});
1123
1375
  }
1124
1376
 
@@ -1244,6 +1496,19 @@ function parseOne(text){
1244
1496
  // same-line repeated option key (last-wins was silent data loss)
1245
1497
  if(dup){ err(n,'duplicate option "'+dup+'=" on one line'); bad=true; }
1246
1498
  for(const u of unk){ err(n,'unknown option "'+u+'="'); bad=true; }
1499
+ // `MEMBERSHIP-KEY-ACCEPTANCE`: the PER-GENRE option-key withdrawal, checked here —
1500
+ // after `unknown option`, so a key the LANGUAGE does not have keeps its
1501
+ // own answer, and before every value check, so a withdrawn key is never
1502
+ // told what its value would have meant. `gwHit` suppresses the id-value
1503
+ // rule below for the same key: ONE token, ONE error, the convention
1504
+ // `enumQ` already follows. The line is abandoned by the caller
1505
+ // (`if(badOpts(kw)) continue;`), so no cascade reaches the resolver and
1506
+ // the author never sees the `unknown group "…"` this ruling removes.
1507
+ const gwOpt=(doc.genre&&GENRE_WITHDRAWN_OPT[doc.genre])||null;
1508
+ const gwHit=new Set();
1509
+ if(gwOpt) for(const o in opts)
1510
+ if(gwOpt[o]!==undefined && allowed.includes(o)){
1511
+ err(n,WITHDRAWN_OPT_FROM_GENRE(o,doc.genre)); gwHit.add(o); bad=true; }
1247
1512
  // Retired spelling: `color=` → `fill=`. Fires only where
1248
1513
  // the key was accepted; on a directive that never took it the existing
1249
1514
  // `<directive> does not take color=` is still the right answer.
@@ -1253,6 +1518,19 @@ function parseOne(text){
1253
1518
  // spelling left the language rather than moving between directives.
1254
1519
  for(const rk in RETIRED_OPT_KEYS)
1255
1520
  if(opts[rk]!==undefined){ err(n,RETIRED_OPT_KEYS[rk]); bad=true; }
1521
+ // `DRAWN-ANNOTATION-FORM`: the two `note=` refusals, in the order that gives
1522
+ // ONE error per line. `field` is checked first and unconditionally,
1523
+ // because it refuses the key at every version — telling a `figdown 0.2`
1524
+ // bitfield author to raise their header would send them to a version
1525
+ // that still refuses them. Every directive that does NOT list `note` in
1526
+ // its row falls through to the generic `<directive> does not take note=`
1527
+ // below, which is the right answer for `external`, `threshold`, `band`,
1528
+ // `bundle`, `plane`, `class` and `cell`: the key is in OPT_KEYS, so none
1529
+ // of them can report `unknown option` for a spelling the language has.
1530
+ if(opts.note!==undefined && allowed.includes('note')){
1531
+ if(k==='field'){ err(n,NOTE_ON_FIELD); bad=true; }
1532
+ else if(belowOptVersion('note',doc.version)){ err(n,NOTE_VERSION(doc.version)); bad=true; }
1533
+ }
1256
1534
  // `RULE-POSITION-ENUMERATION`: RULE 2.4's enum half on the OPTION keys. One loop
1257
1535
  // for every enum-valued key, the same device the id-valued keys below
1258
1536
  // use — so a key that gains an enum grammar later is covered by
@@ -1301,7 +1579,11 @@ function parseOne(text){
1301
1579
  // until then) and `present=` (`PRESENCE-CONDITION-EXPRESSION`). `present=""` is legal and is the
1302
1580
  // "conditional, condition not stated" form — an EMPTY quoted value,
1303
1581
  // not an unquoted one, so the same rule admits it.
1304
- for(const sk of ['description','present'])
1582
+ // `DRAWN-ANNOTATION-FORM`: THREE since `note=` revived. It is prose in the
1583
+ // same sense, and it revives with the value shape it retired with —
1584
+ // which is RULE 4.9 obligation 2 satisfied in the parser rather than
1585
+ // only on paper.
1586
+ for(const sk of ['description','present','note'])
1305
1587
  if(opts[sk]!==undefined && allowed.includes(sk) && !optQ(optT,sk)){
1306
1588
  err(n,sk+'= must be quoted: '+sk+'="'+opts[sk]+'" — '+Q_WHY); bad=true; }
1307
1589
  // `QUOTED-IDS`: `in=` and `plane=` are ID-VALUED options, so the
@@ -1309,7 +1591,7 @@ function parseOne(text){
1309
1591
  // keeps its directive-specific message (`threshold needs in=…`); a written
1310
1592
  // one that is quoted or not a legal id gets the ID RULE.
1311
1593
  for(const k of ['in','plane'])
1312
- if(opts[k]!==undefined && allowed.includes(k)){
1594
+ if(opts[k]!==undefined && allowed.includes(k) && !gwHit.has(k)){
1313
1595
  const e=idErr(opts[k], optHasQ(optT,k), null);
1314
1596
  if(e){ err(n,e); bad=true; }
1315
1597
  }
@@ -1418,7 +1700,7 @@ function parseOne(text){
1418
1700
  // noun in a topology figure (`SHAPE-ENUM-VOCABULARY`: no domain nouns in the presentation
1419
1701
  // vocabulary) while the directive means geometric waypoints;
1420
1702
  // - `render` was a verb naming a zone that admits only geometry
1421
- // (`pin` — and, until this release, `path` and `routing`), and it
1703
+ // (`pin` — and, until 0.1, `path` and `routing`), and it
1422
1704
  // collided with the renderer
1423
1705
  // and the render options of §7. `layout` is the cross-tool word for
1424
1706
  // this half of a diagram language, and the zone it opens carried the
@@ -1436,6 +1718,12 @@ function parseOne(text){
1436
1718
  if(kw==='wrap'){ err(n,RETIRED_WRAP); continue; }
1437
1719
  if(kw==='boundary'){ err(n,RETIRED_BOUNDARY); continue; }
1438
1720
  if(kw==='layer'){ err(n,RETIRED_LAYER); continue; }
1721
+ // `PAINT-ORDER-CONSTRUCT`: `plane` joins this block. RULE 6.2 placement — the
1722
+ // spelling left the LANGUAGE, not one genre, so it fires wherever it
1723
+ // appears at line start, in every genre, AHEAD of the `GENRE-KEYWORD-ALLOWLIST` allowlist. A
1724
+ // `plane` line under `bitfield` gets the withdrawal, not "not allowed in
1725
+ // genre bitfield", which would be true and useless.
1726
+ if(kw==='plane'){ err(n,RETIRED_PLANE); continue; }
1439
1727
  if(kw==='guide'){ err(n,RETIRED_GUIDE); continue; }
1440
1728
  if(kw==='wave'){ err(n,RETIRED_WAVE); continue; }
1441
1729
  if(kw==='size'){ err(n,RETIRED_SIZE); continue; }
@@ -1450,7 +1738,7 @@ function parseOne(text){
1450
1738
  // per-field options. Classic form: field <name> <width> [options].
1451
1739
  // Classic form: field <name> <width-in-bits|*> [fill=] [description=]
1452
1740
  // [present=]
1453
- // Conditional presence was a POSITIONAL FLAG until this release:
1741
+ // Conditional presence was a POSITIONAL FLAG until 0.1:
1454
1742
  // `optional` (…0.1), `conditional` (0.1…0.1),
1455
1743
  // `optional` again (`PRESENCE-FLAG-SPELLING`). `PRESENCE-CONDITION-EXPRESSION` replaces the flag with
1456
1744
  // `present=`, an option key whose value is the presence CONDITION as
@@ -1537,7 +1825,7 @@ function parseOne(text){
1537
1825
  // the overflow.
1538
1826
  if(m[2]!=='*' && +m[2]>cur.word){
1539
1827
  // The suggestion spells the CLASSIC form, and the classic name is
1540
- // QUOTED (`QUOTING-RULES`). Until this release this string said
1828
+ // QUOTED (`QUOTING-RULES`). Until 0.1 this string said
1541
1829
  // `write "field P 64"` — a second line error, so a user who
1542
1830
  // followed the diagnostic was told off twice.
1543
1831
  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; }
@@ -1563,7 +1851,7 @@ function parseOne(text){
1563
1851
  // `break` ends the row after the fields declared since the block
1564
1852
  // opened (or since the previous break). With none there is nothing to
1565
1853
  // break — genre doc: "break with no preceding field in the current row".
1566
- // Spelled `wrap` until this release (`ROW-BREAK-NAMING`): in CSS/typography `wrap` is
1854
+ // Spelled `wrap` until 0.1 (`ROW-BREAK-NAMING`): in CSS/typography `wrap` is
1567
1855
  // AUTOMATIC reflow — a mode — while this is an EXPLICIT break, an
1568
1856
  // event; CSS Fragmentation calls exactly this "a forced break …
1569
1857
  // explicitly indicated by the … author" and HTML spells it `br`.
@@ -1662,7 +1950,7 @@ function parseOne(text){
1662
1950
  // / `TYPED-BLOCK-SILENT-FALLBACK`: data= is ABSENCE vs presence. An empty value, empty
1663
1951
  // members (a,b), or a count that does not match the lane's `=`
1664
1952
  // cells are all line errors — never silent drop or shift.
1665
- // Spelled `labels=` until this release (`SIGNAL-DATA-KEY-SPELLING`): WaveDrom's own key is
1953
+ // Spelled `labels=` until 0.1 (`SIGNAL-DATA-KEY-SPELLING`): WaveDrom's own key is
1666
1954
  // `data`, "an array of signal labels" naming every value cell, and
1667
1955
  // after the `2`-`9` retirement (`TIMING-LANE-ALPHABET`) the two scopes coincide exactly.
1668
1956
  let labels;
@@ -1714,6 +2002,11 @@ function parseOne(text){
1714
2002
  // dispatched by their own scanner above.)
1715
2003
  if(NODE_SPELLINGS.has(kw) && GENRE_NODE_KW[doc.genre])
1716
2004
  err(n, WRONG_WORD(kw, GENRE_NODE_KW[doc.genre], doc.genre));
2005
+ // `SCENE-KEYWORD-MEMBERSHIP`: same argument one step further. A word this genre
2006
+ // WITHDREW is not an unknown word either, and the author holding it
2007
+ // needs the ground, not a spellcheck.
2008
+ else if(GENRE_WITHDRAWN[doc.genre] && GENRE_WITHDRAWN[doc.genre][kw])
2009
+ err(n, WITHDRAWN_FROM_GENRE(kw, doc.genre));
1717
2010
  else
1718
2011
  err(n,'"'+kw+'" is not allowed in genre '+doc.genre);
1719
2012
  continue;
@@ -1740,10 +2033,23 @@ function parseOne(text){
1740
2033
  // different values for the same visible text. One form, one
1741
2034
  // meaning: the token is a normal quoted string and the generic
1742
2035
  // tokenizer above has already resolved its escapes.
1743
- const t0=tk.toks[1];
1744
- if(!t0||!t0.q){ err(n,'title needs a quoted string: title "<text>" (MIGRATIONS 0.1)'); break; }
1745
- if(tk.toks.length>2){ err(n,'unexpected argument "'+tk.toks[2].v+'"'); break; }
1746
- doc.title=t0.v; sawTitle=true; break;
2036
+ // `DRAWN-ANNOTATION-FORM`: read the POSITIONALS, not the raw token stream.
2037
+ // `title` took no options until this release, so `tk.toks[1]` and
2038
+ // `tk.toks.length>2` were the same thing as `pos[1]` and
2039
+ // `pos.length>2`. They stop being the same thing the moment the line
2040
+ // may carry `note=`, and testing the raw stream would report the
2041
+ // annotation as a surplus positional. This is `OPTION-POSITION-PARSING`'s lesson (`bundle`
2042
+ // and `threshold` read `posq` for exactly this reason) applied to the
2043
+ // one directive that had never needed it.
2044
+ const t0v=pos[1], t0q=posq[1];
2045
+ if(t0v===undefined||!t0q){ err(n,'title needs a quoted string: title "<text>" (MIGRATIONS 0.1)'); break; }
2046
+ if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
2047
+ doc.title=t0v; sawTitle=true;
2048
+ // The figure-level note lives on the document, not on an element —
2049
+ // there is no element for it to live on, which is the whole reason
2050
+ // `title` is an acceptor.
2051
+ if(opts.note!==undefined) doc.note=opts.note;
2052
+ break;
1747
2053
  }
1748
2054
  case 'class': {
1749
2055
  // semantic class (`CATEGORICAL-MEANING-MAPPING`): meaning + presentation defaults declared
@@ -1766,30 +2072,11 @@ function parseOne(text){
1766
2072
  if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
1767
2073
  classIds.add(id);
1768
2074
  // `plane=` on a class is the members' default plane (an element's own
1769
- // plane= wins, rigidity `LAYOUT-STABILITY`) — the class carries all five §5 attributes.
2075
+ // `PAINT-ORDER-CONSTRUCT`: `plane=` is withdrawn, so a class carries the
2076
+ // FOUR §5 attributes it can still set. The paint-order attribute is
2077
+ // gone from the language, not merely off this directive.
1770
2078
  doc.classes.push({id,label:pos[2],fill:opts.fill,stroke:opts.stroke,
1771
- style:opts.style,plane:opts.plane,line:n});
1772
- break;
1773
- }
1774
- case 'plane': {
1775
- const id=pos[1];
1776
- { const e=idErr(id,posq[1],'plane needs an id'); if(e){ err(n,e); break; } }
1777
- if(planeIds.has(id)){ err(n,'duplicate plane id "'+id+'"'); break; }
1778
- if(pos[2]!==undefined&&!posq[2]){ err(n,'plane label must be quoted: plane '+id+' "'+pos[2]+'" — '+Q_WHY); break; }
1779
- if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
1780
- // 0.1: `z=` -> `z-index=` (CSS's own spelling for the
1781
- // stacking concept, taken in full per RULE 4.2). The retired `z=`
1782
- // is caught language-wide in RETIRED_OPT_KEYS before this runs.
1783
- let z=doc.planes.length;
1784
- const zi=opts['z-index'];
1785
- if(zi!==undefined){
1786
- if(!/^-?\d+$/.test(zi)){ err(n,'z-index must be a number'); break; }
1787
- z=parseInt(zi,10);
1788
- }
1789
- planeIds.add(id);
1790
- // `EMPTY-LABEL-STATE`: absent is absent, `""` is a written value — same
1791
- // non-collapsing form as node/group/bundle and the typed blocks.
1792
- doc.planes.push({id,label:pos[2]!==undefined?pos[2]:null,z});
2079
+ style:opts.style,line:n});
1793
2080
  break;
1794
2081
  }
1795
2082
  // `FLOWCHART-ROLE-KEYWORDS`: `process` / `decision` / `terminator` DESUGAR to
@@ -1832,6 +2119,7 @@ function parseOne(text){
1832
2119
  // Display falls back to the id in render(), so the figure is unchanged.
1833
2120
  doc.nodes.push({id,label:pos[2]!==undefined?pos[2]:null,shape,role,fill:opts.fill,stroke:opts.stroke,
1834
2121
  style:opts.style,cls:parseClassList(opts['class'],optList(optT,'class')).ids,
2122
+ note:opts.note,
1835
2123
  group:opts['in']||null,plane:opts.plane||'base',line:n});
1836
2124
  break;
1837
2125
  }
@@ -1851,6 +2139,7 @@ function parseOne(text){
1851
2139
  }
1852
2140
  doc.groups.push({id,label:pos[2]!==undefined?pos[2]:null,fill:opts.fill,stroke:opts.stroke,
1853
2141
  style:opts.style,gap:ggap,cls:parseClassList(opts['class'],optList(optT,'class')).ids,
2142
+ note:opts.note,
1854
2143
  plane:opts.plane||null,line:n});
1855
2144
  break;
1856
2145
  }
@@ -1862,7 +2151,7 @@ function parseOne(text){
1862
2151
  // Shares the node/group/block id namespace. Of the §5 attributes it
1863
2152
  // can carry only the two that need no drawn shape: `text=` (the label
1864
2153
  // colour) and `plane=` (organizational, exactly as on a node).
1865
- // Spelled `boundary` until this release (`EXTERNAL-ENDPOINT-NAMING`): three standards claim that
2154
+ // Spelled `boundary` until 0.1 (`EXTERNAL-ENDPOINT-NAMING`): three standards claim that
1866
2155
  // word for the OPPOSITE meaning (UML ECB «boundary» is an internal
1867
2156
  // interface object, C4 System_Boundary is a dashed grouping container,
1868
2157
  // BPMN's is an event), and this spec's own prose had already stopped
@@ -2017,7 +2306,7 @@ function parseOne(text){
2017
2306
  case 'chart': {
2018
2307
  // chart family: chart <table-id> [type=bar3d]
2019
2308
  // rows -> X, columns -> Y, numeric cells -> Z (the table IS the data)
2020
- // Spelled `plot` with `kind=bars3d` until this release (`CHART-BLOCK-NAMING`).
2309
+ // Spelled `plot` with `kind=bars3d` until 0.1 (`CHART-BLOCK-NAMING`).
2021
2310
  // 0.1 (`CHART-LEVEL-KEY`): `level=` is DELETED. Zero uses corpus-wide, zero
2022
2311
  // 3-D bar charts, zero requests; one in-repo example and two fixtures.
2023
2312
  // It was the only construct whose caption the ENGINE wrote rather than
@@ -2058,7 +2347,7 @@ function parseOne(text){
2058
2347
  if(!opts['in']){ err(n,'band needs in=<node-or-group-id>'); break; }
2059
2348
  // 0.1: the `%` is MANDATORY, matching `threshold offset=` (`BARE-FRACTION-VALUES`).
2060
2349
  // `band 15`, `band 15-35` and `band 15%-35` all parsed before (the
2061
- // separator was a hyphen until this release); one concept in one
2350
+ // separator was a hyphen until 0.1); one concept in one
2062
2351
  // document must not have two value grammars (RULE 4.4).
2063
2352
  // `RANGE-SPELLING`: the separator is `..`, and the HYPHEN form it
2064
2353
  // replaces gets its own named diagnostic. `15-35%` reads as
@@ -2156,7 +2445,7 @@ function parseOne(text){
2156
2445
  // the cell border colour, `text=` the block caption colour.
2157
2446
  // `TYPED-BLOCK-SILENT-FALLBACK`: word= empty or non-integer was a silent fallback/truncation
2158
2447
  // (word= → 32, word=8.5 → 8). Positive integer only; absence → 32.
2159
- // Spelled `unit=` until this release (`BITS-PER-ROW-KEY-NAMING`).
2448
+ // Spelled `unit=` until 0.1 (`BITS-PER-ROW-KEY-NAMING`).
2160
2449
  let word=32;
2161
2450
  if(opts.word!==undefined){
2162
2451
  if(opts.word===''||!/^\d+$/.test(opts.word)||+opts.word<1){
@@ -2233,11 +2522,39 @@ function parseOne(text){
2233
2522
  }
2234
2523
  for(const r of doc.ranks) for(const id of r.ids)
2235
2524
  if(!nodeIds.has(id)) errs.push('Line '+r.line+': unknown node "'+id+'" in rank');
2525
+ // `MARKER-TARGET-KINDS`: `in=` on `threshold`/`band` also resolves a REGION id —
2526
+ // a `bitfield`, `table` or `timing` block. This is a WIDENING of the value
2527
+ // domain, not a third sense of `in=` and not a new spelling: the relation is
2528
+ // sense 2 verbatim, *the element this one is drawn across*, and what changes
2529
+ // is only which declared ids the resolver will bind.
2530
+ //
2531
+ // It is UNGATED, and that is argued rather than assumed. A region-targeted
2532
+ // threshold did not merely mean something else before this release — it did
2533
+ // not PARSE. `threshold "Max" in=q offset=50%` over a `table q` answered
2534
+ // `unknown target "q" for threshold`, the same error a nonexistent id gets,
2535
+ // because this set was hard-coded to nodes and groups while `table <id>`
2536
+ // makes the id mandatory and `chart <table-id>` already consumes it from
2537
+ // another directive. So no `figdown 0.1` or `figdown 0.2` document changes
2538
+ // meaning and none becomes invalid; the only documents affected are ones
2539
+ // that produced no figure at all. Core §13.0.1's hazard — "a figure that
2540
+ // looks right and means something else" — needs two readings to choose
2541
+ // between, and here the alternative reading was an error message. That is
2542
+ // exactly why `note=` IS gated a few hundred lines up and this is not: the
2543
+ // key had a prior meaning, this had none. Nothing is added to the option
2544
+ // registry, so a reader of `figdown 0.2` consulting core §10 finds the same
2545
+ // 45 rows either way.
2546
+ //
2547
+ // The two WRED figures this unblocks are the whole of the measured demand
2548
+ // (core §9 `ANNOTATION-LOCATOR-SPLIT`). The locator COORDINATE grammar — `in=q(3)`, addressing a
2549
+ // row inside the region — is designed and deliberately NOT built: it has no
2550
+ // shipping consumer, and RULE 4.7 argues against spending a grammar before
2551
+ // one exists.
2552
+ const regionTarget=id=>blockIds.has(id);
2236
2553
  for(const gl of doc.thresholds)
2237
- if(!groupIds.has(gl.target)&&!nodeIds.has(gl.target))
2554
+ if(!groupIds.has(gl.target)&&!nodeIds.has(gl.target)&&!regionTarget(gl.target))
2238
2555
  errs.push('Line '+gl.line+': unknown target "'+gl.target+'" for threshold');
2239
2556
  for(const f of doc.bands)
2240
- if(!groupIds.has(f.target)&&!nodeIds.has(f.target))
2557
+ if(!groupIds.has(f.target)&&!nodeIds.has(f.target)&&!regionTarget(f.target))
2241
2558
  errs.push('Line '+f.line+': unknown target "'+f.target+'" for band');
2242
2559
  for(const t of doc.trunks) for(const [a,b] of t.pairs){
2243
2560
  if((!nodeIds.has(a)&&!boundaryIds.has(a))||(!nodeIds.has(b)&&!boundaryIds.has(b))){ errs.push('Line '+t.line+': unknown endpoint in "'+a+'--'+b+'"'); continue; }
@@ -2296,7 +2613,7 @@ function parseOne(text){
2296
2613
  // b class=p` was accepted, drew a #555 line, and rendered a legend swatch
2297
2614
  // that showed nothing, so the class's meaning was invisible in its own
2298
2615
  // derived legend. With `color=` retired (`COLOUR-KEY-STATUS`) the remaining shape of the
2299
- // hole is a class carrying only `style=` and/or `plane=`, or nothing at
2616
+ // hole is a class carrying only `style=`, or nothing at
2300
2617
  // all: the edge silently takes the default colour and the author who
2301
2618
  // declared a class to CLASSIFY the edge gets no colour and no warning.
2302
2619
  // Both halves are the same rule — a class an edge joins must declare at
@@ -2466,7 +2783,7 @@ function stackSectionSvgs(results){
2466
2783
  // became the node's LABEL. It is now a line error wherever it is part of the
2467
2784
  // GRAMMAR — that is, everywhere except the FOUR verbatim regions (this
2468
2785
  // function handles three of them; the pipe row is the caller's, see below).
2469
- // The count read "three" until this release, listing four:
2786
+ // The count read "three" until 0.1, listing four:
2470
2787
  // - inside a quoted string ("…;…"),
2471
2788
  // - inside a comment (already stripped before this runs),
2472
2789
  // - inside an edge label (edge a -[packet arrives; TMR != 0]-> b),
@@ -2612,6 +2929,92 @@ function cwMax(s){ return Math.max(...String(s).split('\n').map(cw)); }
2612
2929
  // §5 style= → SVG dash pattern. `def` is the construct's conventional
2613
2930
  // default (the bundle ring and the threshold line are dashed by convention);
2614
2931
  // an explicit style= always wins.
2932
+ // ── the note box (`DRAWN-ANNOTATION-FORM`) ───────────────────────────────────────
2933
+ // The drawn annotation's whole appearance lives in these three functions,
2934
+ // because `DOMAIN-CONVENTION-DIRECTIVES` gives the engine the drawing convention outright: `note=` takes
2935
+ // no `at=`, no `side=`, no colour and no size, so there is exactly one look and
2936
+ // it is decided here rather than by an author.
2937
+ //
2938
+ // The look is the UML note symbol — a rectangle with a folded top-right corner
2939
+ // — which is the notation of the metaclass the SPELLING is borrowed from
2940
+ // (UML 2.5.1's `Comment`; RULE 4.1 takes the standard's word, and taking its
2941
+ // glyph with it is what lets a reader recognise the box as an aside without a
2942
+ // legend entry). It is deliberately unlike a `node`: no rounded corners, a
2943
+ // paler wash, smaller type, and a corner no node shape has.
2944
+ const NOTE_FS=10, NOTE_PAD=6, NOTE_FOLD=9, NOTE_MAXCH=30;
2945
+ const NOTE_FILL='#fdfaf0', NOTE_STROKE='#c9c4b2', NOTE_INK='#5c584c';
2946
+ // The leader is deliberately DARKER than the box outline. Drawn in the box's
2947
+ // own stroke it was legible in the SVG and invisible on the page at 1x — a
2948
+ // leader nobody can see is a leader that is not there, and the note then reads
2949
+ // as annotating whatever it happens to sit above. Checked by eye, not by a
2950
+ // contrast number: the box is a surface and may recede, the leader is a
2951
+ // statement of attachment and may not.
2952
+ const NOTE_LEADER='#9c968a';
2953
+ // Deterministic greedy word wrap. Author newlines are honoured and never
2954
+ // merged; a run longer than the wrap width is broken only between words, so a
2955
+ // long identifier keeps its shape and simply widens the box.
2956
+ function noteWrap(text){
2957
+ const out=[];
2958
+ for(const para of String(text).split('\n')){
2959
+ const words=para.split(/ +/).filter(w=>w.length);
2960
+ if(!words.length){ out.push(''); continue; }
2961
+ let cur=words[0];
2962
+ for(let i=1;i<words.length;i++){
2963
+ if((cur+' '+words[i]).length<=NOTE_MAXCH) cur+=' '+words[i];
2964
+ else { out.push(cur); cur=words[i]; }
2965
+ }
2966
+ out.push(cur);
2967
+ }
2968
+ return out;
2969
+ }
2970
+ function noteBox(text){
2971
+ const lines=noteWrap(text);
2972
+ const lh=NOTE_FS*1.35;
2973
+ const w=Math.max(28, Math.max.apply(null,lines.map(tw))+NOTE_PAD*2+NOTE_FOLD);
2974
+ const h=lines.length*lh+NOTE_PAD*2-lh*0.15;
2975
+ return {w:Math.round(w*100)/100, h:Math.round(h*100)/100, lines, lh};
2976
+ }
2977
+ // The folded-corner outline, plus the small triangle that reads as the back of
2978
+ // the fold. One path each, so the shape is one primitive and the output is
2979
+ // byte-stable.
2980
+ function noteSvg(x,y,box,carrier){
2981
+ const F=NOTE_FOLD, w=box.w, h=box.h;
2982
+ const d='M'+x+' '+y+' H'+(x+w-F)+' L'+(x+w)+' '+(y+F)+' V'+(y+h)+' H'+x+' Z';
2983
+ const fold='M'+(x+w-F)+' '+y+' V'+(y+F)+' H'+(x+w)+' Z';
2984
+ const out=['<g class="fd-note"'+(carrier&&carrier.kind?' data-note-on="'+carrier.kind+'"':'')+'>',
2985
+ '<path d="'+d+'" fill="'+NOTE_FILL+'" stroke="'+NOTE_STROKE+'" stroke-width="1"/>',
2986
+ '<path d="'+fold+'" fill="'+NOTE_STROKE+'" fill-opacity="0.35" stroke="'+NOTE_STROKE+'" stroke-width="1"/>'];
2987
+ const first=y+NOTE_PAD+NOTE_FS*0.85;
2988
+ box.lines.forEach((ln,i)=>{
2989
+ out.push('<text x="'+(x+NOTE_PAD)+'" y="'+Math.round((first+i*box.lh)*100)/100+
2990
+ '" font-size="'+NOTE_FS+'" text-anchor="start" fill="'+NOTE_INK+'">'+esc(ln)+'</text>');
2991
+ });
2992
+ out.push('</g>');
2993
+ return out.join('');
2994
+ }
2995
+ // The leader is drawn ONLY when adjacency failed, and it is drawn AFTER the
2996
+ // box is placed, so it is correct by construction: it runs from the box edge
2997
+ // facing the carrier to the carrier's nearest point, and cannot be stale.
2998
+ function noteLeader(best,box,rect){
2999
+ const bx=best.x, by=best.y, bw=box.w, bh=box.h;
3000
+ const cx=rect.x+rect.w/2, cy=rect.y+rect.h/2;
3001
+ let x1,y1;
3002
+ if(best.side==='right') { x1=bx; y1=by+bh/2; }
3003
+ else if(best.side==='left') { x1=bx+bw; y1=by+bh/2; }
3004
+ else if(best.side==='below') { x1=bx+bw/2; y1=by; }
3005
+ else { x1=bx+bw/2; y1=by+bh; }
3006
+ // land on the carrier's border, not its centre, so the line stops at the
3007
+ // thing it points at
3008
+ const x2=Math.max(rect.x, Math.min(rect.x+rect.w, x1));
3009
+ const y2=Math.max(rect.y, Math.min(rect.y+rect.h, y1));
3010
+ return '<line x1="'+x1+'" y1="'+y1+'" x2="'+(rect.w||rect.h?x2:cx)+'" y2="'+(rect.w||rect.h?y2:cy)+
3011
+ '" stroke="'+NOTE_LEADER+'" stroke-width="1" stroke-dasharray="4 3"/>';
3012
+ }
3013
+ // A band's optional edge stroke. `renderScene` has had this as a local since
3014
+ // 0.1; `MARKER-TARGET-KINDS` needs the same rule for a REGION-scope band, which is drawn
3015
+ // outside the scene, so the one expression moves to module scope rather than
3016
+ // being written twice with a chance to drift.
3017
+ const bandEdgeOf=f=>(f.stroke||f.style)?' stroke="'+(f.stroke||'#8a8880')+'"'+dashOf(f.style,''):'';
2615
3018
  function dashOf(style,def){
2616
3019
  const p = style==='dashed'?'6 4' : style==='dotted'?'2 4' : style==='solid'?'' : def;
2617
3020
  return p?' stroke-dasharray="'+p+'"':'';
@@ -2807,21 +3210,17 @@ function render(doc,ropts){
2807
3210
  if(C[id] && C[id][k]!==undefined) x[k]=C[id][k];
2808
3211
  }
2809
3212
  };
2810
- // a class carries all five §5 attributes; the element's own value wins (`LAYOUT-STABILITY`).
2811
- // `plane` is materialized to 'base' at parse time, so "unset" is 'base' here.
2812
- const rsl=(x)=>{
2813
- if(!(x.plane===undefined||x.plane===null||x.plane==='base')) return;
2814
- for(const id of classIdsOf(x)){
2815
- if(C[id] && C[id].plane!==undefined) x.plane=C[id].plane;
2816
- }
2817
- };
2818
- const rsAll=(x)=>{ rs(x,'fill'); rs(x,'stroke'); rs(x,'style'); rsl(x); };
3213
+ // `PAINT-ORDER-CONSTRUCT`: a class carries the FOUR §5 attributes that survive.
3214
+ // The fifth was `plane`, and its cascade helper (`rsl`) is gone with the
3215
+ // key — a class can no longer set a paint order because the language has
3216
+ // no paint order to set. Everything is on the implicit `base` plane.
3217
+ const rsAll=(x)=>{ rs(x,'fill'); rs(x,'stroke'); rs(x,'style'); };
2819
3218
  for(const n of doc.nodes){ rsAll(n); if(n.style===undefined) n.style='solid'; }
2820
3219
  for(const g of doc.groups){ rsAll(g); }
2821
3220
  // 0.1 (§8.4): an edge has no interior, so it takes every class
2822
3221
  // channel EXCEPT `fill` — which the parser has already guaranteed is
2823
3222
  // accompanied by a `stroke` on any class an edge joins.
2824
- for(const e of doc.edges){ rs(e,'stroke'); rs(e,'style'); rsl(e); if(e.style===undefined) e.style='solid'; }
3223
+ for(const e of doc.edges){ rs(e,'stroke'); rs(e,'style'); if(e.style===undefined) e.style='solid'; }
2825
3224
  for(const b of doc.blocks){
2826
3225
  rsAll(b);
2827
3226
  if(b.fields) for(const f of b.fields) rsAll(f);
@@ -2835,13 +3234,54 @@ function render(doc,ropts){
2835
3234
  const s=renderScene(doc,y); parts.push(s.svg); y=s.y; maxW=Math.max(maxW,s.w);
2836
3235
  sceneMeta=s.meta;
2837
3236
  }
3237
+ // `MARKER-TARGET-KINDS`: a region-scope `threshold`/`band` is drawn HERE and not
3238
+ // in `renderScene`, because a region is not in the scene. Typed blocks stack
3239
+ // in document order OUTSIDE the scene (core §2, the `plane=` carve-out says
3240
+ // so in as many words), so at the moment `renderScene` emits its own
3241
+ // thresholds the region has no geometry yet and sits at a `y` the scene never
3242
+ // sees. The mark therefore travels with its target: each block reports its
3243
+ // box, and the marks that name it are painted over that box in the same
3244
+ // coordinate shape (`x0`/`x1`/`yA`/`yB`) the scene uses for a group.
3245
+ const regionBox={};
2838
3246
  for(const b of doc.blocks){
2839
3247
  let s;
2840
3248
  if(b.type==='bitfield') s=renderBitfield(b,y);
2841
3249
  else if(b.type==='table') s=renderTable(b,y);
2842
3250
  else if(b.type==='chart') s=renderChart(b,y,doc);
2843
3251
  else s=renderTiming(b,y);
2844
- parts.push(s.svg); y=s.y+24; maxW=Math.max(maxW,s.w);
3252
+ parts.push(s.svg);
3253
+ if(s.box) regionBox[b.id]=s.box;
3254
+ y=s.y+24; maxW=Math.max(maxW,s.w);
3255
+ }
3256
+ {
3257
+ const rsvg=[];
3258
+ for(const f of (doc.bands||[])){
3259
+ const B=regionBox[f.target]; if(!B) continue;
3260
+ const w=B.x1-B.x0, h=B.yB-B.yA;
3261
+ let bx,by,bw,bh;
3262
+ if(f.dir==='up') { bx=B.x0; by=B.yB-h*f.to/100; bw=w; bh=h*(f.to-f.from)/100; }
3263
+ else if(f.dir==='down') { bx=B.x0; by=B.yA+h*f.from/100; bw=w; bh=h*(f.to-f.from)/100; }
3264
+ else if(f.dir==='right'){ bx=B.x0+w*f.from/100; by=B.yA; bw=w*(f.to-f.from)/100; bh=h; }
3265
+ else { bx=B.x1-w*f.to/100; by=B.yA; bw=w*(f.to-f.from)/100; bh=h; }
3266
+ rsvg.push('<rect x="'+bx+'" y="'+by+'" width="'+bw+'" height="'+bh+'" fill="'+f.fill+'" opacity="0.35"'+bandEdgeOf(f)+'/>');
3267
+ rsvg.push(textEl(bx+bw/2, by+bh/2+4, 11, 'middle', labelInk(f.fill,'#334155'), f.label,
3268
+ ' paint-order="stroke" stroke="#fff" stroke-width="3"'));
3269
+ }
3270
+ for(const gl of (doc.thresholds||[])){
3271
+ const B=regionBox[gl.target]; if(!B) continue;
3272
+ const ly=B.yB-(B.yB-B.yA)*gl.pct/100;
3273
+ const col=gl.stroke||'#ef4444';
3274
+ rsvg.push('<line x1="'+B.x0+'" y1="'+ly+'" x2="'+B.x1+'" y2="'+ly+'" stroke="'+col+
3275
+ '" stroke-width="'+(gl.pct>=100?4:2)+'"'+dashOf(gl.style,'7 4')+'/>');
3276
+ rsvg.push(textEl(B.x1+8, ly+4, 11, 'start', col, gl.label,' paint-order="stroke" stroke="#fff" stroke-width="3"'));
3277
+ maxW=Math.max(maxW, B.x1+8+tw(gl.label));
3278
+ }
3279
+ // A band is a translucent wash UNDER the grid ink; a threshold is a mark
3280
+ // OVER it. The region has already been pushed, so both go after it and the
3281
+ // band leans on opacity rather than paint order for the "under" reading —
3282
+ // the same compromise `renderChart` makes, and the reason the opacity here
3283
+ // is lower than the scene's 0.9.
3284
+ if(rsvg.length) parts.push(rsvg.join(''));
2845
3285
  }
2846
3286
  // 0.1 (`CLASS-EMPTY-MEANING`): a class whose meaning is the EMPTY string claims no
2847
3287
  // meaning, so it has nothing to explain and draws NO legend entry — it is
@@ -2879,6 +3319,30 @@ function render(doc,ropts){
2879
3319
  parts.push(es.join(''));
2880
3320
  y=ly+rowH;
2881
3321
  }
3322
+ // `DRAWN-ANNOTATION-FORM`: the FIGURE-level note — `title "…" note="…"`. It carries
3323
+ // the 14% of measured annotations that name no single element ("Total: 8k
3324
+ // tunnel indexes", a four-signal legend, a TODO about the whole figure), and
3325
+ // it is the acceptor that removes the last argument for a standalone `note`
3326
+ // keyword: the figure HAS a declaration line, so attachment-by-position
3327
+ // reaches it too.
3328
+ //
3329
+ // It has no geometry to sit beside, so it takes no candidates and NEVER takes
3330
+ // a leader — a leader must point at something, and "the figure" is not a
3331
+ // thing on the canvas. It is placed with the caption, at the bottom, after
3332
+ // the scene, the regions and the derived legend. That is a placement rule and
3333
+ // not an author's choice (`DOMAIN-CONVENTION-DIRECTIVES`), and it is deterministic by construction:
3334
+ // there is exactly one figure-level note and exactly one place for it.
3335
+ //
3336
+ // The title itself is NOT drawn by default (`DEFAULT-VALUE-SELECTION` — an embedded figure sits
3337
+ // under a host caption), and the note does not follow it: the note is the
3338
+ // thing that draws. An author who wants the sentence in the picture writes
3339
+ // it here whether or not the renderer is showing the title.
3340
+ if(doc.note!==null&&doc.note!==undefined){
3341
+ const nb=noteBox(doc.note);
3342
+ const ny=y+8;
3343
+ parts.push(noteSvg(0,ny,nb,{kind:'title'}));
3344
+ y=ny+nb.h+2; maxW=Math.max(maxW,nb.w);
3345
+ }
2882
3346
  const PADL=18, PADT=6;
2883
3347
  const W=Math.ceil(maxW)+PADL+8, H=Math.ceil(y)+PADT+4;
2884
3348
  return {svg:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" font-family="system-ui,sans-serif">'
@@ -3363,7 +3827,7 @@ function renderScene(doc,y0){
3363
3827
  const halo=' paint-order="stroke" stroke="#fff" stroke-width="3"';
3364
3828
  const seg=(p,q,t,lbl,fs)=>reqLabel({p,q,t0:t,text:lbl,fs,col:ecol,halo,e,A,B,kind:'end'});
3365
3829
  if(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)){
3366
- // ── ROUTING-CHANGE ARCHITECTURE NOTE ──────────
3830
+ // ── ROUTING-CHANGE ARCHITECTURE NOTE (`SELF-EDGE-DRAWING`/`EDGE-BEND-RETENTION`) ──────────
3367
3831
  // Edge labels are DEFERRED: every label is registered against its
3368
3832
  // FINAL segment geometry (reqLabel/lblReq above) and placed by ONE
3369
3833
  // greedy pass after all edges are drawn; arrowheads are computed from
@@ -3375,7 +3839,7 @@ function renderScene(doc,y0){
3375
3839
  // arrowheads, orphaned labels): that was external splicing, not an
3376
3840
  // engine gap. Patch routing here; do not "fix" the label machinery.
3377
3841
  if(A===B){
3378
- // Self-transition: a small side loop on the node, the
3842
+ // Self-transition (`SELF-EDGE-DRAWING`): a small side loop on the node, the
3379
3843
  // convention of every drawing tool — never a lap of the figure
3380
3844
  // through the back-edge channel. Side order r,l,b,t; first side
3381
3845
  // whose loop box overlaps no other node wins (deterministic).
@@ -3528,7 +3992,7 @@ function renderScene(doc,y0){
3528
3992
  // their neighbours turns a 40-point staircase into the 2–4 bends a
3529
3993
  // dummy-vertex chain should have, without moving the drawn line.
3530
3994
  simplifyPts(pts);
3531
- // Waypoint prune: after collinear simplification a
3995
+ // Waypoint prune (`EDGE-BEND-RETENTION`): after collinear simplification a
3532
3996
  // chain can still carry a staircase of near-collinear jogs — the drift
3533
3997
  // clamp allows only a few px of sideways movement per rank, so a run
3534
3998
  // that wants to move 35px sideways alternates short diagonals and
@@ -3640,7 +4104,13 @@ function renderScene(doc,y0){
3640
4104
  // the arrowheads, and the other edges — plus a pull back toward the
3641
4105
  // preferred point on the segment. The lowest score wins. No randomness,
3642
4106
  // no iteration to a fixed point: one deterministic pass.
3643
- if(lblReq.length){
4107
+ // `DRAWN-ANNOTATION-FORM`: this block used to be guarded by `if(lblReq.length)`,
4108
+ // with `obst`, `ovl`, `segHit` and `placed` local to it. The note pass below
4109
+ // is a SECOND claimant on exactly that machinery and must see exactly the
4110
+ // same `placed` list — a note that did not know where the edge labels went
4111
+ // could not yield to them, which is the first of the four placement rules.
4112
+ // So the scaffolding is hoisted and only the LOOP keeps the guard.
4113
+ {
3644
4114
  const obst=nodes.filter(n=>!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h,n}));
3645
4115
  const ovl=(a,b)=>{
3646
4116
  const ix=Math.min(a.x+a.w,b.x+b.w)-Math.max(a.x,b.x);
@@ -3678,7 +4148,7 @@ function renderScene(doc,y0){
3678
4148
  return {x,y:by+up,anchor,t,side,box:{x:bx,y:by,w,h}};
3679
4149
  };
3680
4150
  const placed=[];
3681
- for(const r of lblReq){
4151
+ if(lblReq.length) for(const r of lblReq){
3682
4152
  const dx=r.q[0]-r.p[0], dy=r.q[1]-r.p[1];
3683
4153
  const across=Math.abs(dx)>=Math.abs(dy);
3684
4154
  let sides, ts, tPref;
@@ -3718,6 +4188,146 @@ function renderScene(doc,y0){
3718
4188
  W=Math.max(W, best.box.x+best.box.w+4);
3719
4189
  Hh=Math.max(Hh, best.box.y+best.box.h+4-y0-20);
3720
4190
  }
4191
+ // ── note placement (`DRAWN-ANNOTATION-FORM`) — the SAME pass, entered LAST ────────────────
4192
+ // Four rules, and they are in the spec rather than only here because two
4193
+ // engines have to agree on them:
4194
+ //
4195
+ // 1. Notes register LAST, after every edge label and arrowhead. The
4196
+ // reason is semantic, not convenient: a label is ON the thing it names
4197
+ // and an arrowhead IS part of the connector, whereas a note is BESIDE
4198
+ // what it is about. A NOTE YIELDS; NOTHING YIELDS TO A NOTE. That is
4199
+ // why this loop runs after the one above, reads the same `placed`, and
4200
+ // is read by nothing after it.
4201
+ // 2. Candidates are generated around the CARRIER's final geometry — a
4202
+ // node box, a group rect, or the edge's segment list, all of which are
4203
+ // rects or segments by the time this pass runs — and scored by the same
4204
+ // overlap function against `placed`, `obst`, `arrowBox` and `edgeSegs`.
4205
+ // 3. A LEADER LINE is drawn ONLY when the box could not be placed adjacent
4206
+ // to its carrier. This is where attachment-by-syntax pays off twice:
4207
+ // the carrier is known from the line, so the PREFERRED position is
4208
+ // always adjacency and the leader is a fallback the engine reaches for
4209
+ // rather than a permanent part of the construct. It is drawn AFTER
4210
+ // placement, so it is correct by construction — the property `SELF-EDGE-DRAWING`/`EDGE-BEND-RETENTION`
4211
+ // already records for arrowheads.
4212
+ // 4. Determinism is not optional. `RENDERING-DETERMINISM` promises byte-reproducible output,
4213
+ // and one greedy pass in registration order with no iteration is what
4214
+ // delivers it. Registration order here is DOCUMENT order — carriers are
4215
+ // sorted by source line, across kinds — so moving a `group` line above
4216
+ // a `node` line moves the notes with it and nothing else changes.
4217
+ //
4218
+ // `DOMAIN-CONVENTION-DIRECTIVES` binds throughout: `note=` accepts no `at=`, no `side=`, no
4219
+ // `left of`/`right of`. The author names the meaning; the engine owns the
4220
+ // drawing convention. The convention is the UML note symbol — a rectangle
4221
+ // with a folded top-right corner — which is what makes a note readable AS
4222
+ // a note without a legend entry, and is the notation belonging to the
4223
+ // metaclass the spelling is borrowed from (RULE 4.1).
4224
+ const noteCarriers=[];
4225
+ for(const n of nodes) if(n.note!==undefined&&n.note!==null&&!n.boundary)
4226
+ noteCarriers.push({line:n.line,text:n.note,kind:'node',rect:{x:n.x,y:n.y,w:n.w,h:n.h},n});
4227
+ for(const g of doc.groups) if(g.note!==undefined&&g.note!==null){
4228
+ const B=gBox[g.id]; if(!B) continue;
4229
+ noteCarriers.push({line:g.line,text:g.note,kind:'group',
4230
+ rect:{x:B.x0,y:B.yA,w:B.x1-B.x0,h:B.yB-B.yA}});
4231
+ }
4232
+ for(const e of doc.edges) if(e.note!==undefined&&e.note!==null){
4233
+ // An edge is a polyline, not a rect. Its carrier POINT is the midpoint of
4234
+ // the middle registered segment — deterministic, and it is the same
4235
+ // "middle of the run" an author means when they annotate a wire. A note
4236
+ // whose edge never made it to the canvas (an endpoint that did not
4237
+ // resolve) simply has no carrier and is not drawn; the missing endpoint
4238
+ // is already its own line error.
4239
+ const segs=edgeSegs.filter(g=>g.e===e); if(!segs.length) continue;
4240
+ const m=segs[Math.floor((segs.length-1)/2)];
4241
+ const cx=(m.p[0]+m.q[0])/2, cy=(m.p[1]+m.q[1])/2;
4242
+ noteCarriers.push({line:e.line,text:e.note,kind:'edge',rect:{x:cx,y:cy,w:0,h:0},e});
4243
+ }
4244
+ noteCarriers.sort((a,b)=>a.line-b.line);
4245
+ for(const c of noteCarriers){
4246
+ const box=noteBox(c.text);
4247
+ // Adjacency first, then the same four sides pushed out far enough that a
4248
+ // leader is legible. `si` orders the sides; `far` is what decides the
4249
+ // leader, and it costs enough that adjacency wins every time adjacency is
4250
+ // merely imperfect rather than blocked.
4251
+ // Sides in preference order, and for the two that straddle the carrier
4252
+ // an ALIGNMENT as well. Centring a wide note over a narrow carrier at the
4253
+ // left edge of the canvas puts the box off it; aligning the box's left
4254
+ // edge with the carrier's is the same "beside this thing" reading and
4255
+ // stays on the page. Found by eye — see the off-canvas note below.
4256
+ const SIDES=[['right','c'],['left','c'],
4257
+ ['below','c'],['below','l'],['below','r'],
4258
+ ['above','c'],['above','l'],['above','r']];
4259
+ // Three distance tiers, not two. `near` is adjacency and takes no leader;
4260
+ // the two `far` tiers do. The third exists because a crowded figure can
4261
+ // have NO free space within one leader length of the carrier — a note
4262
+ // then had to sit on top of an edge label, which is the exact inversion
4263
+ // of rule 1 (a note yields; nothing yields to a note). Given somewhere
4264
+ // further to go, it goes there and the canvas grows to fit.
4265
+ let best=null,bestS=Infinity, fallback=null,fallbackS=Infinity;
4266
+ for(const tier of [0,1,2]){
4267
+ const far=tier>0, gap=[10,46,96][tier];
4268
+ for(let si=0;si<SIDES.length;si++){
4269
+ const side=SIDES[si][0], al=SIDES[si][1];
4270
+ let bx,by;
4271
+ if(side==='right'){ bx=c.rect.x+c.rect.w+gap; by=c.rect.y+c.rect.h/2-box.h/2; }
4272
+ else if(side==='left'){ bx=c.rect.x-gap-box.w; by=c.rect.y+c.rect.h/2-box.h/2; }
4273
+ else {
4274
+ bx=al==='l'?c.rect.x
4275
+ :al==='r'?c.rect.x+c.rect.w-box.w
4276
+ :c.rect.x+c.rect.w/2-box.w/2;
4277
+ by=side==='below'?c.rect.y+c.rect.h+gap:c.rect.y-gap-box.h;
4278
+ }
4279
+ const cb={x:bx,y:by,w:box.w,h:box.h};
4280
+ let s=0;
4281
+ // A NOTE YIELDS; NOTHING YIELDS TO A NOTE. `placed` holds the edge
4282
+ // labels and the notes already sited, and its weight is the HIGHEST
4283
+ // of the three — higher than a node's — because a label is a small
4284
+ // box and an AREA-weighted penalty would otherwise let a note sit on
4285
+ // one for less than it costs to clip a node's corner. Found by eye: a
4286
+ // transition note landed across two edge labels while a node overlap
4287
+ // three times the area scored higher.
4288
+ for(const b of placed) s+=10*ovl(cb,b);
4289
+ for(const o of obst) s+=6*ovl(cb,o);
4290
+ for(const a of arrowBox) s+=8*ovl(cb,a);
4291
+ for(const g of edgeSegs) if(segHit(g.p,g.q,cb)) s+=26;
4292
+ for(const g of doc.groups){ const B=gBox[g.id];
4293
+ if(B) s+=1.2*ovl(cb,{x:B.x0,y:B.yA,w:B.x1-B.x0,h:B.yB-B.yA}); }
4294
+ s+=si*12;
4295
+ s+=tier*900; // the leader is a LAST resort
4296
+ // Falling off the top or the left margin is not a BAD placement, it
4297
+ // is NO placement. The canvas grows right and down but has no
4298
+ // mechanism here to grow up or left, so such a box is clipped away
4299
+ // and the annotation VANISHES — the author wrote a sentence and the
4300
+ // reader never sees it, which is the worst outcome available. So it
4301
+ // is a HARD FILTER and not a score term: every other penalty is an
4302
+ // AREA and grows without bound, so no constant can outrank one
4303
+ // reliably. Found by eye on a statechart, where the note on the
4304
+ // leftmost state was emitted at x=-267.8 and simply did not appear —
4305
+ // and then found AGAIN when a large constant was tried first and the
4306
+ // box landed on top of two states instead.
4307
+ // `<0`, not `<2`. The edge-label pass keeps a 2px margin because a
4308
+ // label is loose text; a note is a BOX whose left edge at x=0 IS the
4309
+ // canvas origin and is perfectly placed. With the label pass's
4310
+ // threshold copied over, the one candidate that rescues a wide note
4311
+ // on a leftmost element — below, left-aligned, at exactly x=0 — was
4312
+ // filtered as off-canvas, and the note went to the only survivor: on
4313
+ // top of the next two states.
4314
+ if(cb.x<0||cb.y<y0){
4315
+ if(s<fallbackS-1e-9){ fallbackS=s; fallback={x:bx,y:by,far,side}; }
4316
+ continue;
4317
+ }
4318
+ if(s<bestS-1e-9){ bestS=s; best={x:bx,y:by,far,side}; }
4319
+ }
4320
+ }
4321
+ // Only if EVERY candidate was off-canvas: take the least-bad one and
4322
+ // clamp it on. It may overlap something; visible and overlapping is
4323
+ // recoverable by an author, invisible is not.
4324
+ if(!best){ best=fallback; best.x=Math.max(0,best.x); best.y=Math.max(y0,best.y); }
4325
+ lblsvg.push(noteSvg(best.x,best.y,box,c));
4326
+ if(best.far) lblsvg.push(noteLeader(best,box,c.rect));
4327
+ placed.push({x:best.x,y:best.y,w:box.w,h:box.h});
4328
+ W=Math.max(W, best.x+box.w+4);
4329
+ Hh=Math.max(Hh, best.y+box.h+4-y0-20);
4330
+ }
3721
4331
  }
3722
4332
  // nodes on top (each wrapped in a draggable, identifiable group)
3723
4333
  const nsvg=[];
@@ -4030,7 +4640,7 @@ function renderBitfield(b,y0){
4030
4640
  //
4031
4641
  // and 0.1 already ruled that a spanning field follows the RFC's
4032
4642
  // drawing rather than a FigDown one; this is the same ruling applied to the
4033
- // other construct in the same figure. Until this release the engine drew ONE
4643
+ // other construct in the same figure. Until 0.1 the engine drew ONE
4034
4644
  // occurrence and hung `[first] … [last]` on the strip — a FigDown invention
4035
4645
  // where a convention already existed.
4036
4646
  //
@@ -4077,7 +4687,7 @@ function renderBitfield(b,y0){
4077
4687
  const shiftFor=(row)=>elisBands(row)*EL_H;
4078
4688
  // `FIELD-WIDER-THAN-WORD`: ONE FIELD IS ONE BOX.
4079
4689
  //
4080
- // A field wider than `word=` occupies several rows. Until this release each
4690
+ // A field wider than `word=` occupies several rows. Until 0.1 each
4081
4691
  // row was a separate fully-bordered <rect> carrying the full label, so a
4082
4692
  // 128-bit address at word=32 drew as FOUR captioned boxes and a reader saw
4083
4693
  // four fields where the model has one. In examples/srh.fd it was worse: the
@@ -4273,7 +4883,7 @@ function renderBitfield(b,y0){
4273
4883
  }
4274
4884
  boxes.forEach(function(bx,bi){
4275
4885
  // `DESCRIPTION-KEY-SPELLING`: the `<title>` is a CHILD of the shape it names, not
4276
- // a sibling. Until this release it was pushed into the block's stream
4886
+ // a sibling. Until 0.1 it was pushed into the block's stream
4277
4887
  // after the rect and the label, so it landed under the figure's single
4278
4888
  // <g> — and SVG says a <title> names its PARENT, so every description in
4279
4889
  // a figure named the same <g> and a conforming UA showed one arbitrary
@@ -4432,7 +5042,8 @@ function renderBitfield(b,y0){
4432
5042
  }
4433
5043
  yb+=2;
4434
5044
  }
4435
- return {svg:svg.join(''), y:yb, w:wb};
5045
+ return {svg:svg.join(''), y:yb, w:wb,
5046
+ box:{x0:0, x1:wb, yA:y0+18, yB:yb}};
4436
5047
  }
4437
5048
 
4438
5049
  // ---- table (with ^ rowspan / < colspan merging and per-cell marks) ----
@@ -4528,7 +5139,20 @@ function renderTable(t,y0){
4528
5139
  i=>[xAt[i], xAt[i+1]], i=>[cellAt(r-1,i), cellAt(r,i)]));
4529
5140
  svg.push(edgeSvg(EDG, DEF));
4530
5141
  const yEnd=yTop+yAt[grid.length];
4531
- return {svg:svg.join(''), y:yEnd+6, w:totalW+2};
5142
+ // `MARKER-TARGET-KINDS`: the GRID's box, so a region-scope `threshold`/`band`
5143
+ // can be drawn across it. It is the grid and not the returned slot: the slot
5144
+ // includes the caption row and the trailing gap, and `offset=50%` on a table
5145
+ // must mean half way down the ROWS, not half way down the whitespace.
5146
+ // `MARKER-TARGET-KINDS`: the box a region-scope `threshold`/`band` is measured
5147
+ // against spans the DATA ROWS, not the whole grid. Measured over the grid,
5148
+ // `offset=85%` on a three-row table lands on the COLUMN HEADINGS and strikes
5149
+ // through them — found by eye on the WRED figure this widening exists for.
5150
+ // The header tiers are chrome: they name the columns, they are not values,
5151
+ // and a threshold is a statement about values. `h1..hN` and `1..` are already
5152
+ // separate address spaces in this genre (genres/table.md), so the split is
5153
+ // the genre's own and not invented here.
5154
+ return {svg:svg.join(''), y:yEnd+6, w:totalW+2,
5155
+ box:{x0:0, x1:totalW, yA:yTop+yAt[H], yB:yEnd}};
4532
5156
  }
4533
5157
 
4534
5158
  // ---- chart bar3d: deterministic isometric projection of a table ----
@@ -4632,7 +5256,8 @@ function renderTiming(w,y0){
4632
5256
  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"/>');
4633
5257
  }
4634
5258
  const H=y+8+w.signals.length*(laneH+laneGap);
4635
- return {svg:svg.join(''), y:H, w:nameW+cycles*cycleW+2};
5259
+ return {svg:svg.join(''), y:H, w:nameW+cycles*cycleW+2,
5260
+ box:{x0:nameW, x1:nameW+cycles*cycleW, yA:y0+18, yB:H}};
4636
5261
  }
4637
5262
 
4638
5263
  // ============================================================