@brunosouzas/design-system 0.3.1 → 0.4.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.
@@ -0,0 +1,205 @@
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
+ /** Default start radius in layout units: the published dot (bodyDiagram.startRadius at the shown width) laid out at 560. */
198
+ const START_RADIUS = (tokens.bodyDiagram.startRadius * tokens.bodyDiagram.layoutWidth) / tokens.bodyDiagram.width;
199
+ /** The initial state of a state machine (change designer-formats, design §4): a filled dot in the surface's text
200
+ * colour, no outline (its own stroke none, so a cover's outlined group does not grow it), no shadow, no text. */
201
+ export const stateStart = definePiece("stateStart", { cx: pos(), cy: pos(), r: size(), surface: surfaceParam }, ({ cx, cy, r = START_RADIUS, surface = "light" }) =>
202
+ // The radius is written as given (a size, never rounded away); the centre is a position.
203
+ drawn([el("circle", { cx: r2(cx), cy: r2(cy), r, fill: tokens.surface[surface].text, stroke: "none" })]));
204
+ /** The body diagram pieces, in catalogue order (design §3). */
205
+ export const DIAGRAM_PIECES = { diagramBox, decisionNode, storeNode, queueNode, exchangeNode, taggedContainer, lifeline, phaseBand, altFrame, edgeLabel };
@@ -5,5 +5,5 @@ export { COLOUR_TOKENS, DATA_COLOUR, MAX_ITEMS, paint, MAX_SIZE, checkParams, co
5
5
  export { definePiece, type Piece } from "./piece.ts";
6
6
  export * from "./library.ts";
7
7
  export { EIP_ICONS, EIP_ICON_NAMES, EIP_ICON_HEIGHT, EIP_ICON_WIDTH, type EipIcon } from "./eip-icons.ts";
8
- export { FORMATS, SAFE_ID, backgroundNames, bannerRingCount, comparePaths, coverTree, validateCoverSpec, type CoverFormat, type CoverPiece, type CoverSpec, type CustomNode } from "./cover.ts";
8
+ export { FORMATS, LANGUAGE, SAFE_ID, backgroundNames, bannerRingCount, comparePaths, coverTree, diagramRings, validateCoverSpec, type CoverFormat, type CoverOptions, type CoverPiece, type CoverRings, type CoverSpec, type CustomNode } from "./cover.ts";
9
9
  export { tokens, type Tokens } from "../../tokens/generated/tokens.ts";
@@ -10,5 +10,5 @@ export { COLOUR_TOKENS, DATA_COLOUR, MAX_ITEMS, paint, MAX_SIZE, checkParams, co
10
10
  export { definePiece } from "./piece.js";
11
11
  export * from "./library.js";
12
12
  export { EIP_ICONS, EIP_ICON_NAMES, EIP_ICON_HEIGHT, EIP_ICON_WIDTH } from "./eip-icons.js";
13
- export { FORMATS, SAFE_ID, backgroundNames, bannerRingCount, comparePaths, coverTree, validateCoverSpec } from "./cover.js";
13
+ export { FORMATS, LANGUAGE, SAFE_ID, backgroundNames, bannerRingCount, comparePaths, coverTree, diagramRings, validateCoverSpec } from "./cover.js";
14
14
  export { tokens } from "../../tokens/generated/tokens.js";
@@ -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>;
@@ -303,6 +325,7 @@ export declare const PIECES: {
303
325
  readonly eipIcon: import("./piece.ts").Piece<EipIconParams>;
304
326
  readonly uiIcon: import("./piece.ts").Piece<import("./ui-icons.ts").UiIconParams>;
305
327
  readonly linkIcon: import("./piece.ts").Piece<import("./link-icons.ts").LinkIconParams>;
328
+ readonly stateStart: import("./piece.ts").Piece<import("./diagram.ts").StateStartParams>;
306
329
  };
307
330
  export type PieceName = keyof typeof PIECES;
308
331
  export declare const PIECE_NAMES: PieceName[];
@@ -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, stateStart } 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,7 @@ export const PIECES = {
381
383
  ...METRO_PIECES,
382
384
  uiIcon,
383
385
  linkIcon,
386
+ ...DIAGRAM_PIECES,
387
+ stateStart,
384
388
  };
385
389
  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,
@@ -89,7 +89,8 @@ const checkValue = (spec, value, path, out) => {
89
89
  return;
90
90
  case "point":
91
91
  // Any finite number: a map position has no maximum (spec illustration-pieces, "Positions of any size").
92
- 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))) {
93
94
  return report(`expected a point [x, y] of finite numbers, found ${describe(value)}`);
94
95
  }
95
96
  return;
@@ -100,7 +101,9 @@ const checkValue = (spec, value, path, out) => {
100
101
  return report(`expected at least ${spec.minItems} item(s)`);
101
102
  if (value.length > (spec.maxItems ?? MAX_ITEMS))
102
103
  return report(`expected at most ${spec.maxItems ?? MAX_ITEMS} item(s)`);
103
- 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);
104
107
  return;
105
108
  case "object":
106
109
  if (!isPlainObject(value))
@@ -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")) {
@@ -4,6 +4,13 @@ export declare const tokens: {
4
4
  readonly radius: 20;
5
5
  readonly width: 1200;
6
6
  };
7
+ readonly bodyDiagram: {
8
+ readonly layoutWidth: 560;
9
+ readonly radius: 14;
10
+ readonly ringRadius: 70;
11
+ readonly startRadius: 11;
12
+ readonly width: 800;
13
+ };
7
14
  readonly color: {
8
15
  readonly backend: "#FF6B6B";
9
16
  readonly blocked: "#FF8A3D";
@@ -5,6 +5,13 @@ export const tokens = {
5
5
  "radius": 20,
6
6
  "width": 1200
7
7
  },
8
+ "bodyDiagram": {
9
+ "layoutWidth": 560,
10
+ "radius": 14,
11
+ "ringRadius": 70,
12
+ "startRadius": 11,
13
+ "width": 800
14
+ },
8
15
  "color": {
9
16
  "backend": "#FF6B6B",
10
17
  "blocked": "#FF8A3D",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brunosouzas/design-system",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
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": {