@jabworks/condux 0.22.0 → 0.24.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jabworks/condux",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "OpenCode plugin for the condux workflow toolkit — bundles the condux skills and injects the specialist agents (coder, explorer, planner, researcher) plus an opt-in plan-review listener.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -4,18 +4,29 @@
4
4
  // diagram-kit.md) and cannot see its own render. It reads one file, finds
5
5
  // every inline <svg>…</svg> block, and reports the geometry mistakes a human
6
6
  // would catch on sight: an edge that runs through a box it does not connect
7
- // to, a label sitting on another label or on a node's own title text, an
8
- // edge nobody labeled, and text that falls outside the canvas.
7
+ // to, a label sitting on another label or on a node's own title text, a
8
+ // title wider than its own box, an edge nobody labeled, and text that falls
9
+ // outside the canvas.
9
10
  //
10
11
  // This is a regex-level reader, not a DOM. It understands exactly the
11
12
  // idioms diagram-kit.md teaches — `<g transform="translate(x,y)">` offsets,
12
13
  // `var(--…)` fills/strokes (never resolved — colors play no part in any
13
14
  // check), one shared `<marker>` — and two known approximations:
14
15
  //
15
- // - text width is estimated at 0.6 em per character (no font metrics);
16
+ // - text width is estimated at 0.6 em per character (no font metrics) —
17
+ // measured exact for the kit's mono labels, generous for regular sans,
18
+ // a little narrow for semibold titles on wide fallback fonts;
16
19
  // - Bézier and arc commands (C S Q T A) contribute only their final
17
20
  // endpoint as a straight segment — curvature itself is invisible to it.
18
21
  //
22
+ // Font size is resolved the way a browser cascades it, restricted to what a
23
+ // regex can see: a `.class { font-size: Npx }` rule matching the text's
24
+ // `class`, then a bare `text` / `svg text` rule, then the element's own
25
+ // `font-size` attribute, then one inherited from an enclosing `<g>` or the
26
+ // `<svg>` tag, and finally the browser default of 16px — reported on stderr
27
+ // when it has to be assumed, because an unsized diagram is read blind. Any
28
+ // other selector shape, and any unit but px, is invisible to it.
29
+ //
19
30
  // No dependencies, no network, no shelling out, no filesystem writes.
20
31
 
21
32
  import fs from 'node:fs';
@@ -24,9 +35,13 @@ import { pathToFileURL } from 'node:url';
24
35
  // ---------------------------------------------------------------------------
25
36
  // Small geometry helpers. A "box" is always { x0, y0, x1, y1 } in viewBox
26
37
  // units; nodes/boundaries are boxes with a `line` and (once assigned) a
27
- // `title`; texts are boxes with `text`, `line`, `free`, `ownEdge`.
38
+ // `title`; texts are boxes with `text`, `line`, `ax`/`ay` (the anchor
39
+ // point), `free`, `owner`, `ownEdge`.
28
40
  // ---------------------------------------------------------------------------
29
41
 
42
+ // What a browser uses when nothing in the document sizes the text.
43
+ const DEFAULT_FONT_SIZE = 16;
44
+
30
45
  const shrinkBox = (box, n) => ({ x0: box.x0 + n, y0: box.y0 + n, x1: box.x1 - n, y1: box.y1 - n });
31
46
 
32
47
  const expandBox = (box, n) => shrinkBox(box, -n);
@@ -185,6 +200,42 @@ const stripTags = (s) =>
185
200
  // idiom.
186
201
  // ---------------------------------------------------------------------------
187
202
 
