@jay-framework/design-system-validator 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ import { JayHtmlValidatorFn } from '@jay-framework/compiler-shared';
2
+ import { PluginReferencesContext, PluginReferencesResult } from '@jay-framework/stack-server-runtime';
3
+
4
+ declare const validateTokens: JayHtmlValidatorFn;
5
+
6
+ declare const validateComponents: JayHtmlValidatorFn;
7
+
8
+ declare const validateStructure: JayHtmlValidatorFn;
9
+
10
+ declare const validateContrast: JayHtmlValidatorFn;
11
+
12
+ /**
13
+ * References handler for design-system-validator plugin.
14
+ *
15
+ * Runs during `jay-stack agent-kit` to generate AIditor add-menu entries
16
+ * from the project's DESIGN.md tokens.
17
+ */
18
+
19
+ declare function generateDesignSystemReferences(ctx: PluginReferencesContext): Promise<PluginReferencesResult>;
20
+
21
+ export { generateDesignSystemReferences, validateComponents, validateContrast, validateStructure, validateTokens };
package/dist/index.js ADDED
@@ -0,0 +1,1107 @@
1
+ import postcss from "postcss";
2
+ import yaml from "js-yaml";
3
+ import * as fs from "node:fs";
4
+ import fs__default from "node:fs";
5
+ import * as path from "node:path";
6
+ import path__default from "node:path";
7
+ import selectorParser from "postcss-selector-parser";
8
+ import { selectorSpecificity } from "@csstools/selector-specificity";
9
+ import { walkElements } from "@jay-framework/compiler-shared";
10
+ function extractFrontmatter(content) {
11
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
12
+ return match ? match[1] : null;
13
+ }
14
+ function resolveReferences(tokens) {
15
+ const resolve = (value) => {
16
+ return value.replace(/\{([^}]+)\}/g, (_, refPath) => {
17
+ const parts = refPath.split(".");
18
+ let current = tokens;
19
+ for (const part of parts) {
20
+ if (current == null || typeof current !== "object")
21
+ return `{${refPath}}`;
22
+ current = current[part];
23
+ }
24
+ if (typeof current === "string")
25
+ return resolve(current);
26
+ if (typeof current === "number")
27
+ return String(current);
28
+ return `{${refPath}}`;
29
+ });
30
+ };
31
+ if (tokens.components) {
32
+ for (const [name, spec] of Object.entries(tokens.components)) {
33
+ for (const [prop, value] of Object.entries(spec)) {
34
+ if (typeof value === "string" && value.includes("{")) {
35
+ tokens.components[name][prop] = resolve(value);
36
+ }
37
+ }
38
+ }
39
+ }
40
+ }
41
+ function normalizeStringValues(map) {
42
+ if (!map)
43
+ return {};
44
+ const result = {};
45
+ for (const [key, value] of Object.entries(map)) {
46
+ result[key] = String(value);
47
+ }
48
+ return result;
49
+ }
50
+ function parseDesignMd(content) {
51
+ const frontmatter = extractFrontmatter(content);
52
+ if (!frontmatter)
53
+ return null;
54
+ const raw = yaml.load(frontmatter);
55
+ if (!raw || typeof raw !== "object")
56
+ return null;
57
+ resolveReferences(raw);
58
+ return {
59
+ name: raw.name,
60
+ colors: raw.colors || {},
61
+ typography: raw.typography || {},
62
+ spacing: normalizeStringValues(raw.spacing),
63
+ rounded: normalizeStringValues(raw.rounded),
64
+ animations: raw.animations || {},
65
+ components: raw.components || {},
66
+ rules: raw.rules || {}
67
+ };
68
+ }
69
+ function findDesignMd(filePath, projectRoot) {
70
+ let dir = path__default.dirname(path__default.resolve(projectRoot, filePath));
71
+ const root = path__default.resolve(projectRoot);
72
+ while (dir.startsWith(root)) {
73
+ const candidate = path__default.join(dir, "DESIGN.md");
74
+ if (fs__default.existsSync(candidate)) {
75
+ const content = fs__default.readFileSync(candidate, "utf-8");
76
+ const tokens = parseDesignMd(content);
77
+ if (!tokens)
78
+ return null;
79
+ const designMdPath = path__default.relative(projectRoot, candidate);
80
+ return { tokens, designMdPath };
81
+ }
82
+ const parent = path__default.dirname(dir);
83
+ if (parent === dir)
84
+ break;
85
+ dir = parent;
86
+ }
87
+ return null;
88
+ }
89
+ const ALLOW_COMMENT = "design-system: allow";
90
+ function computeSpecificity(selector) {
91
+ let result = [0, 0, 0];
92
+ selectorParser((selectors) => {
93
+ const first = selectors.first;
94
+ if (first) {
95
+ const s = selectorSpecificity(first);
96
+ result = [s.a, s.b, s.c];
97
+ }
98
+ }).processSync(selector);
99
+ return result;
100
+ }
101
+ function hasAllowComment(decl) {
102
+ const next = decl.next();
103
+ if (next?.type === "comment" && next.text.trim() === ALLOW_COMMENT) {
104
+ return true;
105
+ }
106
+ const raw = decl.raws.value;
107
+ if (raw && typeof raw === "object" && "raw" in raw) {
108
+ const rawStr = raw.raw;
109
+ if (rawStr.includes(`/*${ALLOW_COMMENT}*/`) || rawStr.includes(`/* ${ALLOW_COMMENT} */`)) {
110
+ return true;
111
+ }
112
+ }
113
+ return false;
114
+ }
115
+ function getMediaQuery(rule) {
116
+ let node = rule.parent;
117
+ while (node && "type" in node) {
118
+ if (node.type === "atrule" && node.name === "media") {
119
+ return node.params;
120
+ }
121
+ node = node.parent;
122
+ }
123
+ return void 0;
124
+ }
125
+ function parseRules(css, sourceIndexOffset = 0) {
126
+ const rules = [];
127
+ let ruleIndex = sourceIndexOffset;
128
+ const root = postcss.parse(css);
129
+ root.walkRules((rule) => {
130
+ const declarations = [];
131
+ rule.walkDecls((decl) => {
132
+ declarations.push({
133
+ property: decl.prop,
134
+ value: decl.value,
135
+ important: decl.important,
136
+ allowed: hasAllowComment(decl)
137
+ });
138
+ });
139
+ const mediaQuery = getMediaQuery(rule);
140
+ for (const selector of rule.selectors) {
141
+ const trimmed = selector.trim();
142
+ if (!trimmed)
143
+ continue;
144
+ rules.push({
145
+ selector: trimmed,
146
+ specificity: computeSpecificity(trimmed),
147
+ declarations,
148
+ sourceIndex: ruleIndex,
149
+ mediaQuery
150
+ });
151
+ }
152
+ ruleIndex++;
153
+ });
154
+ return rules;
155
+ }
156
+ function parseInlineStyles(style) {
157
+ const declarations = [];
158
+ for (const part of style.split(";")) {
159
+ const colonIdx = part.indexOf(":");
160
+ if (colonIdx === -1)
161
+ continue;
162
+ const prop = part.substring(0, colonIdx).trim();
163
+ const val = part.substring(colonIdx + 1).trim();
164
+ if (prop && val) {
165
+ declarations.push({ property: prop, value: val, allowed: false });
166
+ }
167
+ }
168
+ return declarations;
169
+ }
170
+ function matchesSelector(element, selector, root) {
171
+ try {
172
+ const matches = root.querySelectorAll(selector);
173
+ return matches.some((el) => el === element);
174
+ } catch {
175
+ return false;
176
+ }
177
+ }
178
+ function compareSpecificity(a, b) {
179
+ if (a[0] !== b[0])
180
+ return a[0] - b[0];
181
+ if (a[1] !== b[1])
182
+ return a[1] - b[1];
183
+ return a[2] - b[2];
184
+ }
185
+ function resolveForElement(element, rules, root) {
186
+ const resolved = {};
187
+ const matchingRules = rules.filter((rule) => matchesSelector(element, rule.selector, root));
188
+ matchingRules.sort((a, b) => {
189
+ const specCmp = compareSpecificity(a.specificity, b.specificity);
190
+ if (specCmp !== 0)
191
+ return specCmp;
192
+ return a.sourceIndex - b.sourceIndex;
193
+ });
194
+ for (const rule of matchingRules) {
195
+ for (const decl of rule.declarations) {
196
+ const existing = resolved[decl.property];
197
+ if (existing?.important && !decl.important)
198
+ continue;
199
+ resolved[decl.property] = {
200
+ property: decl.property,
201
+ value: decl.value,
202
+ selector: rule.selector,
203
+ specificity: rule.specificity,
204
+ sourceIndex: rule.sourceIndex,
205
+ important: decl.important,
206
+ allowed: decl.allowed
207
+ };
208
+ }
209
+ }
210
+ const jayDesignAllow = element.getAttribute("jay-design") === "allow";
211
+ const inlineStyle = element.getAttribute("style");
212
+ if (inlineStyle) {
213
+ const inlineDecls = parseInlineStyles(inlineStyle);
214
+ for (const decl of inlineDecls) {
215
+ const existing = resolved[decl.property];
216
+ if (existing?.important)
217
+ continue;
218
+ resolved[decl.property] = {
219
+ property: decl.property,
220
+ value: decl.value,
221
+ selector: "[inline]",
222
+ specificity: [1, 0, 0],
223
+ sourceIndex: Infinity,
224
+ important: false,
225
+ allowed: jayDesignAllow
226
+ };
227
+ }
228
+ }
229
+ return resolved;
230
+ }
231
+ function resolveCascade(cssSources, root) {
232
+ let offset = 0;
233
+ const allRules = [];
234
+ for (const css of cssSources) {
235
+ const rules = parseRules(css, offset);
236
+ allRules.push(...rules);
237
+ offset += rules.length;
238
+ }
239
+ const result = /* @__PURE__ */ new Map();
240
+ function walk(el) {
241
+ const resolved = resolveForElement(el, allRules, root);
242
+ if (Object.keys(resolved).length > 0) {
243
+ result.set(el, resolved);
244
+ }
245
+ for (const child of el.childNodes) {
246
+ if (child.nodeType === 1)
247
+ walk(child);
248
+ }
249
+ }
250
+ for (const child of root.childNodes) {
251
+ if (child.nodeType === 1)
252
+ walk(child);
253
+ }
254
+ return result;
255
+ }
256
+ function resolveCascadeByBreakpoint(cssSources, root) {
257
+ let offset = 0;
258
+ const allRules = [];
259
+ for (const css of cssSources) {
260
+ const rules = parseRules(css, offset);
261
+ allRules.push(...rules);
262
+ offset += rules.length;
263
+ }
264
+ const breakpoints = /* @__PURE__ */ new Set();
265
+ for (const rule of allRules) {
266
+ breakpoints.add(rule.mediaQuery);
267
+ }
268
+ const result = /* @__PURE__ */ new Map();
269
+ for (const breakpoint of breakpoints) {
270
+ let walk = function(el) {
271
+ const resolved = resolveForElement(el, rulesForBreakpoint, root);
272
+ if (Object.keys(resolved).length > 0) {
273
+ breakpointResult.set(el, resolved);
274
+ }
275
+ for (const child of el.childNodes) {
276
+ if (child.nodeType === 1)
277
+ walk(child);
278
+ }
279
+ };
280
+ const rulesForBreakpoint = allRules.filter(
281
+ (r) => r.mediaQuery === void 0 || r.mediaQuery === breakpoint
282
+ );
283
+ const breakpointResult = /* @__PURE__ */ new Map();
284
+ for (const child of root.childNodes) {
285
+ if (child.nodeType === 1)
286
+ walk(child);
287
+ }
288
+ result.set(breakpoint, breakpointResult);
289
+ }
290
+ return result;
291
+ }
292
+ function extractCssSources(root, filePath) {
293
+ const sources = [];
294
+ const linkedFiles = root.querySelectorAll('link[rel="stylesheet"]');
295
+ for (const link of linkedFiles) {
296
+ const href = link.getAttribute("href");
297
+ if (href && !href.startsWith("http")) {
298
+ try {
299
+ const dir = require("node:path").dirname(filePath);
300
+ const cssPath = require("node:path").resolve(dir, href);
301
+ const css = require("node:fs").readFileSync(cssPath, "utf-8");
302
+ sources.push(css);
303
+ } catch {
304
+ }
305
+ }
306
+ }
307
+ const styleBlocks = root.querySelectorAll("style");
308
+ for (const style of styleBlocks) {
309
+ const text = style.textContent;
310
+ if (text)
311
+ sources.push(text);
312
+ }
313
+ return sources;
314
+ }
315
+ function normalizeHex(color) {
316
+ const hex = color.trim().toLowerCase();
317
+ if (!/^#[0-9a-f]{3,8}$/.test(hex))
318
+ return null;
319
+ if (hex.length === 4) {
320
+ return `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
321
+ }
322
+ return hex.length === 7 ? hex : null;
323
+ }
324
+ function parseRgb(value) {
325
+ const match = value.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
326
+ if (!match)
327
+ return null;
328
+ const r = parseInt(match[1], 10);
329
+ const g = parseInt(match[2], 10);
330
+ const b = parseInt(match[3], 10);
331
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
332
+ }
333
+ function normalizeColor(value) {
334
+ const trimmed = value.trim().toLowerCase();
335
+ if (trimmed.startsWith("#"))
336
+ return normalizeHex(trimmed);
337
+ if (trimmed.startsWith("rgb"))
338
+ return parseRgb(trimmed);
339
+ return null;
340
+ }
341
+ const COLOR_PROPERTIES = /* @__PURE__ */ new Set([
342
+ "color",
343
+ "background-color",
344
+ "border-color",
345
+ "border-top-color",
346
+ "border-right-color",
347
+ "border-bottom-color",
348
+ "border-left-color",
349
+ "outline-color",
350
+ "text-decoration-color",
351
+ "fill",
352
+ "stroke"
353
+ ]);
354
+ const SPACING_PROPERTIES = /* @__PURE__ */ new Set([
355
+ "padding",
356
+ "padding-top",
357
+ "padding-right",
358
+ "padding-bottom",
359
+ "padding-left",
360
+ "margin",
361
+ "margin-top",
362
+ "margin-right",
363
+ "margin-bottom",
364
+ "margin-left",
365
+ "gap",
366
+ "row-gap",
367
+ "column-gap",
368
+ "top",
369
+ "right",
370
+ "bottom",
371
+ "left"
372
+ ]);
373
+ const RADIUS_PROPERTIES = /* @__PURE__ */ new Set([
374
+ "border-radius",
375
+ "border-top-left-radius",
376
+ "border-top-right-radius",
377
+ "border-bottom-left-radius",
378
+ "border-bottom-right-radius"
379
+ ]);
380
+ function isColorProperty(property) {
381
+ return COLOR_PROPERTIES.has(property);
382
+ }
383
+ function isSpacingProperty(property) {
384
+ return SPACING_PROPERTIES.has(property);
385
+ }
386
+ function isRadiusProperty(property) {
387
+ return RADIUS_PROPERTIES.has(property);
388
+ }
389
+ function isTypographyProperty(property) {
390
+ return property === "font-size" || property === "font-weight" || property === "line-height" || property === "letter-spacing" || property === "font-family";
391
+ }
392
+ function isAnimationDurationProperty(property) {
393
+ return property === "transition-duration" || property === "animation-duration";
394
+ }
395
+ function isAnimationEasingProperty(property) {
396
+ return property === "transition-timing-function" || property === "animation-timing-function";
397
+ }
398
+ function matchColor(value, colorTokens) {
399
+ if (value.startsWith("var("))
400
+ return { matches: true };
401
+ if (value === "transparent" || value === "inherit" || value === "currentColor" || value === "initial" || value === "unset") {
402
+ return { matches: true };
403
+ }
404
+ const normalized = normalizeColor(value);
405
+ if (!normalized)
406
+ return { matches: true };
407
+ const tokenEntries = Object.entries(colorTokens);
408
+ for (const [name, tokenValue] of tokenEntries) {
409
+ const normalizedToken = normalizeColor(tokenValue);
410
+ if (normalizedToken === normalized)
411
+ return { matches: true };
412
+ }
413
+ let closestName = "";
414
+ let closestDist = Infinity;
415
+ for (const [name, tokenValue] of tokenEntries) {
416
+ const nt = normalizeColor(tokenValue);
417
+ if (!nt)
418
+ continue;
419
+ const dist = colorDistance(normalized, nt);
420
+ if (dist < closestDist) {
421
+ closestDist = dist;
422
+ closestName = name;
423
+ }
424
+ }
425
+ const suggestion = closestName ? `Use token {colors.${closestName}} ("${colorTokens[closestName]}")` : "Add this color to DESIGN.md colors section";
426
+ return { matches: false, suggestion };
427
+ }
428
+ function colorDistance(a, b) {
429
+ const ar = parseInt(a.slice(1, 3), 16);
430
+ const ag = parseInt(a.slice(3, 5), 16);
431
+ const ab = parseInt(a.slice(5, 7), 16);
432
+ const br = parseInt(b.slice(1, 3), 16);
433
+ const bg = parseInt(b.slice(3, 5), 16);
434
+ const bb = parseInt(b.slice(5, 7), 16);
435
+ return Math.sqrt((ar - br) ** 2 + (ag - bg) ** 2 + (ab - bb) ** 2);
436
+ }
437
+ function matchSpacing(value, spacingTokens) {
438
+ if (value.startsWith("var(") || value === "auto" || value === "0" || value === "inherit" || value === "initial" || value === "unset") {
439
+ return { matches: true };
440
+ }
441
+ const parts = value.split(/\s+/);
442
+ const tokenValues = new Set(Object.values(spacingTokens));
443
+ tokenValues.add("0");
444
+ for (const part of parts) {
445
+ if (part === "0" || part === "auto" || part.startsWith("var("))
446
+ continue;
447
+ if (!tokenValues.has(part)) {
448
+ const suggestions = Object.entries(spacingTokens).map(([name, val]) => `{spacing.${name}} ("${val}")`).join(", ");
449
+ return {
450
+ matches: false,
451
+ suggestion: `Use a spacing token: ${suggestions}`
452
+ };
453
+ }
454
+ }
455
+ return { matches: true };
456
+ }
457
+ function matchRounded(value, roundedTokens) {
458
+ if (value.startsWith("var(") || value === "0" || value === "inherit" || value === "initial" || value === "unset") {
459
+ return { matches: true };
460
+ }
461
+ const tokenValues = new Set(Object.values(roundedTokens));
462
+ tokenValues.add("0");
463
+ if (!tokenValues.has(value)) {
464
+ const suggestions = Object.entries(roundedTokens).map(([name, val]) => `{rounded.${name}} ("${val}")`).join(", ");
465
+ return {
466
+ matches: false,
467
+ suggestion: `Use a rounded token: ${suggestions}`
468
+ };
469
+ }
470
+ return { matches: true };
471
+ }
472
+ function matchTypographyProperty(property, value, typographyTokens) {
473
+ if (value.startsWith("var(") || value === "inherit" || value === "initial" || value === "unset") {
474
+ return { matches: true };
475
+ }
476
+ const tokenValues = /* @__PURE__ */ new Set();
477
+ for (const token of Object.values(typographyTokens)) {
478
+ if (property === "font-size" && token.fontSize)
479
+ tokenValues.add(token.fontSize);
480
+ if (property === "font-weight" && token.fontWeight != null)
481
+ tokenValues.add(String(token.fontWeight));
482
+ if (property === "line-height" && token.lineHeight != null)
483
+ tokenValues.add(String(token.lineHeight));
484
+ if (property === "letter-spacing" && token.letterSpacing)
485
+ tokenValues.add(token.letterSpacing);
486
+ if (property === "font-family" && token.fontFamily)
487
+ tokenValues.add(token.fontFamily);
488
+ }
489
+ if (tokenValues.size === 0)
490
+ return { matches: true };
491
+ if (tokenValues.has(value))
492
+ return { matches: true };
493
+ if (property === "font-family") {
494
+ const normalizedValue = value.replace(/['"]/g, "").trim();
495
+ const primaryFont = normalizedValue.split(",")[0].trim();
496
+ for (const tv of tokenValues) {
497
+ if (tv.replace(/['"]/g, "").trim() === primaryFont)
498
+ return { matches: true };
499
+ }
500
+ }
501
+ return {
502
+ matches: false,
503
+ suggestion: `Value "${value}" for ${property} not in typography tokens`
504
+ };
505
+ }
506
+ function matchAnimationDuration(value, animationTokens) {
507
+ if (value.startsWith("var(") || value === "0s" || value === "0ms" || value === "inherit" || value === "initial" || value === "unset") {
508
+ return { matches: true };
509
+ }
510
+ const durations = /* @__PURE__ */ new Set();
511
+ for (const preset of Object.values(animationTokens)) {
512
+ if (preset.duration)
513
+ durations.add(preset.duration);
514
+ }
515
+ if (durations.size === 0)
516
+ return { matches: true };
517
+ if (durations.has(value))
518
+ return { matches: true };
519
+ const suggestions = Object.entries(animationTokens).filter(([, p]) => p.duration).map(([name, p]) => `${name} (${p.duration})`).join(", ");
520
+ return {
521
+ matches: false,
522
+ suggestion: `Use an animation preset duration: ${suggestions}`
523
+ };
524
+ }
525
+ function matchAnimationEasing(value, animationTokens) {
526
+ if (value.startsWith("var(") || value === "inherit" || value === "initial" || value === "unset") {
527
+ return { matches: true };
528
+ }
529
+ const easings = /* @__PURE__ */ new Set();
530
+ for (const preset of Object.values(animationTokens)) {
531
+ if (preset.easing)
532
+ easings.add(preset.easing);
533
+ }
534
+ if (easings.size === 0)
535
+ return { matches: true };
536
+ if (easings.has(value))
537
+ return { matches: true };
538
+ const normalizedValue = value.replace(/\s/g, "");
539
+ for (const e of easings) {
540
+ if (e.replace(/\s/g, "") === normalizedValue)
541
+ return { matches: true };
542
+ }
543
+ const suggestions = Object.entries(animationTokens).filter(([, p]) => p.easing).map(([name, p]) => `${name} (${p.easing})`).join(", ");
544
+ return {
545
+ matches: false,
546
+ suggestion: `Use an animation preset easing: ${suggestions}`
547
+ };
548
+ }
549
+ function matchComponent(elementStyles, componentSpec, componentName) {
550
+ const findings = [];
551
+ const propertyMapping = {
552
+ backgroundColor: "background-color",
553
+ textColor: "color",
554
+ rounded: "border-radius",
555
+ padding: "padding",
556
+ height: "height",
557
+ width: "width"
558
+ };
559
+ for (const [specProp, expectedValue] of Object.entries(componentSpec)) {
560
+ if (specProp === "typography")
561
+ continue;
562
+ const cssProp = propertyMapping[specProp] || specProp;
563
+ const actualValue = elementStyles[cssProp];
564
+ if (actualValue && actualValue !== expectedValue) {
565
+ findings.push({
566
+ matches: false,
567
+ suggestion: `${cssProp} should be "${expectedValue}" per ${componentName} component spec, found "${actualValue}"`
568
+ });
569
+ }
570
+ }
571
+ return findings;
572
+ }
573
+ function hexToRgbValues(hex) {
574
+ const normalized = normalizeHex(hex);
575
+ if (!normalized)
576
+ return null;
577
+ return [
578
+ parseInt(normalized.slice(1, 3), 16),
579
+ parseInt(normalized.slice(3, 5), 16),
580
+ parseInt(normalized.slice(5, 7), 16)
581
+ ];
582
+ }
583
+ function relativeLuminance(r, g, b) {
584
+ const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(
585
+ (c) => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
586
+ );
587
+ return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
588
+ }
589
+ function contrastRatio(l1, l2) {
590
+ const lighter = Math.max(l1, l2);
591
+ const darker = Math.min(l1, l2);
592
+ return (lighter + 0.05) / (darker + 0.05);
593
+ }
594
+ const DESIGNER_GUIDE$3 = "agent-kit/designer/design-system.md";
595
+ function ref(designMdPath) {
596
+ return `
597
+ See ${designMdPath} for tokens, ${DESIGNER_GUIDE$3} for usage guide.`;
598
+ }
599
+ function validateElementStyles(el, styles, tokens, breakpointLabel, designMdPath, findings) {
600
+ const tag = el.rawTagName?.toLowerCase() || "element";
601
+ const prefix = breakpointLabel ? `[${breakpointLabel}] ` : "";
602
+ const refs = ref(designMdPath);
603
+ for (const [property, resolved] of Object.entries(styles)) {
604
+ if (resolved.allowed)
605
+ continue;
606
+ if (isColorProperty(property) && Object.keys(tokens.colors).length > 0) {
607
+ const result = matchColor(resolved.value, tokens.colors);
608
+ if (!result.matches) {
609
+ findings.push({
610
+ severity: "warning",
611
+ message: `${prefix}Hardcoded color "${resolved.value}" for ${property} not in design system`,
612
+ suggestion: result.suggestion + refs,
613
+ element: `<${tag}>`
614
+ });
615
+ }
616
+ }
617
+ if (isSpacingProperty(property) && Object.keys(tokens.spacing).length > 0) {
618
+ const result = matchSpacing(resolved.value, tokens.spacing);
619
+ if (!result.matches) {
620
+ findings.push({
621
+ severity: "warning",
622
+ message: `${prefix}${property} value "${resolved.value}" not in spacing scale`,
623
+ suggestion: result.suggestion + refs,
624
+ element: `<${tag}>`
625
+ });
626
+ }
627
+ }
628
+ if (isRadiusProperty(property) && Object.keys(tokens.rounded).length > 0) {
629
+ const result = matchRounded(resolved.value, tokens.rounded);
630
+ if (!result.matches) {
631
+ findings.push({
632
+ severity: "warning",
633
+ message: `${prefix}border-radius "${resolved.value}" not in rounded scale`,
634
+ suggestion: result.suggestion + refs,
635
+ element: `<${tag}>`
636
+ });
637
+ }
638
+ }
639
+ if (isTypographyProperty(property) && Object.keys(tokens.typography).length > 0) {
640
+ const result = matchTypographyProperty(property, resolved.value, tokens.typography);
641
+ if (!result.matches) {
642
+ findings.push({
643
+ severity: "warning",
644
+ message: `${prefix}${property} value "${resolved.value}" not in typography tokens`,
645
+ suggestion: result.suggestion + refs,
646
+ element: `<${tag}>`
647
+ });
648
+ }
649
+ }
650
+ if (isAnimationDurationProperty(property) && Object.keys(tokens.animations).length > 0) {
651
+ const result = matchAnimationDuration(resolved.value, tokens.animations);
652
+ if (!result.matches) {
653
+ findings.push({
654
+ severity: "warning",
655
+ message: `${prefix}${property} "${resolved.value}" not in animation presets`,
656
+ suggestion: result.suggestion + refs,
657
+ element: `<${tag}>`
658
+ });
659
+ }
660
+ }
661
+ if (isAnimationEasingProperty(property) && Object.keys(tokens.animations).length > 0) {
662
+ const result = matchAnimationEasing(resolved.value, tokens.animations);
663
+ if (!result.matches) {
664
+ findings.push({
665
+ severity: "warning",
666
+ message: `${prefix}${property} "${resolved.value}" not in animation presets`,
667
+ suggestion: result.suggestion + refs,
668
+ element: `<${tag}>`
669
+ });
670
+ }
671
+ }
672
+ }
673
+ }
674
+ const validateTokens = (ctx) => {
675
+ const found = findDesignMd(ctx.filePath, ctx.projectRoot);
676
+ if (!found)
677
+ return [];
678
+ const { tokens, designMdPath } = found;
679
+ const findings = [];
680
+ const cssSources = extractCssSources(ctx.body, ctx.filePath);
681
+ if (cssSources.length === 0)
682
+ return [];
683
+ const byBreakpoint = resolveCascadeByBreakpoint(cssSources, ctx.body);
684
+ for (const [breakpoint, cascade] of byBreakpoint) {
685
+ const label = breakpoint || "";
686
+ for (const [el, styles] of cascade) {
687
+ validateElementStyles(el, styles, tokens, label, designMdPath, findings);
688
+ }
689
+ }
690
+ if (Object.keys(tokens.animations).length > 0) {
691
+ checkReducedMotion(cssSources, designMdPath, findings);
692
+ }
693
+ return findings;
694
+ };
695
+ const ANIMATION_PROPERTIES = /* @__PURE__ */ new Set([
696
+ "transition",
697
+ "transition-duration",
698
+ "transition-property",
699
+ "animation",
700
+ "animation-duration",
701
+ "animation-name"
702
+ ]);
703
+ function checkReducedMotion(cssSources, designMdPath, findings) {
704
+ let hasAnimations = false;
705
+ let hasReducedMotion = false;
706
+ for (const css of cssSources) {
707
+ const root = postcss.parse(css);
708
+ root.walkDecls((decl) => {
709
+ if (ANIMATION_PROPERTIES.has(decl.prop) && decl.value !== "none") {
710
+ hasAnimations = true;
711
+ }
712
+ });
713
+ root.walkAtRules("media", (atRule) => {
714
+ if (atRule.params.includes("prefers-reduced-motion")) {
715
+ hasReducedMotion = true;
716
+ }
717
+ });
718
+ }
719
+ if (hasAnimations && !hasReducedMotion) {
720
+ findings.push({
721
+ severity: "warning",
722
+ message: "Page uses transitions/animations but has no @media (prefers-reduced-motion) override",
723
+ suggestion: "Add @media (prefers-reduced-motion: reduce) { * { transition-duration: 0s !important; animation-duration: 0s !important; } }" + ref(designMdPath)
724
+ });
725
+ }
726
+ }
727
+ const DESIGNER_GUIDE$2 = "agent-kit/designer/design-system.md";
728
+ const validateComponents = (ctx) => {
729
+ const found = findDesignMd(ctx.filePath, ctx.projectRoot);
730
+ if (!found || Object.keys(found.tokens.components).length === 0)
731
+ return [];
732
+ const { tokens, designMdPath } = found;
733
+ const refs = `
734
+ See ${designMdPath} for component specs, ${DESIGNER_GUIDE$2} for usage guide.`;
735
+ const findings = [];
736
+ const cssSources = extractCssSources(ctx.body, ctx.filePath);
737
+ const cascade = resolveCascade(cssSources, ctx.body);
738
+ const jayComponents = /* @__PURE__ */ new Map();
739
+ const htmlComponents = /* @__PURE__ */ new Map();
740
+ for (const componentName of Object.keys(tokens.components)) {
741
+ if (componentName.startsWith("jay:")) {
742
+ jayComponents.set(componentName.substring(4), []);
743
+ } else {
744
+ htmlComponents.set(componentName, `.${componentName}`);
745
+ }
746
+ }
747
+ function walk(el) {
748
+ const tag = el.rawTagName?.toLowerCase();
749
+ if (tag?.startsWith("jay:")) {
750
+ const contractName = tag.substring(4);
751
+ if (jayComponents.has(contractName)) {
752
+ const children = el.childNodes.filter((n) => n.nodeType === 1);
753
+ if (children.length > 0) {
754
+ jayComponents.get(contractName).push(...children);
755
+ }
756
+ }
757
+ }
758
+ for (const child of el.childNodes) {
759
+ if (child.nodeType === 1)
760
+ walk(child);
761
+ }
762
+ }
763
+ walk(ctx.body);
764
+ for (const [contractName, elements] of jayComponents) {
765
+ const componentName = `jay:${contractName}`;
766
+ const spec = tokens.components[componentName];
767
+ if (!spec)
768
+ continue;
769
+ for (const el of elements) {
770
+ const styles = cascade.get(el);
771
+ if (!styles)
772
+ continue;
773
+ const styleValues = {};
774
+ for (const [prop, resolved] of Object.entries(styles)) {
775
+ if (!resolved.allowed)
776
+ styleValues[prop] = resolved.value;
777
+ }
778
+ const results = matchComponent(styleValues, spec, componentName);
779
+ for (const result of results) {
780
+ if (!result.matches) {
781
+ findings.push({
782
+ severity: "warning",
783
+ message: `<${componentName}> inline template: ${result.suggestion}`,
784
+ suggestion: refs.trim(),
785
+ element: `<${el.rawTagName || "element"}>`
786
+ });
787
+ }
788
+ }
789
+ }
790
+ }
791
+ for (const [componentName, selector] of htmlComponents) {
792
+ const spec = tokens.components[componentName];
793
+ if (!spec)
794
+ continue;
795
+ for (const [el, styles] of cascade) {
796
+ try {
797
+ const matchedEls = ctx.body.querySelectorAll(selector);
798
+ if (!matchedEls.some((m) => m === el))
799
+ continue;
800
+ } catch {
801
+ continue;
802
+ }
803
+ const styleValues = {};
804
+ for (const [prop, resolved] of Object.entries(styles)) {
805
+ if (!resolved.allowed)
806
+ styleValues[prop] = resolved.value;
807
+ }
808
+ const results = matchComponent(styleValues, spec, componentName);
809
+ for (const result of results) {
810
+ if (!result.matches) {
811
+ findings.push({
812
+ severity: "warning",
813
+ message: `Component "${componentName}": ${result.suggestion}`,
814
+ suggestion: refs.trim(),
815
+ element: `<${el.rawTagName || "element"}>`
816
+ });
817
+ }
818
+ }
819
+ }
820
+ }
821
+ return findings;
822
+ };
823
+ const DESIGNER_GUIDE$1 = "agent-kit/designer/design-system.md";
824
+ const validateStructure = (ctx) => {
825
+ const found = findDesignMd(ctx.filePath, ctx.projectRoot);
826
+ if (!found)
827
+ return [];
828
+ const { tokens, designMdPath } = found;
829
+ const refs = `
830
+ See ${designMdPath} for rules, ${DESIGNER_GUIDE$1} for usage guide.`;
831
+ const findings = [];
832
+ if (tokens.rules["max-font-weights"]) {
833
+ const cssSources = extractCssSources(ctx.body, ctx.filePath);
834
+ const cascade = resolveCascade(cssSources, ctx.body);
835
+ const fontWeights = /* @__PURE__ */ new Set();
836
+ for (const [, styles] of cascade) {
837
+ const fw = styles["font-weight"];
838
+ if (fw && !fw.allowed)
839
+ fontWeights.add(fw.value);
840
+ }
841
+ const max = tokens.rules["max-font-weights"];
842
+ if (fontWeights.size > max) {
843
+ findings.push({
844
+ severity: "warning",
845
+ message: `${fontWeights.size} unique font-weight values found (max: ${max}): ${[...fontWeights].join(", ")}`,
846
+ suggestion: `Reduce to ${max} font-weight values from the typography tokens.${refs}`
847
+ });
848
+ }
849
+ }
850
+ if (tokens.rules["max-primary-buttons"]) {
851
+ const primaryButtons = /* @__PURE__ */ new Map();
852
+ walkElements(ctx.body, ctx, (el) => {
853
+ const tag = el.rawTagName?.toLowerCase();
854
+ if (tag !== "button")
855
+ return;
856
+ const classes = el.getAttribute?.("class") || "";
857
+ if (!classes.includes("primary") && !classes.includes("btn-primary"))
858
+ return;
859
+ const ref2 = el.getAttribute?.("ref") || "";
860
+ const text = el.textContent?.trim() || "";
861
+ const identity = `${ref2}|${text}`;
862
+ primaryButtons.set(identity, true);
863
+ });
864
+ const max = tokens.rules["max-primary-buttons"];
865
+ if (primaryButtons.size > max) {
866
+ findings.push({
867
+ severity: "warning",
868
+ message: `${primaryButtons.size} distinct primary buttons found (max: ${max})`,
869
+ suggestion: `Reduce to ${max} primary action button per page. Same button (same ref and text) appearing multiple times counts as one.${refs}`
870
+ });
871
+ }
872
+ }
873
+ return findings;
874
+ };
875
+ const DESIGNER_GUIDE = "agent-kit/designer/design-system.md";
876
+ function resolveColorValue(value) {
877
+ if (value.startsWith("#"))
878
+ return value;
879
+ if (value.startsWith("rgb")) {
880
+ const match = value.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
881
+ if (match) {
882
+ const r = parseInt(match[1], 10);
883
+ const g = parseInt(match[2], 10);
884
+ const b = parseInt(match[3], 10);
885
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
886
+ }
887
+ }
888
+ return null;
889
+ }
890
+ function isLargeText(fontSize, fontWeight) {
891
+ if (!fontSize)
892
+ return false;
893
+ const sizeMatch = fontSize.match(/([\d.]+)(px|rem|em)/);
894
+ if (!sizeMatch)
895
+ return false;
896
+ let sizePx = parseFloat(sizeMatch[1]);
897
+ const unit = sizeMatch[2];
898
+ if (unit === "rem" || unit === "em")
899
+ sizePx *= 16;
900
+ const weight = fontWeight ? parseInt(fontWeight, 10) : 400;
901
+ return sizePx >= 24 || sizePx >= 18.66 && weight >= 700;
902
+ }
903
+ const validateContrast = (ctx) => {
904
+ const found = findDesignMd(ctx.filePath, ctx.projectRoot);
905
+ if (!found || !found.tokens.rules["require-contrast-aa"])
906
+ return [];
907
+ const { tokens, designMdPath } = found;
908
+ const refs = `
909
+ See ${designMdPath} for color tokens, ${DESIGNER_GUIDE} for usage guide.`;
910
+ const findings = [];
911
+ const cssSources = extractCssSources(ctx.body, ctx.filePath);
912
+ if (cssSources.length === 0)
913
+ return [];
914
+ const cascade = resolveCascade(cssSources, ctx.body);
915
+ for (const [el, styles] of cascade) {
916
+ const colorStyle = styles["color"];
917
+ const bgStyle = styles["background-color"] || styles["background"];
918
+ if (!colorStyle || !bgStyle)
919
+ continue;
920
+ if (colorStyle.allowed || bgStyle.allowed)
921
+ continue;
922
+ const fgHex = resolveColorValue(colorStyle.value);
923
+ const bgHex = resolveColorValue(bgStyle.value);
924
+ if (!fgHex || !bgHex)
925
+ continue;
926
+ const fgRgb = hexToRgbValues(fgHex);
927
+ const bgRgb = hexToRgbValues(bgHex);
928
+ if (!fgRgb || !bgRgb)
929
+ continue;
930
+ const fgLum = relativeLuminance(...fgRgb);
931
+ const bgLum = relativeLuminance(...bgRgb);
932
+ const ratio = contrastRatio(fgLum, bgLum);
933
+ const large = isLargeText(styles["font-size"]?.value, styles["font-weight"]?.value);
934
+ const threshold = large ? 3 : 4.5;
935
+ if (ratio < threshold) {
936
+ const tag = el.rawTagName?.toLowerCase() || "element";
937
+ findings.push({
938
+ severity: "warning",
939
+ message: `Contrast ratio ${ratio.toFixed(1)}:1 below WCAG AA (${threshold}:1) for color "${colorStyle.value}" on background "${bgStyle.value}"`,
940
+ suggestion: `Darken text color or lighten background to meet minimum contrast.${refs}`,
941
+ element: `<${tag}>`
942
+ });
943
+ }
944
+ }
945
+ return findings;
946
+ };
947
+ const ADD_MENU_OUTPUT_REL = "agent-kit/aiditor/add-menu/design-system.yaml";
948
+ function buildColorItems(tokens) {
949
+ const entries = Object.entries(tokens.colors);
950
+ if (entries.length === 0)
951
+ return [];
952
+ const palette = entries.map(([name, value]) => `${name}: ${value}`).join(", ");
953
+ return [
954
+ {
955
+ id: "design-system:color-palette",
956
+ title: "Color palette",
957
+ category: "Design System",
958
+ subCategory: "Colors",
959
+ prompt: `Apply colors from the project design system.
960
+ Available color tokens: ${palette}.
961
+ Use the exact token values from DESIGN.md. Do not use hardcoded colors outside this palette.`
962
+ }
963
+ ];
964
+ }
965
+ function buildTypographyItems(tokens) {
966
+ const entries = Object.entries(tokens.typography);
967
+ if (entries.length === 0)
968
+ return [];
969
+ const presets = entries.map(([name, t]) => {
970
+ const parts = [`${name}:`];
971
+ if (t.fontFamily)
972
+ parts.push(t.fontFamily);
973
+ if (t.fontSize)
974
+ parts.push(t.fontSize);
975
+ if (t.fontWeight)
976
+ parts.push(`weight ${t.fontWeight}`);
977
+ return parts.join(" ");
978
+ }).join("; ");
979
+ return [
980
+ {
981
+ id: "design-system:typography",
982
+ title: "Typography presets",
983
+ category: "Design System",
984
+ subCategory: "Typography",
985
+ prompt: `Apply typography from the project design system.
986
+ Available presets: ${presets}.
987
+ Use font-size, font-weight, line-height, and font-family values from these presets. Do not introduce typography values outside the design system.`
988
+ }
989
+ ];
990
+ }
991
+ function buildSpacingItems(tokens) {
992
+ const entries = Object.entries(tokens.spacing);
993
+ if (entries.length === 0)
994
+ return [];
995
+ const scale = entries.map(([name, value]) => `${name}: ${value}`).join(", ");
996
+ return [
997
+ {
998
+ id: "design-system:spacing",
999
+ title: "Spacing scale",
1000
+ category: "Design System",
1001
+ subCategory: "Layout",
1002
+ prompt: `Apply spacing from the project design system.
1003
+ Spacing scale: ${scale}.
1004
+ Use these values for padding, margin, and gap. Add /* design-system: allow */ comment to exempt intentional one-off values.`
1005
+ }
1006
+ ];
1007
+ }
1008
+ function buildRoundedItems(tokens) {
1009
+ const entries = Object.entries(tokens.rounded);
1010
+ if (entries.length === 0)
1011
+ return [];
1012
+ const scale = entries.map(([name, value]) => `${name}: ${value}`).join(", ");
1013
+ return [
1014
+ {
1015
+ id: "design-system:rounded",
1016
+ title: "Border radius",
1017
+ category: "Design System",
1018
+ subCategory: "Shapes",
1019
+ prompt: `Apply border-radius from the project design system.
1020
+ Rounded scale: ${scale}.
1021
+ Use these values for border-radius. Do not use arbitrary radius values.`
1022
+ }
1023
+ ];
1024
+ }
1025
+ function buildComponentItems(tokens) {
1026
+ const entries = Object.entries(tokens.components);
1027
+ if (entries.length === 0)
1028
+ return [];
1029
+ return entries.map(([name, spec]) => {
1030
+ const props = Object.entries(spec).map(([prop, value]) => `${prop}: ${value}`).join(", ");
1031
+ const isJayComponent = name.startsWith("jay:");
1032
+ const title = isJayComponent ? name : `Component: ${name}`;
1033
+ return {
1034
+ id: `design-system:component-${name}`,
1035
+ title,
1036
+ category: "Design System",
1037
+ subCategory: "Components",
1038
+ prompt: `Apply the "${name}" component spec from the design system.
1039
+ Required styles: ${props}.
1040
+ Ensure the element matches these values exactly. The design-system validator will flag mismatches.`
1041
+ };
1042
+ });
1043
+ }
1044
+ function findAllDesignMdFiles(projectRoot) {
1045
+ const pagesRoot = path.join(projectRoot, "src", "pages");
1046
+ const files = [];
1047
+ const rootDesignMd = path.join(projectRoot, "DESIGN.md");
1048
+ if (fs.existsSync(rootDesignMd))
1049
+ files.push(rootDesignMd);
1050
+ if (fs.existsSync(pagesRoot)) {
1051
+ let walk = function(dir) {
1052
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1053
+ if (entry.isFile() && entry.name === "DESIGN.md") {
1054
+ files.push(path.join(dir, entry.name));
1055
+ } else if (entry.isDirectory() && !entry.name.startsWith(".")) {
1056
+ walk(path.join(dir, entry.name));
1057
+ }
1058
+ }
1059
+ };
1060
+ walk(pagesRoot);
1061
+ }
1062
+ return files;
1063
+ }
1064
+ async function generateDesignSystemReferences(ctx) {
1065
+ const designMdFiles = findAllDesignMdFiles(ctx.projectRoot);
1066
+ if (designMdFiles.length === 0) {
1067
+ return { referencesCreated: [], message: "No DESIGN.md found in project" };
1068
+ }
1069
+ const allItems = [];
1070
+ const seen = /* @__PURE__ */ new Set();
1071
+ for (const filePath of designMdFiles) {
1072
+ const content = fs.readFileSync(filePath, "utf-8");
1073
+ const tokens = parseDesignMd(content);
1074
+ if (!tokens)
1075
+ continue;
1076
+ const items = [
1077
+ ...buildColorItems(tokens),
1078
+ ...buildTypographyItems(tokens),
1079
+ ...buildSpacingItems(tokens),
1080
+ ...buildRoundedItems(tokens),
1081
+ ...buildComponentItems(tokens)
1082
+ ];
1083
+ for (const item of items) {
1084
+ if (!seen.has(item.id)) {
1085
+ seen.add(item.id);
1086
+ allItems.push(item);
1087
+ }
1088
+ }
1089
+ }
1090
+ if (allItems.length === 0) {
1091
+ return { referencesCreated: [], message: "DESIGN.md found but no tokens defined" };
1092
+ }
1093
+ const outputPath = path.join(ctx.projectRoot, ADD_MENU_OUTPUT_REL);
1094
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
1095
+ fs.writeFileSync(outputPath, yaml.dump({ items: allItems }, { lineWidth: 120 }), "utf-8");
1096
+ return {
1097
+ referencesCreated: [ADD_MENU_OUTPUT_REL],
1098
+ message: `Generated ${allItems.length} add-menu items from DESIGN.md tokens`
1099
+ };
1100
+ }
1101
+ export {
1102
+ generateDesignSystemReferences,
1103
+ validateComponents,
1104
+ validateContrast,
1105
+ validateStructure,
1106
+ validateTokens
1107
+ };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@jay-framework/design-system-validator",
3
+ "version": "0.21.0",
4
+ "type": "module",
5
+ "description": "Design system validation plugin for Jay Framework — validates jay-html CSS against DESIGN.md tokens",
6
+ "license": "Apache-2.0",
7
+ "main": "dist/index.js",
8
+ "files": [
9
+ "dist",
10
+ "plugin.yaml"
11
+ ],
12
+ "exports": {
13
+ ".": "./dist/index.js",
14
+ "./plugin.yaml": "./plugin.yaml"
15
+ },
16
+ "scripts": {
17
+ "build": "npm run clean && npm run build:server && npm run build:types && npm run validate",
18
+ "validate": "jay-stack-cli validate-plugin",
19
+ "build:server": "vite build --ssr",
20
+ "build:types": "tsup lib/index.ts --dts-only --format esm",
21
+ "build:check-types": "tsc",
22
+ "clean": "rimraf dist",
23
+ "confirm": "npm run clean && npm run build && npm run test",
24
+ "test": "vitest run"
25
+ },
26
+ "dependencies": {
27
+ "@csstools/selector-specificity": "^5.0.0",
28
+ "@jay-framework/compiler-shared": "^0.21.0",
29
+ "@jay-framework/stack-server-runtime": "^0.21.0",
30
+ "js-yaml": "^4.1.0",
31
+ "postcss": "^8.5.6",
32
+ "postcss-selector-parser": "^7.1.0"
33
+ },
34
+ "devDependencies": {
35
+ "@jay-framework/dev-environment": "^0.21.0",
36
+ "@jay-framework/jay-stack-cli": "^0.21.0",
37
+ "@types/js-yaml": "^4.0.9",
38
+ "@types/node": "^22.15.21",
39
+ "node-html-parser": "^6.1.0",
40
+ "rimraf": "^5.0.5",
41
+ "tsup": "^8.0.1",
42
+ "typescript": "^5.3.3",
43
+ "vite": "^5.0.11",
44
+ "vitest": "^1.2.1"
45
+ }
46
+ }
package/plugin.yaml ADDED
@@ -0,0 +1,22 @@
1
+ name: design-system-validator
2
+
3
+ setup:
4
+ references: generateDesignSystemReferences
5
+ description: Generate AIditor add-menu entries from DESIGN.md tokens
6
+
7
+ validators:
8
+ - name: design-tokens
9
+ handler: validateTokens
10
+ description: Validates CSS values against DESIGN.md tokens (colors, spacing, rounded, typography)
11
+
12
+ - name: design-components
13
+ handler: validateComponents
14
+ description: 'Validates component styles against DESIGN.md component specs (HTML and jay: components)'
15
+
16
+ - name: design-structure
17
+ handler: validateStructure
18
+ description: Enforces structural design rules (max font weights, primary button count)
19
+
20
+ - name: design-contrast
21
+ handler: validateContrast
22
+ description: Checks WCAG AA color contrast compliance on statically determinable color pairs