@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.
@@ -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
  };
@@ -0,0 +1,137 @@
1
+ import { type Ink, type SurfaceName } from "./ink.ts";
2
+ export type ShapeParams = Ink & {
3
+ x: number;
4
+ y: number;
5
+ width: number;
6
+ height?: number;
7
+ text: string[];
8
+ subtitle?: string;
9
+ fill?: string;
10
+ textSize?: number;
11
+ };
12
+ export type DiagramBoxParams = ShapeParams & {
13
+ radius?: number;
14
+ shape?: "box" | "pill";
15
+ };
16
+ /** A box or a pill with a fill and centred lines: a body diagram's step, system or note. */
17
+ export declare const diagramBox: import("./piece.ts").Piece<DiagramBoxParams>;
18
+ /** A decision: a diamond in its bounds. */
19
+ export declare const decisionNode: import("./piece.ts").Piece<Ink & {
20
+ x: number;
21
+ y: number;
22
+ width: number;
23
+ height?: number;
24
+ text: string[];
25
+ subtitle?: string;
26
+ fill?: string;
27
+ textSize?: number;
28
+ } & {
29
+ height: number;
30
+ }>;
31
+ export type StoreNodeParams = ShapeParams & {
32
+ height: number;
33
+ ry?: number;
34
+ };
35
+ /** A store: a cylinder in its bounds, with the front of its lid. */
36
+ export declare const storeNode: import("./piece.ts").Piece<StoreNodeParams>;
37
+ /** A queue: a box with a rail 10 in from each side. */
38
+ export declare const queueNode: import("./piece.ts").Piece<ShapeParams>;
39
+ /** An exchange: a hexagon with its points 0.26 × height in, as the motion shape (src/brand/flow-shapes.ts). */
40
+ export declare const exchangeNode: import("./piece.ts").Piece<ShapeParams>;
41
+ export type TaggedContainerParams = Ink & {
42
+ x: number;
43
+ y: number;
44
+ width: number;
45
+ height: number;
46
+ tag: string;
47
+ tagWidth?: number;
48
+ tagFill?: string;
49
+ fill?: string;
50
+ };
51
+ /** A container (a platform, a broker) with its name on a tab centred on the top edge. */
52
+ export declare const taggedContainer: import("./piece.ts").Piece<TaggedContainerParams>;
53
+ export type LifelineParams = {
54
+ x: number;
55
+ y1: number;
56
+ y2: number;
57
+ state?: "active" | "inactive";
58
+ width?: number;
59
+ surface?: SurfaceName;
60
+ };
61
+ /** A participant's lifeline: dashed while active, sparse dots once nothing calls it. */
62
+ export declare const lifeline: import("./piece.ts").Piece<LifelineParams>;
63
+ export type PhaseBandParams = Ink & {
64
+ x: number;
65
+ y: number;
66
+ width: number;
67
+ height?: number;
68
+ text: string;
69
+ fill: string;
70
+ textSize?: number;
71
+ };
72
+ /** A phase of a sequence: a pill across the diagram in its state's colour. */
73
+ export declare const phaseBand: import("./piece.ts").Piece<PhaseBandParams>;
74
+ type Head = {
75
+ tag: string;
76
+ condition?: string;
77
+ tagWidth?: number;
78
+ conditionWidth?: number;
79
+ };
80
+ export type AltFrameParams = Head & {
81
+ x: number;
82
+ y: number;
83
+ width: number;
84
+ height: number;
85
+ sections?: (Head & {
86
+ y: number;
87
+ })[];
88
+ surface?: SurfaceName;
89
+ };
90
+ /** An alt/else frame: the frame, a dashed divider per section, and a tag (with its condition) per part. */
91
+ export declare const altFrame: import("./piece.ts").Piece<AltFrameParams>;
92
+ export type EdgeLabelParams = {
93
+ x: number;
94
+ y: number;
95
+ lines: string[];
96
+ anchor?: "start" | "middle" | "end";
97
+ backdrop?: boolean;
98
+ width?: number;
99
+ textSize?: number;
100
+ surface?: SurfaceName;
101
+ };
102
+ /** Words next to an arrow: lines down from the baseline (x, y), optionally on a backdrop that hides what is behind. */
103
+ export declare const edgeLabel: import("./piece.ts").Piece<EdgeLabelParams>;
104
+ 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>;
113
+ /** The body diagram pieces, in catalogue order (design §3). */
114
+ export declare const DIAGRAM_PIECES: {
115
+ readonly diagramBox: import("./piece.ts").Piece<DiagramBoxParams>;
116
+ readonly decisionNode: import("./piece.ts").Piece<Ink & {
117
+ x: number;
118
+ y: number;
119
+ width: number;
120
+ height?: number;
121
+ text: string[];
122
+ subtitle?: string;
123
+ fill?: string;
124
+ textSize?: number;
125
+ } & {
126
+ height: number;
127
+ }>;
128
+ readonly storeNode: import("./piece.ts").Piece<StoreNodeParams>;
129
+ readonly queueNode: import("./piece.ts").Piece<ShapeParams>;
130
+ readonly exchangeNode: import("./piece.ts").Piece<ShapeParams>;
131
+ readonly taggedContainer: import("./piece.ts").Piece<TaggedContainerParams>;
132
+ readonly lifeline: import("./piece.ts").Piece<LifelineParams>;
133
+ readonly phaseBand: import("./piece.ts").Piece<PhaseBandParams>;
134
+ readonly altFrame: import("./piece.ts").Piece<AltFrameParams>;
135
+ readonly edgeLabel: import("./piece.ts").Piece<EdgeLabelParams>;
136
+ };
137
+ export {};