@brunosouzas/design-system 0.3.2 → 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.
package/README.md CHANGED
@@ -90,7 +90,7 @@ npm run stills -- publish-subscribe # 24 phase/final PNGs (six per
90
90
  npm run stills -- publish-subscribe --dry-run # print 24 commands, no effects
91
91
  npm run render -- publish-subscribe --dry-run # print eight commands; real video waits for owner stills approval
92
92
  npm run build # compile the package into dist/ (the package test needs it)
93
- npm run cover -- cover.json --out card.svg # build a blog cover (or a banner) from its spec
93
+ npm run cover -- cover.json --out card.svg # build a blog cover (or a banner, diagram or icon) from its spec
94
94
  npm run pieces:sheet -- --out out/pieces.svg # every piece once, labelled, for review
95
95
  npm run studio # Remotion Studio, for frame-by-frame review
96
96
  ```
@@ -131,7 +131,7 @@ 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 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.
134
+ - The 52 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), plus `stateStart` ([design §4](openspec/changes/archive/2026-10-24-designer-formats/design.md) of `designer-formats`); 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
 
@@ -160,7 +160,8 @@ Ten pieces draw what the blog's body diagrams need beyond the map pieces: `diagr
160
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
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
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).
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). The cover command does this for you with `"format": "diagram"` (below).
164
+ - `stateStart({ cx, cy, r, surface })` draws the filled start dot of a state machine (7.7 units, 11 at 800), in the surface's text colour, with no outline and no shadow.
164
165
 
165
166
  ### A cover
166
167
 
@@ -180,10 +181,10 @@ A cover is a JSON spec kept with its article, outside this repository: drafts ar
180
181
  }
