@brunosouzas/design-system 0.3.0 → 0.3.2

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/README.md CHANGED
@@ -25,7 +25,7 @@ The visual identity of [brunosouzas.com](https://brunosouzas.com): design tokens
25
25
 
26
26
  ## Status
27
27
 
28
- Early. The first change of the [roadmap](docs/roadmap.md) is implemented: the token source and its CSS, TypeScript and JSON exports, the motion components, and the circuit-breaker state machine as the first catalogue scene (16:9 and 4:5, Portuguese and English), with commands to render its stills and videos. The first variety scene, `publish-subscribe`, is implemented with independent message copies, Publish acceptance, two ACKs and a local invoice NACK. Its owner stills/video approval is pending; implementation is not visual approval. The illustration pieces (roadmap item 3), the metro and diagram pieces (item 6) and stackledger's map markers, zones and icons (AB#167, 0.3.0) are implemented: the static drawing library that covers, banners, diagrams and scenes share, with a command that builds a blog cover from a JSON spec. The published covers are not ported to it yet (item 5).
28
+ Early. The first change of the [roadmap](docs/roadmap.md) is implemented: the token source and its CSS, TypeScript and JSON exports, the motion components, and the circuit-breaker state machine as the first catalogue scene (16:9 and 4:5, Portuguese and English), with commands to render its stills and videos. The first variety scene, `publish-subscribe`, is implemented with independent message copies, Publish acceptance, two ACKs and a local invoice NACK. Its owner stills/video approval is pending; implementation is not visual approval. The illustration pieces (roadmap item 3), the metro and diagram pieces (item 6) stackledger's map markers, zones and icons (AB#167, 0.3.0) and the body diagram pieces (AB#141, 0.3.2) are implemented: the static drawing library that covers, banners, diagrams and scenes share, with a command that builds a blog cover from a JSON spec. The published covers are not ported to it yet (item 5).
29
29
 
30
30
  ## What it is
31
31
 
@@ -131,11 +131,11 @@ The components take every colour and size from the tokens, and what is visible a
131
131
  - `toSvg(node)` writes a tree as SVG text; `toReact(node)` turns the same tree into React elements. Both describe the same elements, attributes and text, which the tests check.
132
132
  - `pose(node, { x, y, tilt, squash, hop, scale, shake, opacity })` animates any piece around its base point; a scene recomputes the numbers every frame.
133
133
  - Every colour parameter is a token path (`"color.healthy"`, `"illustration.accent"`), never a hex value, except the three data fills of the map pieces below. Each piece checks its parameters before drawing and names the first wrong one. Positions and points take any finite number, because a map has no edge; sizes stay within 10000. A drawing that would hold a non-finite number throws `<piece>: the drawing overflows`.
134
- - The 41 pieces, with their anchors, parameters and defaults, are listed in [design §4](openspec/changes/archive/2026-10-23-illustration-pieces/design.md) (the 26 cover pieces), in [design §3](openspec/changes/archive/2026-10-23-metro-and-diagram-pieces/design.md) of `metro-and-diagram-pieces` (the nine map and diagram pieces) and in [design §3, §5 and §6](openspec/changes/archive/2026-10-23-stackledger-map-and-link-pieces/design.md) of `stackledger-map-and-link-pieces` (the six markers, zones and icons); the rules are in [`openspec/specs/illustration-pieces/`](openspec/specs/illustration-pieces/spec.md). `npm run pieces:sheet -- --out <file.svg>` draws them all.
134
+ - The 51 pieces, with their anchors, parameters and defaults, are listed in [design §4](openspec/changes/archive/2026-10-23-illustration-pieces/design.md) (the 26 cover pieces), in [design §3](openspec/changes/archive/2026-10-23-metro-and-diagram-pieces/design.md) of `metro-and-diagram-pieces` (the nine map and diagram pieces) and in [design §3, §5 and §6](openspec/changes/archive/2026-10-23-stackledger-map-and-link-pieces/design.md) of `stackledger-map-and-link-pieces` (the six markers, zones and icons) and in [design §3 to §5](openspec/changes/archive/2026-10-23-body-diagram-pieces/design.md) of `body-diagram-pieces` (the ten body diagram pieces); the rules are in [`openspec/specs/illustration-pieces/`](openspec/specs/illustration-pieces/spec.md). `npm run pieces:sheet -- --out <file.svg>` draws them all.
135
135
 
136
136
  ### Maps and diagrams
137
137
 
138
- Thirteen pieces draw a metro map and its page diagrams, for [stackledger](https://github.com/brunosouzas/stackledger) and any other map in this style: `lineBadge`, `stationMarker` (stop, interchange as a capsule, deprecated), `pipe`, `countBadge`, `nodeBlock`, `pillNode`, `hexNode` (normal, focus, placeholder), `messageArrow` (curved or straight, with a head and an optional label) `ledgerMark` (stackledger's mark; `surface: "dark"` lines it with a light rim), `renewalRing` (orange bands round a station), `suggestedHalo` (a dashed muted circle), `licenceMark` (a 10-unit disc, green when held, white to buy) and `zone` (the outline the caller gives, widened by a round 32-unit stroke at the surface's zone opacity, with an optional title). They paint what the caller lays out: positions, hulls, text measurement and truncation stay with the map engine.
138
+ Thirteen pieces draw a metro map and its page diagrams, for [stackledger](https://github.com/brunosouzas/stackledger) and any other map in this style: `lineBadge`, `stationMarker` (stop, interchange as a capsule, deprecated), `pipe`, `countBadge`, `nodeBlock`, `pillNode`, `hexNode` (normal, focus, placeholder), `messageArrow` (curved, straight or through two `controls` points, with a head and an optional label; `kind` `reply`, `failure` or `failFast` dashes it and paints it as ADR 0003 says), `ledgerMark` (stackledger's mark; `surface: "dark"` lines it with a light rim), `renewalRing` (orange bands round a station), `suggestedHalo` (a dashed muted circle), `licenceMark` (a 10-unit disc, green when held, white to buy) and `zone` (the outline the caller gives, widened by a round 32-unit stroke at the surface's zone opacity, with an optional title). They paint what the caller lays out: positions, hulls, text measurement and truncation stay with the map engine.
139
139
 
140
140
  - A `placeholder` node may have a `subtitle` (drawn like a normal node's, all muted), and `lineBadge` may have no `text` (the pill alone, 30 wide by default).
141
141
  - The licence's disc is small, so it has its own room rule: `size` at least three outlines (9 at ink scale 1).
@@ -143,7 +143,8 @@ Thirteen pieces draw a metro map and its page diagrams, for [stackledger](https:
143
143
  - `surface: "light" | "dark"` picks the background they sit on (`surface.*` tokens); every map and diagram piece takes it except `pipe`; `ledgerMark` takes only `surface`. On dark the hard shadow takes `surface.dark.shadow` and the outline gets a light rim ([ADR 0003](docs/adr/0003-visual-language-and-colour-meaning.md), amendment of AB#157).
144
144
  - `inkScale` (0.25 to 8) thickens the outline, rim and shadow without changing the geometry, on `lineBadge`, `stationMarker`, `countBadge` and the three nodes; the map uses 1, the page diagrams 1.6. A shape's height (a marker's diameter) must be at least four outlines; a placeholder node, which draws no ink, is exempt.
145
145
  - A line's colour comes from the map's data, so `pipe.fill`, `lineBadge.fill` and `zone.fill` also accept `#RRGGBB`. Every other colour is a token path, and a cover spec rejects any hex.
146
- - The `map.*` tokens hold the measures (outline 3, shadow 3.5, line 12, casing 6, badge 20, node 46, ring 3, halo 1.5, licence 10, zone padding 32…), `surface.*.zoneOpacity` the zone's opacity (0.12, dark 0.22), and `site.*` the page palette, fonts and type scale of brunosouzas.com in light and dark.
146
+ - `map.bendRadius` (32) is the radius of a bundle's centre line at a 45° or 90° bend; no piece reads it, the map engine draws the arcs (`pipe.d` takes `A`).
147
+ - The `map.*` tokens hold the measures (outline 3, shadow 3.5, line 12, casing 6, badge 20, node 46, ring 3, halo 1.5, licence 10, zone padding 32, bend radius 32…), `surface.*.zoneOpacity` the zone's opacity (0.12, dark 0.22), and `site.*` the page palette, fonts and type scale of brunosouzas.com in light and dark.
147
148
 
148
149
  ```ts
149
150
  import { lineBadge, pipe, stationMarker, toSvg } from "@brunosouzas/design-system";
@@ -152,6 +153,15 @@ const stop = stationMarker({ cx: 120, cy: 40, surface: "dark" });
152
153
  const badge = lineBadge({ x: 250, y: 40, text: "SH", fill: "#7B3FE4", surface: "dark" });
153
154
  ```
154
155
 
156
+ ### Body diagrams
157
+
158
+ Ten pieces draw what the blog's body diagrams need beyond the map pieces: `diagramBox` (a box or pill with a fill and centred lines), `decisionNode` (diamond), `storeNode` (cylinder), `queueNode` (box with two rails), `exchangeNode` (hexagon), `taggedContainer` (a frame with its name on a tab), `lifeline` (dashed, or sparse dots once nothing calls it), `phaseBand` (a pill across a sequence in its state's colour), `altFrame` (alt/else frame with a tag and condition per part) and `edgeLabel` (words next to an arrow, optionally on a backdrop). They take `surface` and, the stickers, `inkScale`, like the map pieces, and paint what the caller lays out.
159
+
160
+ - The shapes take one to three centred lines (`text`), an optional `subtitle` and `textSize`. Without `fill` the face is the surface's panel; with a fill, every word is ink.
161
+ - Backticks mark code: `` "adopts a `correlationId`" `` draws `correlationId` in the monospace stack, as a `tspan`. An odd number of backticks is drawn as is.
162
+ - Widths the piece estimates (`tagWidth`, `conditionWidth`, a label's `width`) can be passed by a caller that measures its text.
163
+ - A body diagram is 800 wide. Lay it out in 560 units and show it 800 wide: the pieces then come out at the sizes of the published diagrams (text about 18, outline about 4); only the hard shadow is shallower (5 instead of 8).
164
+
155
165
  ### A cover
156
166
 
157
167
  A cover is a JSON spec kept with its article, outside this repository: drafts are private, and only the pieces are public. `npm run cover -- <spec.json> --out <file.svg>` validates it, reports every problem with its JSON path, and writes an 800x420 SVG; the same spec always gives the same bytes. With `"format": "banner"` the same command writes a site banner instead (see below).
@@ -191,7 +201,7 @@ Add it to `src/pieces/library.ts` (or `src/pieces/metro.ts` for a map or diagram
191
201
 
192
202
  ### UI and link icons
193
203
 
194
- - `uiIcon({ name, x, y, size, colour })` draws one of seven glyphs of the sites' chrome in a 24-unit box and one token colour: `sun`, `moon`, `zoomIn`, `zoomOut`, `reset`, `calendar`, `download` (data in `UI_ICONS`).
204
+ - `uiIcon({ name, x, y, size, colour })` draws one of seven glyphs of the sites' chrome in a 24-unit box and one token colour: `sun`, `moon`, `zoomIn`, `zoomOut`, `reset`, `calendar`, `download` (data in `UI_ICONS`). `colour: "currentColor"` makes the icon follow its element's text colour (a button that inverts on hover, say); it only works when the SVG is inline in the page, since an `<img>` does not inherit the page's colour, and a cover spec refuses it.
195
205
  - `linkIcon({ icon, x, y, size, surface })` draws a link sticker as brunosouzas.com draws its social icons (46-unit box, light or dark frame): `github`, `linkedin`, `email`, `website` (data in `LINK_ICONS`). The GitHub mark is from [Font Awesome](https://fontawesome.com/), licensed [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
196
206
  - Neither holds a link, a style, a script or a URL, so a consumer can write one with `toSvg` inside its own `<svg xmlns=…>` and ship it as a static file.
197
207
 
@@ -10,7 +10,7 @@
10
10
  import { tokens } from "../../tokens/generated/tokens.js";
11
11
  import { PIECE_NAMES, PIECES, rings } from "./library.js";
12
12
  import { el, g } from "./node.js";
13
- import { COLOUR_TOKENS, checkParams, isColourLiteral, MAX_SIZE, PATH_DATA } from "./params.js";
13
+ import { COLOUR_TOKENS, CURRENT_COLOUR, checkParams, isColourLiteral, MAX_SIZE, PATH_DATA } from "./params.js";
14
14
  export const SAFE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
15
15
  const TOP_LEVEL = ["id", "title", "description", "format", "background", "rings", "pieces", "custom"];
16
16
  /** Cover (800x420, the default) or banner (1200x420, rounded corners): spec cover-cli, "Banner output". */
@@ -191,6 +191,12 @@ export const validateCoverSpec = (spec) => {
191
191
  return void out.push({ path: `${at}.piece`, message: "rings belong to the cover's rings field" });
192
192
  const definition = PIECES[piece];
193
193
  const own = checkParams(definition.schema, params, at);
194
+ // A cover is a standalone image: currentColor (accepted by uiIcon for inline page icons) is refused.
195
+ for (const [name, spec] of Object.entries(definition.schema)) {
196
+ if (spec.kind === "color" && spec.currentColor && params[name] === CURRENT_COLOUR) {
197
+ own.push({ path: `${at}.${name}`, message: "currentColor is for icons inline in a page; a cover uses a token path" });
198
+ }
199
+ }
194
200
  out.push(...own);
195
201
  // Limits between parameters are reported with the schema's problems, except on a parameter
196
202
  // already reported; a relation that cannot read a broken parameter reports nothing.
@@ -0,0 +1,128 @@
1
+ import { type Ink, type SurfaceName } from "./ink.ts";
2
+ export type ShapeParams = Ink & {
3
+ x: number;
4
+ y: number;
5
+ width: number;
6
+ height?: number;
7
+ text: string[];
8
+ subtitle?: string;
9
+ fill?: string;
10
+ textSize?: number;
11
+ };
12
+ export type DiagramBoxParams = ShapeParams & {
13
+ radius?: number;
14
+ shape?: "box" | "pill";
15
+ };
16
+ /** A box or a pill with a fill and centred lines: a body diagram's step, system or note. */
17
+ export declare const diagramBox: import("./piece.ts").Piece<DiagramBoxParams>;
18
+ /** A decision: a diamond in its bounds. */
19
+ export declare const decisionNode: import("./piece.ts").Piece<Ink & {
20
+ x: number;
21
+ y: number;
22
+ width: number;
23
+ height?: number;
24
+ text: string[];
25
+ subtitle?: string;
26
+ fill?: string;
27
+ textSize?: number;
28
+ } & {
29
+ height: number;
30
+ }>;
31
+ export type StoreNodeParams = ShapeParams & {
32
+ height: number;
33
+ ry?: number;
34
+ };
35
+ /** A store: a cylinder in its bounds, with the front of its lid. */
36
+ export declare const storeNode: import("./piece.ts").Piece<StoreNodeParams>;
37
+ /** A queue: a box with a rail 10 in from each side. */
38
+ export declare const queueNode: import("./piece.ts").Piece<ShapeParams>;
39
+ /** An exchange: a hexagon with its points 0.26 × height in, as the motion shape (src/brand/flow-shapes.ts). */
40
+ export declare const exchangeNode: import("./piece.ts").Piece<ShapeParams>;
41
+ export type TaggedContainerParams = Ink & {
42
+ x: number;
43
+ y: number;
44
+ width: number;
45
+ height: number;
46
+ tag: string;
47
+ tagWidth?: number;
48
+ tagFill?: string;
49
+ fill?: string;
50
+ };
51
+ /** A container (a platform, a broker) with its name on a tab centred on the top edge. */
52
+ export declare const taggedContainer: import("./piece.ts").Piece<TaggedContainerParams>;
53
+ export type LifelineParams = {
54
+ x: number;
55
+ y1: number;
56
+ y2: number;
57
+ state?: "active" | "inactive";
58
+ width?: number;
59
+ surface?: SurfaceName;
60
+ };
61
+ /** A participant's lifeline: dashed while active, sparse dots once nothing calls it. */
62
+ export declare const lifeline: import("./piece.ts").Piece<LifelineParams>;
63
+ export type PhaseBandParams = Ink & {
64
+ x: number;
65
+ y: number;
66
+ width: number;
67
+ height?: number;
68
+ text: string;
69
+ fill: string;
70
+ textSize?: number;
71
+ };
72
+ /** A phase of a sequence: a pill across the diagram in its state's colour. */
73
+ export declare const phaseBand: import("./piece.ts").Piece<PhaseBandParams>;
74
+ type Head = {
75
+ tag: string;
76
+ condition?: string;
77
+ tagWidth?: number;
78
+ conditionWidth?: number;
79
+ };
80
+ export type AltFrameParams = Head & {
81
+ x: number;
82
+ y: number;
83
+ width: number;
84
+ height: number;
85
+ sections?: (Head & {
86
+ y: number;
87
+ })[];
88
+ surface?: SurfaceName;
89
+ };
90
+ /** An alt/else frame: the frame, a dashed divider per section, and a tag (with its condition) per part. */
91
+ export declare const altFrame: import("./piece.ts").Piece<AltFrameParams>;
92
+ export type EdgeLabelParams = {
93
+ x: number;
94
+ y: number;
95
+ lines: string[];
96
+ anchor?: "start" | "middle" | "end";
97
+ backdrop?: boolean;
98
+ width?: number;
99
+ textSize?: number;
100
+ surface?: SurfaceName;
101
+ };
102
+ /** Words next to an arrow: lines down from the baseline (x, y), optionally on a backdrop that hides what is behind. */
103
+ export declare const edgeLabel: import("./piece.ts").Piece<EdgeLabelParams>;
104
+ /** The body diagram pieces, in catalogue order (design §3). */
105
+ export declare const DIAGRAM_PIECES: {
106
+ readonly diagramBox: import("./piece.ts").Piece<DiagramBoxParams>;
107
+ readonly decisionNode: import("./piece.ts").Piece<Ink & {
108
+ x: number;
109
+ y: number;
110
+ width: number;
111
+ height?: number;
112
+ text: string[];
113
+ subtitle?: string;
114
+ fill?: string;
115
+ textSize?: number;
116
+ } & {
117
+ height: number;
118
+ }>;
119
+ readonly storeNode: import("./piece.ts").Piece<StoreNodeParams>;
120
+ readonly queueNode: import("./piece.ts").Piece<ShapeParams>;
121
+ readonly exchangeNode: import("./piece.ts").Piece<ShapeParams>;
122
+ readonly taggedContainer: import("./piece.ts").Piece<TaggedContainerParams>;
123
+ readonly lifeline: import("./piece.ts").Piece<LifelineParams>;
124
+ readonly phaseBand: import("./piece.ts").Piece<PhaseBandParams>;
125
+ readonly altFrame: import("./piece.ts").Piece<AltFrameParams>;
126
+ readonly edgeLabel: import("./piece.ts").Piece<EdgeLabelParams>;
127
+ };
128
+ export {};
@@ -0,0 +1,198 @@
1
+ // Body diagram pieces (spec illustration-pieces, change body-diagram-pieces,
2
+ // design §3 and §4): the shapes, lines and labels of the blog's body diagrams
3
+ // that the metro and diagram pieces lack. Like those, they paint what the caller
4
+ // lays out; positions, text measurement and truncation stay with the caller.
5
+ //
6
+ // What can go wrong: every parameter is checked by its schema first; the limits
7
+ // between parameters are checked in the order of design §3 and the first one is
8
+ // thrown as a RangeError naming the parameter.
9
+ import { tokens } from "../../tokens/generated/tokens.js";
10
+ import { inkOf, inkSchema, limits, roomFor, sticker, surfaceParam } from "./ink.js";
11
+ import { drawn, el, r2 } from "./node.js";
12
+ import { col, colour, label, MAX_SIZE, pos, size } from "./params.js";
13
+ import { definePiece } from "./piece.js";
14
+ const ink = tokens.color.ink;
15
+ const font = tokens.font.sans;
16
+ const bold = tokens.font.weightBold;
17
+ const M = tokens.map;
18
+ const lines = (max) => ({ kind: "array", required: true, minItems: 1, maxItems: max, of: label() });
19
+ const textSize = { kind: "number", min: 6, max: 48 };
20
+ // ---------- words ----------
21
+ /** Characters of a word for a width estimate: backticks do not count. */
22
+ const characters = (value) => Array.from(value.replace(/`/g, "")).length;
23
+ /** Backticks mark code (design §4): with an even number of them, each part between backticks becomes a tspan in
24
+ * the monospace stack; empty parts are dropped. With an odd number, the string is drawn as is. */
25
+ const spans = (value) => {
26
+ const parts = value.split("`");
27
+ if (parts.length % 2 === 0)
28
+ return [value];
29
+ return parts.flatMap((part, i) => (part === "" ? [] : i % 2 === 1 ? [el("tspan", { "font-family": tokens.font.mono }, part)] : [part]));
30
+ };
31
+ // Every text sets its own anchor, size and weight, so a surrounding group (a cover's label style) does not leak in.
32
+ const words = (x, y, value, attrs) => el("text", { x: r2(x), y: r2(y), "font-family": font, ...attrs }, ...spans(value));
33
+ /** No fill: the surface's panel with its text; a fill: that token with ink words (ADR 0003). */
34
+ const coloursOf = (fill, params) => {
35
+ const { s } = inkOf(params);
36
+ return fill === undefined ? { face: s.panel, text: s.text, sub: s.muted } : { face: colour(fill, "fill"), text: ink, sub: ink };
37
+ };
38
+ /** Centred lines (bold) and an optional subtitle (11, regular), centred on (cx, cy). */
39
+ const centred = (cx, cy, text, subtitle, c, size_ = M.labelSize) => {
40
+ const step = size_ * 1.25;
41
+ const block = (text.length - 1) * step + (subtitle === undefined ? 0 : 15);
42
+ const first = cy + (4.5 * size_) / 13 - block / 2;
43
+ return [
44
+ ...text.map((line, i) => words(cx, first + i * step, line, { fill: c.text, "font-size": size_, "font-weight": bold, "text-anchor": "middle" })),
45
+ ...(subtitle === undefined ? [] : [words(cx, first + (text.length - 1) * step + 15, subtitle, { fill: c.sub, "font-size": 11, "font-weight": 400, "text-anchor": "middle" })]),
46
+ ];
47
+ };
48
+ /** Room on the smaller of width and height, naming the smaller one. */
49
+ const roomOn = (width, height, params) => width < height ? roomFor("width", width, params) : roomFor("height", height, params);
50
+ const polygon = (points) => (dx, dy, attrs) => el("polygon", { points: points.map(([px, py]) => `${r2(px + dx)},${r2(py + dy)}`).join(" "), ...attrs });
51
+ const rect = (x, y, w, h, rx) => (dx, dy, attrs) => el("rect", { x: r2(x + dx), y: r2(y + dy), width: r2(w), height: r2(h), rx: r2(rx), ...attrs });
52
+ const shapeSchema = (heightRequired) => ({
53
+ x: pos(),
54
+ y: pos(),
55
+ width: size(true),
56
+ height: size(heightRequired),
57
+ text: lines(3),
58
+ subtitle: label(false),
59
+ fill: col(),
60
+ textSize,
61
+ ...inkSchema,
62
+ });
63
+ /** A box or a pill with a fill and centred lines: a body diagram's step, system or note. */
64
+ export const diagramBox = definePiece("diagramBox", { ...shapeSchema(false), radius: { kind: "number", min: 0, max: MAX_SIZE }, shape: { kind: "enum", values: ["box", "pill"] } }, (params) => {
65
+ const { x, y, width, height = M.nodeHeight, text, subtitle, fill, shape = "box" } = params;
66
+ const radius = shape === "pill" ? height / 2 : (params.radius ?? Math.min(M.nodeRadius, height / 2));
67
+ const c = coloursOf(fill, params);
68
+ return drawn(sticker(rect(x, y, width, height, radius), c.face, params), centred(x + width / 2, y + height / 2, text, subtitle, c, params.textSize));
69
+ }, (params) => {
70
+ const height = params.height ?? M.nodeHeight;
71
+ return limits(roomOn(params.width, height, params), params.shape === "pill" && params.radius !== undefined && { path: "radius", message: "cannot be combined with shape pill" }, params.radius !== undefined && params.radius > height / 2 && { path: "radius", message: `must be at most half the height (${r2(height / 2)}), found ${r2(params.radius)}` });
72
+ });
73
+ /** A decision: a diamond in its bounds. */
74
+ export const decisionNode = definePiece("decisionNode", shapeSchema(true), (params) => {
75
+ const { x, y, width: w, height: h, text, subtitle, fill } = params;
76
+ const c = coloursOf(fill, params);
77
+ const shape = polygon([[x + w / 2, y], [x + w, y + h / 2], [x + w / 2, y + h], [x, y + h / 2]]);
78
+ return drawn(sticker(shape, c.face, params), centred(x + w / 2, y + h / 2, text, subtitle, c, params.textSize));
79
+ }, (params) => limits(roomOn(params.width, params.height, params)));
80
+ /** A store: a cylinder in its bounds, with the front of its lid. */
81
+ export const storeNode = definePiece("storeNode", { ...shapeSchema(true), ry: size() }, (params) => {
82
+ const { x, y, width: w, height: h, text, subtitle, fill, ry = 8 } = params;
83
+ const c = coloursOf(fill, params);
84
+ const { outline } = inkOf(params);
85
+ const rx = r2(w / 2);
86
+ const body = (dx, dy, attrs) => el("path", { d: `M${r2(x + dx)},${r2(y + ry + dy)} A${rx},${ry} 0 0 1 ${r2(x + w + dx)},${r2(y + ry + dy)} V${r2(y + h - ry + dy)} A${rx},${ry} 0 0 1 ${r2(x + dx)},${r2(y + h - ry + dy)} Z`, ...attrs });
87
+ const lid = el("path", { d: `M${r2(x)},${r2(y + ry)} A${rx},${ry} 0 0 0 ${r2(x + w)},${r2(y + ry)}`, fill: "none", stroke: ink, "stroke-width": r2(0.75 * outline) });
88
+ return drawn([...sticker(body, c.face, params), lid], centred(x + w / 2, y + (h + ry) / 2, text, subtitle, c, params.textSize));
89
+ }, (params) => {
90
+ const ry = params.ry ?? 8;
91
+ return limits(roomOn(params.width, params.height, params), ry > params.height / 4 && { path: "ry", message: `must be at most a quarter of the height (${r2(params.height / 4)}), found ${r2(ry)}` });
92
+ });
93
+ const RAIL = 10;
94
+ /** A queue: a box with a rail 10 in from each side. */
95
+ export const queueNode = definePiece("queueNode", shapeSchema(false), (params) => {
96
+ const { x, y, width: w, height: h = M.nodeHeight, text, subtitle, fill } = params;
97
+ const c = coloursOf(fill, params);
98
+ const { outline } = inkOf(params);
99
+ const rails = el("path", { d: `M${r2(x + RAIL)},${r2(y)} V${r2(y + h)} M${r2(x + w - RAIL)},${r2(y)} V${r2(y + h)}`, fill: "none", stroke: ink, "stroke-width": r2(0.75 * outline) });
100
+ return drawn([...sticker(rect(x, y, w, h, tokens.radius.small), c.face, params), rails], centred(x + w / 2, y + h / 2, text, subtitle, c, params.textSize));
101
+ }, (params) => {
102
+ const height = params.height ?? M.nodeHeight;
103
+ const least = Math.max(height, 2 * RAIL + 4 * inkOf(params).outline);
104
+ return limits(roomFor("height", height, params), params.width < least && { path: "width", message: `must be at least the height and leave room for the rails (${r2(least)}), found ${r2(params.width)}` });
105
+ });
106
+ /** An exchange: a hexagon with its points 0.26 × height in, as the motion shape (src/brand/flow-shapes.ts). */
107
+ export const exchangeNode = definePiece("exchangeNode", shapeSchema(false), (params) => {
108
+ const { x, y, width: w, height: h = M.nodeHeight, text, subtitle, fill } = params;
109
+ const c = coloursOf(fill, params);
110
+ const k = 0.26 * h;
111
+ const shape = polygon([[x + k, y], [x + w - k, y], [x + w, y + h / 2], [x + w - k, y + h], [x + k, y + h], [x, y + h / 2]]);
112
+ return drawn(sticker(shape, c.face, params), centred(x + w / 2, y + h / 2, text, subtitle, c, params.textSize));
113
+ }, (params) => {
114
+ const height = params.height ?? M.nodeHeight;
115
+ return limits(roomFor("height", height, params), params.width < height && { path: "width", message: `must be at least the height (${r2(height)}), found ${r2(params.width)}` });
116
+ });
117
+ // ---------- container ----------
118
+ const TAB = 24;
119
+ const tabWidth = (params) => params.tagWidth ?? 7.2 * characters(params.tag) + 24;
120
+ /** A container (a platform, a broker) with its name on a tab centred on the top edge. */
121
+ export const taggedContainer = definePiece("taggedContainer", { x: pos(), y: pos(), width: size(true), height: size(true), tag: label(), tagWidth: size(), tagFill: col(), fill: col(), ...inkSchema }, (params) => {
122
+ const { x, y, width, height, tag, tagFill = "color.transitional", fill } = params;
123
+ const tw = tabWidth(params);
124
+ const c = coloursOf(fill, params);
125
+ return drawn([...sticker(rect(x, y, width, height, M.nodeRadius), c.face, params), ...sticker(rect(x + 14, y - TAB / 2, tw, TAB, TAB / 2), colour(tagFill, "tagFill"), params)], [words(x + 14 + tw / 2, y + 4.5, tag, { fill: ink, "font-size": M.badgeTextSize, "font-weight": bold, "text-anchor": "middle" })]);
126
+ }, (params) => {
127
+ const tw = tabWidth(params);
128
+ const need = 4 * inkOf(params).outline;
129
+ return limits(roomOn(params.width, params.height, params), tw < TAB && { path: "tagWidth", message: `must be at least the tab's height (${TAB}), found ${r2(tw)}` }, TAB < need && { path: "inkScale", message: `leaves no room for the tab's outline: the tab is ${TAB} high and needs ${r2(need)}` }, params.width < tw + 28 && { path: "width", message: `leaves no room for the tag: needs at least ${r2(tw + 28)}` });
130
+ });
131
+ /** A participant's lifeline: dashed while active, sparse dots once nothing calls it. */
132
+ export const lifeline = definePiece("lifeline", { x: pos(), y1: pos(), y2: pos(), state: { kind: "enum", values: ["active", "inactive"] }, width: { kind: "number", min: 0, exclusiveMin: true, max: 12 }, surface: surfaceParam }, ({ x, y1, y2, state = "active", width = 1.75, surface = "light" }) => {
133
+ const active = state === "active";
134
+ // Width as given, like pipe's: a valid hairline must not round to zero.
135
+ return drawn([
136
+ el("line", { x1: r2(x), y1: r2(y1), x2: r2(x), y2: r2(y2), stroke: tokens.surface[surface].muted, "stroke-width": width, "stroke-dasharray": active ? "5 5" : "1 6", "stroke-linecap": active ? "butt" : "round" }),
137
+ ]);
138
+ }, ({ y1, y2 }) => limits(y1 === y2 && { path: "y2", message: "must differ from y1" }));
139
+ /** A phase of a sequence: a pill across the diagram in its state's colour. */
140
+ export const phaseBand = definePiece("phaseBand", { x: pos(), y: pos(), width: size(true), height: size(), text: label(), fill: { kind: "color", required: true }, textSize, ...inkSchema }, (params) => {
141
+ const { x, y, width, height = 26, text, fill, textSize: size_ = M.labelSize } = params;
142
+ return drawn(sticker(rect(x, y, width, height, height / 2), colour(fill, "fill"), params), [
143
+ words(x + width / 2, y + height / 2 + (4.5 * size_) / 13, text, { fill: ink, "font-size": size_, "font-weight": bold, "text-anchor": "middle" }),
144
+ ]);
145
+ }, (params) => {
146
+ const height = params.height ?? 26;
147
+ return limits(roomFor("height", height, params), params.width < height && { path: "width", message: `must be at least the height (${r2(height)}), found ${r2(params.width)}` });
148
+ });
149
+ /** An alt/else frame: the frame, a dashed divider per section, and a tag (with its condition) per part. */
150
+ export const altFrame = definePiece("altFrame", {
151
+ x: pos(),
152
+ y: pos(),
153
+ width: size(true),
154
+ height: size(true),
155
+ tag: label(),
156
+ condition: label(false),
157
+ tagWidth: size(),
158
+ conditionWidth: size(),
159
+ sections: {
160
+ kind: "array",
161
+ maxItems: 10,
162
+ of: { kind: "object", fields: { y: pos(), tag: label(), condition: label(false), tagWidth: size(), conditionWidth: size() } },
163
+ },
164
+ surface: surfaceParam,
165
+ }, ({ x, y, width, height, sections = [], surface = "light", ...first }) => {
166
+ const s = tokens.surface[surface];
167
+ const head = (top, { tag, condition, tagWidth, conditionWidth }) => {
168
+ const tw = tagWidth ?? 6.4 * characters(tag) + 14;
169
+ const body = [el("rect", { x: r2(x + 7), y: r2(top + 6), width: r2(tw), height: 17, rx: 4, fill: s.text, stroke: "none" })];
170
+ const text = [words(x + 7 + tw / 2, top + 18.5, tag, { fill: s.panel, "font-size": 10, "font-weight": tokens.font.weightHeavy, "text-anchor": "middle" })];
171
+ if (condition !== undefined) {
172
+ body.push(el("rect", { x: r2(x + 7 + tw + 2), y: r2(top + 7.5), width: r2(conditionWidth ?? 6.2 * characters(condition) + 8), height: 14, rx: 3, fill: s.background, stroke: "none" }));
173
+ text.push(words(x + 7 + tw + 6, top + 18.5, condition, { fill: s.text, "font-size": 11, "font-weight": bold, "text-anchor": "start" }));
174
+ }
175
+ return { body, text };
176
+ };
177
+ const heads = [head(y, first), ...sections.map((section) => head(section.y, section))];
178
+ return drawn([
179
+ el("rect", { x: r2(x), y: r2(y), width: r2(width), height: r2(height), rx: 7, fill: "none", stroke: s.text, "stroke-width": 1.75 }),
180
+ ...sections.map((section) => el("line", { x1: r2(x), y1: r2(section.y), x2: r2(x + width), y2: r2(section.y), stroke: s.text, "stroke-width": 1.4, "stroke-dasharray": "4 4" })),
181
+ ...heads.flatMap((part) => part.body),
182
+ ], heads.flatMap((part) => part.text));
183
+ }, ({ y, height, sections = [] }) => limits(...sections.map((section, i) => (section.y <= y || section.y >= y + height) && { path: `sections[${i}].y`, message: "must lie inside the frame" })));
184
+ /** Words next to an arrow: lines down from the baseline (x, y), optionally on a backdrop that hides what is behind. */
185
+ export const edgeLabel = definePiece("edgeLabel", { x: pos(), y: pos(), lines: lines(4), anchor: { kind: "enum", values: ["start", "middle", "end"] }, backdrop: { kind: "boolean" }, width: size(), textSize, surface: surfaceParam }, ({ x, y, lines: rows, anchor = "middle", backdrop = false, width, textSize: size_ = 11, surface = "light" }) => {
186
+ const s = tokens.surface[surface];
187
+ const k = size_ / 11;
188
+ const body = backdrop
189
+ ? rows.map((row, i) => {
190
+ const w = width ?? (6.2 * characters(row) + 8) * k;
191
+ const left = anchor === "start" ? x - 4 * k : anchor === "end" ? x - w + 4 * k : x - w / 2;
192
+ return el("rect", { x: r2(left), y: r2(y + (i * 13 - 10) * k), width: r2(w), height: r2(14 * k), rx: 3, fill: s.background, stroke: "none" });
193
+ })
194
+ : [];
195
+ return drawn(body, rows.map((row, i) => words(x, y + i * 13 * k, row, { fill: s.text, "font-size": size_, "font-weight": bold, "text-anchor": anchor })));
196
+ });
197
+ /** The body diagram pieces, in catalogue order (design §3). */
198
+ export const DIAGRAM_PIECES = { diagramBox, decisionNode, storeNode, queueNode, exchangeNode, taggedContainer, lifeline, phaseBand, altFrame, edgeLabel };
@@ -0,0 +1,48 @@
1
+ import { type Attrs, type SvgNode } from "./node.ts";
2
+ import type { ParamSpec, Problem, Schema } from "./params.ts";
3
+ export type Point = [number, number];
4
+ export declare const SURFACES: readonly ["light", "dark"];
5
+ export type SurfaceName = (typeof SURFACES)[number];
6
+ export type Ink = {
7
+ surface?: SurfaceName;
8
+ inkScale?: number;
9
+ };
10
+ export declare const surfaceParam: ParamSpec;
11
+ export declare const inkScaleParam: ParamSpec;
12
+ export declare const inkSchema: Schema;
13
+ /** Collects the limits between parameters (piece.ts, `relations`). */
14
+ export declare const limits: (...checks: (Problem | false)[]) => Problem[];
15
+ /** The surface's colours and the ink measures at a scale. */
16
+ export declare const inkOf: ({ surface, inkScale }: Ink) => {
17
+ s: {
18
+ readonly background: "#14215A";
19
+ readonly grid: "#1C2D74";
20
+ readonly muted: "#8F9BD0";
21
+ readonly panel: "#1C2D74";
22
+ readonly rim: "#EEF2FF";
23
+ readonly rimWidth: 1;
24
+ readonly shadow: "#4C5CB8";
25
+ readonly text: "#EEF2FF";
26
+ readonly zoneOpacity: 0.22;
27
+ } | {
28
+ readonly background: "#EEF2FF";
29
+ readonly grid: "#E0E7FB";
30
+ readonly muted: "#5B6694";
31
+ readonly panel: "#FFFFFF";
32
+ readonly rim: "#EEF2FF";
33
+ readonly rimWidth: 0;
34
+ readonly shadow: "#0A1033";
35
+ readonly text: "#0A1033";
36
+ readonly zoneOpacity: 0.12;
37
+ };
38
+ outline: number;
39
+ rim: number;
40
+ offset: number;
41
+ };
42
+ /** Outlined shapes need room for their ink: smallest dimension ≥ 4 × outline. */
43
+ export declare const roomFor: (name: string, smallest: number, inkParams: Ink) => Problem | false;
44
+ export declare const atLeast: (name: string, value: number, least: number, what: string) => Problem | false;
45
+ export type Shape = (dx: number, dy: number, attrs: Attrs) => SvgNode;
46
+ /** The ink sticker (metro-and-diagram-pieces design §3): shadow copy, rim on dark, then the face. */
47
+ export declare const sticker: (shape: Shape, face: string, inkParams: Ink) => SvgNode[];
48
+ export declare const words: (x: number, y: number, value: string, attrs: Attrs) => SvgNode;
@@ -0,0 +1,38 @@
1
+ // Ink helpers shared by the metro, diagram and body diagram pieces (moved from
2
+ // metro.ts unchanged; change body-diagram-pieces, design §8). Internal: the
3
+ // package does not export this module.
4
+ import { tokens } from "../../tokens/generated/tokens.js";
5
+ import { el, r2 } from "./node.js";
6
+ export const SURFACES = ["light", "dark"];
7
+ const ink = tokens.color.ink;
8
+ const font = tokens.font.sans;
9
+ const M = tokens.map;
10
+ export const surfaceParam = { kind: "enum", values: SURFACES };
11
+ export const inkScaleParam = { kind: "number", min: 0.25, max: 8 };
12
+ export const inkSchema = { surface: surfaceParam, inkScale: inkScaleParam };
13
+ /** Collects the limits between parameters (piece.ts, `relations`). */
14
+ export const limits = (...checks) => checks.filter((check) => check !== false);
15
+ /** The surface's colours and the ink measures at a scale. */
16
+ export const inkOf = ({ surface = "light", inkScale = 1 }) => {
17
+ const s = tokens.surface[surface];
18
+ return { s, outline: M.outline * inkScale, rim: s.rimWidth * inkScale, offset: M.shadow * inkScale };
19
+ };
20
+ /** Outlined shapes need room for their ink: smallest dimension ≥ 4 × outline. */
21
+ export const roomFor = (name, smallest, inkParams) => {
22
+ const need = 4 * inkOf(inkParams).outline;
23
+ return smallest < need && { path: name, message: `leaves no room for the outline: needs at least ${r2(need)} at this ink scale` };
24
+ };
25
+ export const atLeast = (name, value, least, what) => value < least && { path: name, message: `must be at least ${what} (${r2(least)}), found ${r2(value)}` };
26
+ /** The ink sticker (metro-and-diagram-pieces design §3): shadow copy, rim on dark, then the face. */
27
+ export const sticker = (shape, face, inkParams) => {
28
+ const { s, outline, rim, offset } = inkOf(inkParams);
29
+ const wide = r2(outline + 2 * rim);
30
+ return [
31
+ // Joins are set here, so a sticker draws the same inside any group.
32
+ shape(offset, offset, { fill: s.shadow, stroke: s.shadow, "stroke-width": wide, "stroke-linejoin": "round" }),
33
+ ...(s.rimWidth > 0 ? [shape(0, 0, { fill: s.rim, stroke: s.rim, "stroke-width": wide, "stroke-linejoin": "round" })] : []),
34
+ shape(0, 0, { fill: face, stroke: ink, "stroke-width": r2(outline), "stroke-linejoin": "round" }),
35
+ ];
36
+ };
37
+ // Every text sets its own anchor, size and weight, so a surrounding group (a cover's label style) does not leak in.
38
+ export const words = (x, y, value, attrs) => el("text", { x: r2(x), y: r2(y), "font-family": font, ...attrs }, value);
@@ -1,4 +1,5 @@
1
1
  export * from "./metro.ts";
2
+ export * from "./diagram.ts";
2
3
  export * from "./ui-icons.ts";
3
4
  export * from "./link-icons.ts";
4
5
  type Point = [number, number];
@@ -275,6 +276,27 @@ export declare const PIECES: {
275
276
  readonly suggestedHalo: import("./piece.ts").Piece<import("./metro.ts").SuggestedHaloParams>;
276
277
  readonly licenceMark: import("./piece.ts").Piece<import("./metro.ts").LicenceMarkParams>;
277
278
  readonly zone: import("./piece.ts").Piece<import("./metro.ts").ZoneParams>;
279
+ readonly diagramBox: import("./piece.ts").Piece<import("./diagram.ts").DiagramBoxParams>;
280
+ readonly decisionNode: import("./piece.ts").Piece<import("./ink.ts").Ink & {
281
+ x: number;
282
+ y: number;
283
+ width: number;
284
+ height?: number;
285
+ text: string[];
286
+ subtitle?: string;
287
+ fill?: string;
288
+ textSize?: number;
289
+ } & {
290
+ height: number;
291
+ }>;
292
+ readonly storeNode: import("./piece.ts").Piece<import("./diagram.ts").StoreNodeParams>;
293
+ readonly queueNode: import("./piece.ts").Piece<import("./diagram.ts").ShapeParams>;
294
+ readonly exchangeNode: import("./piece.ts").Piece<import("./diagram.ts").ShapeParams>;
295
+ readonly taggedContainer: import("./piece.ts").Piece<import("./diagram.ts").TaggedContainerParams>;
296
+ readonly lifeline: import("./piece.ts").Piece<import("./diagram.ts").LifelineParams>;
297
+ readonly phaseBand: import("./piece.ts").Piece<import("./diagram.ts").PhaseBandParams>;
298
+ readonly altFrame: import("./piece.ts").Piece<import("./diagram.ts").AltFrameParams>;
299
+ readonly edgeLabel: import("./piece.ts").Piece<import("./diagram.ts").EdgeLabelParams>;
278
300
  readonly pill: import("./piece.ts").Piece<PillParams>;
279
301
  readonly rings: import("./piece.ts").Piece<RingsParams>;
280
302
  readonly sheet: import("./piece.ts").Piece<SheetParams>;
@@ -12,9 +12,11 @@ import { drawn, el, g, r2, shadowed } from "./node.js";
12
12
  import { col, colour, count, label, MAX_SIZE, PATH_DATA, pos, size } from "./params.js";
13
13
  import { definePiece } from "./piece.js";
14
14
  import { METRO_PIECES } from "./metro.js";
15
+ import { DIAGRAM_PIECES } from "./diagram.js";
15
16
  import { uiIcon } from "./ui-icons.js";
16
17
  import { linkIcon } from "./link-icons.js";
17
18
  export * from "./metro.js";
19
+ export * from "./diagram.js";
18
20
  export * from "./ui-icons.js";
19
21
  export * from "./link-icons.js";
20
22
  const ink = tokens.color.ink;
@@ -381,5 +383,6 @@ export const PIECES = {
381
383
  ...METRO_PIECES,
382
384
  uiIcon,
383
385
  linkIcon,
386
+ ...DIAGRAM_PIECES,
384
387
  };
385
388
  export const PIECE_NAMES = Object.keys(PIECES);
@@ -1,10 +1,5 @@
1
- type Point = [number, number];
2
- export declare const SURFACES: readonly ["light", "dark"];
3
- export type SurfaceName = (typeof SURFACES)[number];
4
- export type Ink = {
5
- surface?: SurfaceName;
6
- inkScale?: number;
7
- };
1
+ import { type Ink, type Point, type SurfaceName } from "./ink.ts";
2
+ export { SURFACES, type Ink, type SurfaceName } from "./ink.ts";
8
3
  export type LineBadgeParams = Ink & {
9
4
  x: number;
10
5
  y: number;
@@ -60,16 +55,20 @@ export declare const nodeBlock: import("./piece.ts").Piece<NodeBlockParams>;
60
55
  export declare const pillNode: import("./piece.ts").Piece<NodeParams>;
61
56
  /** A diagram hexagon (an agent). */
62
57
  export declare const hexNode: import("./piece.ts").Piece<NodeParams>;
58
+ export type ArrowKind = "message" | "reply" | "failure" | "failFast";
63
59
  export type MessageArrowParams = {
64
60
  from: Point;
65
61
  to: Point;
66
62
  curve?: "bezier" | "straight";
63
+ controls?: [Point, Point];
67
64
  head?: boolean;
68
65
  label?: string;
69
66
  width?: number;
67
+ kind?: ArrowKind;
70
68
  surface?: SurfaceName;
71
69
  };
72
- /** An edge with a head at `to`: curved (leaves and enters horizontally) or straight, with an optional label. */
70
+ /** An edge with a head at `to`: curved (leaves and enters horizontally), straight, or through the caller's two
71
+ * control points, with an optional label. `kind` paints a reply, a failure or a fail-fast path (card 141). */
73
72
  export declare const messageArrow: import("./piece.ts").Piece<MessageArrowParams>;
74
73
  export type LedgerMarkParams = {
75
74
  x: number;
@@ -129,4 +128,3 @@ export declare const METRO_PIECES: {
129
128
  readonly licenceMark: import("./piece.ts").Piece<LicenceMarkParams>;
130
129
  readonly zone: import("./piece.ts").Piece<ZoneParams>;
131
130
  };
132
- export {};
@@ -9,46 +9,18 @@
9
9
  // cross-parameter limits (room for the outline, width against height, arrow
10
10
  // length) are checked here and thrown as a RangeError naming the parameter.
11
11
  import { tokens } from "../../tokens/generated/tokens.js";
12
+ import { atLeast, inkOf, inkSchema, limits, roomFor, sticker, surfaceParam, words } from "./ink.js";
12
13
  import { drawn, el, g, r2 } from "./node.js";
13
14
  import { col, colour, label, MAX_SIZE, PATH_DATA, paint, pos, size } from "./params.js";
14
15
  import { definePiece } from "./piece.js";
15
- export const SURFACES = ["light", "dark"];
16
+ export { SURFACES } from "./ink.js";
16
17
  const ink = tokens.color.ink;
17
18
  const white = tokens.color.white;
18
19
  const font = tokens.font.sans;
19
20
  const bold = tokens.font.weightBold;
20
21
  const M = tokens.map;
21
- const surfaceParam = { kind: "enum", values: SURFACES };
22
- const inkScaleParam = { kind: "number", min: 0.25, max: 8 };
23
22
  const dataColour = (required = true) => ({ kind: "dataColor", required });
24
23
  const point = (required = true) => ({ kind: "point", required });
25
- const inkSchema = { surface: surfaceParam, inkScale: inkScaleParam };
26
- /** Collects the limits between parameters (piece.ts, `relations`). */
27
- const limits = (...checks) => checks.filter((check) => check !== false);
28
- /** The surface's colours and the ink measures at a scale. */
29
- const inkOf = ({ surface = "light", inkScale = 1 }) => {
30
- const s = tokens.surface[surface];
31
- return { s, outline: M.outline * inkScale, rim: s.rimWidth * inkScale, offset: M.shadow * inkScale };
32
- };
33
- /** Outlined shapes need room for their ink: smallest dimension ≥ 4 × outline. */
34
- const roomFor = (name, smallest, inkParams) => {
35
- const need = 4 * inkOf(inkParams).outline;
36
- return smallest < need && { path: name, message: `leaves no room for the outline: needs at least ${r2(need)} at this ink scale` };
37
- };
38
- const atLeast = (name, value, least, what) => value < least && { path: name, message: `must be at least ${what} (${r2(least)}), found ${r2(value)}` };
39
- /** The ink sticker (design §3): shadow copy, rim on dark, then the face. */
40
- const sticker = (shape, face, inkParams) => {
41
- const { s, outline, rim, offset } = inkOf(inkParams);
42
- const wide = r2(outline + 2 * rim);
43
- return [
44
- // Joins are set here, so a sticker draws the same inside any group.
45
- shape(offset, offset, { fill: s.shadow, stroke: s.shadow, "stroke-width": wide, "stroke-linejoin": "round" }),
46
- ...(s.rimWidth > 0 ? [shape(0, 0, { fill: s.rim, stroke: s.rim, "stroke-width": wide, "stroke-linejoin": "round" })] : []),
47
- shape(0, 0, { fill: face, stroke: ink, "stroke-width": r2(outline), "stroke-linejoin": "round" }),
48
- ];
49
- };
50
- // Every text sets its own anchor, size and weight, so a surrounding group (a cover's label style) does not leak in.
51
- const words = (x, y, value, attrs) => el("text", { x: r2(x), y: r2(y), "font-family": font, ...attrs }, value);
52
24
  const pill = (x, y, w, h) => (dx, dy, attrs) => el("rect", { x: r2(x + dx), y: r2(y + dy), width: r2(w), height: r2(h), rx: r2(h / 2), ...attrs });
53
25
  // Without a text, the default width is the one-character width (card 167).
54
26
  const badgeWidth = ({ width, text }) => width ?? Math.max(30, 7.6 * (text === undefined ? 1 : Array.from(text).length) + 18);
@@ -167,32 +139,47 @@ export const nodeBlock = definePiece("nodeBlock", { ...nodeSchema, radius: { kin
167
139
  export const pillNode = definePiece("pillNode", nodeSchema, (p) => drawNode("pill", p), nodeLimits("pill"));
168
140
  /** A diagram hexagon (an agent). */
169
141
  export const hexNode = definePiece("hexNode", nodeSchema, (p) => drawNode("hex", p), nodeLimits("hex"));
170
- /** An edge with a head at `to`: curved (leaves and enters horizontally) or straight, with an optional label. */
142
+ /** An edge with a head at `to`: curved (leaves and enters horizontally), straight, or through the caller's two
143
+ * control points, with an optional label. `kind` paints a reply, a failure or a fail-fast path (card 141). */
171
144
  export const messageArrow = definePiece("messageArrow", {
172
145
  from: point(),
173
146
  to: point(),
174
147
  curve: { kind: "enum", values: ["bezier", "straight"] },
148
+ controls: { kind: "array", minItems: 2, maxItems: 2, of: point() },
175
149
  head: { kind: "boolean" },
176
150
  label: label(false),
177
151
  width: { kind: "number", min: 1, max: 12 },
152
+ kind: { kind: "enum", values: ["message", "reply", "failure", "failFast"] },
178
153
  surface: surfaceParam,
179
- }, ({ from, to, curve = "bezier", head = true, label: text, width = M.edgeWidth, surface = "light" }) => {
154
+ }, ({ from, to, curve = "bezier", controls, head = true, label: text, width = M.edgeWidth, kind = "message", surface = "light" }) => {
180
155
  const s = tokens.surface[surface];
156
+ const paint_ = kind === "failure" ? tokens.color.failure : kind === "failFast" ? tokens.color.failFast : s.text;
181
157
  const length = 5 * width;
182
158
  const distance = Math.hypot(to[0] - from[0], to[1] - from[1]);
183
159
  const dx = to[0] - from[0];
184
160
  // A curve whose ends are almost above each other has no horizontal entry: draw it straight.
185
- const bent = curve === "bezier" && Math.abs(dx) > 2 * length;
186
- const direction = bent ? [Math.sign(dx), 0] : [dx / distance, (to[1] - from[1]) / distance];
161
+ const bent = controls === undefined && curve === "bezier" && Math.abs(dx) > 2 * length;
162
+ // With controls, the head points from the last of c2, c1, from that differs from `to` (design §5).
163
+ const towards = (q) => {
164
+ const l = Math.hypot(to[0] - q[0], to[1] - q[1]);
165
+ return [(to[0] - q[0]) / l, (to[1] - q[1]) / l];
166
+ };
167
+ const tail = controls && [controls[1], controls[0], from].find((q) => q[0] !== to[0] || q[1] !== to[1]);
168
+ const direction = tail !== undefined ? towards(tail) : bent ? [Math.sign(dx), 0] : [dx / distance, (to[1] - from[1]) / distance];
187
169
  const end = head ? [to[0] - direction[0] * length, to[1] - direction[1] * length] : to;
188
170
  const mx = (from[0] + to[0]) / 2;
189
171
  const at = (p) => `${r2(p[0])},${r2(p[1])}`;
190
- const d = bent ? `M${at(from)} C${r2(mx)},${r2(from[1])} ${r2(mx)},${r2(to[1])} ${at(end)}` : `M${at(from)} L${at(end)}`;
191
- const body = [el("path", { d, fill: "none", stroke: s.text, "stroke-width": r2(width), "stroke-linecap": "round" })];
172
+ const d = controls
173
+ ? `M${at(from)} C${at(controls[0])} ${at(controls[1])} ${at(end)}`
174
+ : bent
175
+ ? `M${at(from)} C${r2(mx)},${r2(from[1])} ${r2(mx)},${r2(to[1])} ${at(end)}`
176
+ : `M${at(from)} L${at(end)}`;
177
+ const dash = kind === "message" ? {} : { "stroke-dasharray": `${r2(2.4 * width)} ${r2(3.2 * width)}` };
178
+ const body = [el("path", { d, fill: "none", stroke: paint_, "stroke-width": r2(width), "stroke-linecap": "round", ...dash })];
192
179
  if (head) {
193
180
  const normal = [-direction[1] * 2 * width, direction[0] * 2 * width];
194
181
  const points = [to, [end[0] + normal[0], end[1] + normal[1]], [end[0] - normal[0], end[1] - normal[1]]];
195
- body.push(el("polygon", { points: points.map(at).join(" "), fill: s.text, stroke: s.text, "stroke-width": 1, "stroke-linejoin": "round" }));
182
+ body.push(el("polygon", { points: points.map(at).join(" "), fill: paint_, stroke: paint_, "stroke-width": 1, "stroke-linejoin": "round" }));
196
183
  }
197
184
  const words_ = [];
198
185
  if (text !== undefined) {
@@ -202,9 +189,9 @@ export const messageArrow = definePiece("messageArrow", {
202
189
  words_.push(words(middle[0], middle[1] + 4, text, { fill: s.text, "font-size": 11, "font-weight": 400, "text-anchor": "middle" }));
203
190
  }
204
191
  return drawn(body, words_);
205
- }, ({ from, to, head = true, width = M.edgeWidth }) => {
192
+ }, ({ from, to, head = true, width = M.edgeWidth, controls, curve }) => {
206
193
  const distance = Math.hypot(to[0] - from[0], to[1] - from[1]);
207
- return limits(distance === 0 && { path: "to", message: "must differ from from" }, distance > 0 && head && distance < 10 * width && { path: "to", message: `is too close to from for the head: needs at least ${r2(10 * width)}, found ${r2(distance)}` });
194
+ return limits(distance === 0 && { path: "to", message: "must differ from from" }, distance > 0 && head && distance < 10 * width && { path: "to", message: `is too close to from for the head: needs at least ${r2(10 * width)}, found ${r2(distance)}` }, controls !== undefined && curve !== undefined && { path: "controls", message: "cannot be combined with curve" });
208
195
  });
209
196
  /** stackledger's mark (favicon, header): an ink square with a bent line and two stations, 32 units scaled to `size`.
210
197
  * On a surface with a rim (dark), a light rim of 2 × rimWidth lines the square inside its 32 box (card 167). */
@@ -1,4 +1,4 @@
1
- export declare const SVG_TAGS: readonly ["svg", "g", "title", "desc", "defs", "clipPath", "rect", "circle", "ellipse", "line", "polyline", "polygon", "path", "text"];
1
+ export declare const SVG_TAGS: readonly ["svg", "g", "title", "desc", "defs", "clipPath", "rect", "circle", "ellipse", "line", "polyline", "polygon", "path", "text", "tspan"];
2
2
  export type SvgTag = (typeof SVG_TAGS)[number];
3
3
  export type AttrValue = string | number;
4
4
  export type Attrs = Readonly<Record<string, AttrValue>>;
@@ -23,6 +23,7 @@ export const SVG_TAGS = [
23
23
  "polygon",
24
24
  "path",
25
25
  "text",
26
+ "tspan",
26
27
  ];
27
28
  export const el = (tag, attrs = {}, ...children) => ({
28
29
  tag,
@@ -5,9 +5,12 @@ export type ParamSpec = {
5
5
  exclusiveMin?: boolean;
6
6
  max?: number;
7
7
  integer?: boolean;
8
- } | {
8
+ }
9
+ /** A token path; with `currentColor`, also that exact keyword (uiIcon.colour only, card 170). */
10
+ | {
9
11
  kind: "color";
10
12
  required?: boolean;
13
+ currentColor?: boolean;
11
14
  }
12
15
  /** A token path, or a colour from the consumer's data (#RRGGBB): pipe and lineBadge fills only. */
13
16
  | {
@@ -72,6 +75,10 @@ export declare const size: (required?: boolean, min?: number) => ParamSpec;
72
75
  /** A position: any finite number, because a map position has no maximum. */
73
76
  export declare const pos: () => ParamSpec;
74
77
  export declare const col: () => ParamSpec;
78
+ /** The paint keyword that follows the text colour of an inline SVG's element. */
79
+ export declare const CURRENT_COLOUR = "currentColor";
80
+ /** A token path or `currentColor`: for icons written inline in a page. */
81
+ export declare const colOrCurrent: () => ParamSpec;
75
82
  /** SVG path data: commands, digits, signs, dots, commas and spaces only. */
76
83
  export declare const PATH_DATA: RegExp;
77
84
  export declare const label: (required?: boolean) => ParamSpec;
@@ -55,6 +55,8 @@ const checkValue = (spec, value, path, out) => {
55
55
  return;
56
56
  }
57
57
  case "color":
58
+ if (spec.currentColor && value === CURRENT_COLOUR)
59
+ return;
58
60
  if (typeof value !== "string")
59
61
  return report(`expected a colour token path, found ${describe(value)}`);
60
62
  if (isColourLiteral(value))
@@ -87,7 +89,8 @@ const checkValue = (spec, value, path, out) => {
87
89
  return;
88
90
  case "point":
89
91
  // Any finite number: a map position has no maximum (spec illustration-pieces, "Positions of any size").
90
- if (!Array.isArray(value) || value.length !== 2 || !value.every((n) => typeof n === "number" && Number.isFinite(n))) {
92
+ // Both indices read directly: every() skips the holes of a sparse array.
93
+ if (!Array.isArray(value) || value.length !== 2 || ![value[0], value[1]].every((n) => typeof n === "number" && Number.isFinite(n))) {
91
94
  return report(`expected a point [x, y] of finite numbers, found ${describe(value)}`);
92
95
  }
93
96
  return;
@@ -98,7 +101,9 @@ const checkValue = (spec, value, path, out) => {
98
101
  return report(`expected at least ${spec.minItems} item(s)`);
99
102
  if (value.length > (spec.maxItems ?? MAX_ITEMS))
100
103
  return report(`expected at most ${spec.maxItems ?? MAX_ITEMS} item(s)`);
101
- value.forEach((item, index) => checkValue(spec.of, item, `${path}[${index}]`, out));
104
+ // An index loop, not forEach: a hole in a sparse array is checked as a missing item, not skipped.
105
+ for (let index = 0; index < value.length; index++)
106
+ checkValue(spec.of, value[index], `${path}[${index}]`, out);
102
107
  return;
103
108
  case "object":
104
109
  if (!isPlainObject(value))
@@ -144,6 +149,10 @@ export const size = (required = false, min) => min === undefined ? { kind: "numb
144
149
  /** A position: any finite number, because a map position has no maximum. */
145
150
  export const pos = () => ({ kind: "number", required: true });
146
151
  export const col = () => ({ kind: "color" });
152
+ /** The paint keyword that follows the text colour of an inline SVG's element. */
153
+ export const CURRENT_COLOUR = "currentColor";
154
+ /** A token path or `currentColor`: for icons written inline in a page. */
155
+ export const colOrCurrent = () => ({ kind: "color", currentColor: true });
147
156
  /** SVG path data: commands, digits, signs, dots, commas and spaces only. */
148
157
  export const PATH_DATA = /^[MmLlHhVvCcSsQqTtAaZz0-9eE.,+\-\s]+$/;
149
158
  export const label = (required = true) => ({ kind: "string", nonEmpty: true, required });
@@ -10,7 +10,8 @@ export const toSvg = (node, depth = 0) => {
10
10
  if (node.children.length === 0)
11
11
  return `${open}/>`;
12
12
  // Text content, alone or mixed with elements, is written inline: added whitespace would change it.
13
- if (node.children.some((child) => typeof child === "string")) {
13
+ // A text node is always written inline, so one holding only a tspan keeps its words intact (body-diagram-pieces §4).
14
+ if (node.tag === "text" || node.children.some((child) => typeof child === "string")) {
14
15
  return `${open}>${node.children.map((child) => toSvg(child)).join("")}</${node.tag}>`;
15
16
  }
16
17
  if (node.children.every((child) => typeof child === "string")) {
@@ -16,5 +16,6 @@ export type UiIconParams = {
16
16
  size?: number;
17
17
  colour?: string;
18
18
  };
19
- /** One UI icon at (x, y), `size` wide (24 by default), in one token colour (`color.ink` by default). */
19
+ /** One UI icon at (x, y), `size` wide (24 by default), in one token colour (`color.ink` by default)
20
+ * or `currentColor`, so an icon inline in a page follows its element's text colour. */
20
21
  export declare const uiIcon: import("./piece.ts").Piece<UiIconParams>;
@@ -1,5 +1,5 @@
1
1
  import { el, g, drawn, r2 } from "./node.js";
2
- import { col, pos, size } from "./params.js";
2
+ import { CURRENT_COLOUR, colOrCurrent, pos, size } from "./params.js";
3
3
  import { definePiece } from "./piece.js";
4
4
  import { colour } from "./params.js";
5
5
  /** Every UI icon draws in a 24x24 box. */
@@ -26,9 +26,10 @@ export const UI_ICONS = [
26
26
  { name: "download", title: "Download", description: "An arrow down into a tray.", nodes: [stroked("M12 3.5v11M7.5 10l4.5 4.5 4.5-4.5M4.5 16.5v3h15v-3")] },
27
27
  ];
28
28
  export const UI_ICON_NAMES = UI_ICONS.map((icon) => icon.name);
29
- /** One UI icon at (x, y), `size` wide (24 by default), in one token colour (`color.ink` by default). */
30
- export const uiIcon = definePiece("uiIcon", { name: { kind: "enum", required: true, values: UI_ICON_NAMES }, x: pos(), y: pos(), size: size(), colour: col() }, ({ name, x, y, size: side = UI_ICON_SIZE, colour: path = "color.ink" }) => {
29
+ /** One UI icon at (x, y), `size` wide (24 by default), in one token colour (`color.ink` by default)
30
+ * or `currentColor`, so an icon inline in a page follows its element's text colour. */
31
+ export const uiIcon = definePiece("uiIcon", { name: { kind: "enum", required: true, values: UI_ICON_NAMES }, x: pos(), y: pos(), size: size(), colour: colOrCurrent() }, ({ name, x, y, size: side = UI_ICON_SIZE, colour: path = "color.ink" }) => {
31
32
  const icon = UI_ICONS.find((candidate) => candidate.name === name);
32
- const paint = colour(path, "colour");
33
+ const paint = path === CURRENT_COLOUR ? CURRENT_COLOUR : colour(path, "colour");
33
34
  return drawn([g({ transform: `translate(${r2(x)} ${r2(y)}) scale(${side / UI_ICON_SIZE})`, fill: paint, stroke: paint }, ...icon.nodes)]);
34
35
  });
@@ -132,6 +132,7 @@ export declare const tokens: {
132
132
  readonly map: {
133
133
  readonly badgeHeight: 20;
134
134
  readonly badgeTextSize: 12;
135
+ readonly bendRadius: 32;
135
136
  readonly casing: 6;
136
137
  readonly countHeight: 16;
137
138
  readonly countTextSize: 11;
@@ -133,6 +133,7 @@ export const tokens = {
133
133
  "map": {
134
134
  "badgeHeight": 20,
135
135
  "badgeTextSize": 12,
136
+ "bendRadius": 32,
136
137
  "casing": 6,
137
138
  "countHeight": 16,
138
139
  "countTextSize": 11,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brunosouzas/design-system",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Design tokens and illustration pieces of brunosouzas.com: tokens as CSS, JSON and TypeScript, pieces that draw SVG (and React), and a cover command.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -78,6 +78,7 @@
78
78
  --ds-illustration-transitional-shade: #E8B830;
79
79
  --ds-map-badge-height: 20px;
80
80
  --ds-map-badge-text-size: 12px;
81
+ --ds-map-bend-radius: 32px;
81
82
  --ds-map-casing: 6px;
82
83
  --ds-map-count-height: 16px;
83
84
  --ds-map-count-text-size: 11px;
@@ -132,6 +132,7 @@
132
132
  "map": {
133
133
  "badgeHeight": 20,
134
134
  "badgeTextSize": 12,
135
+ "bendRadius": 32,
135
136
  "casing": 6,
136
137
  "countHeight": 16,
137
138
  "countTextSize": 11,
@@ -134,6 +134,7 @@ export const tokens = {
134
134
  "map": {
135
135
  "badgeHeight": 20,
136
136
  "badgeTextSize": 12,
137
+ "bendRadius": 32,
137
138
  "casing": 6,
138
139
  "countHeight": 16,
139
140
  "countTextSize": 11,