justskill-design 0.0.1 → 3.2.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.
Files changed (67) hide show
  1. package/README.en.md +235 -0
  2. package/README.md +184 -11
  3. package/SKILL.md +435 -0
  4. package/canvas/app.mjs +2870 -0
  5. package/canvas/groups.mjs +204 -0
  6. package/canvas/index.html +141 -0
  7. package/canvas/styles.css +1032 -0
  8. package/canvas/tree.mjs +249 -0
  9. package/cordis.patch.yml +8 -0
  10. package/design.mjs +172 -0
  11. package/docs/dsh-workbench.png +0 -0
  12. package/dsh/client.js +864 -0
  13. package/dsh/index.js +354 -0
  14. package/dsh/scan.js +139 -0
  15. package/dsh/skill.js +41 -0
  16. package/lib/base-css.mjs +92 -0
  17. package/lib/detect.mjs +123 -0
  18. package/lib/diagnostics.mjs +382 -0
  19. package/lib/export-html.mjs +309 -0
  20. package/lib/export-svg.mjs +111 -0
  21. package/lib/fragment.mjs +702 -0
  22. package/lib/geometry.mjs +827 -0
  23. package/lib/icons.mjs +106 -0
  24. package/lib/levenshtein.mjs +133 -0
  25. package/lib/node/cli.mjs +387 -0
  26. package/lib/node/export-svg.mjs +75 -0
  27. package/lib/node/fs.mjs +309 -0
  28. package/lib/node/op-json.mjs +75 -0
  29. package/lib/node/raster-run.mjs +115 -0
  30. package/lib/node/server.mjs +1264 -0
  31. package/lib/node/static.mjs +206 -0
  32. package/lib/node/verbs/add.mjs +220 -0
  33. package/lib/node/verbs/export.mjs +161 -0
  34. package/lib/node/verbs/format.mjs +106 -0
  35. package/lib/node/verbs/import.mjs +126 -0
  36. package/lib/node/verbs/ls.mjs +98 -0
  37. package/lib/node/verbs/migrate.mjs +236 -0
  38. package/lib/node/verbs/move.mjs +76 -0
  39. package/lib/node/verbs/new.mjs +284 -0
  40. package/lib/node/verbs/remove.mjs +41 -0
  41. package/lib/node/verbs/rename.mjs +87 -0
  42. package/lib/node/verbs/serve.mjs +164 -0
  43. package/lib/node/verbs/set.mjs +150 -0
  44. package/lib/node/verbs/show.mjs +122 -0
  45. package/lib/node/verbs/tokens.mjs +167 -0
  46. package/lib/node/verbs/validate.mjs +130 -0
  47. package/lib/node/write-verbs.mjs +215 -0
  48. package/lib/normalize.mjs +288 -0
  49. package/lib/ops.mjs +2063 -0
  50. package/lib/parse.mjs +997 -0
  51. package/lib/raster.mjs +186 -0
  52. package/lib/readers/pen.mjs +1684 -0
  53. package/lib/receipt.mjs +203 -0
  54. package/lib/render.mjs +503 -0
  55. package/lib/serialize.mjs +441 -0
  56. package/lib/spans.mjs +177 -0
  57. package/lib/svg-path.mjs +483 -0
  58. package/lib/table.mjs +1047 -0
  59. package/lib/tokens.mjs +295 -0
  60. package/lib/validate.mjs +929 -0
  61. package/package.json +68 -6
  62. package/references/cookbook.md +156 -0
  63. package/references/images.md +171 -0
  64. package/references/product-flow.md +408 -0
  65. package/references/schema.md +90 -0
  66. package/references/styling.md +181 -0
  67. package/scripts/install.mjs +95 -0