181
182
  ```
182
183
 
183
- - `background` names one of the 21 pairs in `coverBackground` (a background and its ring colour, measured from the published covers). Each pair is used by one cover only.
184
+ - `background` names one of the 21 pairs in `coverBackground` (a background and its ring colour, measured from the published covers). Which covers may share a pair is the `designer` skill's rule, not the package's.
184
185
  - `pieces` are drawn in order; their words go on top.
185
186
  - The `rings` centre stays within ±10000 in a cover spec, and a piece that overflows is reported at its entry (`pieces[1]: the drawing overflows`).
186
- - `custom` (optional) is the cover's one free-form object, a JSON node tree limited to drawing tags and a fixed list of attributes, with no external references. An object that repeats becomes a piece instead.
187
+ - `custom` (optional) is the cover's one free-form object, a JSON node tree limited to drawing tags and a fixed list of attributes, with no external references. An object that repeats becomes a piece instead. It is painted after every piece's shapes; `customAt: <i>` paints it before `pieces[i]` instead, so a pill can sit on top of it (words always stay on top).
187
188
  - The three published covers that proved the pieces are the test fixtures in `test/fixtures/covers/`.
188
189
 
189
190
  ### Banners
@@ -195,6 +196,19 @@ A spec with `"format": "banner"` (the default is `"cover"`) builds a home banner
195
196
 
196
197
  The banner is one image that scales down as a whole. Words in it become small on a phone (a 19px label is about 6px at 375px wide), so keep them few or none. `test/fixtures/covers/home-banner.json` is the brunosouzas.com home banner redrawn from the pieces.
197
198
 
199
+ ### Body diagrams and icons from a spec
200
+
201
+ The same command draws the blog's body diagrams and icons, so a consumer needs no script and no local install:
202
+
203
+ ```sh
204
+ npx --yes --package @brunosouzas/design-system@<version> brunosouzas-cover design/<topic>.json --lang pt --out images/<topic>-pt.svg
205
+ ```
206
+
207
+ - `"format": "diagram"` takes a `height` in layout units (1 to 10000) and no `background`: the spec is laid out 560 wide and written 800 wide, on the `color.panel` panel with rounded corners (`bodyDiagram.radius`, 14 at 800) and rings in `color.ring` that show at step 28 and width 2, out to the farthest corner. Pieces get the map outline (3) and label size (13), which show at 4.3 and 18.6. `test/fixtures/covers/circuit-breaker-states.json` is the circuit breaker article's state diagram.
208
+ - `"format": "icon"` holds one `eipIcon`, `uiIcon` or `linkIcon` at 0,0 and writes it in a box of its own size (64x40 for an EIP icon). An EIP icon takes its title and description, with the CC BY 4.0 credit, from the icon's data, so its spec has neither; a UI or link icon spec needs both. No `background`, `rings` or `custom`.
209
+ - **Text tables.** `"text": { "pt": { "open": ["taxa de falha", "> limite"] }, "en": { ... } }` holds one table per language with the same keys; any string `"@open"` elsewhere in the spec (pieces, `title`, `description`, even `id`) is replaced by that key's value for the language passed with `--lang <code>`. Every language must define every key, and the ids in the output gain the language (`<id>-pt`). Without `text`, strings are drawn as written, so `"@home"` stays a label.
210
+ - Like a cover, a diagram or icon is a standalone image: `uiIcon` `colour: "currentColor"` is refused in every format.
211
+
198
212
  ### A new piece
199
213
 
200
214
  Add it to `src/pieces/library.ts` (or `src/pieces/metro.ts` for a map or diagram piece) with `definePiece(name, schema, draw)`, take its colours and sizes from the tokens (a new colour goes to `tokens/source.json` first), register it in `PIECES`, and give it a sample in `scripts/pieces-sheet.ts`. The catalogue test then checks that it draws with only its required parameters and uses token colours only.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- export declare const COVER_USAGE = "usage: npm run cover -- <spec.json> --out <file.svg>";
2
+ export declare const COVER_USAGE = "usage: npm run cover -- <spec.json> --out <file.svg> [--lang <code>]";
3
3
  export type CoverIo = {
4
4
  cwd: string;
5
5
  log: (line: string) => void;
@@ -10,12 +10,13 @@ export declare const parseCoverArgs: (args: string[], cwd: string) => {
10
10
  ok: true;
11
11
  spec: string;
12
12
  out: string;
13
+ lang?: string;
13
14
  } | {
14
15
  ok: false;
15
16
  problem: string;
16
17
  };
17
18
  /** Builds the cover text for a spec file's contents, or the problem lines. */
18
- export declare const buildCover: (text: string, label: string) => {
19
+ export declare const buildCover: (text: string, label: string, lang?: string) => {
19
20
  ok: true;
20
21
  svg: string;
21
22
  } | {
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // `npm run cover -- <spec.json> --out <file.svg>`, installed as the bin
2
+ // `npm run cover -- <spec.json> --out <file.svg> [--lang <code>]`, installed as the bin
3
3
  // `brunosouzas-cover` (spec cover-cli; design §6):
4
4
  // builds one blog cover from a cover spec kept with its article in the vault.
5
5
  //
@@ -14,23 +14,29 @@ import { closeSync, existsSync, openSync, readFileSync, realpathSync, renameSync
14
14
  import { randomUUID } from "node:crypto";
15
15
  import { dirname, resolve } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
- import { coverTree, validateCoverSpec } from "../src/pieces/cover.js";
17
+ import { coverTree, LANGUAGE, validateCoverSpec } from "../src/pieces/cover.js";
18
18
  import { toSvg } from "../src/pieces/render.js";
19
- export const COVER_USAGE = "usage: npm run cover -- <spec.json> --out <file.svg>";
19
+ export const COVER_USAGE = "usage: npm run cover -- <spec.json> --out <file.svg> [--lang <code>]";
20
20
  /** The first invocation problem, in the order of the spec, or the two paths. */
21
21
  export const parseCoverArgs = (args, cwd) => {
22
22
  const specs = [];
23
23
  const outs = [];
24
+ const langs = [];
24
25
  let unknown;
25
26
  let outWithoutValue = false;
27
+ let langWithoutValue = false;
26
28
  for (let i = 0; i < args.length; i++) {
27
29
  const arg = args[i];
28
- if (arg === "--out") {
30
+ if (arg === "--out" || arg === "--lang") {
29
31
  const value = args[i + 1];
30
- if (value === undefined || value.startsWith("--"))
31
- outWithoutValue = true;
32
+ if (value === undefined || value.startsWith("--")) {
33
+ if (arg === "--out")
34
+ outWithoutValue = true;
35
+ else
36
+ langWithoutValue = true;
37
+ }
32
38
  else {
33
- outs.push(value);
39
+ (arg === "--out" ? outs : langs).push(value);
34
40
  i++;
35
41
  }
36
42
  }
@@ -56,11 +62,17 @@ export const parseCoverArgs = (args, cwd) => {
56
62
  const target = resolve(cwd, out);
57
63
  if (existsSync(target) && statSync(target).isDirectory())
58
64
  return { ok: false, problem: `the output is a folder: ${out}` };
65
+ if (langWithoutValue)
66
+ return { ok: false, problem: "--lang needs a value: <code>" };
67
+ if (langs.length > 1)
68
+ return { ok: false, problem: "--lang given more than once" };
69
+ if (langs.length === 1 && !LANGUAGE.test(langs[0]))
70
+ return { ok: false, problem: `--lang expects a language code such as pt or en, found ${langs[0]}` };
59
71
  if (specs.length === 0)
60
72
  return { ok: false, problem: "missing the cover spec: <spec.json>" };
61
73
  if (specs.length > 1)
62
74
  return { ok: false, problem: `one cover spec at a time, found ${specs.length}` };
63
- return { ok: true, spec: specs[0], out };
75
+ return { ok: true, spec: specs[0], out, ...(langs.length === 1 ? { lang: langs[0] } : {}) };
64
76
  };
65
77
  /** Parses the spec text; a syntax error names the line and column. */
66
78
  const parseJson = (text, label) => {
@@ -82,14 +94,14 @@ const parseJson = (text, label) => {
82
94
  }
83
95
  };
84
96
  /** Builds the cover text for a spec file's contents, or the problem lines. */
85
- export const buildCover = (text, label) => {
97
+ export const buildCover = (text, label, lang) => {
86
98
  const parsed = parseJson(text, label);
87
99
  if ("problem" in parsed)
88
100
  return { ok: false, lines: [parsed.problem] };
89
- const problems = validateCoverSpec(parsed.value);
101
+ const problems = validateCoverSpec(parsed.value, { lang });
90
102
  if (problems.length > 0)
91
103
  return { ok: false, lines: problems.map((p) => `${p.path}: ${p.message}`) };
92
- return { ok: true, svg: `${toSvg(coverTree(parsed.value))}\n` };
104
+ return { ok: true, svg: `${toSvg(coverTree(parsed.value, { lang }))}\n` };
93
105
  };
94
106
  export const runCover = (args, io) => {
95
107
  const invocation = parseCoverArgs(args, io.cwd);
@@ -108,7 +120,7 @@ export const runCover = (args, io) => {
108
120
  io.error(COVER_USAGE);
109
121
  return 1;
110
122
  }
111
- const result = buildCover(text, invocation.spec);
123
+ const result = buildCover(text, invocation.spec, invocation.lang);
112
124
  if (!result.ok) {
113
125
  for (const line of result.lines)
114
126
  io.error(line);
@@ -2,8 +2,9 @@ import { type PieceName } from "./library.ts";
2
2
  import { type SvgNode } from "./node.ts";
3
3
  import { type Problem } from "./params.ts";
4
4
  export declare const SAFE_ID: RegExp;
5
- /** Cover (800x420, the default) or banner (1200x420, rounded corners): spec cover-cli, "Banner output". */
6
- export declare const FORMATS: readonly ["cover", "banner"];
5
+ /** Cover (800x420, the default), banner (1200x420, rounded corners), body diagram (560 laid out, 800 shown) or icon:
6
+ * spec cover-cli, "Banner output", "Diagram output", "Icon output". */
7
+ export declare const FORMATS: readonly ["cover", "banner", "diagram", "icon"];
7
8
  export type CoverFormat = (typeof FORMATS)[number];
8
9
  export type CoverPiece = {
9
10
  piece: PieceName;
@@ -13,26 +14,64 @@ export type CustomNode = {
13
14
  attrs?: Record<string, unknown>;
14
15
  children?: (CustomNode | string)[];
15
16
  };
16
- export type CoverSpec = {
17
+ export type CoverRings = {
18
+ cx: number;
19
+ cy: number;
20
+ radius?: number;
21
+ };
22
+ type SpecCommon = {
17
23
  id: string;
24
+ pieces: CoverPiece[];
25
+ /** One table per language; `"@key"` strings elsewhere are replaced for the language asked. */
26
+ text?: Record<string, Record<string, string | string[]>>;
27
+ };
28
+ type Framed = SpecCommon & {
18
29
  title: string;
19
30
  description: string;
20
- format?: CoverFormat;
21
- background: string;
22
- rings: {
23
- cx: number;
24
- cy: number;
25
- radius?: number;
26
- };
27
- pieces: CoverPiece[];
31
+ rings: CoverRings;
28
32
  custom?: CustomNode;
33
+ /** `custom` is drawn before `pieces[customAt]`; default after every piece. */
34
+ customAt?: number;
29
35
  };
36
+ /** A cover spec, by format (spec cover-cli, "Cover spec format" and each format's keys). */
37
+ export type CoverSpec = (Framed & {
38
+ format?: "cover" | "banner";
39
+ background: string;
40
+ height?: never;
41
+ }) | (Framed & {
42
+ format: "diagram"; /** Layout units. */
43
+ height: number;
44
+ background?: never;
45
+ }) | (SpecCommon & {
46
+ format: "icon";
47
+ /** Not in an eipIcon spec: the icon's own title and description, with the credit, are drawn. */
48
+ title?: string;
49
+ description?: string;
50
+ background?: never;
51
+ rings?: never;
52
+ custom?: never;
53
+ customAt?: never;
54
+ height?: never;
55
+ });
30
56
  export declare const backgroundNames: () => string[];
31
57
  /** Compares JSON paths: segment by segment, array indexes as numbers, names by code point. */
32
58
  export declare const comparePaths: (a: string, b: string) => number;
33
- /** Every problem of a parsed spec, sorted by path; empty when the spec is valid. */
34
- export declare const validateCoverSpec: (spec: unknown) => Problem[];
59
+ /** A language of a text table and of `--lang`: `pt`, `en`, `pt-br`. */
60
+ export declare const LANGUAGE: RegExp;
61
+ export type CoverOptions = {
62
+ lang?: string;
63
+ };
64
+ /** Every problem of a parsed spec, sorted by path; empty when the spec is valid. `lang` picks a text table. */
65
+ export declare const validateCoverSpec: (spec: unknown, options?: CoverOptions) => Problem[];
35
66
  /** Rings a banner draws: out to the frame's farthest corner (spec cover-cli, "Banner rings reach the corners"). */
36
67
  export declare const bannerRingCount: (cx: number, cy: number, radius?: number) => number;
37
- /** The cover's (or banner's) node tree. The spec must be valid (validateCoverSpec returned no problem). */
38
- export declare const coverTree: (spec: CoverSpec) => SvgNode;
68
+ /** Rings of a diagram (spec cover-cli, "Diagram rings at the shown scale"): the cover's step and width at the shown scale. */
69
+ export declare const diagramRings: (cx: number, cy: number, height: number, radius?: number) => {
70
+ count: number;
71
+ step: number;
72
+ width: number;
73
+ radius: number;
74
+ };
75
+ /** The cover's (banner's, diagram's, icon's) node tree. The spec must be valid for `lang` (validateCoverSpec returned no problem). */
76
+ export declare const coverTree: (input: CoverSpec, options?: CoverOptions) => SvgNode;
77
+ export {};
@@ -1,5 +1,6 @@
1
1
  // Cover specs (spec cover-cli; design §6): validation of the JSON a cover is
2
- // written in, and the 800x420 frame that draws it from the pieces.
2
+ // written in, and the frames that draw it from the pieces: the 800x420 cover,
3
+ // the banner, the body diagram and the icon (change designer-formats).
3
4
  //
4
5
  // Why here and not in scripts/: the validation and the frame are pure, so the
5
6
  // tests and a future site build can use them without the command line.
@@ -9,12 +10,16 @@
9
10
  // attributes, because the cover is published as SVG.
10
11
  import { tokens } from "../../tokens/generated/tokens.js";
11
12
  import { PIECE_NAMES, PIECES, rings } from "./library.js";
12
- import { el, g } from "./node.js";
13
+ import { EIP_ICON_HEIGHT, EIP_ICON_WIDTH, EIP_ICONS } from "./eip-icons.js";
14
+ import { LINK_ICON_SIZE } from "./link-icons.js";
15
+ import { el, g, r2 } from "./node.js";
16
+ import { UI_ICON_SIZE } from "./ui-icons.js";
13
17
  import { COLOUR_TOKENS, CURRENT_COLOUR, checkParams, isColourLiteral, MAX_SIZE, PATH_DATA } from "./params.js";
14
18
  export const SAFE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
15
- const TOP_LEVEL = ["id", "title", "description", "format", "background", "rings", "pieces", "custom"];
16
- /** Cover (800x420, the default) or banner (1200x420, rounded corners): spec cover-cli, "Banner output". */
17
- export const FORMATS = ["cover", "banner"];
19
+ const TOP_LEVEL = ["id", "title", "description", "format", "background", "rings", "pieces", "custom", "customAt", "height", "text"];
20
+ /** Cover (800x420, the default), banner (1200x420, rounded corners), body diagram (560 laid out, 800 shown) or icon:
21
+ * spec cover-cli, "Banner output", "Diagram output", "Icon output". */
22
+ export const FORMATS = ["cover", "banner", "diagram", "icon"];
18
23
  const CUSTOM_TAGS = ["g", "rect", "circle", "ellipse", "line", "polyline", "polygon", "path", "text"];
19
24
  // Attributes a free-form object may use (design §6): geometry, paint, text and transform.
20
25
  const CUSTOM_ATTRS = new Set([
@@ -134,118 +139,278 @@ export const comparePaths = (a, b) => {
134
139
  }
135
140
  return left.length - right.length;
136
141
  };
137
- /** Every problem of a parsed spec, sorted by path; empty when the spec is valid. */
138
- export const validateCoverSpec = (spec) => {
139
- if (!isPlainObject(spec))
140
- return [{ path: "(root)", message: `a cover spec must be a JSON object, found ${Array.isArray(spec) ? "an array" : describe(spec)}` }];
142
+ // ---------- text tables (spec cover-cli "Text tables", "Text references", "Language choice"; design §3) ----------
143
+ /** A language of a text table and of `--lang`: `pt`, `en`, `pt-br`. */
144
+ export const LANGUAGE = /^[a-z]{2}(-[a-z]{2})?$/;
145
+ const TEXT_KEY = /^[A-Za-z][A-Za-z0-9_-]*$/;
146
+ const REFERENCE = /^@([A-Za-z][A-Za-z0-9_-]*)$/;
147
+ const isTextValue = (value) => (typeof value === "string" && value !== "") ||
148
+ (Array.isArray(value) && value.length > 0 && Array.from({ length: value.length }, (_, i) => value[i]).every((item) => typeof item === "string" && item !== ""));
149
+ /** The table `lang` picks, or the problems of the tables and of the language choice. */
150
+ const pickTable = (spec, lang) => {
151
+ if (spec.text === undefined)
152
+ return { problems: lang === undefined ? [] : [{ path: "text", message: "--lang needs text tables" }] };
153
+ const text = spec.text;
154
+ if (!isPlainObject(text) || Object.keys(text).length === 0)
155
+ return { problems: [{ path: "text", message: "expected an object of languages (such as en, pt), each a table of text keys" }] };
156
+ const problems = [];
157
+ const tables = new Map();
158
+ for (const [code, table] of Object.entries(text)) {
159
+ if (!LANGUAGE.test(code))
160
+ problems.push({ path: `text.${code}`, message: "expected a language code such as pt or en" });
161
+ else if (!isPlainObject(table))
162
+ problems.push({ path: `text.${code}`, message: "expected an object of text keys" });
163
+ else
164
+ tables.set(code, table);
165
+ }
166
+ const keys = new Set();
167
+ for (const [code, table] of tables) {
168
+ for (const [key, value] of Object.entries(table)) {
169
+ keys.add(key);
170
+ if (!TEXT_KEY.test(key))
171
+ problems.push({ path: `text.${code}.${key}`, message: "expected a text key (a letter, then letters, digits, _ or -)" });
172
+ else if (!isTextValue(value))
173
+ problems.push({ path: `text.${code}.${key}`, message: "expected a non-empty string or a non-empty array of non-empty strings" });
174
+ }
175
+ }
176
+ for (const [code, table] of tables) {
177
+ for (const key of keys)
178
+ if (!Object.hasOwn(table, key))
179
+ problems.push({ path: `text.${code}.${key}`, message: "missing: another language defines it" });
180
+ }
181
+ if (problems.length > 0)
182
+ return { problems };
183
+ const codes = [...tables.keys()].sort().join(", ");
184
+ if (lang === undefined)
185
+ return { problems: [{ path: "text", message: `choose a language with --lang (${codes})` }] };
186
+ if (!tables.has(lang))
187
+ return { problems: [{ path: "text", message: `no table for ${lang} (${codes})` }] };
188
+ return { table: tables.get(lang), problems: [] };
189
+ };
190
+ /** The spec with every `@key` outside `text` replaced once by the table's value; unknown keys by path. */
191
+ const resolveText = (spec, table) => {
192
+ const unknown = [];
193
+ const visit = (value, path) => {
194
+ if (typeof value === "string") {
195
+ const match = REFERENCE.exec(value);
196
+ if (!match)
197
+ return value;
198
+ if (!Object.hasOwn(table, match[1])) {
199
+ unknown.push({ path, message: `unknown text key ${match[1]}` });
200
+ return value;
201
+ }
202
+ const found = table[match[1]];
203
+ return Array.isArray(found) ? [...found] : found;
204
+ }
205
+ if (Array.isArray(value))
206
+ return Array.from({ length: value.length }, (_, i) => visit(value[i], `${path}[${i}]`));
207
+ if (isPlainObject(value))
208
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, visit(child, path ? `${path}.${key}` : key)]));
209
+ return value;
210
+ };
211
+ const { text, ...rest } = spec;
212
+ return { spec: { ...visit(rest, ""), text }, unknown };
213
+ };
214
+ /** The spec the frame draws: resolved for `lang`, with the language in its id (decision 4). */
215
+ const forLanguage = (spec, lang) => {
216
+ if (spec.text === undefined || lang === undefined)
217
+ return spec;
218
+ const resolved = resolveText(spec, spec.text[lang]).spec;
219
+ // The id may be a reference too: the language goes on the resolved id.
220
+ return { ...resolved, id: `${resolved.id}-${lang}` };
221
+ };
222
+ // ---------- validation ----------
223
+ /** Icon pieces and the box each draws in (spec cover-cli, "Icon output"). */
224
+ const ICON_PIECES = ["eipIcon", "uiIcon", "linkIcon"];
225
+ const iconBox = (entry) => {
226
+ if (entry.piece === "eipIcon") {
227
+ const scale = entry.scale ?? 1;
228
+ return [EIP_ICON_WIDTH * scale, EIP_ICON_HEIGHT * scale];
229
+ }
230
+ const side = entry.size ?? (entry.piece === "uiIcon" ? UI_ICON_SIZE : LINK_ICON_SIZE);
231
+ return [side, side];
232
+ };
233
+ /** Problems of one `pieces` entry against its piece's parameters (spec cover-cli, "Piece parameters checked"). */
234
+ const checkEntry = (piece, params, at, out) => {
235
+ const definition = PIECES[piece];
236
+ const own = checkParams(definition.schema, params, at);
237
+ // Every output is a standalone image: currentColor (accepted by uiIcon for inline page icons) is refused.
238
+ for (const [name, spec] of Object.entries(definition.schema)) {
239
+ if (spec.kind === "color" && spec.currentColor && params[name] === CURRENT_COLOUR) {
240
+ own.push({ path: `${at}.${name}`, message: "currentColor is for icons inline in a page; a cover uses a token path" });
241
+ }
242
+ }
243
+ out.push(...own);
244
+ // Limits between parameters are reported with the schema's problems, except on a parameter
245
+ // already reported; a relation that cannot read a broken parameter reports nothing.
246
+ let related = [];
247
+ if (definition.relations) {
248
+ try {
249
+ // A relation that reads a broken parameter may return anything: keep only real problems.
250
+ const found = definition.relations(params);
251
+ related = Array.isArray(found) ? found.filter((p) => isPlainObject(p) && typeof p.path === "string" && typeof p.message === "string") : [];
252
+ }
253
+ catch {
254
+ related = [];
255
+ }
256
+ const reported = new Set(own.map((problem) => problem.path));
257
+ out.push(...related.map((problem) => ({ path: `${at}.${problem.path}`, message: problem.message })).filter((problem) => !reported.has(problem.path)));
258
+ }
259
+ // A valid entry is drawn once: an overflow is a spec problem at the entry, not a crash.
260
+ if (own.length === 0 && related.length === 0) {
261
+ try {
262
+ PIECES[piece](params);
263
+ }
264
+ catch (error) {
265
+ if (error instanceof RangeError && error.message.endsWith(": the drawing overflows"))
266
+ out.push({ path: at, message: "the drawing overflows" });
267
+ }
268
+ }
269
+ };
270
+ const checkRings = (spec, out) => {
271
+ if (!isPlainObject(spec.rings))
272
+ return void out.push({ path: "rings", message: "missing or not an object (expected cx, cy and an optional radius)" });
273
+ const ringProblems = checkParams(rings.schema, spec.rings, "rings").filter((problem) => problem.path !== "rings.color" && problem.path !== "rings.count");
274
+ out.push(...ringProblems);
275
+ // The piece takes any finite centre; a spec keeps 0.1.0's ±10000, which bounds a banner's and a diagram's ring count.
276
+ for (const axis of ["cx", "cy"]) {
277
+ const value = spec.rings[axis];
278
+ if (ringProblems.some((p) => p.path === `rings.${axis}`) || typeof value !== "number")
279
+ continue;
280
+ if (value < -MAX_SIZE)
281
+ out.push({ path: `rings.${axis}`, message: `expected a number at least -${MAX_SIZE}, found ${value}` });
282
+ else if (value > MAX_SIZE)
283
+ out.push({ path: `rings.${axis}`, message: `expected a number at most ${MAX_SIZE}, found ${value}` });
284
+ }
285
+ if (Object.hasOwn(spec.rings, "color"))
286
+ out.push({ path: "rings.color", message: "unknown parameter: the ring colour comes from the background" });
287
+ if (Object.hasOwn(spec.rings, "count"))
288
+ out.push({ path: "rings.count", message: "unknown parameter: the ring count comes from the format" });
289
+ };
290
+ const NOT_FOR_ICONS = "not used by the icon format";
291
+ const EIP_TEXT = "comes from the EIP icon, with its CC BY 4.0 credit";
292
+ /** Every problem of a resolved spec (no text references left to resolve). */
293
+ const validateResolved = (spec) => {
141
294
  const out = [];
295
+ const blocked = [];
142
296
  colourLiterals(spec, "", out);
143
297
  for (const key of Object.keys(spec)) {
144
298
  if (!TOP_LEVEL.includes(key))
145
299
  out.push({ path: key, message: `unknown key (expected ${TOP_LEVEL.join(", ")})` });
146
300
  }
301
+ if (spec.format !== undefined && (typeof spec.format !== "string" || !FORMATS.includes(spec.format))) {
302
+ out.push({ path: "format", message: `expected one of ${FORMATS.join(", ")}` });
303
+ }
304
+ // An unknown format is checked as a cover, as before this change.
305
+ const format = FORMATS.includes(spec.format) ? spec.format : "cover";
147
306
  if (typeof spec.id !== "string" || !SAFE_ID.test(spec.id))
148
307
  out.push({ path: "id", message: `expected a safe slug (lowercase letters, digits, single hyphens), found ${describe(spec.id)}` });
308
+ const pieces = spec.pieces;
309
+ const eipIconSpec = format === "icon" && Array.isArray(pieces) && pieces.length === 1 && isPlainObject(pieces[0]) && pieces[0].piece === "eipIcon";
149
310
  for (const key of ["title", "description"]) {
150
- if (typeof spec[key] !== "string" || spec[key].trim() === "")
311
+ if (eipIconSpec) {
312
+ if (spec[key] !== undefined)
313
+ out.push({ path: key, message: EIP_TEXT });
314
+ }
315
+ else if (typeof spec[key] !== "string" || spec[key].trim() === "")
151
316
  out.push({ path: key, message: "expected a non-empty string" });
152
317
  }
153
- if (typeof spec.background !== "string" || !backgroundNames().includes(spec.background)) {
154
- out.push({ path: "background", message: `expected one of ${backgroundNames().join(", ")}, found ${describe(spec.background)}` });
318
+ if (format === "icon") {
319
+ for (const key of ["background", "rings", "custom", "customAt", "height"])
320
+ if (spec[key] !== undefined)
321
+ out.push({ path: key, message: NOT_FOR_ICONS });
322
+ for (const key of ["background", "rings", "custom"])
323
+ if (spec[key] !== undefined)
324
+ blocked.push(key);
155
325
  }
156
- if (!isPlainObject(spec.rings))
157
- out.push({ path: "rings", message: "missing or not an object (expected cx, cy and an optional radius)" });
158
326
  else {
159
- const ringProblems = checkParams(rings.schema, spec.rings, "rings").filter((problem) => problem.path !== "rings.color" && problem.path !== "rings.count");
160
- out.push(...ringProblems);
161
- // The piece takes any finite centre; a cover spec keeps 0.1.0's ±10000, which bounds a banner's ring count.
162
- for (const axis of ["cx", "cy"]) {
163
- const value = spec.rings[axis];
164
- if (ringProblems.some((p) => p.path === `rings.${axis}`) || typeof value !== "number")
165
- continue;
166
- if (value < -MAX_SIZE)
167
- out.push({ path: `rings.${axis}`, message: `expected a number at least -${MAX_SIZE}, found ${value}` });
168
- else if (value > MAX_SIZE)
169
- out.push({ path: `rings.${axis}`, message: `expected a number at most ${MAX_SIZE}, found ${value}` });
327
+ if (format === "diagram") {
328
+ if (spec.background !== undefined)
329
+ out.push({ path: "background", message: "a diagram is drawn on color.panel; background is for covers and banners" });
330
+ const height = spec.height;
331
+ if (typeof height !== "number" || !Number.isFinite(height) || height < 1 || height > MAX_SIZE)
332
+ out.push({ path: "height", message: `expected a number from 1 to ${MAX_SIZE}` });
333
+ }
334
+ else {
335
+ if (typeof spec.background !== "string" || !backgroundNames().includes(spec.background)) {
336
+ out.push({ path: "background", message: `expected one of ${backgroundNames().join(", ")}, found ${describe(spec.background)}` });
337
+ }
338
+ if (spec.height !== undefined)
339
+ out.push({ path: "height", message: "only a diagram takes a height" });
340
+ }
341
+ checkRings(spec, out);
342
+ if (!isPlainObject(spec.rings) && spec.rings !== undefined)
343
+ blocked.push("rings");
344
+ if (spec.custom !== undefined) {
345
+ if (Array.isArray(spec.custom)) {
346
+ out.push({ path: "custom", message: "one free-form object per cover; a repeated object must be a piece" });
347
+ blocked.push("custom");
348
+ }
349
+ else
350
+ checkCustom(spec.custom, "custom", out);
351
+ }
352
+ if (spec.customAt !== undefined) {
353
+ const at = spec.customAt;
354
+ if (spec.custom === undefined)
355
+ out.push({ path: "customAt", message: "needs custom" });
356
+ else if (Array.isArray(pieces)) {
357
+ if (typeof at !== "number" || !Number.isInteger(at) || at < 0 || at > pieces.length)
358
+ out.push({ path: "customAt", message: `expected an integer from 0 to ${pieces.length}` });
359
+ }
360
+ else if (typeof at !== "number" || !Number.isInteger(at) || at < 0) {
361
+ // Without a pieces array the count is unknown: the type is still checked.
362
+ out.push({ path: "customAt", message: "expected an integer from 0 to the number of pieces" });
363
+ }
170
364
  }
171
365
  }
172
- if (isPlainObject(spec.rings) && Object.hasOwn(spec.rings, "color"))
173
- out.push({ path: "rings.color", message: "unknown parameter: the ring colour comes from the background" });
174
- if (isPlainObject(spec.rings) && Object.hasOwn(spec.rings, "count"))
175
- out.push({ path: "rings.count", message: "unknown parameter: the ring count comes from the format" });
176
- if (spec.format !== undefined && (typeof spec.format !== "string" || !FORMATS.includes(spec.format))) {
177
- out.push({ path: "format", message: `expected one of ${FORMATS.join(", ")}` });
178
- }
179
- if (!Array.isArray(spec.pieces))
366
+ if (!Array.isArray(pieces)) {
180
367
  out.push({ path: "pieces", message: "missing or not an array" });
181
- else
182
- spec.pieces.forEach((entry, index) => {
368
+ if (pieces !== undefined)
369
+ blocked.push("pieces");
370
+ }
371
+ else if (format === "icon") {
372
+ const entry = pieces[0];
373
+ if (pieces.length !== 1 || !isPlainObject(entry) || !ICON_PIECES.includes(entry.piece)) {
374
+ out.push({ path: "pieces", message: "an icon spec holds exactly one piece: eipIcon, uiIcon or linkIcon" });
375
+ blocked.push("pieces");
376
+ }
377
+ else {
378
+ const { piece, ...params } = entry;
379
+ for (const axis of ["x", "y"])
380
+ if (params[axis] !== undefined && params[axis] !== 0)
381
+ out.push({ path: `pieces[0].${axis}`, message: "an icon is drawn at 0, 0" });
382
+ checkEntry(piece, { ...params, x: 0, y: 0 }, "pieces[0]", out);
383
+ }
384
+ }
385
+ else {
386
+ for (let index = 0; index < pieces.length; index++) {
387
+ const entry = pieces[index];
183
388
  const at = `pieces[${index}]`;
184
- if (!isPlainObject(entry))
185
- return void out.push({ path: at, message: "expected an object with piece and its parameters" });
389
+ if (!isPlainObject(entry)) {
390
+ out.push({ path: at, message: "expected an object with piece and its parameters" });
391
+ blocked.push(at);
392
+ continue;
393
+ }
186
394
  const { piece, ...params } = entry;
187
395
  if (typeof piece !== "string" || !PIECE_NAMES.includes(piece)) {
188
- return void out.push({ path: `${at}.piece`, message: `unknown piece ${describe(piece)} (expected one of ${PIECE_NAMES.join(", ")})` });
189
- }
190
- if (piece === "rings")
191
- return void out.push({ path: `${at}.piece`, message: "rings belong to the cover's rings field" });
192
- const definition = PIECES[piece];
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
- }
200
- out.push(...own);
201
- // Limits between parameters are reported with the schema's problems, except on a parameter
202
- // already reported; a relation that cannot read a broken parameter reports nothing.
203
- if (definition.relations) {
204
- let related = [];
205
- try {
206
- related = definition.relations(params);
207
- }
208
- catch {
209
- related = [];
210
- }
211
- const reported = new Set(own.map((problem) => problem.path));
212
- out.push(...related.map((problem) => ({ path: `${at}.${problem.path}`, message: problem.message })).filter((problem) => !reported.has(problem.path)));
396
+ out.push({ path: `${at}.piece`, message: `unknown piece ${describe(piece)} (expected one of ${PIECE_NAMES.join(", ")})` });
397
+ blocked.push(at);
398
+ continue;
213
399
  }
214
- // A valid entry is drawn once: an overflow is a spec problem at the entry, not a crash.
215
- if (own.length === 0 && !(definition.relations?.(params) ?? []).length) {
216
- try {
217
- PIECES[piece](params);
218
- }
219
- catch (error) {
220
- if (error instanceof RangeError && error.message.endsWith(": the drawing overflows"))
221
- out.push({ path: at, message: "the drawing overflows" });
222
- }
400
+ if (piece === "rings") {
401
+ out.push({ path: `${at}.piece`, message: "rings belong to the cover's rings field" });
402
+ blocked.push(at);
403
+ continue;
223
404
  }
224
- });
225
- if (spec.custom !== undefined) {
226
- if (Array.isArray(spec.custom))
227
- out.push({ path: "custom", message: "one free-form object per cover; a repeated object must be a piece" });
228
- else
229
- checkCustom(spec.custom, "custom", out);
230
- }
231
- // A colour literal is reported once, by the literal check, not again as a bad token path.
232
- // A container with the wrong shape is one problem: nothing inside it is reported.
233
- const blocked = [];
234
- if (!Array.isArray(spec.pieces) && spec.pieces !== undefined)
235
- blocked.push("pieces");
236
- if (Array.isArray(spec.pieces)) {
237
- spec.pieces.forEach((entry, index) => {
238
- const known = isPlainObject(entry) && typeof entry.piece === "string" && PIECE_NAMES.includes(entry.piece) && entry.piece !== "rings";
239
- if (!known)
240
- blocked.push(`pieces[${index}]`);
241
- });
405
+ checkEntry(piece, params, at, out);
406
+ }
242
407
  }
243
- if (Array.isArray(spec.custom))
244
- blocked.push("custom");
245
- if (!isPlainObject(spec.rings) && spec.rings !== undefined)
246
- blocked.push("rings");
247
- const insideBlocked = (path) => blocked.some((b) => (path.startsWith(`${b}.`) || path.startsWith(`${b}[`)) && path !== `${b}.piece`);
248
- out.splice(0, out.length, ...out.filter((p) => !insideBlocked(p.path)));
408
+ return { problems: out, blocked };
409
+ };
410
+ /** Under a path (the path itself included): `a` covers `a`, `a.b` and `a[0]`. */
411
+ const under = (path, base) => path === base || path.startsWith(`${base}.`) || path.startsWith(`${base}[`);
412
+ /** Sorted, one per path and message; a colour literal replaces any other problem at its path. */
413
+ const finish = (out) => {
249
414
  const literalPaths = new Set(out.filter((p) => p.message.startsWith("colour literals")).map((p) => p.path));
250
415
  const seen = new Set();
251
416
  const unique = out.filter((p) => {
@@ -257,6 +422,26 @@ export const validateCoverSpec = (spec) => {
257
422
  });
258
423
  return unique.sort((a, b) => comparePaths(a.path, b.path) || compareCodePoints(a.message, b.message));
259
424
  };
425
+ /** Every problem of a parsed spec, sorted by path; empty when the spec is valid. `lang` picks a text table. */
426
+ export const validateCoverSpec = (spec, options = {}) => {
427
+ if (!isPlainObject(spec))
428
+ return [{ path: "(root)", message: `a cover spec must be a JSON object, found ${Array.isArray(spec) ? "an array" : describe(spec)}` }];
429
+ // Text problems stop the check: references cannot be resolved, so the rest would report false problems.
430
+ const { table, problems: textProblems } = pickTable(spec, options.lang);
431
+ if (textProblems.length > 0)
432
+ return finish(textProblems);
433
+ const { spec: resolved, unknown } = table ? resolveText(spec, table) : { spec, unknown: [] };
434
+ const { problems, blocked } = validateResolved(resolved);
435
+ // A container with the wrong shape is one problem: nothing inside it is reported (its `piece` problem stays).
436
+ // An unknown text key replaces every other problem at its path.
437
+ const insideBlocked = (path) => blocked.some((b) => under(path, b) && path !== b && path !== `${b}.piece`);
438
+ // Unknown references inside a wrong container are not reported either: the container is one problem.
439
+ // A reference in an entry's `piece` names the piece: it is reported even when its container is refused.
440
+ const unknownKept = unknown.filter((u) => !insideBlocked(u.path) || /^pieces\[\d+\]\.piece$/.test(u.path));
441
+ const kept = problems.filter((p) => !insideBlocked(p.path) && !unknownKept.some((u) => under(p.path, u.path)));
442
+ return finish([...kept, ...unknownKept]);
443
+ };
444
+ // ---------- drawing ----------
260
445
  const resolveCustom = (node) => {
261
446
  const attrs = {};
262
447
  for (const [name, value] of Object.entries(node.attrs ?? {})) {
@@ -273,28 +458,62 @@ export const bannerRingCount = (cx, cy, radius = tokens.cover.ringRadius) => {
273
458
  const far = Math.max(...[[0, 0], [width, 0], [0, height], [width, height]].map(([x, y]) => Math.hypot(x - cx, y - cy)));
274
459
  return Math.max(1, Math.ceil((far - radius) / tokens.cover.ringStep) + 1);
275
460
  };
276
- /** The cover's (or banner's) node tree. The spec must be valid (validateCoverSpec returned no problem). */
277
- export const coverTree = (spec) => {
461
+ /** Layout units per shown pixel of a body diagram (560 / 800). */
462
+ const LAYOUT_SCALE = tokens.bodyDiagram.layoutWidth / tokens.bodyDiagram.width;
463
+ /** Rings of a diagram (spec cover-cli, "Diagram rings at the shown scale"): the cover's step and width at the shown scale. */
464
+ export const diagramRings = (cx, cy, height, radius = tokens.bodyDiagram.ringRadius * LAYOUT_SCALE) => {
465
+ const width = tokens.bodyDiagram.layoutWidth;
466
+ const step = tokens.cover.ringStep * LAYOUT_SCALE;
467
+ const far = Math.max(...[[0, 0], [width, 0], [0, height], [width, height]].map(([x, y]) => Math.hypot(x - cx, y - cy)));
468
+ return { count: Math.max(1, Math.ceil((far - radius) / step) + 1), step, width: tokens.cover.ringWidth * LAYOUT_SCALE, radius };
469
+ };
470
+ const draw = (entry) => {
471
+ const { piece, ...params } = entry;
472
+ return PIECES[piece](params);
473
+ };
474
+ /** The outlined group's children: every body in paint order, `custom` before `pieces[customAt]` (default: last). */
475
+ const bodies = (spec, drawnPieces) => {
476
+ const custom = spec.custom ? resolveCustom(spec.custom) : null;
477
+ const at = spec.customAt ?? drawnPieces.length;
478
+ return [...drawnPieces.slice(0, at).flatMap((d) => d.body), custom, ...drawnPieces.slice(at).flatMap((d) => d.body)];
479
+ };
480
+ const root = (id, width, height, viewBox, ...children) => el("svg", { xmlns: "http://www.w3.org/2000/svg", width, height, viewBox, role: "img", "aria-labelledby": `${id}-title ${id}-desc`, "font-family": tokens.font.sans }, ...children);
481
+ /** An icon (spec cover-cli, "Icon output"): the piece at 0,0 in a box of its own size. */
482
+ const iconTree = (spec) => {
483
+ const entry = spec.pieces[0];
484
+ const [width, height] = iconBox(entry);
485
+ const eip = entry.piece === "eipIcon" ? EIP_ICONS.find((icon) => icon.name === entry.name) : undefined;
486
+ return root(spec.id, width, height, `0 0 ${width} ${height}`, el("title", { id: `${spec.id}-title` }, eip?.title ?? spec.title), el("desc", { id: `${spec.id}-desc` }, eip?.description ?? spec.description), ...draw({ ...entry, x: 0, y: 0 }).body);
487
+ };
488
+ /** A body diagram (spec cover-cli, "Diagram output"): laid out 560 wide, shown 800 wide, on the panel. */
489
+ const diagramTree = (spec) => {
490
+ const width = tokens.bodyDiagram.layoutWidth;
491
+ const height = spec.height;
492
+ const rx = r2(tokens.bodyDiagram.radius * LAYOUT_SCALE);
493
+ const frame = `url(#${spec.id}-frame)`;
494
+ const ring = diagramRings(spec.rings.cx, spec.rings.cy, height, spec.rings.radius);
495
+ const drawnPieces = spec.pieces.map(draw);
496
+ return root(spec.id, tokens.bodyDiagram.width, r2(height / LAYOUT_SCALE), `0 0 ${width} ${height}`, el("title", { id: `${spec.id}-title` }, spec.title), el("desc", { id: `${spec.id}-desc` }, spec.description), el("defs", {}, el("clipPath", { id: `${spec.id}-frame` }, el("rect", { width, height, rx }))), el("rect", { width, height, rx, fill: tokens.color.panel }), g({ "clip-path": frame }, g({ fill: "none", stroke: tokens.color.ring, "stroke-width": r2(ring.width) }, ...Array.from({ length: ring.count }, (_, i) => el("circle", { cx: spec.rings.cx, cy: spec.rings.cy, r: r2(ring.radius + i * ring.step) })))), g({ "clip-path": frame }, g({ stroke: tokens.color.ink, "stroke-width": tokens.map.outline, "stroke-linejoin": "round" }, ...bodies(spec, drawnPieces)), g({ "font-size": tokens.map.labelSize, "font-weight": tokens.cover.labelWeight, fill: tokens.color.ink, "text-anchor": "middle" }, ...drawnPieces.flatMap((d) => d.text))));
497
+ };
498
+ /** The cover's (banner's, diagram's, icon's) node tree. The spec must be valid for `lang` (validateCoverSpec returned no problem). */
499
+ export const coverTree = (input, options = {}) => {
500
+ const spec = forLanguage(input, options.lang);
501
+ if (spec.format === "icon")
502
+ return iconTree(spec);
503
+ if (spec.format === "diagram")
504
+ return diagramTree(spec);
278
505
  const banner = spec.format === "banner";
279
506
  const { width, height } = banner ? tokens.banner : tokens.cover;
280
507
  // A banner's corners are rounded; a cover keeps today's square frame, attribute for attribute.
281
508
  const corner = banner ? { rx: tokens.banner.radius } : {};
282
509
  const pair = tokens.coverBackground[spec.background];
283
- const drawnPieces = spec.pieces.map(({ piece, ...params }) => PIECES[piece](params));
510
+ const drawnPieces = spec.pieces.map(draw);
284
511
  const ringPath = `coverBackground.${spec.background}.ring`;
285
512
  const ringCount = banner ? { count: bannerRingCount(spec.rings.cx, spec.rings.cy, spec.rings.radius) } : {};
286
513
  const frame = `url(#${spec.id}-frame)`;
287
- const outlined = g({ stroke: tokens.color.ink, "stroke-width": tokens.outline.block, "stroke-linejoin": "round" }, ...drawnPieces.flatMap((d) => d.body), spec.custom ? resolveCustom(spec.custom) : null);
514
+ const outlined = g({ stroke: tokens.color.ink, "stroke-width": tokens.outline.block, "stroke-linejoin": "round" }, ...bodies(spec, drawnPieces));
288
515
  const labels = g({ "font-size": tokens.cover.labelSize, "font-weight": tokens.cover.labelWeight, fill: tokens.color.ink, "text-anchor": "middle" }, ...drawnPieces.flatMap((d) => d.text));
289
- return el("svg", {
290
- xmlns: "http://www.w3.org/2000/svg",
291
- width,
292
- height,
293
- viewBox: `0 0 ${width} ${height}`,
294
- role: "img",
295
- "aria-labelledby": `${spec.id}-title ${spec.id}-desc`,
296
- "font-family": tokens.font.sans,
297
- }, el("title", { id: `${spec.id}-title` }, spec.title), el("desc", { id: `${spec.id}-desc` }, spec.description), el("defs", {}, el("clipPath", { id: `${spec.id}-frame` }, el("rect", { width, height, ...corner }))), el("rect", { width, height, ...corner, fill: pair.fill }), g({ "clip-path": frame }, ...rings({ ...spec.rings, color: ringPath, ...ringCount }).body),
516
+ return root(spec.id, width, height, `0 0 ${width} ${height}`, el("title", { id: `${spec.id}-title` }, spec.title), el("desc", { id: `${spec.id}-desc` }, spec.description), el("defs", {}, el("clipPath", { id: `${spec.id}-frame` }, el("rect", { width, height, ...corner }))), el("rect", { width, height, ...corner, fill: pair.fill }), g({ "clip-path": frame }, ...rings({ ...spec.rings, color: ringPath, ...ringCount }).body),
298
517
  // On a banner, pieces and labels are clipped too, so nothing pokes past a rounded corner.
299
518
  ...(banner ? [g({ "clip-path": frame }, outlined, labels)] : [outlined, labels]));
300
519
  };
@@ -101,6 +101,15 @@ export type EdgeLabelParams = {
101
101
  };
102
102
  /** Words next to an arrow: lines down from the baseline (x, y), optionally on a backdrop that hides what is behind. */
103
103
  export declare const edgeLabel: import("./piece.ts").Piece<EdgeLabelParams>;
104
+ export type StateStartParams = {
105
+ cx: number;
106
+ cy: number;
107
+ r?: number;
108
+ surface?: SurfaceName;
109
+ };
110
+ /** The initial state of a state machine (change designer-formats, design §4): a filled dot in the surface's text
111
+ * colour, no outline (its own stroke none, so a cover's outlined group does not grow it), no shadow, no text. */
112
+ export declare const stateStart: import("./piece.ts").Piece<StateStartParams>;
104
113
  /** The body diagram pieces, in catalogue order (design §3). */
105
114
  export declare const DIAGRAM_PIECES: {
106
115
  readonly diagramBox: import("./piece.ts").Piece<DiagramBoxParams>;
@@ -194,5 +194,12 @@ export const edgeLabel = definePiece("edgeLabel", { x: pos(), y: pos(), lines: l
194
194
  : [];
195
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
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" })]));
197
204
  /** The body diagram pieces, in catalogue order (design §3). */
198
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";
@@ -325,6 +325,7 @@ export declare const PIECES: {
325
325
  readonly eipIcon: import("./piece.ts").Piece<EipIconParams>;
326
326
  readonly uiIcon: import("./piece.ts").Piece<import("./ui-icons.ts").UiIconParams>;
327
327
  readonly linkIcon: import("./piece.ts").Piece<import("./link-icons.ts").LinkIconParams>;
328
+ readonly stateStart: import("./piece.ts").Piece<import("./diagram.ts").StateStartParams>;
328
329
  };
329
330
  export type PieceName = keyof typeof PIECES;
330
331
  export declare const PIECE_NAMES: PieceName[];
@@ -12,7 +12,7 @@ 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
+ import { DIAGRAM_PIECES, stateStart } from "./diagram.js";
16
16
  import { uiIcon } from "./ui-icons.js";
17
17
  import { linkIcon } from "./link-icons.js";
18
18
  export * from "./metro.js";
@@ -384,5 +384,6 @@ export const PIECES = {
384
384
  uiIcon,
385
385
  linkIcon,
386
386
  ...DIAGRAM_PIECES,
387
+ stateStart,
387
388
  };
388
389
  export const PIECE_NAMES = Object.keys(PIECES);
@@ -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.2",
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": {
@@ -3,6 +3,11 @@
3
3
  --ds-banner-height: 420px;
4
4
  --ds-banner-radius: 20px;
5
5
  --ds-banner-width: 1200px;
6
+ --ds-body-diagram-layout-width: 560px;
7
+ --ds-body-diagram-radius: 14px;
8
+ --ds-body-diagram-ring-radius: 70px;
9
+ --ds-body-diagram-start-radius: 11px;
10
+ --ds-body-diagram-width: 800px;
6
11
  --ds-color-backend: #FF6B6B;
7
12
  --ds-color-blocked: #FF8A3D;
8
13
  --ds-color-caller: #8FB2FF;
@@ -4,6 +4,13 @@
4
4
  "radius": 20,
5
5
  "width": 1200
6
6
  },
7
+ "bodyDiagram": {
8
+ "layoutWidth": 560,
9
+ "radius": 14,
10
+ "ringRadius": 70,
11
+ "startRadius": 11,
12
+ "width": 800
13
+ },
7
14
  "color": {
8
15
  "backend": "#FF6B6B",
9
16
  "blocked": "#FF8A3D",
@@ -6,6 +6,13 @@ export const tokens = {
6
6
  "radius": 20,
7
7
  "width": 1200
8
8
  },
9
+ "bodyDiagram": {
10
+ "layoutWidth": 560,
11
+ "radius": 14,
12
+ "ringRadius": 70,
13
+ "startRadius": 11,
14
+ "width": 800
15
+ },
9
16
  "color": {
10
17
  "backend": "#FF6B6B",
11
18
  "blocked": "#FF8A3D",