@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.
- package/README.md +29 -6
- package/dist/scripts/cover.d.ts +3 -2
- package/dist/scripts/cover.js +24 -12
- package/dist/src/pieces/cover.d.ts +54 -15
- package/dist/src/pieces/cover.js +327 -108
- package/dist/src/pieces/diagram.d.ts +137 -0
- package/dist/src/pieces/diagram.js +205 -0
- package/dist/src/pieces/index.d.ts +1 -1
- package/dist/src/pieces/index.js +1 -1
- package/dist/src/pieces/ink.d.ts +48 -0
- package/dist/src/pieces/ink.js +38 -0
- package/dist/src/pieces/library.d.ts +23 -0
- package/dist/src/pieces/library.js +4 -0
- package/dist/src/pieces/metro.d.ts +7 -9
- package/dist/src/pieces/metro.js +26 -39
- package/dist/src/pieces/node.d.ts +1 -1
- package/dist/src/pieces/node.js +1 -0
- package/dist/src/pieces/params.js +5 -2
- package/dist/src/pieces/render.js +2 -1
- package/dist/tokens/generated/tokens.d.ts +7 -0
- package/dist/tokens/generated/tokens.js +7 -0
- package/package.json +1 -1
- package/tokens/generated/tokens.css +5 -0
- package/tokens/generated/tokens.json +7 -0
- package/tokens/generated/tokens.ts +7 -0
package/dist/src/pieces/cover.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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)
|
|
17
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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 (
|
|
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 (
|
|
154
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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 (
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
/**
|
|
277
|
-
|
|
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(
|
|
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" }, ...
|
|
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("
|
|
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 {};
|