@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.
|
|
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,
|
|
8
|
-
// edge nobody labeled, and text that falls
|
|
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`, `
|
|
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
|
|
300
|
-
//
|
|
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
|
-
|
|
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
|
|
326
|
-
//
|
|
327
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
740
|
-
// each. Returns Finding[] = { code, line, detail }[], sorted by line
|
|
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%">`
|
|
12
|
-
the page wraps it in the wireframe kit's `.frame` shell
|
|
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)`.
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
63
|
-
|
|
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.
|
|
66
|
-
|
|
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
|
|
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
|
-
|
|
140
|
-
`var(--
|
|
141
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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 |
|