203
+ // The two style-rule shapes the checker reads, from every <style> block in
204
+ // the document (comments already blanked): `text { font-size: Npx }` or
205
+ // `svg text { … }` sets the base, `.name { … }` / `text.name { … }` /
206
+ // `svg .name { … }` maps a class. Later rules win, as in a stylesheet.
207
+ // Anything else — element ids, descendant chains, `em`/`rem`/`var()` — is
208
+ // skipped: a size the checker cannot resolve must not become a guess.
209
+ const BASE_SELECTOR = /^(?:svg\s+)?text$/;
210
+ const CLASS_SELECTOR = /^(?:svg\s+)?(?:text)?\.([\w-]+)$/;
211
+
212
+ const parseStyleFontSizes = (source) => {
213
+ const styles = { base: null, classes: new Map() };
214
+ const styleRe = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
215
+ const ruleRe = /([^{}]+)\{([^}]*)\}/g;
216
+ let sm;
217
+
218
+ while ((sm = styleRe.exec(source))) {
219
+ // A CSS comment is not a rule, and left in place it glues itself onto
220
+ // the selector that follows it.
221
+ const css = sm[1].replace(/\/\*[\s\S]*?\*\//g, ' ');
222
+ let rm;
223
+ while ((rm = ruleRe.exec(css))) {
224
+ const size = /(?:^|[\s;{])font-size\s*:\s*(\d+(?:\.\d+)?)px\b/.exec(rm[2]);
225
+ if (!size) continue;
226
+
227
+ for (const selector of rm[1].split(',')) {
228
+ const s = selector.trim();
229
+ if (BASE_SELECTOR.test(s)) styles.base = parseFloat(size[1]);
230
+ const cm = CLASS_SELECTOR.exec(s);
231
+ if (cm) styles.classes.set(cm[1], parseFloat(size[1]));
232
+ }
233
+ }
234
+ }
235
+
236
+ return styles;
237
+ };
238
+
188
239
  const CURVE_ARG_COUNTS = { C: 6, S: 4, Q: 4, T: 2, A: 7 };
189
240
 
190
241
  const parsePathSegments = (d, warnCurve) => {
@@ -296,14 +347,20 @@ const parsePathSegments = (d, warnCurve) => {
296
347
  // ---------------------------------------------------------------------------
297
348
  // Per-<svg>-block walk. A single left-to-right tag scan tracks a stack of
298
349
  // <g> offsets (and font-size inheritance), skips <defs>/<marker> subtrees
299
- // entirely, and special-cases <text>…</text> to pull out its raw content
300
- // (and any positioned <tspan> children) without a general text-node model.
350
+ // entirely — the skip survives any container nested inside them (<g>,
351
+ // <symbol>, …), since only the defs/marker frame itself may end it — and
352
+ // special-cases <text>…</text> to pull out its raw content (and any
353
+ // positioned <tspan> children) without a general text-node model.
301
354
  // ---------------------------------------------------------------------------
302
355
 
303
- const walkBlock = (source, blockOffset, blockSource, findLine, notify) => {
356
+ const walkBlock = (source, blockOffset, blockSource, findLine, notify, styles) => {
304
357
  const rects = [];
305
358
  const texts = [];
306
359
  const edges = [];
360
+ let defaulted = 0;
361
+ const onDefault = () => {
362
+ defaulted++;
363
+ };
307
364
 
308
365
  const svgOpenMatch = /^<svg\b[^>]*>/.exec(blockSource);
309
366
  const svgOpenTag = svgOpenMatch ? svgOpenMatch[0] : '<svg>';
@@ -319,12 +376,16 @@ const walkBlock = (source, blockOffset, blockSource, findLine, notify) => {
319
376
 
320
377
  let curOffsetX = 0;
321
378
  let curOffsetY = 0;
322
- let curFontSize = parseFloat(getAttr(svgOpenTag, 'font-size') ?? '') || 13;
379
+ // null until some enclosing tag carries a font-size — the text element then
380
+ // decides between its own attribute, the style rules, and the default.
381
+ let curFontSize = parseFloat(getAttr(svgOpenTag, 'font-size') ?? '') || null;
323
382
  let skipDepth = 0;
324
383
  const stack = [];
325
- // The frame a closing tag restores: offsets, inherited font size, and
326
- // whether the subtree was inside <defs>/<marker>.
327
- const snapshot = () => ({ prevX: curOffsetX, prevY: curOffsetY, prevFontSize: curFontSize, skip: skipDepth > 0 });
384
+ // The frame a closing tag restores: offsets and inherited font size.
385
+ // `skip` means "this frame incremented skipDepth", which only the
386
+ // defs/marker branch below ever does — a container opened inside a
387
+ // skipped subtree restores offsets on close, nothing more.
388
+ const snapshot = () => ({ prevX: curOffsetX, prevY: curOffsetY, prevFontSize: curFontSize, skip: false });
328
389
 
329
390
  const tagRe = /<\/?[a-zA-Z][^>]*>/g;
330
391
  let m;
@@ -364,6 +425,8 @@ const walkBlock = (source, blockOffset, blockSource, findLine, notify) => {
364
425
  offX: curOffsetX,
365
426
  offY: curOffsetY,
366
427
  curFontSize,
428
+ styles,
429
+ onDefault,
367
430
  texts,
368
431
  lineNo,
369
432
  });
@@ -430,6 +493,12 @@ const walkBlock = (source, blockOffset, blockSource, findLine, notify) => {
430
493
  if (!selfClosing) stack.push(snapshot());
431
494
  }
432
495
 
496
+ if (defaulted > 0) {
497
+ notify(
498
+ `diagram-check: ${defaulted} text element(s) carry no font-size from an attribute, an enclosing tag, or a text/.class style rule — assumed ${DEFAULT_FONT_SIZE}px; set font-size on the <svg> tag so widths are read as drawn`,
499
+ );
500
+ }
501
+
433
502
  return { rects, texts, edges, viewBox };
434
503
  };
435
504
 
@@ -518,15 +587,39 @@ const pushTextBox = (texts, text, absX, absY, anchor, fontSize, lineNo) => {
518
587
  x1 = absX + width;
519
588
  }
520
589
 
521
- texts.push({ x0, y0: top, x1, y1: bottom, text, line: lineNo });
590
+ texts.push({ x0, y0: top, x1, y1: bottom, ax: absX, ay: absY, text, line: lineNo });
522
591
  };
523
592
 
524
- const processTextElement = ({ tag, rawContent, contentOffset, findLine, offX, offY, curFontSize, texts, lineNo }) => {
593
+ // The cascade a browser applies to a <text>, restricted to the shapes the
594
+ // style scan understands: a matching class rule beats a bare `text` rule,
595
+ // which beats the element's own attribute, which beats an inherited one.
596
+ // `inherited` is null when no enclosing tag carried a size; `onDefault` is
597
+ // called when the browser default has to stand in.
598
+ const resolveFontSize = (tag, inherited, styles, onDefault) => {
599
+ const classAttr = getAttr(tag, 'class');
600
+ if (classAttr) {
601
+ for (const cls of classAttr.trim().split(/\s+/)) {
602
+ if (styles.classes.has(cls)) return styles.classes.get(cls);
603
+ }
604
+ }
605
+
606
+ if (styles.base !== null) return styles.base;
607
+
608
+ const attr = getAttr(tag, 'font-size');
609
+ if (attr) return parseFloat(attr);
610
+
611
+ if (inherited !== null) return inherited;
612
+
613
+ onDefault();
614
+
615
+ return DEFAULT_FONT_SIZE;
616
+ };
617
+
618
+ const processTextElement = ({ tag, rawContent, contentOffset, findLine, offX, offY, curFontSize, styles, onDefault, texts, lineNo }) => {
525
619
  const x = parseFloat(getAttr(tag, 'x') ?? '0');
526
620
  const y = parseFloat(getAttr(tag, 'y') ?? '0');
527
621
  const anchor = getAttr(tag, 'text-anchor') ?? 'start';
528
- const fontSizeAttr = getAttr(tag, 'font-size');
529
- const fontSize = fontSizeAttr ? parseFloat(fontSizeAttr) : curFontSize;
622
+ const fontSize = resolveFontSize(tag, curFontSize, styles, onDefault);
530
623
 
531
624
  const tspanRe = /<tspan\b([^>]*)>([\s\S]*?)<\/tspan>/g;
532
625
  const tspans = [];
@@ -594,9 +687,12 @@ const findingsForBlock = ({ rects, texts, edges, viewBox }) => {
594
687
  const nodes = rects.filter((r) => !r.isBoundary);
595
688
 
596
689
  // Owned vs. free text, and each node's title (first owned text, in
597
- // document order).
690
+ // document order). A text belongs to the node its box sits in — or, when
691
+ // the box has outgrown the node, the node its anchor point sits in: a
692
+ // title too wide for its box is still that box's title, and reporting it
693
+ // as a free label straddling the node would misname the defect.
598
694
  for (const text of texts) {
599
- const owner = nodes.find((node) => fullyInside(text, node, 1));
695
+ const owner = nodes.find((node) => fullyInside(text, node, 1) || pointInBox(text.ax, text.ay, node));
600
696
  if (owner) {
601
697
  text.free = false;
602
698
  text.owner = owner;
@@ -666,11 +762,13 @@ const findingsForBlock = ({ rects, texts, edges, viewBox }) => {
666
762
  }
667
763
  }
668
764
 
669
- // 3. label-over-box (free text straddling a node's border)
765
+ // 3. label-over-box (a text box straddling the border of a node it does
766
+ // not belong to) and text-overflows-box (a node's own text wider or
767
+ // taller than the node, past the 1-unit tolerance)
670
768
  for (const text of texts) {
671
- if (!text.free) continue;
672
-
673
769
  for (const node of nodes) {
770
+ if (node === text.owner) continue;
771
+
674
772
  if (rectIntersect(shrinkBox(text, 1), node)) {
675
773
  findings.push({
676
774
  code: 'label-over-box',
@@ -679,6 +777,20 @@ const findingsForBlock = ({ rects, texts, edges, viewBox }) => {
679
777
  });
680
778
  }
681
779
  }
780
+
781
+ if (text.owner && !fullyInside(text, text.owner, 1)) {
782
+ const { owner } = text;
783
+ const over = Math.max(owner.x0 - text.x0, text.x1 - owner.x1, owner.y0 - text.y0, text.y1 - owner.y1);
784
+ const where =
785
+ owner.title === text.text
786
+ ? `its own ${fmt(owner.x1 - owner.x0)}×${fmt(owner.y1 - owner.y0)} box`
787
+ : describeNode(owner);
788
+ findings.push({
789
+ code: 'text-overflows-box',
790
+ line: text.line,
791
+ detail: `"${text.text}" overflows ${where} by ${fmt(over)}`,
792
+ });
793
+ }
682
794
  }
683
795
 
684
796
  // 4. edge-through-label (excluding a free text's own edge)
@@ -736,8 +848,9 @@ const findingsForBlock = ({ rects, texts, edges, viewBox }) => {
736
848
  // Public API
737
849
  // ---------------------------------------------------------------------------
738
850
 
739
- // Finds every <svg>…</svg> block in `source` and runs all six checks against
740
- // each. Returns Finding[] = { code, line, detail }[], sorted by line then code.
851
+ // Finds every <svg>…</svg> block in `source` and runs all seven checks
852
+ // against each. Returns Finding[] = { code, line, detail }[], sorted by line
853
+ // then code.
741
854
  export const checkSvg = (rawSource) => {
742
855
  // The kit's own marker block carries `<!-- usage: <line … /> -->`, and the
743
856
  // kit says to paste it verbatim — scanned raw, that comment is a phantom
@@ -745,6 +858,7 @@ export const checkSvg = (rawSource) => {
745
858
  // comments character-for-character (newlines kept) so line numbers hold.
746
859
  const source = rawSource.replace(/<!--[\s\S]*?-->/g, (comment) => comment.replace(/[^\n]/g, ' '));
747
860
  const findLine = makeLineFinder(source);
861
+ const styles = parseStyleFontSizes(source);
748
862
  let curveWarned = false;
749
863
  const notify = (msg) => process.stderr.write(msg + '\n');
750
864
  notify.curve = () => {
@@ -758,7 +872,7 @@ export const checkSvg = (rawSource) => {
758
872
  let m;
759
873
 
760
874
  while ((m = blockRe.exec(source))) {
761
- const parsed = walkBlock(source, m.index, m[0], findLine, notify);
875
+ const parsed = walkBlock(source, m.index, m[0], findLine, notify, styles);
762
876
  findings.push(...findingsForBlock(parsed));
763
877
  }
764
878
 
@@ -8,17 +8,36 @@ of the `<style>` block, verbatim.
8
8
 
9
9
  ## Shared Conventions
10
10
 
11
- - Canvas: `<svg viewBox="0 0 W H" style="max-width:100%">` sized to content;
12
- the page wraps it in the wireframe kit's `.frame` shell (either mode's).
11
+ - Canvas: `<svg viewBox="0 0 W H" font-size="13" style="max-width:100%">`
12
+ sized to content; the page wraps it in the wireframe kit's `.frame` shell
13
+ (either mode's). The `font-size` is the base every text inherits — see
14
+ Sizing below.
13
15
  - Palette is token roles, referenced with `var(--…)` (CSS variables work in
14
16
  inline SVG attributes): fills `var(--card)` / `var(--muted)`, strokes
15
- `var(--border)`, edges `var(--subtle)`, text `var(--foreground)`. The one
16
- accent is the `--primary` family — for the one thing the diagram is about
17
- (the new entity, the changed edge) — never more than one accent use.
17
+ `var(--border)`, edges `var(--subtle)`, text `var(--foreground)`. Node fills
18
+ and strokes come from the categorical set instead — see Visual Language.
19
+ - Accent: categorical colour is identity, never emphasis. `--primary` stays
20
+ the one accent, for the one thing the diagram is about (the new entity, the
21
+ changed edge): a 2.5px `var(--primary)` stroke and a `var(--primary-text)`
22
+ title over the node's role tint — never more than one accent use. Semantic
23
+ tokens (`--success`, `--warning`, `--destructive`, `--info`) are state only,
24
+ never role — a persistence-green node and a success-green node in one
25
+ diagram read as the same claim.
18
26
  - Text: `--sans` for titles, `--mono` for everything that is data — field
19
27
  rows, cardinalities, edge labels (D2). 13–14px labels, 11px annotations
20
28
  in `var(--muted-foreground)`. If a label doesn't fit its box, the box
21
29
  grows — never shrink the font below 11px.
30
+ - Sizing: the base size goes on the `<svg>` tag (`font-size="13"`), every
31
+ other size as a `font-size` attribute on the `<text>` itself. Never a
32
+ `text { font-size }` rule in `<style>` — in the browser it overrides every
33
+ `font-size` attribute in the diagram, so the sizes you wrote are not the
34
+ sizes that render. The checker resolves sizes the way the browser does but
35
+ sees only attributes, a bare `text` rule and `.class` rules; a text it
36
+ cannot size is read at the browser's 16px and reported on stderr. Budget
37
+ width at 0.6 em per character (exact for mono, generous for regular sans,
38
+ a little tight for semibold titles): at 16px a 23-character title needs
39
+ more than a 210 box, and when it doesn't fit the box grows — the checker
40
+ reports `text-overflows-box`, never a smaller font.
22
41
  - Arrowheads via one shared `<marker>`:
23
42
 
24
43
  ```html
@@ -36,6 +55,197 @@ of the `<style>` block, verbatim.
36
55
  - Every relationship line gets a label. An unlabeled arrow is a guess the
37
56
  reader has to make.
38
57
 
58
+ ## Visual Language
59
+
60
+ Three channels, one meaning each: **colour is role**, **mark is kind**, **dash
61
+ is protocol**. Two nodes in the same colour make a claim — that they play the
62
+ same part in the system — so colour is never picked for looks.
63
+
64
+ ### Roles
65
+
66
+ Fixed slots. A role always gets the same categorical token, in every diagram of
67
+ every design, because a per-diagram choice is decoration and a fixed table is a
68
+ language.
69
+
70
+ | Role | Slot | Applies to |
71
+ |---|---|---|
72
+ | frontend runtime | `--cat-4` | UI services, widget hosts, client-side stores |
73
+ | API / service | `--cat-2` | backend services, gateways, resolvers |
74
+ | persistence | `--cat-3` | databases, caches, object stores |
75
+ | external system | `--cat-7` | third parties, upstream sources, providers |
76
+ | messaging / queue / bus | `--cat-8` | brokers, streams, topics |
77
+ | actor / user | `--cat-6` | people and devices in flow diagrams |
78
+ | batch / scheduler | `--cat-5` | jobs, cron, pipelines |
79
+ | unassigned | `--cat-other` | anything the design has not named yet |
80
+
81
+ - Tint recipe, verbatim, as attributes on the tag:
82
+ `fill="var(--cat-N)" fill-opacity="0.10" stroke="var(--cat-N)"`.
83
+ Never move the stroke into a `.class` rule — the checker reads `stroke` off
84
+ the tag, and a class-styled node reads as a halo and vanishes from the check.
85
+ - At most five roles per diagram. A sixth means the diagram is answering two
86
+ questions (same rule as "Choosing the Shape") — split it, or fold the tail
87
+ roles into `--cat-other`.
88
+ - No new tokens. `--cat-1` … `--cat-8` and `--cat-other` are the whole set;
89
+ there is no muted variant, and inventing one crosses into the token core
90
+ every surface inherits.
91
+ - Opaque role fills are banned: body text loses contrast, worst on the dark
92
+ ground. `fill-opacity="0.10"` is the recipe, not a starting point.
93
+
94
+ ### Kinds
95
+
96
+ | Kind | Body | Mark | Border |
97
+ |---|---|---|---|
98
+ | service | rounded `<rect>` (`rx="6"`) | none | solid |
99
+ | store | rounded `<rect>` | `mark-store` | solid |
100
+ | external system | rounded `<rect>` | `mark-external` | dashed `6 4` |
101
+ | UI surface | rounded `<rect>` | `mark-ui` | solid |
102
+ | actor / user | pill `<rect>` (`rx` = height / 2) | `mark-actor` | solid |
103
+ | queue / bus | rounded `<rect>` | `mark-queue` | solid |
104
+ | batch / scheduler | rounded `<rect>` | `mark-batch` | solid |
105
+ | boundary | dashed `<rect>` behind its members | title strip | dashed `6 4` |
106
+
107
+ - **Invariant: a node's body is exactly one stroked `<rect>`.** Everything else
108
+ on it — halo, title strip, glyph — is a stroke-less `<rect>` or a `<use>`.
109
+ The checker reads `stroke` from the tag and treats a stroke-less rect as
110
+ decoration, so the one stroked rect *is* the node's footprint: a second
111
+ stroked rect inside it reclassifies the node as a boundary, and a body drawn
112
+ as a cylinder, hexagon or ellipse is invisible to the check — a false
113
+ `clean`, not a clever shape.
114
+ - No footprint-changing geometry. Kind is carried by the mark and the border,
115
+ never by a new body shape.
116
+
117
+ ### Marks
118
+
119
+ Paste this into the `<defs>` beside the arrow marker. `<defs>` content is never
120
+ walked by the checker, so a mark path is a glyph; the same path drawn in the
121
+ body of the svg is read as an unlabeled edge.
122
+
123
+ ```html
124
+ <g id="mark-store"><path d="M1,4 a8,3 0 1,0 16,0 a8,3 0 1,0 -16,0 M1,4 V13 a8,3 0 0,0 16,0 V4" fill="none" stroke="currentColor" stroke-width="1.3"/></g>
125
+ <g id="mark-external"><path d="M3,12 L15,3 M7,3 h8 v8" fill="none" stroke="currentColor" stroke-width="1.3"/></g>
126
+ <g id="mark-ui"><path d="M1,1 h16 v13 h-16 z M1,5 h16" fill="none" stroke="currentColor" stroke-width="1.3"/></g>
127
+ <g id="mark-actor"><path d="M9,2 a3,3 0 1,0 0.01,0 M2,15 a7,5 0 0,1 14,0" fill="none" stroke="currentColor" stroke-width="1.3"/></g>
128
+ <g id="mark-queue"><path d="M1,3 h12 M3,8 h12 M5,13 h12" fill="none" stroke="currentColor" stroke-width="1.3"/></g>
129
+ <g id="mark-batch"><path d="M2,8 a7,7 0 1,0 14,0 a7,7 0 1,0 -14,0 M9,4 V8 h4" fill="none" stroke="currentColor" stroke-width="1.3"/></g>
130
+ ```
131
+
132
+ - Every glyph is drawn on an 18×16 grid. Six marks for eight kinds: a service
133
+ has no mark (it is the unmarked default) and a boundary uses its title strip.
134
+ - Placement: `<use href="#mark-store" x="…" y="…" width="18" height="16"
135
+ style="color:var(--cat-N)"/>`, 14px in from the node's top-left — `x` =
136
+ node `x` + 14, `y` = node `y` + 12, the two extra pixels of headroom keeping
137
+ the glyph clear of the `rx="6"` corner. `currentColor` picks up the
138
+ `style="color:…"`, so a mark is always its node's role colour.
139
+ - A `<use>` has no footprint the checker can see, so a mark never collides
140
+ with anything. It can still collide visually: keep the node's title clear of
141
+ the glyph's 18px column.
142
+
143
+ ### Edges
144
+
145
+ | Edge means | Stroke width | Dash |
146
+ |---|---|---|
147
+ | synchronous call (HTTP, gRPC, GraphQL) | 1.5 | solid |
148
+ | in-process hand-off within a boundary | 1 | solid |
149
+ | asynchronous message or event (queue, stream, topic) | 1.5 | `6 4` |
150
+ | file, batch, or adapter protocol transfer | 1.5 | `2 3` |
151
+
152
+ - Stroke is always `var(--subtle)`. Colour never goes on an edge: colour is
153
+ role identity, and two channels on one line reads as noise.
154
+ - Everything from Layout and Routing still holds — the shared arrowhead, one
155
+ haloed `--mono` 11px label per segment, orthogonal paths, distinct ports. A
156
+ two-way edge adds `marker-start`; flow diagrams keep their hop numbers.
157
+ - Halo fill is `var(--card)`, everywhere (Layout and Routing rule 5).
158
+ Boundaries in this language are unfilled — only the title strip is tinted —
159
+ so the ground under every label is the frame's card surface.
160
+
161
+ ### Boundaries
162
+
163
+ - Body: a dashed `<rect>` in the boundary's role colour,
164
+ `fill="none" stroke="var(--cat-N)" stroke-dasharray="6 4" rx="4"`, drawn
165
+ behind its members.
166
+ - Title strip: a stroke-less tinted `<rect>` at the top of the boundary, full
167
+ width, **48 tall**, `fill="var(--cat-N)" fill-opacity="0.10" rx="4"`. It has
168
+ no stroke, so the checker reads it as decoration, not a nested node.
169
+ - Title inside the strip: `font-size="14" font-weight="600"
170
+ letter-spacing="0.04em"` in `var(--cat-N)`, 24px in from the boundary's left
171
+ edge, baseline at strip top + 30. A title strip makes a region scannable
172
+ zoomed out; a bare corner label reads at close range only.
173
+ - Members start **≥ 45px below the strip's bottom edge** — boundary top + 93.
174
+ Labels and their halos need only clear the band: halo top ≥ strip bottom.
175
+ A halo in the strip punches a `var(--card)` hole through the tint, so a
176
+ corridor that would put one there moves down, never the strip.
177
+
178
+ ### Legend
179
+
180
+ An HTML block under the `<svg>`, still inside `.frame` — never inside the svg,
181
+ where the checker scans it and stroked swatches become nodes. At most four
182
+ rows (role, kind, edge, accent), and only entries the diagram actually
183
+ contains: a legend row you cannot point at in the drawing is the five-role cap
184
+ failing out loud. The block below is the legend for the worked fragment at the
185
+ end of this section — three roles, three kinds, two edges, the accent.
186
+
187
+ ```html
188
+ <div class="legend">
189
+ <div><b>role</b><span><span class="sw" style="background:var(--cat-4);opacity:.35;border:1px solid var(--cat-4)"></span> frontend runtime</span><span><span class="sw" style="background:var(--cat-2);opacity:.35;border:1px solid var(--cat-2)"></span> API / service</span><span><span class="sw" style="background:var(--cat-8);opacity:.35;border:1px solid var(--cat-8)"></span> messaging</span></div>
190
+ <div><b>kind</b><span><span class="sw" style="border:1px solid var(--border);border-radius:4px"></span> service</span><span><svg width="18" height="16"><use href="#mark-store" width="18" height="16"/></svg> store</span><span><svg width="18" height="16"><use href="#mark-queue" width="18" height="16"/></svg> queue / bus</span></div>
191
+ <div><b>edge</b><span><i class="ln" style="border-top:1.5px solid var(--subtle)"></i> sync call</span><span><i class="ln" style="border-top:1.5px dashed var(--subtle)"></i> async message</span></div>
192
+ <div><b>accent</b><span><span class="sw" style="border:2.5px solid var(--primary);border-radius:4px"></span> the one thing this diagram is about</span></div>
193
+ </div>
194
+ <style>
195
+ .legend { display: grid; gap: var(--space-2); font-family: var(--mono); font-size: var(--text-xs); color: var(--muted-foreground); margin-top: var(--space-4); }
196
+ .legend b { color: var(--foreground); margin-right: var(--space-3); display: inline-block; width: 6ch; }
197
+ .legend span { margin-right: var(--space-4); display: inline-flex; align-items: center; gap: 6px; }
198
+ .legend .sw { display: inline-block; width: 14px; height: 14px; }
199
+ .legend .ln { display: inline-block; width: 28px; height: 0; }
200
+ </style>
201
+ ```
202
+
203
+ - The kind swatches `<use>` the same mark ids: one inline `<svg>` per row
204
+ entry, resolving against the `<defs>` in the diagram above it (same
205
+ document, so the fragment reference works).
206
+ - A CSS border cannot carry an SVG dash array, so an edge swatch approximates:
207
+ `6 4` is `dashed`, `2 3` is `dotted`. At 28px they read the same. Add the
208
+ `2 3` row only when the diagram has a file / batch / adapter edge in it.
209
+
210
+ ### A worked fragment
211
+
212
+ Two boundaries, four nodes, two protocols. Gutters are 72+, every edge is
213
+ orthogonal and leaves a 50% port, every label is haloed and clear of both strip
214
+ bands, and every node body is one stroked rect.
215
+
216
+ ```html
217
+ <!-- needs the arrow marker and the mark <defs> above. viewBox 0 0 692 352 -->
218
+ <rect x="20" y="20" width="280" height="312" rx="4" fill="none" stroke="var(--cat-4)" stroke-dasharray="6 4"/>
219
+ <rect x="20" y="20" width="280" height="48" rx="4" fill="var(--cat-4)" fill-opacity="0.10"/>
220
+ <text x="44" y="50" font-family="var(--sans)" font-size="14" font-weight="600" letter-spacing="0.04em" fill="var(--cat-4)">FRONTEND RUNTIME</text>
221
+ <rect x="392" y="20" width="280" height="312" rx="4" fill="none" stroke="var(--cat-2)" stroke-dasharray="6 4"/>
222
+ <rect x="392" y="20" width="280" height="48" rx="4" fill="var(--cat-2)" fill-opacity="0.10"/>
223
+ <text x="416" y="50" font-family="var(--sans)" font-size="14" font-weight="600" letter-spacing="0.04em" fill="var(--cat-2)">BACKEND REPORTING API</text>
224
+ <!-- members start at y=116, 48 below the strip that ends at y=68 (45 is the floor) -->
225
+ <rect x="44" y="116" width="232" height="56" rx="6" fill="var(--cat-4)" fill-opacity="0.10" stroke="var(--cat-4)"/>
226
+ <text x="160" y="150" text-anchor="middle" font-family="var(--sans)" font-size="14" font-weight="600" fill="var(--foreground)">Filter + query planner</text>
227
+ <rect x="44" y="252" width="232" height="56" rx="6" fill="var(--cat-4)" fill-opacity="0.10" stroke="var(--cat-4)"/>
228
+ <use href="#mark-store" x="58" y="264" width="18" height="16" style="color:var(--cat-4)"/>
229
+ <text x="160" y="286" text-anchor="middle" font-family="var(--sans)" font-size="14" font-weight="600" fill="var(--foreground)">Frontend catalogs</text>
230
+ <!-- the accent: role tint kept, 2.5px primary stroke, primary-text title -->
231
+ <rect x="416" y="116" width="232" height="56" rx="6" fill="var(--cat-2)" fill-opacity="0.10" stroke="var(--primary)" stroke-width="2.5"/>
232
+ <text x="532" y="150" text-anchor="middle" font-family="var(--sans)" font-size="14" font-weight="600" fill="var(--primary-text)">Reporting query API</text>
233
+ <rect x="416" y="252" width="232" height="56" rx="6" fill="var(--cat-8)" fill-opacity="0.10" stroke="var(--cat-8)"/>
234
+ <use href="#mark-queue" x="430" y="264" width="18" height="16" style="color:var(--cat-8)"/>
235
+ <text x="532" y="286" text-anchor="middle" font-family="var(--sans)" font-size="14" font-weight="600" fill="var(--foreground)">Export queue</text>
236
+ <!-- sync call: 1.5 solid, right 50% port to left 50% port, label 4px above -->
237
+ <path d="M 276 144 H 416" fill="none" stroke="var(--subtle)" stroke-width="1.5" marker-end="url(#arrow)"/>
238
+ <rect x="306" y="129" width="81" height="15" fill="var(--card)"/>
239
+ <text x="346" y="140" text-anchor="middle" font-family="var(--mono)" font-size="11" fill="var(--muted-foreground)">POST /query</text>
240
+ <!-- async message: 1.5 dashed 6 4, bottom 50% port to top 50% port -->
241
+ <path d="M 532 172 V 252" fill="none" stroke="var(--subtle)" stroke-width="1.5" stroke-dasharray="6 4" marker-end="url(#arrow)"/>
242
+ <rect x="465" y="205" width="134" height="15" fill="var(--card)"/>
243
+ <text x="532" y="216" text-anchor="middle" font-family="var(--mono)" font-size="11" fill="var(--muted-foreground)">queue: report.ready</text>
244
+ ```
245
+
246
+ Three roles, two kinds marked, one accent — the `### Legend` block above is
247
+ this fragment's legend, verbatim.
248
+
39
249
  ## Layout and Routing
40
250
 
41
251
  1. *Grid first.* Assign every node a column and a row before drawing anything.
@@ -59,11 +269,13 @@ of the `<style>` block, verbatim.
59
269
  `--mono` 11px: on a horizontal segment it sits 4px above the line, on a
60
270
  vertical segment it is centred on the line over its halo.
61
271
  Every label gets a halo so a crossing edge stays legible: a stroke-less
62
- `<rect>` in the fill the label sits on (`var(--background)` on open
63
- canvas, `var(--muted)` inside a filled boundary) drawn behind the text —
272
+ `<rect>` in `var(--card)` — the frame ground every diagram sits on — drawn
273
+ behind the text —
64
274
  the checker ignores stroke-less rects, so the halo is never mistaken for a
65
- node. Two labels never share a corridor position: stagger by 14px or move
66
- one to another segment.
275
+ node. Size the halo from the same budget as the label — characters × 11 ×
276
+ 0.6, plus 4px each side — a halo narrower than its label lets the crossing
277
+ edge show through the ends. Two labels never share a corridor position:
278
+ stagger by 14px or move one to another segment.
67
279
  6. *Fan-in is a smell.* More than four edges into one box, or more than three
68
280
  edges in one gutter, means the diagram is answering two questions — split
69
281
  it (same rule as "Choosing the Shape").
@@ -78,7 +290,8 @@ of the `<style>` block, verbatim.
78
290
  |---|---|
79
291
  | `edge-through-box` | an edge segment crosses a node rect it doesn't attach to |
80
292
  | `label-over-label` | two text boxes intersect |
81
- | `label-over-box` | a text box straddles a node rect border |
293
+ | `label-over-box` | a text box straddles the border of a node it doesn't belong to |
294
+ | `text-overflows-box` | a node's own text is wider or taller than the node |
82
295
  | `edge-through-label` | an edge crosses a text box that isn't its own label |
83
296
  | `unlabeled-edge` | no text within 24px of the edge |
84
297
  | `text-outside-canvas` | text escapes the viewBox |
@@ -136,9 +349,15 @@ labels carry cardinality (`1..*`, `0..1`).
136
349
  <text x="290" y="72" text-anchor="middle" font-size="11" fill="var(--muted-foreground)">1..*</text>
137
350
  ```
138
351
 
139
- An accented entity (the diagram's one accent) swaps its title-bar fill to
140
- `var(--primary-muted)`, its strokes to `var(--primary)`, and its title text to
141
- `var(--primary-text)`.
352
+ Visual Language on this family: the title bar carries the tint of the entity's
353
+ *owning* role (`fill="var(--cat-N)" fill-opacity="0.10"`, and `stroke="var(--cat-N)"`
354
+ on both rects), and the kind mark sits 14px in from the title bar's top-left —
355
+ so a reader sees which service owns which table before reading a field name.
356
+ The bar and the body are siblings, never nested: a bar drawn *inside* the body
357
+ rect makes the checker read the whole entity as a boundary.
358
+
359
+ An accented entity (the diagram's one accent) keeps its role tint and swaps its
360
+ strokes to 2.5px `var(--primary)` and its title text to `var(--primary-text)`.
142
361
 
143
362
  ## 2. Request / Data Flow
144
363
 
@@ -146,12 +365,18 @@ Actors and stores as boxes on one horizontal band; numbered edges show
146
365
  sequence. Number every hop (`1. POST /orders`, `2. validate`, `3. INSERT`) —
147
366
  the numbers are what make it a flow rather than a topology.
148
367
 
368
+ Visual Language on this family: actors on the band are pill rects
369
+ (`rx` = height / 2) in `--cat-6` with `mark-actor`, stores are rounded rects
370
+ with `mark-store` in their owning role's slot, and the hop numbers stay — kind
371
+ and colour say *what* each box is, the numbers still say *when*.
372
+
149
373
  ## 3. Architecture (boxes-and-arrows)
150
374
 
151
- Services as rounded rects (`rx="6"`), external systems as rects with a dashed
152
- stroke (`stroke-dasharray="4 3"`), boundaries (network, process, trust) as a
153
- large dashed `<rect>` behind its members with the boundary name in the top-left
154
- corner. Edges labeled with protocol or payload (`HTTP`, `queue: order.created`).
375
+ The family the Visual Language section is written for — it uses every channel:
376
+ services, stores, externals, UI surfaces, queues and batch jobs from the kinds
377
+ table; boundaries (network, process, trust) as large dashed rects with a title
378
+ strip; edges dashed by protocol and labeled with the protocol or payload
379
+ (`HTTP`, `queue: order.created`). Take every rule and both fences from there.
155
380
 
156
381
  ## 4. State Machine
157
382
 
@@ -160,6 +385,12 @@ with a filled dot and an arrow, terminal states with a double border (nested
160
385
  `<rect>` inset by 3px). Every transition label is the *event* that causes it,
161
386
  not a description of the target state.
162
387
 
388
+ Visual Language on this family: no role colour and no marks — a state is not a
389
+ component, so a categorical fill would claim a kinship that does not exist.
390
+ Neutral bodies (`var(--card)` / `var(--border)`), `--primary` on the one state
391
+ the diagram is about, and semantic tokens where a state *is* a state
392
+ (`--success` on settled, `--destructive` on failed).
393
+
163
394
  ## Choosing the Shape
164
395
 
165
396
  | The question being asked | Shape |