@jay-framework/design-system-validator 0.22.2 → 0.24.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/dist/index.js CHANGED
@@ -1,12 +1,17 @@
1
1
  import postcss from "postcss";
2
- import yaml from "js-yaml";
2
+ import yaml, { load, dump } from "js-yaml";
3
3
  import * as fs from "node:fs";
4
- import fs__default from "node:fs";
4
+ import fs__default, { promises } from "node:fs";
5
5
  import * as path from "node:path";
6
6
  import path__default from "node:path";
7
7
  import selectorParser from "postcss-selector-parser";
8
8
  import { selectorSpecificity } from "@csstools/selector-specificity";
9
- import { walkElements } from "@jay-framework/compiler-shared";
9
+ import { walkElements, JAY_EXTENSION } from "@jay-framework/compiler-shared";
10
+ import { makeJayAction, makeJayQuery, makeJayStackComponent, phaseOutput } from "@jay-framework/fullstack-component";
11
+ import { fontFamilyToCamelCase } from "@capsizecss/metrics";
12
+ import { createFontStack } from "@capsizecss/core";
13
+ import { fileURLToPath } from "node:url";
14
+ import { parseJayFile, JAY_IMPORT_RESOLVER } from "@jay-framework/compiler-jay-html";
10
15
  function extractFrontmatter(content) {
11
16
  const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
12
17
  return match ? match[1] : null;
@@ -17,14 +22,11 @@ function resolveReferences(tokens) {
17
22
  const parts = refPath.split(".");
18
23
  let current = tokens;
19
24
  for (const part of parts) {
20
- if (current == null || typeof current !== "object")
21
- return `{${refPath}}`;
25
+ if (current == null || typeof current !== "object") return `{${refPath}}`;
22
26
  current = current[part];
23
27
  }
24
- if (typeof current === "string")
25
- return resolve(current);
26
- if (typeof current === "number")
27
- return String(current);
28
+ if (typeof current === "string") return resolve(current);
29
+ if (typeof current === "number") return String(current);
28
30
  return `{${refPath}}`;
29
31
  });
30
32
  };
@@ -39,8 +41,7 @@ function resolveReferences(tokens) {
39
41
  }
40
42
  }
