figdown 0.5.0 → 0.5.1

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.
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "figdown",
4
4
  "displayName": "FigDown",
5
- "version": "0.5.0",
5
+ "version": "0.5.1",
6
6
  "description": "A missing edge still looks fine. Text doesn't. FigDown keeps figures as text whose source states the meaning: participants, containment, field widths, table structure. The next reader can check it, not just look at it. Deterministic SVG, embedded in your Markdown — block diagrams, topologies, flowcharts, bit-level layouts, tables, timing waveforms.",
7
7
  "author": {
8
8
  "name": "FigDown",
package/README.md CHANGED
@@ -215,7 +215,7 @@ did not make.
215
215
 
216
216
  | part | meaning |
217
217
  |---|---|
218
- | **`Z`** | **Bug fixes only.** The language does not move. `v0.1.1` may fix a rendering defect with **no `.fd` file altered**. |
218
+ | **`Z`** | **The language does not move — that is the test, not the size of the change.** No `.fd` document's meaning changes and none needs a rewrite. A `Z` release may add a tool, a document, a non-core profile, a schema, a render option or a gate; none of those is a language construct. `v0.1.1` may fix a rendering defect with **no `.fd` file altered**. |
219
219
  | **`Y`** | **Features are added. Nothing is ever removed.** Every document a `Y` release accepted, the next one still accepts. |
220
220
  | **`X`** | **The only point at which support may be removed** — and removing it forces a migration. |
221
221
 
package/dist/figdown.js CHANGED
@@ -1,4 +1,4 @@
1
- // figdown.js — FigDown embeddable library (0.5.0)
1
+ // figdown.js — FigDown embeddable library (0.5.1)
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.5.0";
13
+ var VERSION = "0.5.1";
14
14
 
15
15
  // ---- engine (extracted verbatim from editor/figdown.html) ----
16
16
  var __engine = (function () {
@@ -24,14 +24,14 @@ 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.5.0';
27
+ const FIGDOWN_VERSION = '0.5.1';
28
28
  // `TYPED-BLOCK-TITLE-CANVAS`: a `Z`-only dev bump — RENDERER ONLY, no keyword, no
29
29
  // option key, no model field, so `figdown 0.5` still names one language. A
30
30
  // typed block's own title (bitfield/table/timing/chart) now joins the
31
31
  // section's canvas computation: the returned width is the union of the data
32
32
  // extent and the title extent (`typedBlockTitleW`, shared by all four
33
33
  // renderers), so a title wider than the data widens the canvas instead of
34
- // running off it at x=0. Fixes backlog item 66 (FR-4). Corpus impact
34
+ // running off it at x=0. Fixes backlog item 66, raised downstream. Corpus impact
35
35
  // surveyed first: zero shipped drawings change (every typed-block title in
36
36
  // the corpus already fit its data-derived canvas); the fix only reaches
37
37
  // fixtures the corpus does not yet have. See spec/migrations.md.
@@ -1298,6 +1298,35 @@ const WORD_WHY={
1298
1298
  const WRONG_WORD=(surf,want,genre)=>
1299
1299
  '"'+surf+'" is not the word genre '+genre+' uses for this — write "'+want+'": '+WORD_WHY[want]+
1300
1300
  '. Each genre takes the term its own domain uses (block/topology `node` `edge`, flowchart `node` `flowline`, statechart `state` `transition`, sequence `lifeline` `message`) — run tools/migrate-figdown.js to rewrite it (MIGRATIONS 0.2)';
1301
+ // `LANGUAGE-EXTENSION-POLICY`/`RESERVED-PREFIX-ENFORCEMENT` (2026-08-23): the `x-` prefix is RESERVED against FigDown's own
1302
+ // vocabulary and is NOT an extension namespace. Until this entry it was the
1303
+ // one reservation core §10 wrote down by name and NOTHING enforced: `x-note`
1304
+ // earned `"x-note" is not allowed in genre block` — the same words a typo
1305
+ // gets — while `page`, `;` and `step` each got a diagnostic naming their
1306
+ // reservation. `SEMICOLON-STATUS`'s own title says a reservation that is not enforced is not
1307
+ // a reservation, so this is what makes `x-` one.
1308
+ //
1309
+ // The message says three things, and the THIRD is the whole value: `LANGUAGE-EXTENSION-POLICY`
1310
+ // RETIRED the promise of a future extension mechanism, so an author who reads
1311
+ // core §10's pre-0.5 wording is being told the opposite of the rule. It must
1312
+ // say the prefix will not open, not merely that it is taken.
1313
+ //
1314
+ // IT MUST NOT REACH IDENTIFIERS. The reservation is about the language's own
1315
+ // vocabulary; an id is the author's. `node x-foo "A"` and `class x-bar
1316
+ // "meaning"` parsed and rendered before this entry and still do, which is why
1317
+ // both call sites below are in KEYWORD position and OPTION-KEY position and
1318
+ // nowhere else.
1319
+ const X_RESERVED_WHY =
1320
+ ': the "x-" prefix is reserved against FigDown\'s OWN vocabulary — no standard '+
1321
+ 'keyword or option key may begin "x-" — and it is NOT an extension namespace. '+
1322
+ 'The language is CLOSED, so this does not become legal by waiting (core §10, `LANGUAGE-EXTENSION-POLICY`). '+
1323
+ 'Record the fact in a publication manifest instead, spelled x-<owner>-<key> '+
1324
+ '(spec/figdown-manifest.md §2.1); propose the construct through .github/CONTRIBUTING.md §2; '+
1325
+ 'or fork with your own header token. Identifiers are untouched: node x-foo "A" and '+
1326
+ 'class x-bar "meaning" both parse';
1327
+ const X_RESERVED_KW = kw => '"'+kw+'" is RESERVED, not an extension keyword'+X_RESERVED_WHY;
1328
+ const X_RESERVED_OPT = k => 'option "'+k+'=" is RESERVED, not an extension option key'+X_RESERVED_WHY;
1329
+ const isXReserved = s => typeof s==='string' && s.slice(0,2)==='x-';
1301
1330
  // `SCENE-KEYWORD-MEMBERSHIP`: a word WITHDRAWN FROM ONE GENRE is not an unknown word,
1302
1331
  // and `"threshold" is not allowed in genre topology` would send an author
1303
1332
  // looking for a typo. Each cell below was legal until 0.3 and states
@@ -1937,7 +1966,12 @@ function parseOne(text){
1937
1966
  // `message` is scanned by this function and never reaches that one.
1938
1967
  if(u2.length){
1939
1968
  const ro=(doc.genre&&REFUSED_OPT_IN[doc.genre])||null;
1940
- if(ro && ro[u2[0]]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u2[0],doc.genre));
1969
+ // `RESERVED-PREFIX-ENFORCEMENT` (2026-08-23): CALL SITE 2a of 2 — the reserved `x-` prefix in
1970
+ // OPTION-KEY position, on the connector path. `message` is scanned by
1971
+ // this function and never reaches `badOpts`, which is why the check has
1972
+ // to exist in both places (the same reason `UNDELIVERED-MESSAGE-MARKING`'s genre refusal does).
1973
+ if(isXReserved(u2[0])) err(n,X_RESERVED_OPT(u2[0]));
1974
+ else if(ro && ro[u2[0]]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u2[0],doc.genre));
1941
1975
  else err(n,'unknown option "'+u2[0]+'="');
1942
1976
  return; }
1943
1977
  // `SEQUENCE-GENRE-VOCABULARY`: `message` adds ONE trailing positional — the
@@ -2278,7 +2312,12 @@ function parseOne(text){
2278
2312
  // ground and the replacement spelling instead of a spellcheck.
2279
2313
  const roOpt=(doc.genre&&REFUSED_OPT_IN[doc.genre])||null;
2280
2314
  for(const u of unk){
2281
- if(roOpt && roOpt[u]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u,doc.genre));
2315
+ // `RESERVED-PREFIX-ENFORCEMENT` (2026-08-23): CALL SITE 2b of 2 — the reserved `x-` prefix in
2316
+ // OPTION-KEY position, on the ordinary directive path. Checked before
2317
+ // the genre refusal and before the generic message, because a key the
2318
+ // language will never register is a stronger fact than either.
2319
+ if(isXReserved(u)) err(n,X_RESERVED_OPT(u));
2320
+ else if(roOpt && roOpt[u]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u,doc.genre));
2282
2321
  else err(n,'unknown option "'+u+'="');
2283
2322
  bad=true; }
2284
2323
  // `MEMBERSHIP-KEY-ACCEPTANCE`: the PER-GENRE option-key withdrawal, checked here —
@@ -2460,6 +2499,18 @@ function parseOne(text){
2460
2499
  } else { err(n,'first line must be "figdown 0.1 <genre>"'); }
2461
2500
  } else if(kw==='figdown'){ err(n,'duplicate version header'); continue; }
2462
2501
 
2502
+ // `RESERVED-PREFIX-ENFORCEMENT` (2026-08-23): CALL SITE 1 of 2 — the reserved `x-` prefix in
2503
+ // KEYWORD position. It sits here, above every other keyword answer, for
2504
+ // two reasons: no registered keyword begins `x-`, so it can pre-empt
2505
+ // nothing legitimate; and every downstream answer this would otherwise
2506
+ // reach (`not allowed in genre <g>`, `not valid inside <block>`,
2507
+ // `unrecognized line`) is a message about a typo, which is what §8.4 of
2508
+ // decisions/registry.md measured and what this replaces.
2509
+ // It sits BELOW the first-line header check deliberately: "first line
2510
+ // must be figdown …" is the more important thing to tell an author, and
2511
+ // an `x-` first line reports both, exactly as it reported two before.
2512
+ if(isXReserved(kw)){ err(n,X_RESERVED_KW(kw)); continue; }
2513
+
2463
2514
  // Retired spelling: `colw` → `width`. Keyword naming
2464
2515
  // discipline — one lowercase word, borrowed standard terminology; `colw`
2465
2516
  // was the only invented abbreviation in the registry. Reported wherever
@@ -3930,6 +3981,163 @@ function stackSectionSvgs(results){
3930
3981
  +chunks.join('')+'</svg>';
3931
3982
  }
3932
3983
 
3984
+ // ---- the accessibility profile's emission (spec/figdown-a11y.md) ----------
3985
+ // `ACCESSIBILITY-PROFILE`/`ACCESSIBLE-DESCRIPTION-SOURCES`/`ACCESSIBLE-TEXT-EMISSION` (decisions/registry.md).
3986
+ // The publication profile wants three things in a published artifact: a role,
3987
+ // a NON-VISUAL name, and a description whose review state a machine can read.
3988
+ // None of it is emitted by default and none of it is a language construct —
3989
+ // the `with-a11y` RENDER OPTION (core §7, `ACCESSIBLE-TEXT-EMISSION` route 1) is the only way in,
3990
+ // exactly as `with-title` (`TITLE-RENDER-DEFAULT`) is the only way to draw the title as ink.
3991
+ //
3992
+ // WHY THIS SITS OUTSIDE `render` AND NOT INSIDE IT. Two reasons, and the
3993
+ // second is the load-bearing one:
3994
+ // 1. Core §7/`RENDERING-DETERMINISM` make an artifact a pure function of (source, recorded
3995
+ // options) and promise byte-identical output for one engine version.
3996
+ // Code that never runs cannot move a byte, and `render` is left exactly
3997
+ // as it was so that every existing artifact and golden is untouched.
3998
+ // 2. `ACCESSIBILITY-PROFILE` rules ONE artifact, ONE root, ONE name — the FIRST section's
3999
+ // `title`. A multi-section artifact's root is built by
4000
+ // `stackSectionSvgs` from N per-section renders, so a `<title>` emitted
4001
+ // inside `render` would land inside a `<g>` (naming the group, per `DESCRIPTION-KEY-SPELLING`
4002
+ // and SVG 1.1 §5.4) and there would be N of them. The name belongs to
4003
+ // the finished root, which only the artifact layer holds.
4004
+ // So the artifact layer (tools/build-svg.js, dist/figdown.js's `artifact()`)
4005
+ // calls `a11yApply(svg, docs[0])` on the finished root and records
4006
+ // `with-a11y` in `data-render-options`.
4007
+ const A11Y_ROLE = 'graphics-document';
4008
+
4009
+ // `ACCESSIBILITY-PROFILE` item 3: the accessible name is the document's `title` string,
4010
+ // UNMODIFIED — not truncated, not prefixed, never invented. `title` is
4011
+ // optional in the grammar and stays optional (`ACCESSIBILITY-PROFILE` is explicit that the
4012
+ // untitled figure still parses and still renders): a document with no
4013
+ // `title` line gets NO root `<title>`, and whether such a figure may be
4014
+ // PUBLISHED is the profile's question and the verifier's, never the
4015
+ // engine's. The test is ABSENCE, not truthiness (`EMPTY-LABEL-STATE`): `title ""` is a
4016
+ // written empty name and is emitted as one.
4017
+ function a11yTitleOf(doc){
4018
+ return (doc && doc.title!==null && doc.title!==undefined) ? String(doc.title) : null;
4019
+ }
4020
+
4021
+ // ---- the derived description (`ACCESSIBLE-DESCRIPTION-SOURCES` item 7) --------------------------------
4022
+ // A `derived` description is a deterministic projection of the MODEL into
4023
+ // prose — "exactly as good as the model, and no better" — and `ACCESSIBLE-DESCRIPTION-SOURCES`'s
4024
+ // operative half is the NEGATIVE rule: it MUST NOT state what the model does
4025
+ // not assert. No causality, no "this shows how X works", no count the model
4026
+ // cannot produce. Every sentence below is an inventory statement over a
4027
+ // collection the model actually carries.
4028
+ //
4029
+ // COVERED GENRES ARE EXACTLY THE THREE `ACCESSIBLE-DESCRIPTION-SOURCES` RULED CONTENT RULES FOR:
4030
+ // `topology`, `bitfield`, `sequence`. Every other genre the engine supports
4031
+ // (`block`, `flowchart`, `statechart`, `table`, `timing`, and the
4032
+ // experimental `chart` region) gets NO derived description — the state stays
4033
+ // `absent` — because inventing content rules for them here would be design,
4034
+ // not implementation. That boundary is stated, not silent: spec/figdown-a11y.md
4035
+ // §4.4 records which genres are covered, which are not, and why.
4036
+ const A11Y_DERIVED_GENRES = ['topology','bitfield','sequence'];
4037
+ function a11yQ(s){ return '"'+String(s)+'"'; }
4038
+ function a11yCount(n,word){ return n+' '+word+(n===1?'':'s'); }
4039
+ // id, plus the label the model carries WHEN it carries one. The renderer's
4040
+ // id-for-absent-label fallback (`OMITTED-LABEL-RECORDING`) is a DISPLAY rule and deliberately does
4041
+ // not run here: the model records absence, and a derived description that
4042
+ // silently promoted an id to a name would assert a name nobody wrote.
4043
+ function a11yNamed(x){
4044
+ return (x.label===null||x.label===undefined) ? String(x.id) : String(x.id)+' '+a11yQ(x.label);
4045
+ }
4046
+ function a11yDeriveTopology(doc){
4047
+ const s=[];
4048
+ s.push('Topology figure'+(a11yTitleOf(doc)!==null?' '+a11yQ(doc.title):'')+'.');
4049
+ s.push(a11yCount(doc.nodes.length,'node')+', '+a11yCount(doc.edges.length,'connector')
4050
+ +', '+a11yCount(doc.groups.length,'group')+'.');
4051
+ s.push('Flow direction: '+String(doc.flow)+'.');
4052
+ if(doc.nodes.length) s.push('Nodes: '+doc.nodes.map(a11yNamed).join(', ')+'.');
4053
+ for(const g of doc.groups){
4054
+ const mem=doc.nodes.filter(n=>n.group===g.id).map(n=>String(n.id));
4055
+ s.push('Group '+a11yNamed(g)+(mem.length?' contains: '+mem.join(', ')+'.':' has no declared members.'));
4056
+ }
4057
+ // A class's MEANING is `label` on the engine object (the canonical
4058
+ // projection renames it `meaning`). `ACCESSIBLE-DESCRIPTION-SOURCES` says "each declared class and its
4059
+ // stated meaning (which the legend already prints)", so the legend's own
4060
+ // filter is inherited rather than re-invented: `class x ""` is an attribute
4061
+ // grouping that states no meaning, it prints no legend entry, and it makes
4062
+ // no sentence here either.
4063
+ for(const c of (doc.classes||[])) if(c.label!=='')
4064
+ s.push('Class '+String(c.id)+' means '+a11yQ(c.label)+'.');
4065
+ return s.join(' ');
4066
+ }
4067
+ function a11yDeriveBitfield(doc){
4068
+ const s=[];
4069
+ s.push('Bitfield figure'+(a11yTitleOf(doc)!==null?' '+a11yQ(doc.title):'')+'.');
4070
+ const blocks=(doc.blocks||[]).filter(b=>b.type==='bitfield');
4071
+ if(!blocks.length) return null;
4072
+ for(const b of blocks){
4073
+ s.push('Bitfield '+a11yNamed(b)+': word '+a11yCount(b.word,'bit')
4074
+ +', numbering '+String(b.numbering)+'.');
4075
+ // `wrap` entries are ROW BREAKS (projected as `break`), a division of the
4076
+ // drawing and not a field; `ACCESSIBLE-DESCRIPTION-SOURCES`'s rule is "fields in order with widths".
4077
+ // A field's width is `w` on the engine object: a bit count, or the string
4078
+ // `*` for the remainder of the row.
4079
+ const fields=(b.fields||[]).filter(f=>!f.wrap);
4080
+ if(!fields.length){ s.push('No fields declared.'); continue; }
4081
+ s.push('Fields in order: '+fields.map(f=>
4082
+ a11yQ(f.name)+' '+(String(f.w)==='*'?'width *':a11yCount(f.w,'bit'))).join(', ')+'.');
4083
+ }
4084
+ return s.join(' ');
4085
+ }
4086
+ function a11yDeriveSequence(doc){
4087
+ const s=[];
4088
+ s.push('Sequence figure'+(a11yTitleOf(doc)!==null?' '+a11yQ(doc.title):'')+'.');
4089
+ const lls=doc.lifelines||[], msgs=doc.messages||[], frags=doc.fragments||[];
4090
+ s.push('Lifelines in declaration order: '+(lls.length?lls.map(a11yNamed).join(', '):'none')+'.');
4091
+ // The time axis is declaration order and it is TOTAL (`SEQUENCE-GENRE-VOCABULARY`, draft §31),
4092
+ // so "in their stated total order" is `line` ascending — the model's own
4093
+ // ordering key, not a re-reading of the picture.
4094
+ const ordered=msgs.slice().sort((p,q)=>p.line-q.line);
4095
+ s.push('Messages in stated order: '+(ordered.length?ordered.map((m,i)=>
4096
+ (i+1)+'. '+String(m.a)+' '+String(m.op)+' '+String(m.b)
4097
+ +(m.label===null||m.label===undefined?'':' '+a11yQ(m.label))).join('; '):'none')+'.');
4098
+ if(frags.length) s.push('Fragments: '+frags.map(f=>
4099
+ a11yNamed(f)+' (type '+String(f.type)+')').join(', ')+'.');
4100
+ return s.join(' ');
4101
+ }
4102
+ function a11yDerivedDesc(doc){
4103
+ if(!doc||A11Y_DERIVED_GENRES.indexOf(doc.genre)<0) return null;
4104
+ if(doc.genre==='topology') return a11yDeriveTopology(doc);
4105
+ if(doc.genre==='bitfield') return a11yDeriveBitfield(doc);
4106
+ if(doc.genre==='sequence') return a11yDeriveSequence(doc);
4107
+ return null; // state `absent` — an uncovered genre, spec/figdown-a11y.md §4.4
4108
+ }
4109
+
4110
+ // Apply the profile to a FINISHED root `<svg>`: the role on the root, the
4111
+ // non-visual `<title>` as its FIRST child (SVG 1.1 §5.4 / `DESCRIPTION-KEY-SPELLING` — a `<title>`
4112
+ // names its parent and belongs first), then the `<desc>` carrying the derived
4113
+ // description with its state in a `data-*` attribute (`ACCESSIBLE-DESCRIPTION-SOURCES` item 6, way 1; the
4114
+ // manifest carries the evidence, way 3). Idempotent: a root that already
4115
+ // declares a role is returned unchanged.
4116
+ function a11yApply(svg, doc){
4117
+ const s=String(svg);
4118
+ const m=/^<svg\b[^>]*>/.exec(s);
4119
+ if(!m) return s;
4120
+ if(/\brole="/.test(m[0])) return s;
4121
+ const parts=[];
4122
+ const t=a11yTitleOf(doc);
4123
+ if(t!==null) parts.push('<title>'+esc(t)+'</title>');
4124
+ const d=a11yDerivedDesc(doc);
4125
+ if(d!==null&&d!=='') parts.push('<desc data-desc-state="derived">'+esc(d)+'</desc>');
4126
+ return m[0].replace(/>$/,' role="'+A11Y_ROLE+'">')+parts.join('')+s.slice(m[0].length);
4127
+ }
4128
+
4129
+ // The `data-render-options` value for a render (core §7). ONE spelling, in
4130
+ // ONE place, so the artifact layers cannot drift: options in declaration
4131
+ // order, space-separated — the SVG/HTML idiom for a token list. A default
4132
+ // render writes no attribute at all, which is why the empty string is
4133
+ // returned rather than an empty attribute.
4134
+ function renderOptionsAttr(opts){
4135
+ const names=[];
4136
+ if(opts&&opts.title===true) names.push('with-title');
4137
+ if(opts&&opts.a11y===true) names.push('with-a11y');
4138
+ return names.length?' data-render-options="'+names.join(' ')+'"':'';
4139
+ }
4140
+
3933
4141
  // `SEMICOLON-STATUS`: `;` is RESERVED for a future statement separator, and
3934
4142
  // RULE 1.3 says a reserved mark MUST NOT be given any other meaning. Until
3935
4143
  // this release it was an ordinary character: `node a ;` parsed and `;`
@@ -4006,7 +4214,7 @@ function findComment(s){
4006
4214
  // ============================================================
4007
4215
  const FONT=13, CH=7.2, PADX=14, NH=36, GAPX=56, GAPY=44;
4008
4216
 
4009
- // ---- cw(): script-aware advance width --------------------------------
4217
+ // ---- cw(): script-aware advance width (`TEXT-ADVANCE-MEASUREMENT`) --------------------------------
4010
4218
  // Every px-per-character constant in this file — CH here, and its siblings at
4011
4219
  // the other font sizes (8.6 title, 6.6 legend, 6.5 edge label, 6.3/6.2 bitfield
4012
4220
  // caption) — is calibrated on LATIN, where one character advances ~0.554 em.
@@ -4577,15 +4785,80 @@ function render(doc,ropts){
4577
4785
  // Retiring `color=` removes the first case; omitting the swatch removes
4578
4786
  // the second. Every channel a class can still declare (`fill`, `stroke`,
4579
4787
  // `style`) is drawn, so "declared but not shown" is now unreachable.
4788
+ // 0.5 (`LEGEND-SWATCH-SHAPE`): the swatch depicts THE CHANNEL THE CLASS ACTUALLY
4789
+ // PAINTS. `CLASS-PAINT-REQUIREMENT` ruled WHETHER a swatch is drawn and never WHAT SHAPE it
4790
+ // takes, so every painting class got a `<rect>` — and a class that
4791
+ // declares only `stroke=` and is carried only by CONNECTORS then appeared
4792
+ // as a white box with a coloured outline while the drawing showed a
4793
+ // coloured LINE. The legend's visual vocabulary did not match the
4794
+ // figure's, and with `style=dashed` the dash landed on a rectangle's
4795
+ // perimeter instead of along a run. Measured on 2026-08-26: 36 of this
4796
+ // repository's 76 legend-bearing class-uses are connector-only (33 of them
4797
+ // stroke-without-fill) and 10 figures are entirely connector-only; in the
4798
+ // production corpus behind the field reports, 160 of 1350 legend-bearing
4799
+ // class-uses are connector-only and 159 of those are stroke-without-fill.
4800
+ //
4801
+ // WHICH KINDS ARE CONNECTORS is read off the model, not guessed and not
4802
+ // stored: the four connector spellings (`edge` `flowline` `transition`
4803
+ // `message`, core §10) are exactly `doc.edges` and `doc.messages` in the
4804
+ // model — every other collection that carries `class=` is a box, a frame
4805
+ // or a cell, i.e. something with an area. This adds no model field, no
4806
+ // option key and no spelling; it is a byte-moving RENDER change.
4807
+ const clsUse={};
4808
+ { const use=(x,conn)=>{
4809
+ const ids=(x.cls===undefined||x.cls===null)?[]:(Array.isArray(x.cls)?x.cls:[x.cls]);
4810
+ for(const id of ids){ const u=clsUse[id]||(clsUse[id]={conn:false,box:false});
4811
+ if(conn) u.conn=true; else u.box=true; } };
4812
+ for(const x of doc.nodes) use(x,false);
4813
+ for(const x of doc.groups) use(x,false);
4814
+ for(const x of doc.edges) use(x,true);
4815
+ for(const x of (doc.messages||[])) use(x,true);
4816
+ for(const x of (doc.lifelines||[])) use(x,false);
4817
+ for(const x of (doc.states||[])) use(x,false);
4818
+ for(const x of (doc.fragments||[])) use(x,false);
4819
+ for(const x of (doc.operands||[])) use(x,false);
4820
+ for(const b of doc.blocks){ use(b,false);
4821
+ if(b.fields) for(const f of b.fields) use(f,false);
4822
+ if(b.marks) for(const mk of b.marks) use(mk,false); } }
4580
4823
  for(const c of legendCls){
4581
4824
  const paints=c.fill!==undefined||c.stroke!==undefined||c.style!==undefined;
4582
- const tw=cw(c.label)*6.6+(paints?30:9);
4583
- if(lx>0 && lx+tw>wrapW){ lx=0; ly+=rowH; }
4825
+ // The samples this class earns, in the ruled order: LINE then BOX.
4826
+ // · carried only by connectors → a line sample (`LEGEND-SWATCH-SHAPE` clause 1)
4827
+ // · carried only by boxes → the `<rect>` `CLASS-PAINT-REQUIREMENT` already drew (2)
4828
+ // · carried by both → both samples, side by side (3): the
4829
+ // legend exists to explain, and the extra width is cheaper than
4830
+ // making a reader guess which half of the figure an entry is about.
4831
+ // · declares `fill=` → a box sample WHATEVER it is carried
4832
+ // by (4), because a fill needs an area to be visible. A box on a
4833
+ // connector-only class is then the class declaring a channel its
4834
+ // elements cannot show — `CLASS-CHANNEL-COLLISION`'s territory, reported there, not
4835
+ // re-ruled here (and `gate:legend`'s unreachable-channel check
4836
+ // already refuses that shape in this repository's corpus).
4837
+ // · declares no paint → no sample at all (5, `CLASS-PAINT-REQUIREMENT` unchanged).
4838
+ // · declared but never carried → the shape comes from the DECLARED
4839
+ // channels alone: `fill` → box, stroke-only → line. Nothing else is
4840
+ // known about it, and its own paint is the only evidence there is.
4841
+ const u=clsUse[c.id];
4842
+ const samples=[];
4584
4843
  if(paints){
4585
- const dash=c.style==='dashed'?' stroke-dasharray="6 4"':(c.style==='dotted'?' stroke-dasharray="2 4"':'');
4586
- es.push('<rect x="'+lx+'" y="'+(ly+3)+'" width="16" height="11" fill="'+(c.fill||'#fff')+'" stroke="'+(c.stroke||'#555')+'"'+dash+'/>');
4844
+ if(u ? u.conn : c.fill===undefined) samples.push('line');
4845
+ if(u ? (u.box||c.fill!==undefined) : c.fill!==undefined) samples.push('box');
4587
4846
  }
4588
- es.push('<text x="'+(lx+(paints?21:0))+'" y="'+(ly+12.5)+'" font-size="11" fill="#1d1d1b">'+esc(c.label)+'</text>');
4847
+ const tw=cw(c.label)*6.6+9+21*samples.length;
4848
+ if(lx>0 && lx+tw>wrapW){ lx=0; ly+=rowH; }
4849
+ const dash=c.style==='dashed'?' stroke-dasharray="6 4"':(c.style==='dotted'?' stroke-dasharray="2 4"':'');
4850
+ let sx=lx;
4851
+ for(const s of samples){
4852
+ // Both samples take the SAME 16 px advance, so a mixed entry is two
4853
+ // swatches and not a wider one. The line is drawn at the rect's own
4854
+ // vertical centre (3 + 11/2) and at 1.6 — the stroke-width every
4855
+ // router in this engine gives a drawn connector — so the sample is
4856
+ // the same ink the figure lays down, at the same weight.
4857
+ if(s==='line') es.push('<line x1="'+sx+'" y1="'+(ly+8.5)+'" x2="'+(sx+16)+'" y2="'+(ly+8.5)+'" stroke="'+(c.stroke||'#555')+'" stroke-width="1.6"'+dash+'/>');
4858
+ else es.push('<rect x="'+sx+'" y="'+(ly+3)+'" width="16" height="11" fill="'+(c.fill||'#fff')+'" stroke="'+(c.stroke||'#555')+'"'+dash+'/>');
4859
+ sx+=21;
4860
+ }
4861
+ es.push('<text x="'+(lx+21*samples.length)+'" y="'+(ly+12.5)+'" font-size="11" fill="#1d1d1b">'+esc(c.label)+'</text>');
4589
4862
  lx+=tw+14; maxW=Math.max(maxW,lx);
4590
4863
  }
4591
4864
  parts.push(es.join(''));
@@ -6514,7 +6787,18 @@ function renderScene(doc,y0){
6514
6787
  // node box, which is already an obstacle.
6515
6788
  //
6516
6789
  // An external's label is NOT an obstacle to its OWN edge — that edge must
6517
- // reach the anchor the label names.
6790
+ // reach the anchor the label names. A GROUP's name is the same rule one
6791
+ // construct over, and 0.4 shipped only half of it (`GROUP-BOUNDARY-OBSTACLE`): a group's
6792
+ // name strip runs the full width of the band's TOP, so it lies across every
6793
+ // approach an outside node has to a member inside. Made an obstacle to all
6794
+ // comers, it turned every boundary-crossing edge into a detour that left the
6795
+ // corridor, ran down the band's outer edge and entered the member from the
6796
+ // side — the drawing then said the line arrives at the CONTAINER when the
6797
+ // source says it arrives at the MEMBER. `EDGE-BEND-RETENTION` had already ruled the group
6798
+ // BOX is an obstacle only to an edge that "neither starts nor ends inside";
6799
+ // the name is part of the same band and takes the same exemption. A foreign
6800
+ // shaft — neither endpoint in the group — is still detoured, which is the
6801
+ // whole of what 0.4 was right about.
6518
6802
  const extLbl=n=>{
6519
6803
  const cx=n.x+n.w/2, cy=n.y+n.h/2, [bdx,bdy]=bDir(n);
6520
6804
  const bw=lblPx(n.label), bl=String(n.label).split('\n').length, bh=13*bl;
@@ -6529,18 +6813,24 @@ function renderScene(doc,y0){
6529
6813
  for(const k in gBox){ const g=doc.groups.find(z=>z.id===k);
6530
6814
  if(!g||!g.label) continue;
6531
6815
  const B=gBox[k];
6532
- nameObs.push({x:B.x0+10, y:B.yA+16-11.5*0.85, w:cwMax(g.label)*6.5*11.5/11, h:11.5*1.1});
6816
+ nameObs.push({x:B.x0+10, y:B.yA+16-11.5*0.85, w:cwMax(g.label)*6.5*11.5/11, h:11.5*1.1, id:k});
6533
6817
  }
6534
- // The obstacle list a given edge must respect: every band name, plus every
6535
- // external label except the ones this edge itself terminates at.
6818
+ // Which group an endpoint id belongs to, for the name-strip exemption below.
6819
+ const grpOfId=new Map();
6820
+ for(const n of nodes) if(n.group) grpOfId.set(n.id,n.group);
6821
+ // The obstacle list a given edge must respect: every band name except the
6822
+ // ones this edge terminates AT or INSIDE, plus every external label except
6823
+ // the ones this edge itself terminates at.
6536
6824
  // A label's ink is its glyphs plus the clearance that keeps a line from
6537
6825
  // READING as struck through it. 4 px on every side — the same number the
6538
6826
  // legibility floor uses for a label's association margin — so a shaft that
6539
6827
  // grazes a name at 3 px is detoured rather than tolerated.
6540
6828
  const LBL_PAD=4;
6541
6829
  const padded=o=>({x:o.x-LBL_PAD,y:o.y-LBL_PAD,w:o.w+2*LBL_PAD,h:o.h+2*LBL_PAD});
6830
+ const ownsName=(o,e)=>o.id===e.a||o.id===e.b||
6831
+ o.id===grpOfId.get(e.a)||o.id===grpOfId.get(e.b);
6542
6832
  const lblObs=e=>extObs.filter(o=>o.id!==e.a&&o.id!==e.b).map(padded)
6543
- .concat(nameObs.map(padded));
6833
+ .concat(nameObs.filter(o=>!ownsName(o,e)).map(padded));
6544
6834
  const rings=new Map();
6545
6835
  for(const t of doc.trunks||[]){ const R=ringOf(t); if(R) rings.set(t,R); }
6546
6836
  // ── A LASSO THAT ENCLOSES A NON-MEMBER IS A FALSE DRAWING (item 69) ──────
@@ -9659,8 +9949,8 @@ function r2(v){ return Math.round(v*100)/100; }
9659
9949
  // canvas grows right and down only, so text at a negative coordinate is
9660
9950
  // CLIPPED, never merely misplaced (`LABEL-PLACEMENT-METRIC`, which fixed exactly this
9661
9951
  // for a `table` caption and a `chart` row/column label gutter but not for a
9662
- // bitfield/timing title or a chart's own top caption — production field
9663
- // report FR-4, decisions/registry.md).
9952
+ // bitfield/timing title or a chart's own top caption — reported from
9953
+ // downstream production authoring).
9664
9954
  //
9665
9955
  // ONE MEASUREMENT, used by all four renderers below, so the calibration lives
9666
9956
  // in one place rather than four. Bold text at this size is measured ~8%
@@ -10167,9 +10457,14 @@ function renderTable(t,y0){
10167
10457
  const mk=markOf(r,c);
10168
10458
  // block fill= is the default DATA-cell fill (header tint is structural)
10169
10459
  const fill=(mk&&mk.fill)||(cell.hdr?'#eeede6':(hlRow(r)?'#fef3c7':(t.fill||'#fff')));
10170
- // addressable cells carry table-id:row:col (row 0 = bottom header tier)
10171
- const addrR = r>=H ? (r-H+1) : (r===H-1 ? 0 : null);
10172
- const addr = addrR===null ? '' : ' data-cell="'+t.id+':'+addrR+':'+(c+1)+'" style="cursor:pointer"';
10460
+ // addressable cells carry table-id:row:col for EVERY tier (backlog 71):
10461
+ // data rows are 1..n, the bottom header tier keeps its historical `0`
10462
+ // spelling so existing consumers of that channel see no change, and
10463
+ // every tier above it is `hN` (1-indexed from the top) — the same
10464
+ // spelling the editor's own internal row-token model already used, so
10465
+ // the editor can read this channel with no translation table.
10466
+ const addrR = r>=H ? String(r-H+1) : (r===H-1 ? '0' : 'h'+(r+1));
10467
+ const addr = ' data-cell="'+t.id+':'+addrR+':'+(c+1)+'" style="cursor:pointer"';
10173
10468
  // A merged cell owns every grid square it spans, so its internal
10174
10469
  // boundaries have the same owner on both sides and are never drawn.
10175
10470
  const rec={id:'c'+r+'_'+c, c:(mk&&mk.stroke)||null, d:false};
@@ -10457,7 +10752,8 @@ function renderTiming(w,y0){
10457
10752
  }
10458
10753
 
10459
10754
  // ============================================================
10460
- return { parse: parse, render: render, stackSectionSvgs: stackSectionSvgs };
10755
+ return { parse: parse, render: render, stackSectionSvgs: stackSectionSvgs,
10756
+ a11yApply: a11yApply, renderOptionsAttr: renderOptionsAttr };
10461
10757
  })();
10462
10758
 
10463
10759
  // ---- minimal synchronous SHA-256 (FIPS 180-4), hex output ----
@@ -10528,7 +10824,11 @@ function __stackSectionSvgs(results) {
10528
10824
  // (determinism over convenience: no partial renders of invalid input).
10529
10825
  // opts (presentation, renderer tier): { title: true } draws the title;
10530
10826
  // the default does NOT (embedded figures almost always sit under the
10531
- // host document's caption — the majority case).
10827
+ // host document's caption — the majority case). { a11y: true } adds the
10828
+ // accessibility profile's emission (spec/figdown-a11y.md; ACCESSIBILITY-PROFILE):
10829
+ // role="graphics-document" on the root, the non-visual <title> as its first
10830
+ // child, and a state-flagged derived <desc>. The two are ORTHOGONAL — one
10831
+ // decides ink, the other the accessible name — and both default to off.
10532
10832
  // Multi-section sources are stacked vertically into a single SVG (MULTI-FIGURE-DOCUMENTS).
10533
10833
  //
10534
10834
  // TWO ERROR CHANNELS REACH ONE errors ARRAY. parse cannot see a
@@ -10550,17 +10850,27 @@ function render(text, opts) {
10550
10850
  for (var i = 0; i < rs.length; i++) errs = errs.concat(rs[i].errs || []);
10551
10851
  if (errs.length) return { svg: null, errors: errs };
10552
10852
  var svg = rs.length > 1 ? __engine.stackSectionSvgs(rs) : rs[0].svg;
10553
- return { svg: svg, errors: [] };
10853
+ return { svg: __a11y(svg, p.docs[0], opts), errors: [] };
10854
+ }
10855
+ // The accessibility profile's emission (spec/figdown-a11y.md; ACCESSIBILITY-PROFILE).
10856
+ // Applied to the FINISHED root, never inside a per-section render: ACCESSIBILITY-PROFILE gives
10857
+ // an artifact ONE root, ONE name, and the name is the FIRST section's title.
10858
+ // A caller that does not ask gets the byte-identical default (core §7 / RENDERING-DETERMINISM).
10859
+ function __a11y(svg, doc, opts) {
10860
+ if (!svg || !(opts && opts.a11y === true)) return svg;
10861
+ if (typeof __engine.a11yApply !== 'function') return svg;
10862
+ return __engine.a11yApply(svg, doc);
10554
10863
  }
10555
10864
  // renderDoc(doc, opts) -> svg string, for an already-validated doc from parse().
10556
10865
  // For multi-section, pass parse().docs to renderDocs instead.
10557
10866
  function renderDoc(doc, opts) {
10558
- return __engine.render(doc, opts).svg;
10867
+ return __a11y(__engine.render(doc, opts).svg, doc, opts);
10559
10868
  }
10560
10869
  function renderDocs(docs, opts) {
10561
10870
  if (!docs || !docs.length) return '';
10562
- if (docs.length === 1) return __engine.render(docs[0], opts).svg;
10563
- return __engine.stackSectionSvgs(docs.map(function (d) { return __engine.render(d, opts); }));
10871
+ if (docs.length === 1) return __a11y(__engine.render(docs[0], opts).svg, docs[0], opts);
10872
+ return __a11y(__engine.stackSectionSvgs(docs.map(function (d) { return __engine.render(d, opts); })),
10873
+ docs[0], opts);
10564
10874
  }
10565
10875
  // artifact(text) -> { svg, errors } svg is the full self-carrying SVG:
10566
10876
  // the render plus a <metadata id="figdown-source"> block
@@ -10577,7 +10887,7 @@ function artifact(text, opts) {
10577
10887
  // The artifact records the SHA-256 OF THE SOURCE, the ENGINE VERSION that
10578
10888
  // rendered it, and any non-default render option (core §7) — together they
10579
10889
  // keep third-party rebuilds bit-identical and give a diff somewhere to point
10580
- var optAttr = (opts && opts.title === true) ? ' data-render-options="with-title"' : '';
10890
+ var optAttr = __engine.renderOptionsAttr(opts);
10581
10891
  var meta = '<metadata id="figdown-source" data-sha256="' + __sha256hex(src) + '"'
10582
10892
  + ' data-engine-version="' + VERSION + '"' + optAttr + '><![CDATA[\n'
10583
10893
  + src.replace(/]]>/g, ']]]]><![CDATA[>') + '\n]]></metadata>';