@@ -0,0 +1,929 @@
1
+ /**
2
+ * lib/validate.mjs — four layers.
3
+ *
4
+ * syntax/ the parser already reported these; they are passed through
5
+ * style/ is every property in the table, is every value legal
6
+ * structure/ ids, types, tags, required attributes
7
+ * a11y/ image alt text
8
+ * asset/ image src
9
+ * version/ the design-schema marker
10
+ * ops/ reported by lib/ops.mjs, passed through here
11
+ *
12
+ * `geometry/` is not in that list and has no codes: overflow, collapse and
13
+ * clipping are not checked anywhere yet. `docs/format-v3.md` §6 says so.
14
+ *
15
+ * The rule that matters most in this file: every element is either valid or it
16
+ * gets a diagnostic. An element is never ignored. Ignoring is how a design file
17
+ * rots, because the canvas cannot render what the validator does not describe.
18
+ *
19
+ * Pure. No node: imports.
20
+ */
21
+
22
+ import { walk, getAttr, hasAttr, pagesOf, dataAttrs, elementChildren, textOf, nodePath, parseDeclarations, styleMapOf } from "./parse.mjs";
23
+ import {
24
+ TYPE_TAGS, TAG_TO_TYPE, TYPE_HAS_CHILDREN, ATTR_BY_TYPE, COMMON_OPTIONAL,
25
+ SHAPE_KINDS, ICON_LIBRARIES, ID_PATTERN, CSS_ALLOWED, CSS_ALIASES,
26
+ REJECTED_PROPERTIES, ensureGenericFamily, normalizeValue,
27
+ PAGE_PLACEMENT,
28
+ } from "./table.mjs";
29
+ import { BASE_RULES, hasBaseRules, missingBaseRules } from "./base-css.mjs";
30
+ import { ICON_VIEW_BOX } from "./icons.mjs";
31
+ import { suggest } from "./levenshtein.mjs";
32
+ import { diagnosticKey } from "./diagnostics.mjs";
33
+ import { findTokenCycles, tokenNameProblem, tokenSites, resolveTokens } from "./tokens.mjs";
34
+ import { lintGeometry, MAX_REPORTED_PER_CODE } from "./geometry.mjs";
35
+ import { normalizeColor, splitTopLevel } from "./table.mjs";
36
+
37
+ // The key lives in `lib/diagnostics.mjs` and is re-exported here because this is
38
+ // the module callers already load for it (`server.mjs` takes it through
39
+ // `loadExport("validate.mjs", [...])`). It used to be defined in both files, with
40
+ // two different field lists; see the note on the implementation.
41
+ export { diagnosticKey };
42
+
43
+ export const SCHEMA_VERSION = "3.0";
44
+ export const SCHEMA_META_NAME = "design-schema";
45
+
46
+ const DATA_TYPE_VALUES = Object.keys(TYPE_TAGS);
47
+
48
+ /**
49
+ * The one required attribute that is not a wording problem.
50
+ *
51
+ * `docs/format-v3.md` §6 gives `a11y/` and `asset/` to this module — image alt
52
+ * text and image `src` — and until now neither prefix was ever emitted: both
53
+ * absences came out as `structure/missing-attribute`, so the contract named two
54
+ * families of diagnostic that did not exist. `lib/table.mjs` owns the rule; this
55
+ * is only where the rule gets its name.
56
+ *
57
+ * Everything else keeps the generic code: an `icon` with no `data-icon-library`
58
+ * is a malformed element, not an accessibility finding.
59
+ */
60
+ const REQUIRED_CODE = {
61
+ "image:alt": "a11y/missing-alt",
62
+ "image:src": "asset/missing-src",
63
+ };
64
+
65
+ function makeDiagnostic(severity, code, message, extra = {}) {
66
+ const d = { severity, code, message };
67
+ if (extra.target) d.target = extra.target;
68
+ if (extra.path) d.path = extra.path;
69
+ if (extra.offset !== undefined && extra.offset !== null) d.offset = extra.offset;
70
+ if (extra.hint) d.hint = extra.hint;
71
+ if (extra.fix) d.fix = extra.fix;
72
+ return d;
73
+ }
74
+
75
+ export function errorKeys(diagnostics) {
76
+ const keys = new Set();
77
+ for (const d of diagnostics) {
78
+ if (d.severity === "error") keys.add(diagnosticKey(d));
79
+ }
80
+ return keys;
81
+ }
82
+
83
+ /** Errors present in `after` that `before` did not already have. */
84
+ export function newErrors(before, after) {
85
+ const known = errorKeys(before);
86
+ return after.filter((d) => d.severity === "error" && !known.has(diagnosticKey(d)));
87
+ }
88
+
89
+ export function summarize(diagnostics) {
90
+ let errors = 0;
91
+ let warnings = 0;
92
+ for (const d of diagnostics) {
93
+ if (d.severity === "error") errors += 1;
94
+ else warnings += 1;
95
+ }
96
+ return { errors, warnings, total: diagnostics.length };
97
+ }
98
+
99
+ // -- tokens -------------------------------------------------------------------
100
+
101
+ /** Every `--name: value` declaration in every token block, merged. */
102
+ export function collectTokens(doc) {
103
+ const { sites, duplicates } = tokenSites(doc);
104
+ return { tokens: sites, duplicates: duplicates.map((entry) => entry.name) };
105
+ }
106
+
107
+ // -- the validator ------------------------------------------------------------
108
+
109
+ /**
110
+ * @param {object} doc parsed document
111
+ * @param {object} options `{ strict, tokens }`
112
+ * @returns {Array} diagnostics
113
+ */
114
+ export function validateDesign(doc, options = {}) {
115
+ const out = [];
116
+ for (const d of doc.diagnostics || []) out.push(d);
117
+
118
+ const strict = options.strict === true;
119
+ const warn = (code, message, extra) => out.push(makeDiagnostic(strict ? "error" : "warning", code, message, extra));
120
+ const error = (code, message, extra) => out.push(makeDiagnostic("error", code, message, extra));
121
+
122
+ // -- version ----------------------------------------------------------------
123
+ const meta = findMeta(doc, SCHEMA_META_NAME);
124
+ if (!meta) {
125
+ error("version/missing-marker",
126
+ `The file has no <meta name="${SCHEMA_META_NAME}" content="${SCHEMA_VERSION}">.`,
127
+ { hint: `Add <meta name="${SCHEMA_META_NAME}" content="${SCHEMA_VERSION}"> inside <head>.` });
128
+ } else if (meta.value !== SCHEMA_VERSION) {
129
+ error("version/unsupported",
130
+ `The file says schema ${JSON.stringify(meta.value)}. This build reads ${SCHEMA_VERSION}.`,
131
+ { offset: meta.offset, hint: `Run \`design migrate\` to move the file to ${SCHEMA_VERSION}.` });
132
+ }
133
+
134
+ // -- style blocks -----------------------------------------------------------
135
+ const headStyleCount = (doc.styleBlocks || []).filter((b) => b.inHead).length;
136
+ for (const block of doc.styleBlocks || []) {
137
+ if (!block.inHead) {
138
+ error("style/style-in-body",
139
+ "A <style> element sits outside <head>.",
140
+ { offset: block.node.start, hint: "Only two style blocks are read, and both live in <head>." });
141
+ continue;
142
+ }
143
+ if (block.role === "unknown") {
144
+ error("style/unsupported-block",
145
+ "This <style> block is neither the token block nor the page-frame block.",
146
+ {
147
+ offset: block.node.start,
148
+ hint: 'Allowed content: `:root{--name:value}` and `section[data-type="page"]{...}`.',
149
+ });
150
+ }
151
+ }
152
+ if (headStyleCount === 0) {
153
+ warn("style/no-token-block",
154
+ "The file has no token block, so it has no design tokens.",
155
+ { hint: "Add <style>:root{--color-ink:#111827}</style> in <head>." });
156
+ }
157
+
158
+ // The base rules are what make a `width` mean the width of the box. Without
159
+ // them a browser reads `width:375px;padding:0 24px` as 423px, and a `<p>`
160
+ // brings a margin the file never asked for. Every renderer of this file — the
161
+ // browser, the canvas, and `export` — reads the rules that are written down,
162
+ // and none of them supplies a box model of its own.
163
+ const styleText = (doc.styleBlocks || []).map((block) => block.text).join("\n");
164
+ if (!hasBaseRules(styleText)) {
165
+ warn("style/no-base-rules",
166
+ "The file states no box model, so a browser reads every `width` as a content width and gives text tags a default margin.",
167
+ {
168
+ offset: (doc.styleBlocks || [])[0]?.node.start,
169
+ hint: `Put \`${BASE_RULES[0]}\` and \`${BASE_RULES[1]}\` in front of the token block. \`design new\` and \`design import\` write them.`,
170
+ });
171
+ } else {
172
+ // A file written before a rule existed keeps its box model and loses one
173
+ // shape or one glyph — `data-kind="ellipse"` with nothing to round it draws
174
+ // a square, and nothing about the page looks broken. So the missing rules
175
+ // are named, because a silent difference is the one thing this format does
176
+ // not allow.
177
+ const missing = missingBaseRules(styleText);
178
+ if (missing.length > 0) {
179
+ warn("style/incomplete-base-rules",
180
+ `The file states a box model but omits ${missing.length === 1 ? "one base rule" : `${missing.length} base rules`}: ${missing.join(" ")}`,
181
+ {
182
+ offset: (doc.styleBlocks || [])[0]?.node.start,
183
+ hint: "Add them in front of the token block. `design new` and `design import` write the current set.",
184
+ });
185
+ }
186
+ }
187
+
188
+ const { tokens, duplicates } = collectTokens(doc);
189
+ for (const name of new Set(duplicates)) {
190
+ // Not "the last one wins", which is what this used to say and what §4.1 forbids:
191
+ // both style blocks are collected, and the later block must not overwrite the
192
+ // earlier one. A reader who is told the last declaration wins has been told to
193
+ // edit the wrong one — and `tokens set` would have written to the first.
194
+ const site = tokens.get(name);
195
+ warn("style/duplicate-token", `The token ${name} is declared more than once.`, {
196
+ offset: site.offset,
197
+ hint: "A design has one token block. Keep one declaration per token.",
198
+ });
199
+ }
200
+ for (const cycle of findTokenCycles(tokens)) {
201
+ error("style/token-cycle",
202
+ cycle.names.length === 1
203
+ ? `The token ${cycle.names[0]} references itself.`
204
+ : `The tokens ${cycle.names.join(" → ")} → ${cycle.names[0]} reference each other in a cycle.`,
205
+ {
206
+ offset: cycle.offsets[0],
207
+ hint: "A token may reference a token, but the chain has to reach a value. Break the cycle.",
208
+ });
209
+ }
210
+ for (const token of tokens.values()) {
211
+ const problem = tokenNameProblem(token.name);
212
+ if (problem !== null) {
213
+ warn("style/token-name", problem.message, { offset: token.offset, hint: problem.hint });
214
+ }
215
+ }
216
+
217
+ // -- pages ------------------------------------------------------------------
218
+ const pages = pagesOf(doc);
219
+ if (pages.length === 0) {
220
+ error("structure/no-page",
221
+ 'The file has no page. A page is <section data-type="page">.',
222
+ { hint: 'Add <section data-id="home" data-type="page" style="width:390px;height:844px"></section>.' });
223
+ }
224
+
225
+ const seenIds = new Map();
226
+ const seenCanvas = new Map();
227
+
228
+ walk(doc, (node) => {
229
+ if (node.kind !== "element") return;
230
+ const inSvg = insideSvg(node);
231
+ checkElement(node, { doc, out, error, warn, tokens, seenIds, seenCanvas, pages, parseDeclarations, inSvg });
232
+ });
233
+
234
+ // -- page placement ---------------------------------------------------------
235
+ for (const [key, entries] of seenCanvas) {
236
+ if (entries.length < 2) continue;
237
+ for (const entry of entries.slice(1)) {
238
+ warn("structure/duplicate-canvas",
239
+ `Two pages sit at the same canvas point ${key}.`,
240
+ {
241
+ target: entry.id || undefined, offset: entry.node.start,
242
+ hint: "The canvas will draw one over the other. Move this page, or run `design format` to lay the pages out again.",
243
+ });
244
+ }
245
+ }
246
+
247
+ // -- geometry --------------------------------------------------------------
248
+ // Certainty-only arithmetic over the declared layout. `lib/geometry.mjs`
249
+ // reports nothing it cannot prove, so a text-heavy design stays quiet.
250
+ //
251
+ // Every one of these is a warning unless `--strict` promotes it, which is what
252
+ // keeps a layout observation from blocking a write: `newErrors` compares only
253
+ // the diagnostics whose severity is `error`.
254
+ //
255
+ // They carry no `offset`, because they speak about layout rather than about a
256
+ // position in the text. That makes the diagnostic key
257
+ // `code|""|target|message` — which is why §6 of the contract says `target` is
258
+ // the one part of that key that cannot be dropped.
259
+ for (const entry of lintGeometry(doc, tokens)) {
260
+ out.push(makeDiagnostic(strict ? "error" : "warning", entry.code, entry.message, {
261
+ target: entry.target,
262
+ path: entry.path,
263
+ hint: entry.hint,
264
+ }));
265
+ }
266
+
267
+ // -- contrast --------------------------------------------------------------
268
+ // The one `a11y/` check besides `a11y/missing-alt`. Three cases report nothing,
269
+ // and each one is a case where the ratio is not knowable: a photograph, a
270
+ // gradient whose every stop is translucent, and a translucent layer with nothing
271
+ // opaque under it. See `paintOf`.
272
+ //
273
+ // `lintContrast` applies the per-code limit itself, the same way
274
+ // `lintGeometry` does, so the canvas and this function report the same set.
275
+ for (const entry of lintContrast(doc, tokens)) {
276
+ out.push(makeDiagnostic(strict ? "error" : "warning", entry.code, entry.message, {
277
+ target: entry.target,
278
+ path: entry.path,
279
+ hint: entry.hint,
280
+ }));
281
+ }
282
+
283
+ out.sort(bySeverityThenOffset);
284
+ return out;
285
+ }
286
+
287
+ /* ---- contrast ------------------------------------------------------------ */
288
+
289
+ /** The WCAG AA minimum for body text. One threshold, no large-text exception. */
290
+ export const CONTRAST_MINIMUM = 4.5;
291
+
292
+ function hexToRgb(hex) {
293
+ const body = hex.slice(1);
294
+ return {
295
+ r: parseInt(body.slice(0, 2), 16),
296
+ g: parseInt(body.slice(2, 4), 16),
297
+ b: parseInt(body.slice(4, 6), 16),
298
+ };
299
+ }
300
+
301
+ function srgbToLinear(channel) {
302
+ const c = channel / 255;
303
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
304
+ }
305
+
306
+ function relativeLuminance(hex) {
307
+ const { r, g, b } = hexToRgb(hex);
308
+ return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
309
+ }
310
+
311
+ /** The WCAG contrast ratio between two six-digit hex colours. */
312
+ export function contrastRatio(hexA, hexB) {
313
+ const a = relativeLuminance(hexA);
314
+ const b = relativeLuminance(hexB);
315
+ const [hi, lo] = a >= b ? [a, b] : [b, a];
316
+ return (hi + 0.05) / (lo + 0.05);
317
+ }
318
+
319
+ /** One colour of a gradient stop, or null when the part is not a colour. */
320
+ function stopColorOf(part, tokens) {
321
+ const words = splitTopLevel(String(part).trim(), " ");
322
+ // The colour of a stop is its last resolvable word: `#111 0%`, or just `#111`.
323
+ for (const word of [...words].reverse()) {
324
+ try {
325
+ const hex = normalizeColor(resolveTokens(word, tokens));
326
+ if (hex === "transparent") return null;
327
+ return hex;
328
+ } catch {
329
+ // Not a colour. Try the word before it.
330
+ }
331
+ }
332
+ return null;
333
+ }
334
+
335
+ /**
336
+ * An sRGB alpha composite: `foreground` (8-digit hex) laid over `background`
337
+ * (6-digit hex), giving the colour a reader actually sees.
338
+ *
339
+ * Browsers composite a plain `background-color` alpha in sRGB, which is what makes
340
+ * this arithmetic instead of a measurement. Verified against one: white text on a
341
+ * `#00000080` scrim over a white surface reads as `#808080`, and
342
+ * `contrastRatio("#ffffff", "#808080")` is 3.95 — a real failure that abstaining
343
+ * used to hide.
344
+ */
345
+ function compositeOver(foreground, background) {
346
+ const alpha = parseInt(foreground.slice(7, 9), 16) / 255;
347
+ const channel = (offset) => {
348
+ const over = parseInt(foreground.slice(offset, offset + 2), 16);
349
+ const under = parseInt(background.slice(offset, offset + 2), 16);
350
+ return Math.round(over * alpha + under * (1 - alpha));
351
+ };
352
+ const hex = (value) => value.toString(16).padStart(2, "0");
353
+ return `#${hex(channel(1))}${hex(channel(3))}${hex(channel(5))}`;
354
+ }
355
+
356
+ /**
357
+ * What a node paints behind its children.
358
+ *
359
+ * - `{ kind: "paint", colors }` — one or more opaque colours the text sits on.
360
+ * A gradient gives every opaque stop, because the text sits on all of them.
361
+ * - `{ kind: "layer", color }` — a translucent colour. It is not itself a
362
+ * background; it is a layer over whatever the ancestor resolved to, and
363
+ * `compositeOver` joins the two.
364
+ * - `{ kind: "stop" }` — the ratio is not knowable, so no check runs below.
365
+ * - `{ kind: "inherit" }` — this node paints nothing, so the ancestor shows.
366
+ */
367
+ function paintOf(style, tokens) {
368
+ const image = style.get("background-image");
369
+ if (image !== undefined) {
370
+ const resolved = resolveTokens(String(image), tokens).trim();
371
+ const lower = resolved.toLowerCase();
372
+ // A photograph: the checker cannot see what is in the image. Saying "white
373
+ // on #f8f1ee" about text that sits on a photo is worse than saying nothing,
374
+ // because it is confidently wrong.
375
+ if (lower.startsWith("url(")) return { kind: "stop" };
376
+ if (!/^(?:repeating-)?linear-gradient\(/i.test(lower)) return { kind: "stop" };
377
+ const open = resolved.indexOf("(");
378
+ const close = resolved.lastIndexOf(")");
379
+ if (open === -1 || close <= open) return { kind: "stop" };
380
+ const colors = [];
381
+ for (const part of splitTopLevel(resolved.slice(open + 1, close), ",")) {
382
+ const hex = stopColorOf(part, tokens);
383
+ if (hex && hex.length === 7) colors.push(hex);
384
+ }
385
+ return colors.length > 0 ? { kind: "paint", colors } : { kind: "stop" };
386
+ }
387
+
388
+ const color = style.get("background-color");
389
+ if (color === undefined) return { kind: "inherit" };
390
+ let canonical;
391
+ try {
392
+ canonical = normalizeColor(resolveTokens(String(color), tokens));
393
+ } catch {
394
+ return { kind: "stop" };
395
+ }
396
+ if (canonical === "transparent") return { kind: "inherit" };
397
+ // A translucent paint is a layer, not a background. Composing it over an opaque
398
+ // ancestor is arithmetic, so it is done rather than abstained on — a 50% black
399
+ // scrim over a white card is `#808080`, and white text on it fails at 3.95:1.
400
+ // `docs/format-v3.md` §6 calls this the translucent-fill rule.
401
+ //
402
+ // What is still not knowable, and is still abstained on, is a layer with nothing
403
+ // opaque under it: the surface below the page is the deliverable's, not the
404
+ // file's, so the ratio would be a guess. Version 1 kept the ancestor's fill
405
+ // outright, which scored the scrim as if it were the card.
406
+ if (canonical.length === 9) return { kind: "layer", color: canonical };
407
+ return { kind: "paint", colors: [canonical] };
408
+ }
409
+
410
+ /**
411
+ * Contrast findings, as plain objects, capped by the same rule the geometry pass
412
+ * uses. Exported so the canvas can show them without running the whole validator:
413
+ * it draws the file, and this is the one `a11y/` fact that is a fact about the
414
+ * drawing rather than about the file's shape.
415
+ */
416
+ export function lintContrast(doc, tokens) {
417
+ const findings = [];
418
+ const counts = new Map();
419
+
420
+ const visit = (node, inherited) => {
421
+ const style = styleMapOf(node);
422
+ const own = paintOf(style, tokens);
423
+ let paint = inherited;
424
+ if (own.kind === "paint") paint = own;
425
+ else if (own.kind === "stop") paint = null;
426
+ // A translucent layer joins whatever is under it. With nothing opaque under it
427
+ // the ratio is a guess, so the branch leaves `paint` null and nothing below is
428
+ // checked. A gradient is several backgrounds, and the layer goes over each.
429
+ else if (own.kind === "layer") {
430
+ paint = inherited ? { colors: inherited.colors.map((under) => compositeOver(own.color, under)) } : null;
431
+ }
432
+
433
+ if (getAttr(node, "data-type") === "text" && paint && textOf(node).trim() !== "") {
434
+ const raw = style.get("color");
435
+ if (raw !== undefined) {
436
+ let foreground = null;
437
+ try {
438
+ foreground = normalizeColor(resolveTokens(String(raw), tokens));
439
+ } catch {
440
+ foreground = null;
441
+ }
442
+ if (foreground && foreground.length === 7) {
443
+ let worst = null;
444
+ for (const background of paint.colors) {
445
+ const ratio = contrastRatio(foreground, background);
446
+ if (worst === null || ratio < worst.ratio) worst = { ratio, background };
447
+ }
448
+ if (worst && worst.ratio < CONTRAST_MINIMUM) {
449
+ // The worst stop decides. Passing on the best one would be a claim
450
+ // about a gradient the text does not sit on everywhere.
451
+ const over = paint.colors.length > 1 ? ` (worst of ${paint.colors.length} gradient stops)` : "";
452
+ findings.push({
453
+ code: "a11y/low-contrast",
454
+ target: getAttr(node, "data-id") || undefined,
455
+ path: nodePath(node),
456
+ message: `Contrast ratio ${worst.ratio.toFixed(2)}:1 is below the WCAG AA minimum of ${CONTRAST_MINIMUM}:1`,
457
+ hint: `${foreground} text on ${worst.background}${over}`,
458
+ });
459
+ }
460
+ }
461
+ }
462
+ }
463
+
464
+ for (const child of elementChildren(node)) visit(child, paint);
465
+ };
466
+
467
+ for (const page of pagesOf(doc)) visit(page, null);
468
+
469
+ // Bounded like the geometric pass: one faint token on the reference file
470
+ // produces 92 true findings of one code, and a report nobody reads is a check
471
+ // that does not work.
472
+ return findings.filter((entry) => {
473
+ const seen = counts.get(entry.code) || 0;
474
+ counts.set(entry.code, seen + 1);
475
+ return seen < MAX_REPORTED_PER_CODE;
476
+ });
477
+ }
478
+
479
+ function bySeverityThenOffset(a, b) {
480
+ if (a.severity !== b.severity) return a.severity === "error" ? -1 : 1;
481
+ const ao = a.offset ?? Number.MAX_SAFE_INTEGER;
482
+ const bo = b.offset ?? Number.MAX_SAFE_INTEGER;
483
+ if (ao !== bo) return ao - bo;
484
+ return a.code < b.code ? -1 : a.code > b.code ? 1 : 0;
485
+ }
486
+
487
+ export function findMeta(doc, name) {
488
+ let found = null;
489
+ const visit = (node) => {
490
+ for (const child of node.children || []) {
491
+ if (child.kind !== "element") continue;
492
+ if (child.tag === "meta" && (getAttr(child, "name") || "") === name) {
493
+ if (!found) found = { value: getAttr(child, "content"), offset: child.start, node: child };
494
+ }
495
+ if (child.tag !== "body") visit(child);
496
+ }
497
+ };
498
+ visit(doc);
499
+ return found;
500
+ }
501
+
502
+ /**
503
+ * The document skeleton is not part of the design layer, but it is still
504
+ * checked. Without this list a valid file would report <html> and <head> as
505
+ * unsupported tags, which is the loudest possible way to be wrong.
506
+ */
507
+ export const SKELETON_TAGS = new Set(["html", "head", "body", "title", "meta", "link", "style", "script", "base"]);
508
+
509
+ /** An `<svg>` draws its own geometry. Those children are not design elements. */
510
+ export function insideSvg(node) {
511
+ let current = node.parent;
512
+ while (current) {
513
+ if (current.kind === "element" && current.tag === "svg") return true;
514
+ current = current.parent;
515
+ }
516
+ return false;
517
+ }
518
+
519
+ function checkElement(node, ctx) {
520
+ const { out, error, warn, tokens, seenIds, seenCanvas, pages } = ctx;
521
+ const id = getAttr(node, "data-id");
522
+ const type = getAttr(node, "data-type");
523
+ const path = nodePath(node);
524
+
525
+ // The parser reports its own syntax problems. An element we cannot model is
526
+ // still in the tree, and it is still an error: silently keeping it would let
527
+ // a write delete it later.
528
+ if (SKELETON_TAGS.has(node.tag) && !type) return;
529
+
530
+ // Inside `<svg>` is the opaque layer: `<path>`, `<circle>`, `<title>` and the
531
+ // rest of the geometry. It stays in the tree byte for byte, and the design
532
+ // layer does not claim it. A glyph is validated through its `<svg>`.
533
+ if (ctx.inSvg) {
534
+ if (getAttr(node, "data-id") || getAttr(node, "data-type")) {
535
+ error("structure-id-inside-icon",
536
+ `<${node.tag}> is inside an <svg>, so it is drawing geometry, not a design element.`,
537
+ {
538
+ target: getAttr(node, "data-id") || undefined,
539
+ offset: node.start,
540
+ hint: 'Remove data-id and data-type. Address the icon through its <svg data-type="icon"> element.',
541
+ });
542
+ }
543
+ return;
544
+ }
545
+
546
+ if (!TAG_TO_TYPE[node.tag]) {
547
+ error("structure/unsupported-tag",
548
+ `<${node.tag}> is not part of the format.`,
549
+ {
550
+ target: id || undefined,
551
+ path,
552
+ offset: node.start,
553
+ hint: `Allowed tags: ${[...new Set(Object.values(TYPE_TAGS).flat())].join(", ")}.`,
554
+ });
555
+ return;
556
+ }
557
+
558
+ if (!id) {
559
+ error("structure/missing-id",
560
+ `<${node.tag}> has no data-id.`,
561
+ {
562
+ path,
563
+ offset: node.start,
564
+ hint: `Every element is addressable. A page-named id such as home__${node.tag === "p" ? "title" : "box"} is the convention.`,
565
+ });
566
+ } else if (!ID_PATTERN.test(id)) {
567
+ error("structure/bad-id",
568
+ `The id ${JSON.stringify(id)} is not a legal id.`,
569
+ {
570
+ target: id, path, offset: node.start,
571
+ hint: "Start with a letter or _, then letters, digits, _ and - only. 64 characters at most.",
572
+ });
573
+ } else if (seenIds.has(id)) {
574
+ const keeper = seenIds.get(id);
575
+ // A duplicate is a collision, not a typo. The useful hint is a name that is
576
+ // free, so the author can copy it instead of inventing one.
577
+ const free = firstFreeId(id, seenIds);
578
+ error("structure/duplicate-id",
579
+ `The id ${id} is already used at offset ${keeper.node.start}.`,
580
+ {
581
+ target: id,
582
+ offset: node.start,
583
+ hint: `Rename this one to ${free}, or rename the other. Ids are unique across the whole file, including every page.`,
584
+ });
585
+ } else {
586
+ seenIds.set(id, { node });
587
+ }
588
+
589
+ if (!type) {
590
+ error("structure/missing-type",
591
+ `<${node.tag}> has no data-type.`,
592
+ {
593
+ target: id || undefined, path, offset: node.start,
594
+ hint: `Choose one of: ${DATA_TYPE_VALUES.join(", ")}. <${node.tag}> defaults to ${TAG_TO_TYPE[node.tag]}.`,
595
+ });
596
+ return;
597
+ }
598
+
599
+ if (!DATA_TYPE_VALUES.includes(type)) {
600
+ const suggestion = suggest(type, DATA_TYPE_VALUES);
601
+ error("structure/unknown-type",
602
+ `${type} is not an element type.`,
603
+ {
604
+ target: id || undefined, path, offset: node.start,
605
+ hint: suggestion ? `Did you mean ${suggestion}? Valid: ${DATA_TYPE_VALUES.join(", ")}.` : `Valid: ${DATA_TYPE_VALUES.join(", ")}.`,
606
+ });
607
+ return;
608
+ }
609
+
610
+ const allowedTags = TYPE_TAGS[type];
611
+ if (!allowedTags.includes(node.tag)) {
612
+ error("structure/type-tag-mismatch",
613
+ `data-type="${type}" needs one of ${allowedTags.join(", ")}, and this element is <${node.tag}>.`,
614
+ {
615
+ target: id || undefined, path, offset: node.start,
616
+ hint: `<${node.tag}> carries data-type="${TAG_TO_TYPE[node.tag]}".`,
617
+ });
618
+ return;
619
+ }
620
+
621
+ // Children are only allowed on some types.
622
+ const childElements = elementChildren(node);
623
+ const text = textOf(node);
624
+ if (type === "icon" && childElements.length === 0 && !hasAttr(node, "data-hidden")) {
625
+ warn("structure/empty-icon",
626
+ "This icon has no geometry, so it renders as nothing.",
627
+ {
628
+ target: id || undefined, path, offset: node.start,
629
+ hint: "Put the glyph's <path> or <circle> elements inside the <svg>.",
630
+ });
631
+ }
632
+ if (!TYPE_HAS_CHILDREN.has(type) && type !== "icon" && childElements.length) {
633
+ error("structure/children-not-allowed",
634
+ `data-type="${type}" cannot contain elements, and this one contains ${childElements.map((c) => `<${c.tag}>`).join(", ")}.`,
635
+ {
636
+ target: id || undefined, path, offset: node.start,
637
+ hint: type === "text"
638
+ ? "A text element holds one run of text. Wrap the parts in a frame, or use <span> for inline emphasis."
639
+ : `Wrap the content in a frame, then place the ${type} beside it.`,
640
+ });
641
+ }
642
+ if (type === "text" && childElements.length === 0 && text.trim() === "" && !hasAttr(node, "data-hidden")) {
643
+ warn("structure/empty-text", "This text element has no text.", { target: id || undefined, path, offset: node.start, hint: "Add text, or set data-hidden=\"true\"." });
644
+ }
645
+
646
+ // Required and optional attributes.
647
+ const contract = ATTR_BY_TYPE[type] || { required: [], optional: [] };
648
+ for (const name of contract.required) {
649
+ const value = getAttr(node, name);
650
+ if (value === null || value === "") {
651
+ error(REQUIRED_CODE[`${type}:${name}`] ?? "structure/missing-attribute",
652
+ `data-type="${type}" requires ${name}.`,
653
+ { target: id || undefined, path, offset: node.start, hint: requiredHint(type, name) });
654
+ }
655
+ }
656
+
657
+ const allowedData = new Set([...COMMON_OPTIONAL, ...contract.required, ...contract.optional]);
658
+ for (const attr of dataAttrs(node)) {
659
+ if (attr.name === "data-id" || attr.name === "data-type") continue;
660
+ if (allowedData.has(attr.name)) continue;
661
+ const suggestion = suggest(attr.name, [...allowedData]);
662
+ error("structure/unknown-attribute",
663
+ `${attr.name} is not an attribute of data-type="${type}".`,
664
+ {
665
+ target: id || undefined, path, offset: attr.start,
666
+ hint: suggestion ? `Did you mean ${suggestion}?` : `Allowed: ${[...allowedData].join(", ")}.`,
667
+ });
668
+ }
669
+
670
+ if (type === "shape") {
671
+ const kind = getAttr(node, "data-kind");
672
+ if (kind && !SHAPE_KINDS.includes(kind)) {
673
+ const suggestion = suggest(kind, SHAPE_KINDS);
674
+ error("structure/bad-shape-kind",
675
+ `${kind} is not a shape kind.`,
676
+ { target: id || undefined, offset: node.start, hint: suggestion ? `Did you mean ${suggestion}?` : `Valid: ${SHAPE_KINDS.join(", ")}.` });
677
+ }
678
+ // `data-kind="ellipse"` is a claim about shape, and a `div` is a rectangle
679
+ // until something rounds it. The declaration has to be in the node's own
680
+ // style: a `[data-kind="ellipse"]` rule in the head would round it in the
681
+ // file and stop matching in the deliverable, where `export` drops the design
682
+ // attributes — a circle that ships as a square.
683
+ if (kind === "ellipse" && !roundsItself(node)) {
684
+ warn("style/ellipse-without-radius",
685
+ "This node declares data-kind=\"ellipse\" and nothing rounds it, so it draws as a rectangle.",
686
+ {
687
+ target: id || undefined,
688
+ offset: node.start,
689
+ hint: "Add `border-radius:50%` to the element's style. `%` measures against both axes, so a 60×20 node is an ellipse and not a pill.",
690
+ });
691
+ }
692
+ }
693
+
694
+ if (type === "icon") {
695
+ const library = getAttr(node, "data-icon-library");
696
+ if (library && !ICON_LIBRARIES.includes(library)) {
697
+ error("structure/unknown-icon-library",
698
+ `${library} is not a known icon library.`,
699
+ { target: id || undefined, offset: node.start, hint: `Known: ${ICON_LIBRARIES.join(", ")}.` });
700
+ }
701
+ const glyph = getAttr(node, "data-icon");
702
+ if (glyph && !/^[a-z0-9-]{1,40}$/.test(glyph)) {
703
+ error("structure/bad-icon-name",
704
+ `${JSON.stringify(glyph)} is not a glyph name.`,
705
+ { target: id || undefined, offset: node.start, hint: "Lower case letters, digits and hyphens, such as shield-check." });
706
+ }
707
+ // Geometry without a grid is not scaled, it is clipped: a 24×24 glyph in a
708
+ // 15×15 element shows its top-left corner and nothing else. That is how
709
+ // `target`'s three rings became one arc.
710
+ if (childElements.length && !hasAttr(node, "viewBox")) {
711
+ error("structure/icon-without-viewbox",
712
+ "This icon has geometry but no viewBox, so the browser clips it instead of scaling it.",
713
+ {
714
+ target: id || undefined,
715
+ offset: node.start,
716
+ hint: `Declare the grid the geometry was drawn on: viewBox="${ICON_VIEW_BOX}".`,
717
+ });
718
+ }
719
+ }
720
+
721
+ if (type === "page") {
722
+ const canvas = getAttr(node, "data-canvas");
723
+ if (canvas !== null) {
724
+ const parsed = parseCanvasPosition(canvas);
725
+ if (!parsed) {
726
+ error("structure/bad-canvas",
727
+ `data-canvas must be "x,y", and this one is ${JSON.stringify(canvas)}.`,
728
+ { target: id || undefined, offset: node.start, hint: 'Such as data-canvas="0,920".' });
729
+ } else {
730
+ const key = `${parsed.x},${parsed.y}`;
731
+ if (!seenCanvas.has(key)) seenCanvas.set(key, []);
732
+ seenCanvas.get(key).push({ node, id });
733
+ }
734
+ }
735
+ // A page's place on the canvas is `data-canvas`. A canvas drag once wrote
736
+ // that place as CSS on the page as well, and the two spellings then
737
+ // disagree: the canvas positions a page from `data-canvas` and shows one
738
+ // thing, while `export` drops the CSS and would have shipped another. The
739
+ // export drops it either way, so this is a warning about a stale spelling,
740
+ // not a broken deliverable.
741
+ const declarations = parseDeclarations(getAttr(node, "style") ?? "");
742
+ const placement = declarations
743
+ .map((d) => d.property)
744
+ .filter((property) => PAGE_PLACEMENT.has(property));
745
+ if (placement.length > 0) {
746
+ warn("structure/page-position",
747
+ `This page states its own placement in style: ${placement.join(", ")}. The canvas places a page with data-canvas.`,
748
+ {
749
+ target: id || undefined, offset: node.start,
750
+ hint: canvas === null ? "Remove those declarations, or move the page with data-canvas." : `Remove those declarations. This page is at data-canvas="${canvas}".`,
751
+ });
752
+ }
753
+ // A page is the deliverable: `export` writes its box as the image, and the
754
+ // canvas places it by `data-canvas`. A rotation on it turns the artboard and
755
+ // clips the corners inside its own bounds, which is never what the design
756
+ // meant — the tilt belongs to the card.
757
+ const rotated = declarations.find((declaration) => declaration.property === "transform");
758
+ if (rotated) {
759
+ error("style/transform-on-page",
760
+ "A page cannot carry a transform; it is the deliverable and the canvas places it with data-canvas.",
761
+ {
762
+ target: id || undefined,
763
+ offset: node.start,
764
+ hint: "Move the transform to the page's content — the cards on the canvas — and leave the page a plain section.",
765
+ });
766
+ }
767
+ if (type === "page" && node.tag !== "section") return;
768
+ }
769
+
770
+ // -- style -------------------------------------------------------------------
771
+ const styleAttr = node.attrs.find((a) => a.name === "style");
772
+ if (!styleAttr) {
773
+ if (node.tag !== "svg" && !TYPE_HAS_CHILDREN.has(type)) {
774
+ warn("style/no-style",
775
+ `<${node.tag}> has no style attribute, so it renders with browser defaults.`,
776
+ { target: id || undefined, path, offset: node.start, hint: "Add the properties this element actually needs." });
777
+ }
778
+ return;
779
+ }
780
+
781
+ if (!styleAttr.value || styleAttr.value.trim() === "") {
782
+ warn("style/empty-style", "The style attribute is empty.", { target: id || undefined, offset: styleAttr.start, hint: "Remove the attribute, or put a declaration in it." });
783
+ return;
784
+ }
785
+
786
+ checkStyle(node, styleAttr, { ...ctx, tokens, path, id });
787
+ }
788
+
789
+ /**
790
+ * True when this element's own style rounds it.
791
+ *
792
+ * Only the element's own `style` counts. `data-kind` is a design attribute, so
793
+ * `export` strips it: a `[data-kind="ellipse"]` rule in the head rounds the node
794
+ * in the file and leaves it square in the deliverable.
795
+ */
796
+ function roundsItself(node) {
797
+ const styleAttr = node.attrs?.find((a) => a.name === "style");
798
+ if (!styleAttr || !styleAttr.value) return false;
799
+ return parseDeclarations(styleAttr.value).some((d) => d.property === "border-radius");
800
+ }
801
+
802
+ function requiredHint(type, name) {
803
+ if (type === "image" && name === "alt") return 'Write alt="" for a decorative image. A missing alt is not the same as an empty one.';
804
+ if (type === "image" && name === "src") return 'Such as src="hero.png".';
805
+ if (type === "icon" && name === "data-icon") return 'Such as data-icon="shield-check".';
806
+ if (type === "icon" && name === "data-icon-library") return 'Such as data-icon-library="lucide".';
807
+ return "See docs/format-v3.md section 3.";
808
+ }
809
+
810
+ /** The first `id-2`, `id-3`, … that nothing else uses. */
811
+ export function firstFreeId(id, taken) {
812
+ const base = id.replace(/-\d+$/, "");
813
+ for (let n = 2; n < 1000; n += 1) {
814
+ const candidate = `${base}-${n}`;
815
+ if (!taken.has(candidate)) return candidate;
816
+ }
817
+ return `${base}-copy`;
818
+ }
819
+
820
+ /**
821
+ * The shorthand for a one-sided box property, with the value that was written.
822
+ * Returns null for anything that is not a box side.
823
+ */
824
+ export function shorthandHint(property, value) {
825
+ const side = /^(margin|padding|border)-(top|right|bottom|left)$/.exec(property);
826
+ if (!side) return null;
827
+ const [, base, where] = side;
828
+ if (base === "border") return null;
829
+ const parts = ["0", "0", "0", "0"];
830
+ parts[["top", "right", "bottom", "left"].indexOf(where)] = String(value).trim();
831
+ return `Write the shorthand: ${base}: ${parts.join(" ").replace(/ 0 0 0$/, " 0 0 0")}.`;
832
+ }
833
+
834
+ export function parseCanvasPosition(value) {
835
+ const match = /^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/.exec(String(value));
836
+ if (!match) return null;
837
+ return { x: Number(match[1]), y: Number(match[2]) };
838
+ }
839
+
840
+ /**
841
+ * Style rules. Every declaration is either accepted or reported.
842
+ * Nothing is dropped here: a declaration that cannot be accepted becomes a
843
+ * diagnostic, because a silently dropped property is a property the canvas
844
+ * would then not render either, and the author would never find out.
845
+ */
846
+ function checkStyle(node, styleAttr, ctx) {
847
+ const { error, warn, tokens, path, id } = ctx;
848
+ const declarations = (ctx.parseDeclarations || parseDeclarations)(styleAttr.value);
849
+
850
+ const base = styleAttr.valueStart;
851
+ for (const decl of declarations) {
852
+ const offset = base + decl.textStart;
853
+ const property = decl.property;
854
+ if (property === "") {
855
+ error("style/no-property",
856
+ "A declaration has no property name.",
857
+ { target: id || undefined, path, offset, hint: `Write name:value, such as color:#111827. Got ${JSON.stringify(decl.rawValue.trim().slice(0, 40))}.` });
858
+ continue;
859
+ }
860
+ if (property.startsWith("--")) continue;
861
+
862
+ const rejected = REJECTED_PROPERTIES[property];
863
+ if (rejected) {
864
+ // A one-sided margin is refused in favour of the shorthand, and the hint
865
+ // has to name the shorthand FOR THIS VALUE. A fixed string would tell the
866
+ // author to write `margin: 8px 0 0 0` for a 16px offset.
867
+ error("style/rejected-property",
868
+ rejected.reason,
869
+ {
870
+ target: id || undefined, path, offset,
871
+ hint: shorthandHint(property, decl.value) ?? (rejected.replacement ? `Use ${rejected.replacement} instead.` : "Remove it."),
872
+ });
873
+ continue;
874
+ }
875
+ if (CSS_ALIASES[property]) {
876
+ warn("style/aliased-property",
877
+ `${property} is written as ${CSS_ALIASES[property]}.`,
878
+ { target: id || undefined, path, offset, hint: `Write ${CSS_ALIASES[property]}:${decl.value} directly.` });
879
+ continue;
880
+ }
881
+ if (!CSS_ALLOWED[property]) {
882
+ const suggestion = suggest(property, Object.keys(CSS_ALLOWED));
883
+ error("style/unknown-property",
884
+ `${property} is not in the format.`,
885
+ {
886
+ target: id || undefined, path, offset,
887
+ hint: suggestion ? `Did you mean ${suggestion}?` : "See references/styling.md for the full list.",
888
+ });
889
+ continue;
890
+ }
891
+
892
+ try {
893
+ normalizeValue(property, decl.value);
894
+ } catch (e) {
895
+ error("style/bad-value",
896
+ `${property}:${decl.value} — ${e.message}`,
897
+ { target: id || undefined, path, offset, hint: e.hint });
898
+ continue;
899
+ }
900
+
901
+ if (decl.value.includes("var(")) {
902
+ for (const match of decl.value.matchAll(/var\(\s*(--[A-Za-z0-9_-]+)/g)) {
903
+ const name = match[1];
904
+ if (tokens.size && !tokens.has(name)) {
905
+ const suggestion = suggest(name, [...tokens.keys()]);
906
+ warn("style/unknown-token",
907
+ `${name} is not declared in the token block.`,
908
+ {
909
+ target: id || undefined, path, offset,
910
+ hint: suggestion ? `Did you mean ${suggestion}?` : `Declared tokens: ${[...tokens.keys()].join(", ") || "none"}.`,
911
+ });
912
+ }
913
+ }
914
+ }
915
+
916
+ // A family list with no generic family at the end has no defined fallback.
917
+ // When the webfont does not arrive — offline, behind a proxy, on a build
918
+ // machine — the browser uses the DOCUMENT default, which is a serif face on
919
+ // most engines, and the design stops looking like the design.
920
+ if (property === "font-family" && !decl.value.includes("var(") && ensureGenericFamily(decl.value) !== decl.value) {
921
+ warn("style/no-generic-family",
922
+ `${decl.value} ends in a family the machine may not have, so the fallback is the document default.`,
923
+ {
924
+ target: id || undefined, path, offset,
925
+ hint: `Write ${ensureGenericFamily(decl.value)}.`,
926
+ });
927
+ }
928
+ }
929
+ }