41
43
  function normalizeStringValues(map) {
42
- if (!map)
43
- return {};
44
+ if (!map) return {};
44
45
  const result = {};
45
46
  for (const [key, value] of Object.entries(map)) {
46
47
  result[key] = String(value);
@@ -49,11 +50,9 @@ function normalizeStringValues(map) {
49
50
  }
50
51
  function parseDesignMd(content) {
51
52
  const frontmatter = extractFrontmatter(content);
52
- if (!frontmatter)
53
- return null;
53
+ if (!frontmatter) return null;
54
54
  const raw = yaml.load(frontmatter);
55
- if (!raw || typeof raw !== "object")
56
- return null;
55
+ if (!raw || typeof raw !== "object") return null;
57
56
  const rawComponents = {};
58
57
  if (raw.components) {
59
58
  for (const [name, spec] of Object.entries(raw.components)) {
@@ -82,19 +81,17 @@ function findDesignMd(filePath, projectRoot) {
82
81
  if (fs__default.existsSync(candidate)) {
83
82
  const content = fs__default.readFileSync(candidate, "utf-8");
84
83
  const tokens = parseDesignMd(content);
85
- if (!tokens)
86
- return null;
84
+ if (!tokens) return null;
87
85
  const designMdPath = path__default.relative(projectRoot, candidate);
88
86
  return { tokens, designMdPath };
89
87
  }
90
88
  const parent = path__default.dirname(dir);
91
- if (parent === dir)
92
- break;
89
+ if (parent === dir) break;
93
90
  dir = parent;
94
91
  }
95
92
  return null;
96
93
  }
97
- const ALLOW_COMMENT = "design-system: allow";
94
+ const ALLOW_COMMENT$1 = "design-system: allow";
98
95
  function computeSpecificity(selector) {
99
96
  let result = [0, 0, 0];
100
97
  selectorParser((selectors) => {
@@ -106,15 +103,15 @@ function computeSpecificity(selector) {
106
103
  }).processSync(selector);
107
104
  return result;
108
105
  }
109
- function hasAllowComment(decl) {
106
+ function hasAllowComment$1(decl) {
110
107
  const next = decl.next();
111
- if (next?.type === "comment" && next.text.trim() === ALLOW_COMMENT) {
108
+ if (next?.type === "comment" && next.text.trim() === ALLOW_COMMENT$1) {
112
109
  return true;
113
110
  }
114
111
  const raw = decl.raws.value;
115
112
  if (raw && typeof raw === "object" && "raw" in raw) {
116
113
  const rawStr = raw.raw;
117
- if (rawStr.includes(`/*${ALLOW_COMMENT}*/`) || rawStr.includes(`/* ${ALLOW_COMMENT} */`)) {
114
+ if (rawStr.includes(`/*${ALLOW_COMMENT$1}*/`) || rawStr.includes(`/* ${ALLOW_COMMENT$1} */`)) {
118
115
  return true;
119
116
  }
120
117
  }
@@ -151,14 +148,13 @@ function parseRules(css, sourceIndexOffset = 0, customProperties) {
151
148
  property: decl.prop,
152
149
  value: decl.value,
153
150
  important: decl.important,
154
- allowed: hasAllowComment(decl)
151
+ allowed: hasAllowComment$1(decl)
155
152
  });
156
153
  });
157
154
  const mediaQuery = getMediaQuery(rule);
158
155
  for (const selector of rule.selectors) {
159
156
  const trimmed = selector.trim();
160
- if (!trimmed)
161
- continue;
157
+ if (!trimmed) continue;
162
158
  rules.push({
163
159
  selector: trimmed,
164
160
  specificity: computeSpecificity(trimmed),
@@ -176,10 +172,8 @@ function resolveVarReferences(value, customProperties) {
176
172
  /var\(\s*(--[a-zA-Z0-9-]+)(?:\s*,\s*([^)]+))?\s*\)/g,
177
173
  (_, name, fallback) => {
178
174
  const resolved = customProperties.get(name);
179
- if (resolved !== void 0)
180
- return resolveVarReferences(resolved, customProperties);
181
- if (fallback !== void 0)
182
- return fallback.trim();
175
+ if (resolved !== void 0) return resolveVarReferences(resolved, customProperties);
176
+ if (fallback !== void 0) return fallback.trim();
183
177
  return _;
184
178
  }
185
179
  );
@@ -188,8 +182,7 @@ function parseInlineStyles(style) {
188
182
  const declarations = [];
189
183
  for (const part of style.split(";")) {
190
184
  const colonIdx = part.indexOf(":");
191
- if (colonIdx === -1)
192
- continue;
185
+ if (colonIdx === -1) continue;
193
186
  const prop = part.substring(0, colonIdx).trim();
194
187
  const val = part.substring(colonIdx + 1).trim();
195
188
  if (prop && val) {
@@ -201,8 +194,7 @@ function parseInlineStyles(style) {
201
194
  function buildSelectorCache(rules, root) {
202
195
  const cache = /* @__PURE__ */ new Map();
203
196
  for (const rule of rules) {
204
- if (cache.has(rule.selector))
205
- continue;
197
+ if (cache.has(rule.selector)) continue;
206
198
  try {
207
199
  const matches = root.querySelectorAll(rule.selector);
208
200
  cache.set(rule.selector, new Set(matches));
@@ -213,10 +205,8 @@ function buildSelectorCache(rules, root) {
213
205
  return cache;
214
206
  }
215
207
  function compareSpecificity(a, b) {
216
- if (a[0] !== b[0])
217
- return a[0] - b[0];
218
- if (a[1] !== b[1])
219
- return a[1] - b[1];
208
+ if (a[0] !== b[0]) return a[0] - b[0];
209
+ if (a[1] !== b[1]) return a[1] - b[1];
220
210
  return a[2] - b[2];
221
211
  }
222
212
  function resolveForElement(element, rules, selectorCache) {
@@ -226,15 +216,13 @@ function resolveForElement(element, rules, selectorCache) {
226
216
  );
227
217
  matchingRules.sort((a, b) => {
228
218
  const specCmp = compareSpecificity(a.specificity, b.specificity);
229
- if (specCmp !== 0)
230
- return specCmp;
219
+ if (specCmp !== 0) return specCmp;
231
220
  return a.sourceIndex - b.sourceIndex;
232
221
  });
233
222
  for (const rule of matchingRules) {
234
223
  for (const decl of rule.declarations) {
235
224
  const existing = resolved[decl.property];
236
- if (existing?.important && !decl.important)
237
- continue;
225
+ if (existing?.important && !decl.important) continue;
238
226
  resolved[decl.property] = {
239
227
  property: decl.property,
240
228
  value: decl.value,
@@ -252,8 +240,7 @@ function resolveForElement(element, rules, selectorCache) {
252
240
  const inlineDecls = parseInlineStyles(inlineStyle);
253
241
  for (const decl of inlineDecls) {
254
242
  const existing = resolved[decl.property];
255
- if (existing?.important)
256
- continue;
243
+ if (existing?.important) continue;
257
244
  resolved[decl.property] = {
258
245
  property: decl.property,
259
246
  value: decl.value,
@@ -291,13 +278,11 @@ function resolveCascade(cssSources, root) {
291
278
  result.set(el, resolved);
292
279
  }
293
280
  for (const child of el.childNodes) {
294
- if (child.nodeType === 1)
295
- walk(child);
281
+ if (child.nodeType === 1) walk(child);
296
282
  }
297
283
  }
298
284
  for (const child of root.childNodes) {
299
- if (child.nodeType === 1)
300
- walk(child);
285
+ if (child.nodeType === 1) walk(child);
301
286
  }
302
287
  return result;
303
288
  }
@@ -329,8 +314,7 @@ function resolveCascadeByBreakpoint(cssSources, root) {
329
314
  breakpointResult.set(el, resolved);
330
315
  }
331
316
  for (const child of el.childNodes) {
332
- if (child.nodeType === 1)
333
- walk(child);
317
+ if (child.nodeType === 1) walk(child);
334
318
  }
335
319
  };
336
320
  const rulesForBreakpoint = allRules.filter(
@@ -339,8 +323,7 @@ function resolveCascadeByBreakpoint(cssSources, root) {
339
323
  const selectorCache = buildSelectorCache(rulesForBreakpoint, root);
340
324
  const breakpointResult = /* @__PURE__ */ new Map();
341
325
  for (const child of root.childNodes) {
342
- if (child.nodeType === 1)
343
- walk(child);
326
+ if (child.nodeType === 1) walk(child);
344
327
  }
345
328
  result.set(breakpoint, breakpointResult);
346
329
  }
@@ -348,8 +331,7 @@ function resolveCascadeByBreakpoint(cssSources, root) {
348
331
  }
349
332
  function normalizeHex(color) {
350
333
  const hex = color.trim().toLowerCase();
351
- if (!/^#[0-9a-f]{3,8}$/.test(hex))
352
- return null;
334
+ if (!/^#[0-9a-f]{3,8}$/.test(hex)) return null;
353
335
  if (hex.length === 4) {
354
336
  return `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
355
337
  }
@@ -357,8 +339,7 @@ function normalizeHex(color) {
357
339
  }
358
340
  function parseRgb(value) {
359
341
  const match = value.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
360
- if (!match)
361
- return null;
342
+ if (!match) return null;
362
343
  const r = parseInt(match[1], 10);
363
344
  const g = parseInt(match[2], 10);
364
345
  const b = parseInt(match[3], 10);
@@ -366,10 +347,8 @@ function parseRgb(value) {
366
347
  }
367
348
  function normalizeColor(value) {
368
349
  const trimmed = value.trim().toLowerCase();
369
- if (trimmed.startsWith("#"))
370
- return normalizeHex(trimmed);
371
- if (trimmed.startsWith("rgb"))
372
- return parseRgb(trimmed);
350
+ if (trimmed.startsWith("#")) return normalizeHex(trimmed);
351
+ if (trimmed.startsWith("rgb")) return parseRgb(trimmed);
373
352
  return null;
374
353
  }
375
354
  const COLOR_PROPERTIES = /* @__PURE__ */ new Set([
@@ -430,26 +409,22 @@ function isAnimationEasingProperty(property) {
430
409
  return property === "transition-timing-function" || property === "animation-timing-function";
431
410
  }
432
411
  function matchColor(value, colorTokens) {
433
- if (value.startsWith("var("))
434
- return { matches: true };
412
+ if (value.startsWith("var(")) return { matches: true };
435
413
  if (value === "transparent" || value === "inherit" || value === "currentColor" || value === "initial" || value === "unset") {
436
414
  return { matches: true };
437
415
  }
438
416
  const normalized = normalizeColor(value);
439
- if (!normalized)
440
- return { matches: true };
417
+ if (!normalized) return { matches: true };
441
418
  const tokenEntries = Object.entries(colorTokens);
442
419
  for (const [name, tokenValue] of tokenEntries) {
443
420
  const normalizedToken = normalizeColor(tokenValue);
444
- if (normalizedToken === normalized)
445
- return { matches: true };
421
+ if (normalizedToken === normalized) return { matches: true };
446
422
  }
447
423
  let closestName = "";
448
424
  let closestDist = Infinity;
449
425
  for (const [name, tokenValue] of tokenEntries) {
450
426
  const nt = normalizeColor(tokenValue);
451
- if (!nt)
452
- continue;
427
+ if (!nt) continue;
453
428
  const dist = colorDistance(normalized, nt);
454
429
  if (dist < closestDist) {
455
430
  closestDist = dist;
@@ -476,8 +451,7 @@ function matchSpacing(value, spacingTokens) {
476
451
  const tokenValues = new Set(Object.values(spacingTokens));
477
452
  tokenValues.add("0");
478
453
  for (const part of parts) {
479
- if (part === "0" || part === "auto" || part.startsWith("var("))
480
- continue;
454
+ if (part === "0" || part === "auto" || part.startsWith("var(")) continue;
481
455
  if (!tokenValues.has(part)) {
482
456
  return {
483
457
  matches: false,
@@ -507,27 +481,22 @@ function matchTypographyProperty(property, value, typographyTokens) {
507
481
  }
508
482
  const tokenValues = /* @__PURE__ */ new Set();
509
483
  for (const token of Object.values(typographyTokens)) {
510
- if (property === "font-size" && token.fontSize)
511
- tokenValues.add(token.fontSize);
484
+ if (property === "font-size" && token.fontSize) tokenValues.add(token.fontSize);
512
485
  if (property === "font-weight" && token.fontWeight != null)
513
486
  tokenValues.add(String(token.fontWeight));
514
487
  if (property === "line-height" && token.lineHeight != null)
515
488
  tokenValues.add(String(token.lineHeight));
516
489
  if (property === "letter-spacing" && token.letterSpacing)
517
490
  tokenValues.add(token.letterSpacing);
518
- if (property === "font-family" && token.fontFamily)
519
- tokenValues.add(token.fontFamily);
491
+ if (property === "font-family" && token.fontFamily) tokenValues.add(token.fontFamily);
520
492
  }
521
- if (tokenValues.size === 0)
522
- return { matches: true };
523
- if (tokenValues.has(value))
524
- return { matches: true };
493
+ if (tokenValues.size === 0) return { matches: true };
494
+ if (tokenValues.has(value)) return { matches: true };
525
495
  if (property === "font-family") {
526
496
  const normalizedValue = value.replace(/['"]/g, "").trim();
527
497
  const primaryFont = normalizedValue.split(",")[0].trim();
528
498
  for (const tv of tokenValues) {
529
- if (tv.replace(/['"]/g, "").trim() === primaryFont)
530
- return { matches: true };
499
+ if (tv.replace(/['"]/g, "").trim() === primaryFont) return { matches: true };
531
500
  }
532
501
  }
533
502
  return {
@@ -541,13 +510,10 @@ function matchAnimationDuration(value, animationTokens) {
541
510
  }
542
511
  const durations = /* @__PURE__ */ new Set();
543
512
  for (const preset of Object.values(animationTokens)) {
544
- if (preset.duration)
545
- durations.add(preset.duration);
513
+ if (preset.duration) durations.add(preset.duration);
546
514
  }
547
- if (durations.size === 0)
548
- return { matches: true };
549
- if (durations.has(value))
550
- return { matches: true };
515
+ if (durations.size === 0) return { matches: true };
516
+ if (durations.has(value)) return { matches: true };
551
517
  return {
552
518
  matches: false,
553
519
  suggestion: `Use a DESIGN.md animation preset duration`
@@ -559,17 +525,13 @@ function matchAnimationEasing(value, animationTokens) {
559
525
  }
560
526
  const easings = /* @__PURE__ */ new Set();
561
527
  for (const preset of Object.values(animationTokens)) {
562
- if (preset.easing)
563
- easings.add(preset.easing);
528
+ if (preset.easing) easings.add(preset.easing);
564
529
  }
565
- if (easings.size === 0)
566
- return { matches: true };
567
- if (easings.has(value))
568
- return { matches: true };
530
+ if (easings.size === 0) return { matches: true };
531
+ if (easings.has(value)) return { matches: true };
569
532
  const normalizedValue = value.replace(/\s/g, "");
570
533
  for (const e of easings) {
571
- if (e.replace(/\s/g, "") === normalizedValue)
572
- return { matches: true };
534
+ if (e.replace(/\s/g, "") === normalizedValue) return { matches: true };
573
535
  }
574
536
  return {
575
537
  matches: false,
@@ -587,8 +549,7 @@ function matchComponent(elementStyles, componentSpec, rawSpec) {
587
549
  width: "width"
588
550
  };
589
551
  for (const [specProp, expectedValue] of Object.entries(componentSpec)) {
590
- if (specProp === "typography")
591
- continue;
552
+ if (specProp === "typography") continue;
592
553
  const cssProp = propertyMapping[specProp] || specProp;
593
554
  const actualValue = elementStyles[cssProp];
594
555
  if (actualValue && actualValue !== expectedValue) {
@@ -622,10 +583,8 @@ function splitTopLevelCommas(value) {
622
583
  let depth = 0;
623
584
  let current = "";
624
585
  for (const ch of value) {
625
- if (ch === "(" || ch === "[")
626
- depth++;
627
- else if (ch === ")" || ch === "]")
628
- depth--;
586
+ if (ch === "(" || ch === "[") depth++;
587
+ else if (ch === ")" || ch === "]") depth--;
629
588
  if (ch === "," && depth === 0) {
630
589
  parts.push(current.trim());
631
590
  current = "";
@@ -633,8 +592,7 @@ function splitTopLevelCommas(value) {
633
592
  current += ch;
634
593
  }
635
594
  }
636
- if (current.trim())
637
- parts.push(current.trim());
595
+ if (current.trim()) parts.push(current.trim());
638
596
  return parts;
639
597
  }
640
598
  function extractBackgroundColors(value) {
@@ -661,8 +619,7 @@ function extractBackgroundColors(value) {
661
619
  }
662
620
  function hexToRgbValues(hex) {
663
621
  const normalized = normalizeHex(hex);
664
- if (!normalized)
665
- return null;
622
+ if (!normalized) return null;
666
623
  return [
667
624
  parseInt(normalized.slice(1, 3), 16),
668
625
  parseInt(normalized.slice(3, 5), 16),
@@ -680,7 +637,7 @@ function contrastRatio(l1, l2) {
680
637
  const darker = Math.min(l1, l2);
681
638
  return (lighter + 0.05) / (darker + 0.05);
682
639
  }
683
- const GUIDE_SUGGESTION$1 = "See design-system-validator agent-kit/designer/design-system.md for usage guide";
640
+ const GUIDE_SUGGESTION$2 = "See design-system-validator agent-kit/designer/design-system.md for usage guide";
684
641
  function elementHint(el) {
685
642
  const tag = el.rawTagName?.toLowerCase() || "element";
686
643
  const cls = el.getAttribute?.("class");
@@ -688,20 +645,17 @@ function elementHint(el) {
688
645
  const words = text ? text.split(/\s+/).slice(0, 3).join(" ") : "";
689
646
  const truncated = words && text.split(/\s+/).length > 3 ? words + "..." : words;
690
647
  const parts = [`<${tag}`];
691
- if (cls)
692
- parts.push(`class="${cls}"`);
648
+ if (cls) parts.push(`class="${cls}"`);
693
649
  parts.push(">");
694
- if (truncated)
695
- parts.push(`"${truncated}"`);
650
+ if (truncated) parts.push(`"${truncated}"`);
696
651
  return parts.join(" ");
697
652
  }
698
653
  function validateElementStyles(el, styles, tokens, breakpointLabel, designMdPath, findings) {
699
654
  const hint = elementHint(el);
700
655
  const prefix = breakpointLabel ? `[${breakpointLabel}] ` : "";
701
- const sug = (s) => s?.replace("DESIGN.md", designMdPath);
656
+ const sug = (s) => s ? s.replace("DESIGN.md", designMdPath) + "\nTo suppress intentional exceptions, see: agent-kit/designer/design-system.md" : void 0;
702
657
  for (const [property, resolved] of Object.entries(styles)) {
703
- if (resolved.allowed)
704
- continue;
658
+ if (resolved.allowed) continue;
705
659
  if (isColorProperty(property) && Object.keys(tokens.colors).length > 0) {
706
660
  const result = matchColor(resolved.value, tokens.colors);
707
661
  if (!result.matches) {
@@ -786,12 +740,10 @@ function validateElementStyles(el, styles, tokens, breakpointLabel, designMdPath
786
740
  }
787
741
  const validateTokens = (ctx) => {
788
742
  const found = findDesignMd(ctx.filePath, ctx.projectRoot);
789
- if (!found)
790
- return [];
743
+ if (!found) return [];
791
744
  const { tokens, designMdPath } = found;
792
745
  const findings = [];
793
- if (!ctx.css)
794
- return [];
746
+ if (!ctx.css) return [];
795
747
  const cssSources = [ctx.css];
796
748
  const breakpointNames = /* @__PURE__ */ new Map();
797
749
  for (const [name, value] of Object.entries(tokens.breakpoints)) {
@@ -822,7 +774,7 @@ const validateTokens = (ctx) => {
822
774
  checkReducedMotion(cssSources, findings);
823
775
  }
824
776
  if (findings.length > 0) {
825
- findings.push({ severity: "warning", message: "", suggestion: GUIDE_SUGGESTION$1 });
777
+ findings.push({ severity: "warning", message: "", suggestion: GUIDE_SUGGESTION$2 });
826
778
  }
827
779
  return findings;
828
780
  };
@@ -858,30 +810,25 @@ function checkReducedMotion(cssSources, findings) {
858
810
  });
859
811
  }
860
812
  }
861
- const GUIDE_SUGGESTION = "See design-system-validator agent-kit/designer/design-system.md for usage guide";
813
+ const GUIDE_SUGGESTION$1 = "See design-system-validator agent-kit/designer/design-system.md for usage guide";
862
814
  function describeElement(el) {
863
815
  const tag = el.rawTagName?.toLowerCase() || "element";
864
816
  const cls = el.getAttribute?.("class");
865
817
  const ref = el.getAttribute?.("ref");
866
818
  const text = el.textContent?.trim().slice(0, 30);
867
819
  const parts = [`<${tag}`];
868
- if (cls)
869
- parts.push(`class="${cls}"`);
870
- if (ref)
871
- parts.push(`ref="${ref}"`);
820
+ if (cls) parts.push(`class="${cls}"`);
821
+ if (ref) parts.push(`ref="${ref}"`);
872
822
  parts.push(">");
873
- if (text)
874
- parts.push(`"${text}${el.textContent.trim().length > 30 ? "…" : ""}"`);
823
+ if (text) parts.push(`"${text}${el.textContent.trim().length > 30 ? "…" : ""}"`);
875
824
  return parts.join(" ");
876
825
  }
877
826
  const validateComponents = (ctx) => {
878
827
  const found = findDesignMd(ctx.filePath, ctx.projectRoot);
879
- if (!found || Object.keys(found.tokens.components).length === 0)
880
- return [];
828
+ if (!found || Object.keys(found.tokens.components).length === 0) return [];
881
829
  const { tokens, designMdPath } = found;
882
830
  const findings = [];
883
- if (!ctx.css)
884
- return [];
831
+ if (!ctx.css) return [];
885
832
  const cascade = resolveCascade([ctx.css], ctx.body);
886
833
  const jayComponents = /* @__PURE__ */ new Map();
887
834
  const htmlComponents = /* @__PURE__ */ new Map();
@@ -904,8 +851,7 @@ const validateComponents = (ctx) => {
904
851
  }
905
852
  }
906
853
  for (const child of el.childNodes) {
907
- if (child.nodeType === 1)
908
- walk(child);
854
+ if (child.nodeType === 1) walk(child);
909
855
  }
910
856
  }
911
857
  walk(ctx.body);
@@ -913,16 +859,13 @@ const validateComponents = (ctx) => {
913
859
  const componentName = `jay:${contractName}`;
914
860
  const spec = tokens.components[componentName];
915
861
  const rawSpec = tokens.rawComponents[componentName];
916
- if (!spec)
917
- continue;
862
+ if (!spec) continue;
918
863
  for (const el of elements) {
919
864
  const styles = cascade.get(el);
920
- if (!styles)
921
- continue;
865
+ if (!styles) continue;
922
866
  const styleValues = {};
923
867
  for (const [prop, resolved] of Object.entries(styles)) {
924
- if (!resolved.allowed)
925
- styleValues[prop] = resolved.value;
868
+ if (!resolved.allowed) styleValues[prop] = resolved.value;
926
869
  }
927
870
  const mismatches = matchComponent(styleValues, spec, rawSpec);
928
871
  if (mismatches.length > 0) {
@@ -939,20 +882,17 @@ const validateComponents = (ctx) => {
939
882
  for (const [componentName, selector] of htmlComponents) {
940
883
  const spec = tokens.components[componentName];
941
884
  const rawSpec = tokens.rawComponents[componentName];
942
- if (!spec)
943
- continue;
885
+ if (!spec) continue;
944
886
  for (const [el, styles] of cascade) {
945
887
  try {
946
888
  const matchedEls = ctx.body.querySelectorAll(selector);
947
- if (!matchedEls.some((m) => m === el))
948
- continue;
889
+ if (!matchedEls.some((m) => m === el)) continue;
949
890
  } catch {
950
891
  continue;
951
892
  }
952
893
  const styleValues = {};
953
894
  for (const [prop, resolved] of Object.entries(styles)) {
954
- if (!resolved.allowed)
955
- styleValues[prop] = resolved.value;
895
+ if (!resolved.allowed) styleValues[prop] = resolved.value;
956
896
  }
957
897
  const mismatches = matchComponent(styleValues, spec, rawSpec);
958
898
  if (mismatches.length > 0) {
@@ -967,14 +907,13 @@ const validateComponents = (ctx) => {
967
907
  }
968
908
  }
969
909
  if (findings.length > 0) {
970
- findings.push({ severity: "warning", message: "", suggestion: GUIDE_SUGGESTION });
910
+ findings.push({ severity: "warning", message: "", suggestion: GUIDE_SUGGESTION$1 });
971
911
  }
972
912
  return findings;
973
913
  };
974
914
  const validateStructure = (ctx) => {
975
915
  const found = findDesignMd(ctx.filePath, ctx.projectRoot);
976
- if (!found)
977
- return [];
916
+ if (!found) return [];
978
917
  const { tokens } = found;
979
918
  const findings = [];
980
919
  if (tokens.rules["max-font-weights"] && ctx.css) {
@@ -982,8 +921,7 @@ const validateStructure = (ctx) => {
982
921
  const fontWeights = /* @__PURE__ */ new Set();
983
922
  for (const [, styles] of cascade) {
984
923
  const fw = styles["font-weight"];
985
- if (fw && !fw.allowed)
986
- fontWeights.add(fw.value);
924
+ if (fw && !fw.allowed) fontWeights.add(fw.value);
987
925
  }
988
926
  const max = tokens.rules["max-font-weights"];
989
927
  if (fontWeights.size > max) {
@@ -998,11 +936,9 @@ const validateStructure = (ctx) => {
998
936
  const primaryButtons = /* @__PURE__ */ new Map();
999
937
  walkElements(ctx.body, ctx, (el) => {
1000
938
  const tag = el.rawTagName?.toLowerCase();
1001
- if (tag !== "button")
1002
- return;
939
+ if (tag !== "button") return;
1003
940
  const classes = el.getAttribute?.("class") || "";
1004
- if (!classes.includes("primary") && !classes.includes("btn-primary"))
1005
- return;
941
+ if (!classes.includes("primary") && !classes.includes("btn-primary")) return;
1006
942
  const ref = el.getAttribute?.("ref") || "";
1007
943
  const text = el.textContent?.trim() || "";
1008
944
  const identity = `${ref}|${text}`;
@@ -1020,8 +956,7 @@ const validateStructure = (ctx) => {
1020
956
  return findings;
1021
957
  };
1022
958
  function resolveColorValue(value) {
1023
- if (value.startsWith("#"))
1024
- return value;
959
+ if (value.startsWith("#")) return value;
1025
960
  if (value.startsWith("rgb")) {
1026
961
  const match = value.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
1027
962
  if (match) {
@@ -1034,41 +969,33 @@ function resolveColorValue(value) {
1034
969
  return null;
1035
970
  }
1036
971
  function isLargeText(fontSize, fontWeight) {
1037
- if (!fontSize)
1038
- return false;
972
+ if (!fontSize) return false;
1039
973
  const sizeMatch = fontSize.match(/([\d.]+)(px|rem|em)/);
1040
- if (!sizeMatch)
1041
- return false;
974
+ if (!sizeMatch) return false;
1042
975
  let sizePx = parseFloat(sizeMatch[1]);
1043
976
  const unit = sizeMatch[2];
1044
- if (unit === "rem" || unit === "em")
1045
- sizePx *= 16;
977
+ if (unit === "rem" || unit === "em") sizePx *= 16;
1046
978
  const weight = fontWeight ? parseInt(fontWeight, 10) : 400;
1047
979
  return sizePx >= 24 || sizePx >= 18.66 && weight >= 700;
1048
980
  }
1049
981
  const validateContrast = (ctx) => {
1050
982
  const found = findDesignMd(ctx.filePath, ctx.projectRoot);
1051
- if (!found || !found.tokens.rules["require-contrast-aa"])
1052
- return [];
983
+ if (!found || !found.tokens.rules["require-contrast-aa"]) return [];
984
+ const { tokens } = found;
1053
985
  const findings = [];
1054
- if (!ctx.css)
1055
- return [];
986
+ if (!ctx.css) return [];
1056
987
  const cascade = resolveCascade([ctx.css], ctx.body);
1057
988
  for (const [el, styles] of cascade) {
1058
989
  const colorStyle = styles["color"];
1059
990
  const bgStyle = styles["background-color"] || styles["background"];
1060
- if (!colorStyle || !bgStyle)
1061
- continue;
1062
- if (colorStyle.allowed || bgStyle.allowed)
1063
- continue;
991
+ if (!colorStyle || !bgStyle) continue;
992
+ if (colorStyle.allowed || bgStyle.allowed) continue;
1064
993
  const fgHex = resolveColorValue(colorStyle.value);
1065
994
  const bgHex = resolveColorValue(bgStyle.value);
1066
- if (!fgHex || !bgHex)
1067
- continue;
995
+ if (!fgHex || !bgHex) continue;
1068
996
  const fgRgb = hexToRgbValues(fgHex);
1069
997
  const bgRgb = hexToRgbValues(bgHex);
1070
- if (!fgRgb || !bgRgb)
1071
- continue;
998
+ if (!fgRgb || !bgRgb) continue;
1072
999
  const fgLum = relativeLuminance(...fgRgb);
1073
1000
  const bgLum = relativeLuminance(...bgRgb);
1074
1001
  const ratio = contrastRatio(fgLum, bgLum);
@@ -1086,164 +1013,476 @@ const validateContrast = (ctx) => {
1086
1013
  }
1087
1014
  return findings;
1088
1015
  };
1089
- const ADD_MENU_OUTPUT_REL = "agent-kit/aiditor/add-menu/design-system.yaml";
1090
- function categoryName(designMdPath, projectRoot, tokens) {
1091
- if (tokens.name)
1092
- return tokens.name;
1093
- const rel = path.relative(projectRoot, designMdPath);
1094
- if (rel === "DESIGN.md")
1095
- return "Design System";
1096
- const dir = path.dirname(rel).replace(/^src\/pages\/?/, "");
1097
- return dir ? `Design System (${dir})` : "Design System";
1016
+ const GUIDE_SUGGESTION = "See design-system-validator agent-kit/designer/font-fallback-patterns.md for usage guide";
1017
+ const METRIC_OVERRIDE_PROPERTIES = /* @__PURE__ */ new Set([
1018
+ "size-adjust",
1019
+ "ascent-override",
1020
+ "descent-override",
1021
+ "line-gap-override"
1022
+ ]);
1023
+ function unquote(value) {
1024
+ return value.replace(/^(['"])(.*)\1$/, "$2");
1025
+ }
1026
+ function parseFontFaces(css) {
1027
+ const result = [];
1028
+ const root = postcss.parse(css);
1029
+ root.walkAtRules("font-face", (atRule) => {
1030
+ let family = "";
1031
+ let hasUrlSrc = false;
1032
+ let hasLocalSrc = false;
1033
+ let hasMetricOverrides = false;
1034
+ atRule.walkDecls((decl) => {
1035
+ if (decl.prop === "font-family") {
1036
+ family = unquote(decl.value);
1037
+ }
1038
+ if (decl.prop === "src") {
1039
+ if (/url\s*\(/.test(decl.value)) hasUrlSrc = true;
1040
+ if (/local\s*\(/.test(decl.value)) hasLocalSrc = true;
1041
+ }
1042
+ if (METRIC_OVERRIDE_PROPERTIES.has(decl.prop)) {
1043
+ hasMetricOverrides = true;
1044
+ }
1045
+ });
1046
+ if (family) {
1047
+ result.push({ family, hasUrlSrc, hasLocalSrc, hasMetricOverrides });
1048
+ }
1049
+ });
1050
+ return result;
1051
+ }
1052
+ const FONT_SERVICE_HOSTS = [
1053
+ "fonts.googleapis.com",
1054
+ "fonts.bunny.net",
1055
+ "fonts.cdnfonts.com",
1056
+ "use.typekit.net"
1057
+ ];
1058
+ function parseFontServiceUrl(url) {
1059
+ if (!FONT_SERVICE_HOSTS.some((host) => url.includes(host))) return [];
1060
+ const queryStart = url.indexOf("?");
1061
+ if (queryStart === -1) return [];
1062
+ const query = url.substring(queryStart + 1);
1063
+ const families = [];
1064
+ for (const param of query.split("&")) {
1065
+ const eqIdx = param.indexOf("=");
1066
+ const key = eqIdx === -1 ? param : param.substring(0, eqIdx);
1067
+ const value = eqIdx === -1 ? "" : param.substring(eqIdx + 1);
1068
+ if (key === "family") {
1069
+ const parts = value.split("|");
1070
+ for (const part of parts) {
1071
+ const name = decodeURIComponent(part.split(":")[0]).replace(/\+/g, " ");
1072
+ if (name) families.push(name);
1073
+ }
1074
+ }
1075
+ }
1076
+ return families;
1077
+ }
1078
+ function parseFontImports(css) {
1079
+ const families = [];
1080
+ const root = postcss.parse(css);
1081
+ root.walkAtRules("import", (atRule) => {
1082
+ const raw = atRule.params;
1083
+ const urlMatch = raw.match(/url\s*\(\s*(['"]?)(.+?)\1\s*\)/) || raw.match(/(['"]?)(.+?)\1/);
1084
+ if (!urlMatch) return;
1085
+ const url = urlMatch[2];
1086
+ families.push(...parseFontServiceUrl(url));
1087
+ });
1088
+ return families;
1089
+ }
1090
+ function parseFontLinks(head) {
1091
+ if (!head?.links) return [];
1092
+ const families = [];
1093
+ for (const link of head.links) {
1094
+ if (link.rel !== "stylesheet") continue;
1095
+ const staticParts = link.href.filter((p) => p.kind === "static");
1096
+ if (staticParts.length !== link.href.length) continue;
1097
+ const url = staticParts.map((p) => p.value).join("");
1098
+ families.push(...parseFontServiceUrl(url));
1099
+ }
1100
+ return families;
1098
1101
  }
1102
+ function parseFontFamilyStacks(css) {
1103
+ const stacks = /* @__PURE__ */ new Map();
1104
+ const root = postcss.parse(css);
1105
+ root.walkDecls("font-family", (decl) => {
1106
+ const parent = decl.parent;
1107
+ if (parent?.type === "atrule" && parent.name === "font-face") return;
1108
+ const families = decl.value.split(",").map((f) => unquote(f.trim()));
1109
+ const key = families.join(",");
1110
+ if (!stacks.has(key)) {
1111
+ stacks.set(key, families);
1112
+ }
1113
+ });
1114
+ return stacks;
1115
+ }
1116
+ const validateFontFallbacks = (ctx) => {
1117
+ const findings = [];
1118
+ const fontFaces = ctx.css ? parseFontFaces(ctx.css) : [];
1119
+ const importedFonts = ctx.css ? parseFontImports(ctx.css) : [];
1120
+ const linkedFonts = parseFontLinks(ctx.head);
1121
+ if (fontFaces.length === 0 && importedFonts.length === 0 && linkedFonts.length === 0)
1122
+ return findings;
1123
+ if (!ctx.css && linkedFonts.length === 0) return findings;
1124
+ const webFontFamilies = /* @__PURE__ */ new Set();
1125
+ const metricMatchedFallbacks = /* @__PURE__ */ new Set();
1126
+ for (const ff of fontFaces) {
1127
+ if (ff.hasUrlSrc) {
1128
+ webFontFamilies.add(ff.family);
1129
+ }
1130
+ if (ff.hasLocalSrc && ff.hasMetricOverrides) {
1131
+ metricMatchedFallbacks.add(ff.family);
1132
+ }
1133
+ }
1134
+ for (const family of importedFonts) {
1135
+ webFontFamilies.add(family);
1136
+ }
1137
+ for (const family of linkedFonts) {
1138
+ webFontFamilies.add(family);
1139
+ }
1140
+ if (webFontFamilies.size === 0) return findings;
1141
+ const fontStacks = ctx.css ? parseFontFamilyStacks(ctx.css) : /* @__PURE__ */ new Map();
1142
+ const designMd = findDesignMd(ctx.filePath, ctx.projectRoot);
1143
+ if (designMd) {
1144
+ for (const [, token] of Object.entries(designMd.tokens.typography)) {
1145
+ if (token.fontFamily) {
1146
+ const families = token.fontFamily.split(",").map((f) => unquote(f.trim()));
1147
+ const key = families.join(",");
1148
+ if (!fontStacks.has(key)) {
1149
+ fontStacks.set(key, families);
1150
+ }
1151
+ }
1152
+ }
1153
+ }
1154
+ const flagged = /* @__PURE__ */ new Set();
1155
+ function flagIfMissing(family) {
1156
+ if (flagged.has(family)) return;
1157
+ const hasFallbackFace = fontFaces.some(
1158
+ (ff) => ff.hasLocalSrc && ff.hasMetricOverrides && ff.family.startsWith(family) && ff.family !== family
1159
+ );
1160
+ const familyItselfIsMetricMatched = metricMatchedFallbacks.has(family);
1161
+ if (!hasFallbackFace && !familyItselfIsMetricMatched) {
1162
+ flagged.add(family);
1163
+ findings.push({
1164
+ severity: "warning",
1165
+ message: `font-family "${family}" loads from a URL but has no metric-matched fallback. This causes layout shift (CLS) when the font loads.`,
1166
+ suggestion: `Generate a fallback with: npx jay-stack-cli action design-system-validator/fontFallback --input '{"primary":"${family}","fallback":"Arial"}'`
1167
+ });
1168
+ }
1169
+ }
1170
+ for (const [, families] of fontStacks) {
1171
+ for (const family of families) {
1172
+ if (!webFontFamilies.has(family)) continue;
1173
+ flagIfMissing(family);
1174
+ }
1175
+ }
1176
+ for (const family of webFontFamilies) {
1177
+ flagIfMissing(family);
1178
+ }
1179
+ if (findings.length > 0) {
1180
+ findings.push({ severity: "warning", message: "", suggestion: GUIDE_SUGGESTION });
1181
+ }
1182
+ return findings;
1183
+ };
1184
+ const ALLOW_COMMENT = "design-system: allow";
1185
+ const VAR_REGEX = /var\(\s*(--[a-zA-Z0-9-]+)(?:\s*,\s*([^)]+))?\s*\)/g;
1186
+ function hasAllowComment(decl) {
1187
+ const next = decl.next();
1188
+ if (next?.type === "comment" && next.text.trim() === ALLOW_COMMENT) {
1189
+ return true;
1190
+ }
1191
+ const raw = decl.raws.value;
1192
+ if (raw && typeof raw === "object" && "raw" in raw) {
1193
+ const rawStr = raw.raw;
1194
+ if (rawStr.includes(`/*${ALLOW_COMMENT}*/`) || rawStr.includes(`/* ${ALLOW_COMMENT} */`)) {
1195
+ return true;
1196
+ }
1197
+ }
1198
+ return false;
1199
+ }
1200
+ const validateUndefinedVars = (ctx) => {
1201
+ if (!ctx.css) return [];
1202
+ const findings = [];
1203
+ const definedVars = /* @__PURE__ */ new Set();
1204
+ const usedVars = /* @__PURE__ */ new Map();
1205
+ const root = postcss.parse(ctx.css);
1206
+ root.walkDecls((decl) => {
1207
+ if (decl.prop.startsWith("--")) {
1208
+ definedVars.add(decl.prop);
1209
+ }
1210
+ if (decl.value.includes("var(") && !hasAllowComment(decl)) {
1211
+ const selector = decl.parent && "selector" in decl.parent ? decl.parent.selector : "[unknown]";
1212
+ let match;
1213
+ VAR_REGEX.lastIndex = 0;
1214
+ while ((match = VAR_REGEX.exec(decl.value)) !== null) {
1215
+ const varName = match[1];
1216
+ if (!usedVars.has(varName)) {
1217
+ usedVars.set(varName, {
1218
+ property: decl.prop,
1219
+ selector,
1220
+ hasFallback: match[2] !== void 0,
1221
+ fallbackValue: match[2]?.trim()
1222
+ });
1223
+ }
1224
+ }
1225
+ }
1226
+ });
1227
+ for (const [varName, usage] of usedVars) {
1228
+ if (definedVars.has(varName)) continue;
1229
+ const fallbackNote = usage.hasFallback ? ` (falls back to "${usage.fallbackValue}")` : "";
1230
+ findings.push({
1231
+ severity: "warning",
1232
+ message: `CSS variable "${varName}" is used but never defined${fallbackNote}`,
1233
+ suggestion: `Define ${varName} in a :root block, or replace with a DESIGN.md token value directly.
1234
+ To suppress: add /* design-system: allow */ on the same line or the line after the declaration.
1235
+ See agent-kit/designer/design-system.md for usage guide.`
1236
+ });
1237
+ }
1238
+ return findings;
1239
+ };
1240
+ async function loadMetrics(familyName) {
1241
+ const camelCase = fontFamilyToCamelCase(familyName);
1242
+ try {
1243
+ const mod = await import(`@capsizecss/metrics/${camelCase}`);
1244
+ return mod.default;
1245
+ } catch {
1246
+ throw new Error(
1247
+ `Font "${familyName}" not found in @capsizecss/metrics. Use a known Google Font or system font name (e.g. "Inter", "Arial", "Georgia").`
1248
+ );
1249
+ }
1250
+ }
1251
+ const fontFallback = makeJayAction("designSystem.fontFallback").withHandler(
1252
+ async (input) => {
1253
+ const primaryMetrics = await loadMetrics(input.primary);
1254
+ const fallbackMetrics = await loadMetrics(input.fallback);
1255
+ const result = createFontStack([primaryMetrics, fallbackMetrics]);
1256
+ return {
1257
+ fontFamily: result.fontFamily,
1258
+ fontFaces: result.fontFaces
1259
+ };
1260
+ }
1261
+ );
1262
+ const PLUGIN_ATTRS = {
1263
+ pluginName: "design-system-validator",
1264
+ packageName: "@jay-framework/design-system-validator"
1265
+ };
1099
1266
  function esc(s) {
1100
1267
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1101
1268
  }
1102
- function buildColorItems(tokens, category) {
1103
- return Object.entries(tokens.colors).map(([name, value]) => ({
1269
+ function scopedFragment(innerHtml, scopedCss = "") {
1270
+ const styleBlock = `<style>@scope { ${scopedCss} }</style>`;
1271
+ return `<div>${styleBlock}${innerHtml}</div>`;
1272
+ }
1273
+ function isLargeFontSize(fontSize) {
1274
+ if (!fontSize) return false;
1275
+ if (/\d+(\.\d+)?\s*(vw|vh|vmin|vmax)\b/.test(fontSize)) return true;
1276
+ const clampMatch = fontSize.match(/clamp\([^,]+,[^,]+,\s*([\d.]+)\s*(px|rem|em)/);
1277
+ if (clampMatch) {
1278
+ const max = parseFloat(clampMatch[1]);
1279
+ const unit = clampMatch[2];
1280
+ if (unit === "px") return max > 20;
1281
+ return max > 1.25;
1282
+ }
1283
+ const simple = parseFloat(fontSize);
1284
+ if (isNaN(simple)) return false;
1285
+ if (fontSize.includes("rem") || fontSize.includes("em")) return simple > 1.25;
1286
+ return simple > 20;
1287
+ }
1288
+ function spacingBrowseSize(value) {
1289
+ if (/calc|clamp|var\(/.test(value)) return "large";
1290
+ const px = parseFloat(value);
1291
+ if (!isNaN(px) && !value.includes("rem") && !value.includes("em")) {
1292
+ if (px <= 30) return "small";
1293
+ if (px > 100) return "large";
1294
+ return void 0;
1295
+ }
1296
+ const rem = parseFloat(value);
1297
+ if (!isNaN(rem)) {
1298
+ if (rem <= 2) return "small";
1299
+ if (rem > 6) return "large";
1300
+ }
1301
+ return void 0;
1302
+ }
1303
+ function buildColorItem(name, value, category) {
1304
+ return {
1104
1305
  id: `design-system:color-${name}`,
1105
- title: `${name} (${value})`,
1306
+ ...PLUGIN_ATTRS,
1307
+ title: name,
1106
1308
  category,
1107
1309
  subCategory: "Colors",
1310
+ browse: { size: "small" },
1108
1311
  prompt: `Use color token "${name}" with value ${value} from DESIGN.md.`,
1312
+ interaction: {
1313
+ mode: "stage-place",
1314
+ stagePromptTemplate: `Apply the color token "${name}" (${value}) from DESIGN.md at this location.`
1315
+ },
1109
1316
  presentation: {
1110
1317
  type: "html-fragment",
1111
- html: `<div style="display:flex;align-items:center;gap:10px;font-family:sans-serif;">
1112
- <div style="width:40px;height:40px;border-radius:6px;background:${esc(value)};border:1px solid rgba(0,0,0,0.1);flex-shrink:0;"></div>
1113
- <div>
1114
- <div style="font-size:13px;font-weight:600;">${esc(name)}</div>
1115
- <div style="font-size:11px;color:#888;font-family:monospace;">${esc(value)}</div>
1116
- </div>
1117
- </div>`
1318
+ html: scopedFragment(`<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;font-family:sans-serif;height:100%;">
1319
+ <div style="width:40px;height:40px;border-radius:6px;background:${esc(value)};border:1px solid rgba(0,0,0,0.1);"></div>
1320
+ <div style="font-size:10px;color:#888;font-family:monospace;">${esc(value)}</div>
1321
+ </div>`)
1118
1322
  }
1119
- }));
1120
- }
1121
- function buildTypographyItems(tokens, category) {
1122
- return Object.entries(tokens.typography).map(([name, t]) => {
1123
- const parts = [];
1124
- if (t.fontFamily)
1125
- parts.push(t.fontFamily);
1126
- if (t.fontSize)
1127
- parts.push(t.fontSize);
1128
- if (t.fontWeight)
1129
- parts.push(`weight ${t.fontWeight}`);
1130
- if (t.lineHeight)
1131
- parts.push(`line-height ${t.lineHeight}`);
1132
- if (t.letterSpacing)
1133
- parts.push(`letter-spacing ${t.letterSpacing}`);
1134
- const desc = parts.join(", ");
1135
- const styles = [];
1136
- if (t.fontFamily)
1137
- styles.push(`font-family:${t.fontFamily},sans-serif`);
1138
- if (t.fontSize)
1139
- styles.push(`font-size:${t.fontSize}`);
1140
- if (t.fontWeight)
1141
- styles.push(`font-weight:${t.fontWeight}`);
1142
- if (t.lineHeight)
1143
- styles.push(`line-height:${t.lineHeight}`);
1144
- if (t.letterSpacing)
1145
- styles.push(`letter-spacing:${t.letterSpacing}`);
1146
- return {
1147
- id: `design-system:typography-${name}`,
1148
- title: name,
1149
- category,
1150
- subCategory: "Typography",
1151
- prompt: `Apply typography preset "${name}" from DESIGN.md: ${desc}.`,
1152
- presentation: {
1153
- type: "html-fragment",
1154
- html: `<div style="font-family:sans-serif;">
1155
- <div style="${styles.join(";")};margin:0;">The quick brown fox</div>
1156
- <div style="font-size:10px;color:#888;margin-top:6px;font-family:monospace;">${esc(name)}: ${esc(desc)}</div>
1157
- </div>`
1158
- }
1159
- };
1160
- });
1323
+ };
1161
1324
  }
1162
- function buildSpacingItems(tokens, category) {
1163
- return Object.entries(tokens.spacing).map(([name, value]) => ({
1325
+ function buildSpacingItem(name, value, category) {
1326
+ return {
1164
1327
  id: `design-system:spacing-${name}`,
1165
- title: `${name} (${value})`,
1328
+ ...PLUGIN_ATTRS,
1329
+ title: name,
1166
1330
  category,
1167
1331
  subCategory: "Spacing",
1168
1332
  prompt: `Use spacing token "${name}" with value ${value} from DESIGN.md for padding, margin, or gap.`,
1333
+ ...spacingBrowseSize(value) ? { browse: { size: spacingBrowseSize(value) } } : {},
1334
+ interaction: {
1335
+ mode: "stage-place",
1336
+ stagePromptTemplate: `Apply the spacing token "${name}" (${value}) from DESIGN.md at this location.`
1337
+ },
1169
1338
  presentation: {
1170
1339
  type: "html-fragment",
1171
- html: `<div style="display:flex;align-items:center;gap:8px;font-family:sans-serif;">
1340
+ html: scopedFragment(`<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;font-family:sans-serif;height:100%;">
1172
1341
  <div style="display:flex;align-items:center;gap:0;">
1173
1342
  <div style="width:16px;height:24px;background:#cbd5e1;border-radius:2px 0 0 2px;"></div>
1174
1343
  <div style="width:${esc(value)};height:24px;background:repeating-linear-gradient(45deg,#e0f2fe,#e0f2fe 2px,#bae6fd 2px,#bae6fd 4px);"></div>
1175
1344
  <div style="width:16px;height:24px;background:#cbd5e1;border-radius:0 2px 2px 0;"></div>
1176
1345
  </div>
1177
- <div>
1178
- <span style="font-size:12px;font-weight:600;">${esc(name)}</span>
1179
- <span style="font-size:11px;color:#888;font-family:monospace;margin-left:4px;">${esc(value)}</span>
1180
- </div>
1181
- </div>`
1346
+ <div style="font-size:10px;color:#888;font-family:monospace;">${esc(value)}</div>
1347
+ </div>`)
1182
1348
  }
1183
- }));
1349
+ };
1184
1350
  }
1185
- function buildRoundedItems(tokens, category) {
1186
- return Object.entries(tokens.rounded).map(([name, value]) => ({
1351
+ function buildRoundedItem(name, value, category) {
1352
+ return {
1187
1353
  id: `design-system:rounded-${name}`,
1188
- title: `${name} (${value})`,
1354
+ ...PLUGIN_ATTRS,
1355
+ title: name,
1189
1356
  category,
1190
1357
  subCategory: "Rounded",
1358
+ browse: { size: "small" },
1191
1359
  prompt: `Use border-radius token "${name}" with value ${value} from DESIGN.md.`,
1360
+ interaction: {
1361
+ mode: "stage-place",
1362
+ stagePromptTemplate: `Apply the border-radius token "${name}" (${value}) from DESIGN.md at this location.`
1363
+ },
1192
1364
  presentation: {
1193
1365
  type: "html-fragment",
1194
- html: `<div style="display:flex;align-items:center;gap:10px;font-family:sans-serif;">
1195
- <div style="width:40px;height:40px;border-radius:${esc(value)};background:#e2e8f0;border:1.5px solid #94a3b8;flex-shrink:0;"></div>
1196
- <div>
1197
- <div style="font-size:12px;font-weight:600;">${esc(name)}</div>
1198
- <div style="font-size:11px;color:#888;font-family:monospace;">${esc(value)}</div>
1199
- </div>
1200
- </div>`
1366
+ html: scopedFragment(`<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;font-family:sans-serif;height:100%;">
1367
+ <div style="width:40px;height:40px;border-radius:${esc(value)};background:#e2e8f0;border:1.5px solid #94a3b8;"></div>
1368
+ <div style="font-size:10px;color:#888;font-family:monospace;">${esc(value)}</div>
1369
+ </div>`)
1201
1370
  }
1202
- }));
1371
+ };
1372
+ }
1373
+ function formatTypographyCssValue(value) {
1374
+ return String(value);
1375
+ }
1376
+ function buildTypographyItem(name, typography, category) {
1377
+ const parts = [];
1378
+ if (typography.fontFamily) parts.push(typography.fontFamily);
1379
+ if (typography.fontSize) parts.push(typography.fontSize);
1380
+ if (typography.fontWeight != null) {
1381
+ parts.push(`weight ${formatTypographyCssValue(typography.fontWeight)}`);
1382
+ }
1383
+ if (typography.lineHeight != null) {
1384
+ parts.push(`line-height ${formatTypographyCssValue(typography.lineHeight)}`);
1385
+ }
1386
+ if (typography.letterSpacing) parts.push(`letter-spacing ${typography.letterSpacing}`);
1387
+ const desc = parts.join(", ");
1388
+ const styles = [];
1389
+ if (typography.fontFamily) styles.push(`font-family:${typography.fontFamily},sans-serif`);
1390
+ if (typography.fontSize) styles.push(`font-size:${typography.fontSize}`);
1391
+ if (typography.fontWeight != null) {
1392
+ styles.push(`font-weight:${formatTypographyCssValue(typography.fontWeight)}`);
1393
+ }
1394
+ if (typography.lineHeight != null) {
1395
+ styles.push(`line-height:${formatTypographyCssValue(typography.lineHeight)}`);
1396
+ }
1397
+ if (typography.letterSpacing) styles.push(`letter-spacing:${typography.letterSpacing}`);
1398
+ const isLargeFont = isLargeFontSize(typography.fontSize);
1399
+ return {
1400
+ id: `design-system:typography-${name}`,
1401
+ ...PLUGIN_ATTRS,
1402
+ title: name,
1403
+ category,
1404
+ subCategory: "Typography",
1405
+ prompt: `Apply typography preset "${name}" from DESIGN.md: ${desc}.`,
1406
+ interaction: {
1407
+ mode: "stage-place",
1408
+ stagePromptTemplate: `Apply the typography preset "${name}" (${desc}) from DESIGN.md at this location.`
1409
+ },
1410
+ ...isLargeFont ? { browse: { size: "large" } } : {},
1411
+ presentation: {
1412
+ type: "html-fragment",
1413
+ html: scopedFragment(`<div style="font-family:sans-serif;">
1414
+ <div style="${styles.join(";")};margin:0;">The quick brown fox</div>
1415
+ <div style="font-size:10px;color:#888;margin-top:6px;font-family:monospace;">${esc(name)}: ${esc(desc)}</div>
1416
+ </div>`)
1417
+ }
1418
+ };
1419
+ }
1420
+ function buildColorItems(tokens, category) {
1421
+ return Object.entries(tokens.colors).map(
1422
+ ([name, value]) => buildColorItem(name, value, category)
1423
+ );
1424
+ }
1425
+ function buildTypographyItems(tokens, category) {
1426
+ return Object.entries(tokens.typography).map(
1427
+ ([name, typography]) => buildTypographyItem(name, typography, category)
1428
+ );
1429
+ }
1430
+ function buildSpacingItems(tokens, category) {
1431
+ return Object.entries(tokens.spacing).map(
1432
+ ([name, value]) => buildSpacingItem(name, value, category)
1433
+ );
1434
+ }
1435
+ function buildRoundedItems(tokens, category) {
1436
+ return Object.entries(tokens.rounded).map(
1437
+ ([name, value]) => buildRoundedItem(name, value, category)
1438
+ );
1203
1439
  }
1204
1440
  function buildBreakpointItems(tokens, category) {
1205
1441
  return Object.entries(tokens.breakpoints).map(([name, value]) => ({
1206
1442
  id: `design-system:breakpoint-${name}`,
1443
+ ...PLUGIN_ATTRS,
1207
1444
  title: `${name} (${value})`,
1208
1445
  category,
1209
1446
  subCategory: "Breakpoints",
1210
- prompt: `Use breakpoint "${name}" at max-width ${value} from DESIGN.md: @media (max-width: ${value}) { ... }`
1447
+ prompt: `Use breakpoint "${name}" at max-width ${value} from DESIGN.MD: @media (max-width: ${value}) { ... }`,
1448
+ interaction: {
1449
+ mode: "stage-place",
1450
+ stagePromptTemplate: `Add a responsive breakpoint at "${name}" (${value}) from DESIGN.md for this element.`
1451
+ }
1211
1452
  }));
1212
1453
  }
1213
1454
  function buildAnimationItems(tokens, category) {
1214
1455
  return Object.entries(tokens.animations).map(([name, preset]) => {
1215
1456
  const parts = [];
1216
- if (preset.duration)
1217
- parts.push(preset.duration);
1218
- if (preset.easing)
1219
- parts.push(preset.easing);
1457
+ if (preset.duration) parts.push(preset.duration);
1458
+ if (preset.easing) parts.push(preset.easing);
1220
1459
  const desc = parts.join(", ");
1221
1460
  const dur = preset.duration || "300ms";
1222
1461
  const ease = preset.easing || "ease";
1223
1462
  return {
1224
1463
  id: `design-system:animation-${name}`,
1464
+ ...PLUGIN_ATTRS,
1225
1465
  title: name,
1226
1466
  category,
1227
1467
  subCategory: "Animations",
1228
1468
  prompt: `Use animation preset "${name}" from DESIGN.md: ${desc}. Apply to transition-duration and transition-timing-function.`,
1469
+ interaction: {
1470
+ mode: "stage-place",
1471
+ stagePromptTemplate: `Apply the animation preset "${name}" (${desc}) from DESIGN.md to this element's transitions.`
1472
+ },
1229
1473
  presentation: {
1230
1474
  type: "html-fragment",
1231
- html: `<div style="font-family:sans-serif;">
1232
- <style>
1233
- @scope {
1234
- .anim-preview { width:40px;height:40px;border-radius:6px;background:#8b5cf6;transition:transform ${esc(dur)} ${esc(ease)};cursor:pointer; }
1235
- .anim-preview:hover { transform:scale(1.3); }
1236
- }
1237
- </style>
1238
- <div style="display:flex;align-items:center;gap:10px;">
1475
+ html: scopedFragment(
1476
+ `<div style="display:flex;align-items:center;gap:10px;font-family:sans-serif;">
1239
1477
  <div class="anim-preview"></div>
1240
1478
  <div>
1241
1479
  <div style="font-size:12px;font-weight:600;">${esc(name)}</div>
1242
1480
  <div style="font-size:11px;color:#888;font-family:monospace;">${esc(desc)}</div>
1243
1481
  <div style="font-size:10px;color:#aaa;">hover to preview</div>
1244
1482
  </div>
1245
- </div>
1246
- </div>`
1483
+ </div>`,
1484
+ `.anim-preview { width:40px;height:40px;border-radius:6px;background:#8b5cf6;transition:transform ${esc(dur)} ${esc(ease)};cursor:pointer; } .anim-preview:hover { transform:scale(1.3); }`
1485
+ )
1247
1486
  }
1248
1487
  };
1249
1488
  });
@@ -1264,63 +1503,125 @@ function buildComponentItems(tokens, category) {
1264
1503
  borderColor: "border-color"
1265
1504
  };
1266
1505
  for (const [prop, value] of Object.entries(spec)) {
1267
- if (prop === "typography")
1268
- continue;
1506
+ if (prop === "typography") continue;
1269
1507
  const cssProp = propertyMapping[prop] || prop;
1270
1508
  previewStyles.push(`${cssProp}:${value}`);
1271
1509
  }
1272
- if (!spec.padding)
1273
- previewStyles.push("padding:8px 16px");
1274
- if (spec.borderColor)
1275
- previewStyles.push("border:1.5px solid " + spec.borderColor);
1510
+ if (!spec.padding) previewStyles.push("padding:8px 16px");
1511
+ if (spec.borderColor) previewStyles.push("border:1.5px solid " + spec.borderColor);
1276
1512
  return {
1277
1513
  id: `design-system:component-${name}`,
1514
+ ...PLUGIN_ATTRS,
1278
1515
  title: name,
1279
1516
  category,
1280
1517
  subCategory: "Components",
1281
1518
  prompt: `Apply the "${name}" component spec from DESIGN.md. Required styles: ${props}. The design-system validator will flag mismatches.`,
1519
+ interaction: {
1520
+ mode: "stage-place",
1521
+ stagePromptTemplate: `Apply the "${name}" component styles (${props}) from DESIGN.md to this element.`
1522
+ },
1282
1523
  presentation: {
1283
1524
  type: "html-fragment",
1284
- html: `<div style="font-family:sans-serif;">
1525
+ html: scopedFragment(`<div style="font-family:sans-serif;">
1285
1526
  <div style="${previewStyles.join(";")};display:inline-block;font-size:13px;min-width:80px;text-align:center;">${esc(name)}</div>
1286
1527
  <div style="font-size:10px;color:#888;margin-top:6px;font-family:monospace;">${esc(props)}</div>
1287
- </div>`
1528
+ </div>`)
1288
1529
  }
1289
1530
  };
1290
1531
  });
1291
1532
  }
1533
+ const ADD_MENU_GENERATED_REL = "agent-kit/aiditor/add-menu/design-system.generated.yaml";
1534
+ function readAddMenuCatalog(projectRoot) {
1535
+ const catalogPath = path.join(projectRoot, ADD_MENU_GENERATED_REL);
1536
+ if (!fs.existsSync(catalogPath)) {
1537
+ return [];
1538
+ }
1539
+ const parsed = load(fs.readFileSync(catalogPath, "utf-8"));
1540
+ if (!Array.isArray(parsed.items)) {
1541
+ return [];
1542
+ }
1543
+ return parsed.items;
1544
+ }
1545
+ function writeAddMenuCatalog(projectRoot, items) {
1546
+ const outputPath = path.join(projectRoot, ADD_MENU_GENERATED_REL);
1547
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
1548
+ fs.writeFileSync(outputPath, dump({ items }, { lineWidth: 120, noRefs: true }), "utf-8");
1549
+ return ADD_MENU_GENERATED_REL;
1550
+ }
1551
+ const AIDITOR_SETTINGS_OUTPUT_REL = "agent-kit/aiditor/settings/design-system-validator.yaml";
1552
+ const SETTINGS_TEMPLATE_REL = "agent-kit/aiditor/settings.template.yaml";
1553
+ function resolvePackagedAgentKitPath(relativePath, moduleUrl = import.meta.url) {
1554
+ let directory = path.dirname(fileURLToPath(moduleUrl));
1555
+ for (let depth = 0; depth < 4; depth++) {
1556
+ const candidate = path.join(directory, relativePath);
1557
+ if (fs.existsSync(candidate)) {
1558
+ return candidate;
1559
+ }
1560
+ const parent = path.dirname(directory);
1561
+ if (parent === directory) {
1562
+ break;
1563
+ }
1564
+ directory = parent;
1565
+ }
1566
+ return null;
1567
+ }
1568
+ function writeAiditorSettingsContribution(projectRoot, templatePath, force = false) {
1569
+ const outputPath = path.join(projectRoot, AIDITOR_SETTINGS_OUTPUT_REL);
1570
+ if (fs.existsSync(outputPath) && !force) {
1571
+ return null;
1572
+ }
1573
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
1574
+ fs.copyFileSync(templatePath, outputPath);
1575
+ return AIDITOR_SETTINGS_OUTPUT_REL;
1576
+ }
1577
+ function materializeDesignSystemAiditorSettings(projectRoot, force = false) {
1578
+ const templatePath = resolvePackagedAgentKitPath(SETTINGS_TEMPLATE_REL);
1579
+ if (!templatePath) {
1580
+ return null;
1581
+ }
1582
+ return writeAiditorSettingsContribution(projectRoot, templatePath, force);
1583
+ }
1292
1584
  function findAllDesignMdFiles(projectRoot) {
1293
1585
  const pagesRoot = path.join(projectRoot, "src", "pages");
1294
1586
  const files = [];
1295
1587
  const rootDesignMd = path.join(projectRoot, "DESIGN.md");
1296
- if (fs.existsSync(rootDesignMd))
1297
- files.push(rootDesignMd);
1588
+ if (fs.existsSync(rootDesignMd)) files.push(rootDesignMd);
1298
1589
  if (fs.existsSync(pagesRoot)) {
1299
- let walk = function(dir) {
1300
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1301
- if (entry.isFile() && entry.name === "DESIGN.md") {
1302
- files.push(path.join(dir, entry.name));
1303
- } else if (entry.isDirectory() && !entry.name.startsWith(".")) {
1304
- walk(path.join(dir, entry.name));
1305
- }
1306
- }
1307
- };
1308
- walk(pagesRoot);
1590
+ walkDesignMdDir(pagesRoot, files);
1309
1591
  }
1310
1592
  return files;
1311
1593
  }
1594
+ function walkDesignMdDir(dir, files) {
1595
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1596
+ if (entry.isFile() && entry.name === "DESIGN.md") {
1597
+ files.push(path.join(dir, entry.name));
1598
+ } else if (entry.isDirectory() && !entry.name.startsWith(".")) {
1599
+ walkDesignMdDir(path.join(dir, entry.name), files);
1600
+ }
1601
+ }
1602
+ }
1603
+ function categoryName(designMdPath, projectRoot, tokens) {
1604
+ if (tokens.name) return tokens.name;
1605
+ const rel = path.relative(projectRoot, designMdPath);
1606
+ if (rel === "DESIGN.md") return "Design System";
1607
+ const dir = path.dirname(rel).replace(/^src\/pages\/?/, "");
1608
+ return dir ? `Design System (${dir})` : "Design System";
1609
+ }
1312
1610
  async function generateDesignSystemAgentKit(ctx) {
1313
1611
  const designMdFiles = findAllDesignMdFiles(ctx.projectRoot);
1612
+ const settingsGenerated = materializeDesignSystemAiditorSettings(ctx.projectRoot, ctx.force);
1314
1613
  if (designMdFiles.length === 0) {
1315
- return { agentKitCreated: [], message: "No DESIGN.md found in project" };
1614
+ return {
1615
+ agentKitCreated: settingsGenerated ? [settingsGenerated] : [],
1616
+ message: settingsGenerated ? "AIditor settings materialized; no DESIGN.md found for add-menu" : "No DESIGN.md found in project"
1617
+ };
1316
1618
  }
1317
1619
  const allItems = [];
1318
1620
  const seen = /* @__PURE__ */ new Set();
1319
1621
  for (const filePath of designMdFiles) {
1320
1622
  const content = fs.readFileSync(filePath, "utf-8");
1321
1623
  const tokens = parseDesignMd(content);
1322
- if (!tokens)
1323
- continue;
1624
+ if (!tokens) continue;
1324
1625
  const category = categoryName(filePath, ctx.projectRoot, tokens);
1325
1626
  const items = [
1326
1627
  ...buildColorItems(tokens, category),
@@ -1339,20 +1640,245 @@ async function generateDesignSystemAgentKit(ctx) {
1339
1640
  }
1340
1641
  }
1341
1642
  if (allItems.length === 0) {
1342
- return { agentKitCreated: [], message: "DESIGN.md found but no tokens defined" };
1643
+ return {
1644
+ agentKitCreated: settingsGenerated ? [settingsGenerated] : [],
1645
+ message: "DESIGN.md found but no tokens defined"
1646
+ };
1647
+ }
1648
+ writeAddMenuCatalog(ctx.projectRoot, allItems);
1649
+ const agentKitCreated = [ADD_MENU_GENERATED_REL];
1650
+ if (settingsGenerated) {
1651
+ agentKitCreated.push(settingsGenerated);
1343
1652
  }
1344
- const outputPath = path.join(ctx.projectRoot, ADD_MENU_OUTPUT_REL);
1345
- fs.mkdirSync(path.dirname(outputPath), { recursive: true });
1346
- fs.writeFileSync(outputPath, yaml.dump({ items: allItems }, { lineWidth: 120 }), "utf-8");
1347
1653
  return {
1348
- agentKitCreated: [ADD_MENU_OUTPUT_REL],
1654
+ agentKitCreated,
1349
1655
  message: `Generated ${allItems.length} add-menu items from ${designMdFiles.length} DESIGN.md file(s)`
1350
1656
  };
1351
1657
  }
1658
+ const DESIGN_VALIDATORS = [
1659
+ { name: "design-tokens", run: validateTokens },
1660
+ { name: "design-components", run: validateComponents },
1661
+ { name: "design-structure", run: validateStructure },
1662
+ { name: "design-contrast", run: validateContrast }
1663
+ ];
1664
+ async function findJayHtmlFiles(scanDir) {
1665
+ const files = [];
1666
+ async function walk(dir) {
1667
+ let entries;
1668
+ try {
1669
+ entries = await promises.readdir(dir, { withFileTypes: true });
1670
+ } catch {
1671
+ return;
1672
+ }
1673
+ for (const entry of entries) {
1674
+ const fullPath = path__default.join(dir, entry.name);
1675
+ if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
1676
+ await walk(fullPath);
1677
+ } else if (entry.isFile() && entry.name.endsWith(JAY_EXTENSION)) {
1678
+ files.push(fullPath);
1679
+ }
1680
+ }
1681
+ }
1682
+ await walk(scanDir);
1683
+ return files;
1684
+ }
1685
+ function toAnalysisFinding(relativePath, validator, finding) {
1686
+ if (!finding.message) return null;
1687
+ return {
1688
+ file: relativePath,
1689
+ validator,
1690
+ severity: finding.severity === "error" ? "error" : "warning",
1691
+ message: finding.message,
1692
+ suggestion: finding.suggestion
1693
+ };
1694
+ }
1695
+ function buildValidationContext(projectRoot, relativePath, parsed) {
1696
+ return {
1697
+ filePath: relativePath,
1698
+ body: parsed.body,
1699
+ css: parsed.css,
1700
+ head: parsed.headMeta,
1701
+ headlessImports: parsed.headlessImports.map((imp) => ({
1702
+ key: imp.key,
1703
+ contractName: imp.contractName,
1704
+ contract: imp.contract ? {
1705
+ name: imp.contract.name,
1706
+ tags: imp.contract.tags,
1707
+ props: imp.contract.props,
1708
+ params: imp.contract.params
1709
+ } : void 0
1710
+ })),
1711
+ projectRoot
1712
+ };
1713
+ }
1714
+ async function runDesignSystemAnalysis(projectRoot) {
1715
+ const pagesDir = path__default.join(projectRoot, "src", "pages");
1716
+ const componentsDir = path__default.join(projectRoot, "src", "components");
1717
+ const jayHtmlFiles = [
1718
+ ...await findJayHtmlFiles(pagesDir).catch(() => []),
1719
+ ...await findJayHtmlFiles(componentsDir).catch(() => [])
1720
+ ];
1721
+ const findings = [];
1722
+ for (const jayHtmlPath of jayHtmlFiles) {
1723
+ const relativePath = path__default.relative(projectRoot, jayHtmlPath);
1724
+ const filename = path__default.basename(jayHtmlPath.replace(JAY_EXTENSION, ""));
1725
+ const dirname = path__default.dirname(jayHtmlPath);
1726
+ let parsed;
1727
+ try {
1728
+ const content = await promises.readFile(jayHtmlPath, "utf-8");
1729
+ const parseResult = await parseJayFile(
1730
+ content,
1731
+ filename,
1732
+ dirname,
1733
+ {},
1734
+ JAY_IMPORT_RESOLVER,
1735
+ projectRoot
1736
+ );
1737
+ if (!parseResult.value) continue;
1738
+ parsed = parseResult.value;
1739
+ } catch {
1740
+ continue;
1741
+ }
1742
+ const ctx = buildValidationContext(projectRoot, relativePath, parsed);
1743
+ for (const validator of DESIGN_VALIDATORS) {
1744
+ const validatorFindings = await Promise.resolve(validator.run(ctx));
1745
+ for (const finding of validatorFindings) {
1746
+ const mapped = toAnalysisFinding(relativePath, validator.name, finding);
1747
+ if (mapped) findings.push(mapped);
1748
+ }
1749
+ }
1750
+ }
1751
+ const errorCount = findings.filter((finding) => finding.severity === "error").length;
1752
+ const warningCount = findings.filter((finding) => finding.severity === "warning").length;
1753
+ return {
1754
+ filesScanned: jayHtmlFiles.length,
1755
+ findings,
1756
+ errorCount,
1757
+ warningCount
1758
+ };
1759
+ }
1760
+ function countAddMenuItems(projectRoot) {
1761
+ return readAddMenuCatalog(projectRoot).length;
1762
+ }
1763
+ function summarizeDesignMd(projectRoot, filePath) {
1764
+ const content = fs.readFileSync(filePath, "utf-8");
1765
+ const tokens = parseDesignMd(content);
1766
+ if (!tokens) return null;
1767
+ const relativePath = path.relative(projectRoot, filePath);
1768
+ const name = tokens.name ?? (relativePath === "DESIGN.md" ? "Design System" : `Design System (${path.dirname(relativePath)})`);
1769
+ return {
1770
+ relativePath,
1771
+ name,
1772
+ colorCount: Object.keys(tokens.colors).length,
1773
+ typographyCount: Object.keys(tokens.typography).length,
1774
+ spacingCount: Object.keys(tokens.spacing).length,
1775
+ componentCount: Object.keys(tokens.components).length
1776
+ };
1777
+ }
1778
+ const getDesignSystemSettingsStatus = makeJayQuery(
1779
+ "designSystem.getDesignSystemSettingsStatus"
1780
+ ).withHandler(async () => {
1781
+ const projectRoot = process.cwd();
1782
+ const designMdPaths = findAllDesignMdFiles(projectRoot);
1783
+ const designFiles = designMdPaths.map((filePath) => summarizeDesignMd(projectRoot, filePath)).filter((summary) => summary !== null);
1784
+ return {
1785
+ hasDesignMd: designFiles.length > 0,
1786
+ designFiles,
1787
+ addMenuItemCount: countAddMenuItems(projectRoot),
1788
+ addMenuCatalogRel: ADD_MENU_GENERATED_REL,
1789
+ message: designFiles.length > 0 ? `${designFiles.length} DESIGN.md file(s); ${countAddMenuItems(projectRoot)} Add Menu items.` : "No DESIGN.md found — add one at the project root or under src/pages/."
1790
+ };
1791
+ });
1792
+ const runDesignSystemAnalysisAction = makeJayQuery(
1793
+ "designSystem.runDesignSystemAnalysis"
1794
+ ).withHandler(async () => {
1795
+ const projectRoot = process.cwd();
1796
+ return runDesignSystemAnalysis(projectRoot);
1797
+ });
1798
+ const loadDesignSystemAddMenuCatalog = makeJayQuery(
1799
+ "designSystem.loadAddMenuCatalog"
1800
+ ).withHandler(async () => {
1801
+ const projectRoot = process.cwd();
1802
+ const items = readAddMenuCatalog(projectRoot);
1803
+ return {
1804
+ items,
1805
+ outputRel: ADD_MENU_GENERATED_REL,
1806
+ itemCount: items.length
1807
+ };
1808
+ });
1809
+ const saveDesignSystemAddMenuCatalog = makeJayAction(
1810
+ "designSystem.saveAddMenuCatalog"
1811
+ ).withHandler(async (input) => {
1812
+ const projectRoot = process.cwd();
1813
+ const outputRel = writeAddMenuCatalog(projectRoot, input.items);
1814
+ return {
1815
+ itemCount: input.items.length,
1816
+ outputRel,
1817
+ message: `Saved ${input.items.length} add-menu items to ${outputRel}.`
1818
+ };
1819
+ });
1820
+ const regenerateDesignSystemAddMenu = makeJayQuery(
1821
+ "designSystem.regenerateDesignSystemAddMenu"
1822
+ ).withHandler(async () => {
1823
+ const projectRoot = process.cwd();
1824
+ const ctx = {
1825
+ projectRoot,
1826
+ referencesDir: path.join(projectRoot, "agent-kit", "references", "design-system-validator"),
1827
+ force: true
1828
+ };
1829
+ const result = await generateDesignSystemAgentKit(ctx);
1830
+ const itemCount = countAddMenuItems(projectRoot);
1831
+ return {
1832
+ itemCount,
1833
+ outputRel: ADD_MENU_GENERATED_REL,
1834
+ agentKitCreated: result.agentKitCreated,
1835
+ message: result.message
1836
+ };
1837
+ });
1838
+ async function renderFast() {
1839
+ return phaseOutput({
1840
+ statusMessage: "",
1841
+ statusTone: "info",
1842
+ showStatusMessage: false,
1843
+ catalogLoading: true,
1844
+ showNoCatalog: false,
1845
+ addMenuListRows: [],
1846
+ showAddMenuEditor: false,
1847
+ editorTitle: "",
1848
+ editorSubCategory: "",
1849
+ editorTokenValue: "",
1850
+ editorFontSize: "",
1851
+ editorFontWeight: "",
1852
+ editorFontFamily: "",
1853
+ showTypographyFields: false,
1854
+ showTokenValueField: false,
1855
+ editorReadOnly: false,
1856
+ editorReadOnlyReason: "",
1857
+ unsavedChangeCount: 0,
1858
+ saveButtonLabel: "Save and sync",
1859
+ saveDisabled: true,
1860
+ discardDisabled: true,
1861
+ showAnalysisSection: false,
1862
+ showReport: false,
1863
+ reportSummary: "",
1864
+ reportBody: ""
1865
+ }, {});
1866
+ }
1867
+ const designSystemSettingsPage = makeJayStackComponent().withProps().withFastRender(renderFast).withInteractiveMark();
1352
1868
  export {
1869
+ ADD_MENU_GENERATED_REL,
1870
+ designSystemSettingsPage,
1871
+ fontFallback,
1353
1872
  generateDesignSystemAgentKit,
1873
+ getDesignSystemSettingsStatus,
1874
+ loadDesignSystemAddMenuCatalog,
1875
+ regenerateDesignSystemAddMenu,
1876
+ runDesignSystemAnalysisAction as runDesignSystemAnalysis,
1877
+ saveDesignSystemAddMenuCatalog,
1354
1878
  validateComponents,
1355
1879
  validateContrast,
1880
+ validateFontFallbacks,
1356
1881
  validateStructure,
1357
- validateTokens
1882
+ validateTokens,
1883
+ validateUndefinedVars
1358
1884
  };