@jay-framework/design-system-validator 0.22.1 → 0.23.1
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/agent-kit/aiditor/settings.template.yaml +2 -0
- package/agent-kit/designer/font-fallback-patterns.md +78 -0
- package/dist/actions/font-fallback.jay-action +12 -0
- package/dist/index.client.d.ts +7 -0
- package/dist/index.client.js +657 -0
- package/dist/index.d.ts +89 -1
- package/dist/index.js +760 -291
- package/dist/pages/settings/page.jay-html +415 -0
- package/dist/pages/settings/page.jay-html.d.ts +74 -0
- package/package.json +24 -8
- package/plugin.yaml +20 -0
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
|
-
|
|
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,14 +81,12 @@ 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;
|
|
@@ -157,8 +154,7 @@ function parseRules(css, sourceIndexOffset = 0, customProperties) {
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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$
|
|
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
|
|
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$
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,420 @@ const validateContrast = (ctx) => {
|
|
|
1086
1013
|
}
|
|
1087
1014
|
return findings;
|
|
1088
1015
|
};
|
|
1089
|
-
const
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
return
|
|
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");
|
|
1098
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;
|
|
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
|
+
async function loadMetrics(familyName) {
|
|
1185
|
+
const camelCase = fontFamilyToCamelCase(familyName);
|
|
1186
|
+
try {
|
|
1187
|
+
const mod = await import(`@capsizecss/metrics/${camelCase}`);
|
|
1188
|
+
return mod.default;
|
|
1189
|
+
} catch {
|
|
1190
|
+
throw new Error(
|
|
1191
|
+
`Font "${familyName}" not found in @capsizecss/metrics. Use a known Google Font or system font name (e.g. "Inter", "Arial", "Georgia").`
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
const fontFallback = makeJayAction("designSystem.fontFallback").withHandler(
|
|
1196
|
+
async (input) => {
|
|
1197
|
+
const primaryMetrics = await loadMetrics(input.primary);
|
|
1198
|
+
const fallbackMetrics = await loadMetrics(input.fallback);
|
|
1199
|
+
const result = createFontStack([primaryMetrics, fallbackMetrics]);
|
|
1200
|
+
return {
|
|
1201
|
+
fontFamily: result.fontFamily,
|
|
1202
|
+
fontFaces: result.fontFaces
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
);
|
|
1206
|
+
const PLUGIN_ATTRS = {
|
|
1207
|
+
pluginName: "design-system-validator",
|
|
1208
|
+
packageName: "@jay-framework/design-system-validator"
|
|
1209
|
+
};
|
|
1099
1210
|
function esc(s) {
|
|
1100
1211
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1101
1212
|
}
|
|
1102
|
-
function
|
|
1103
|
-
|
|
1213
|
+
function scopedFragment(innerHtml, scopedCss = "") {
|
|
1214
|
+
const styleBlock = `<style>@scope { ${scopedCss} }</style>`;
|
|
1215
|
+
return `<div>${styleBlock}${innerHtml}</div>`;
|
|
1216
|
+
}
|
|
1217
|
+
function isLargeFontSize(fontSize) {
|
|
1218
|
+
if (!fontSize) return false;
|
|
1219
|
+
if (/\d+(\.\d+)?\s*(vw|vh|vmin|vmax)\b/.test(fontSize)) return true;
|
|
1220
|
+
const clampMatch = fontSize.match(/clamp\([^,]+,[^,]+,\s*([\d.]+)\s*(px|rem|em)/);
|
|
1221
|
+
if (clampMatch) {
|
|
1222
|
+
const max = parseFloat(clampMatch[1]);
|
|
1223
|
+
const unit = clampMatch[2];
|
|
1224
|
+
if (unit === "px") return max > 20;
|
|
1225
|
+
return max > 1.25;
|
|
1226
|
+
}
|
|
1227
|
+
const simple = parseFloat(fontSize);
|
|
1228
|
+
if (isNaN(simple)) return false;
|
|
1229
|
+
if (fontSize.includes("rem") || fontSize.includes("em")) return simple > 1.25;
|
|
1230
|
+
return simple > 20;
|
|
1231
|
+
}
|
|
1232
|
+
function spacingBrowseSize(value) {
|
|
1233
|
+
if (/calc|clamp|var\(/.test(value)) return "large";
|
|
1234
|
+
const px = parseFloat(value);
|
|
1235
|
+
if (!isNaN(px) && !value.includes("rem") && !value.includes("em")) {
|
|
1236
|
+
if (px <= 30) return "small";
|
|
1237
|
+
if (px > 100) return "large";
|
|
1238
|
+
return void 0;
|
|
1239
|
+
}
|
|
1240
|
+
const rem = parseFloat(value);
|
|
1241
|
+
if (!isNaN(rem)) {
|
|
1242
|
+
if (rem <= 2) return "small";
|
|
1243
|
+
if (rem > 6) return "large";
|
|
1244
|
+
}
|
|
1245
|
+
return void 0;
|
|
1246
|
+
}
|
|
1247
|
+
function buildColorItem(name, value, category) {
|
|
1248
|
+
return {
|
|
1104
1249
|
id: `design-system:color-${name}`,
|
|
1105
|
-
|
|
1250
|
+
...PLUGIN_ATTRS,
|
|
1251
|
+
title: name,
|
|
1106
1252
|
category,
|
|
1107
1253
|
subCategory: "Colors",
|
|
1254
|
+
browse: { size: "small" },
|
|
1108
1255
|
prompt: `Use color token "${name}" with value ${value} from DESIGN.md.`,
|
|
1256
|
+
interaction: {
|
|
1257
|
+
mode: "stage-place",
|
|
1258
|
+
stagePromptTemplate: `Apply the color token "${name}" (${value}) from DESIGN.md at this location.`
|
|
1259
|
+
},
|
|
1109
1260
|
presentation: {
|
|
1110
1261
|
type: "html-fragment",
|
|
1111
|
-
html: `<div style="display:flex;align-items:center;gap:
|
|
1112
|
-
<div style="width:40px;height:40px;border-radius:6px;background:${esc(value)};border:1px solid rgba(0,0,0,0.1);
|
|
1113
|
-
<div>
|
|
1114
|
-
|
|
1115
|
-
<div style="font-size:11px;color:#888;font-family:monospace;">${esc(value)}</div>
|
|
1116
|
-
</div>
|
|
1117
|
-
</div>`
|
|
1262
|
+
html: scopedFragment(`<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;font-family:sans-serif;height:100%;">
|
|
1263
|
+
<div style="width:40px;height:40px;border-radius:6px;background:${esc(value)};border:1px solid rgba(0,0,0,0.1);"></div>
|
|
1264
|
+
<div style="font-size:10px;color:#888;font-family:monospace;">${esc(value)}</div>
|
|
1265
|
+
</div>`)
|
|
1118
1266
|
}
|
|
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
|
-
});
|
|
1267
|
+
};
|
|
1161
1268
|
}
|
|
1162
|
-
function
|
|
1163
|
-
return
|
|
1269
|
+
function buildSpacingItem(name, value, category) {
|
|
1270
|
+
return {
|
|
1164
1271
|
id: `design-system:spacing-${name}`,
|
|
1165
|
-
|
|
1272
|
+
...PLUGIN_ATTRS,
|
|
1273
|
+
title: name,
|
|
1166
1274
|
category,
|
|
1167
1275
|
subCategory: "Spacing",
|
|
1168
1276
|
prompt: `Use spacing token "${name}" with value ${value} from DESIGN.md for padding, margin, or gap.`,
|
|
1277
|
+
...spacingBrowseSize(value) ? { browse: { size: spacingBrowseSize(value) } } : {},
|
|
1278
|
+
interaction: {
|
|
1279
|
+
mode: "stage-place",
|
|
1280
|
+
stagePromptTemplate: `Apply the spacing token "${name}" (${value}) from DESIGN.md at this location.`
|
|
1281
|
+
},
|
|
1169
1282
|
presentation: {
|
|
1170
1283
|
type: "html-fragment",
|
|
1171
|
-
html: `<div style="display:flex;align-items:center;gap:
|
|
1284
|
+
html: scopedFragment(`<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;font-family:sans-serif;height:100%;">
|
|
1172
1285
|
<div style="display:flex;align-items:center;gap:0;">
|
|
1173
1286
|
<div style="width:16px;height:24px;background:#cbd5e1;border-radius:2px 0 0 2px;"></div>
|
|
1174
1287
|
<div style="width:${esc(value)};height:24px;background:repeating-linear-gradient(45deg,#e0f2fe,#e0f2fe 2px,#bae6fd 2px,#bae6fd 4px);"></div>
|
|
1175
1288
|
<div style="width:16px;height:24px;background:#cbd5e1;border-radius:0 2px 2px 0;"></div>
|
|
1176
1289
|
</div>
|
|
1177
|
-
<div>
|
|
1178
|
-
|
|
1179
|
-
<span style="font-size:11px;color:#888;font-family:monospace;margin-left:4px;">${esc(value)}</span>
|
|
1180
|
-
</div>
|
|
1181
|
-
</div>`
|
|
1290
|
+
<div style="font-size:10px;color:#888;font-family:monospace;">${esc(value)}</div>
|
|
1291
|
+
</div>`)
|
|
1182
1292
|
}
|
|
1183
|
-
}
|
|
1293
|
+
};
|
|
1184
1294
|
}
|
|
1185
|
-
function
|
|
1186
|
-
return
|
|
1295
|
+
function buildRoundedItem(name, value, category) {
|
|
1296
|
+
return {
|
|
1187
1297
|
id: `design-system:rounded-${name}`,
|
|
1188
|
-
|
|
1298
|
+
...PLUGIN_ATTRS,
|
|
1299
|
+
title: name,
|
|
1189
1300
|
category,
|
|
1190
1301
|
subCategory: "Rounded",
|
|
1302
|
+
browse: { size: "small" },
|
|
1191
1303
|
prompt: `Use border-radius token "${name}" with value ${value} from DESIGN.md.`,
|
|
1304
|
+
interaction: {
|
|
1305
|
+
mode: "stage-place",
|
|
1306
|
+
stagePromptTemplate: `Apply the border-radius token "${name}" (${value}) from DESIGN.md at this location.`
|
|
1307
|
+
},
|
|
1192
1308
|
presentation: {
|
|
1193
1309
|
type: "html-fragment",
|
|
1194
|
-
html: `<div style="display:flex;align-items:center;gap:
|
|
1195
|
-
<div style="width:40px;height:40px;border-radius:${esc(value)};background:#e2e8f0;border:1.5px solid #94a3b8;
|
|
1196
|
-
<div>
|
|
1197
|
-
|
|
1198
|
-
<div style="font-size:11px;color:#888;font-family:monospace;">${esc(value)}</div>
|
|
1199
|
-
</div>
|
|
1200
|
-
</div>`
|
|
1310
|
+
html: scopedFragment(`<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;font-family:sans-serif;height:100%;">
|
|
1311
|
+
<div style="width:40px;height:40px;border-radius:${esc(value)};background:#e2e8f0;border:1.5px solid #94a3b8;"></div>
|
|
1312
|
+
<div style="font-size:10px;color:#888;font-family:monospace;">${esc(value)}</div>
|
|
1313
|
+
</div>`)
|
|
1201
1314
|
}
|
|
1202
|
-
}
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
function formatTypographyCssValue(value) {
|
|
1318
|
+
return String(value);
|
|
1319
|
+
}
|
|
1320
|
+
function buildTypographyItem(name, typography, category) {
|
|
1321
|
+
const parts = [];
|
|
1322
|
+
if (typography.fontFamily) parts.push(typography.fontFamily);
|
|
1323
|
+
if (typography.fontSize) parts.push(typography.fontSize);
|
|
1324
|
+
if (typography.fontWeight != null) {
|
|
1325
|
+
parts.push(`weight ${formatTypographyCssValue(typography.fontWeight)}`);
|
|
1326
|
+
}
|
|
1327
|
+
if (typography.lineHeight != null) {
|
|
1328
|
+
parts.push(`line-height ${formatTypographyCssValue(typography.lineHeight)}`);
|
|
1329
|
+
}
|
|
1330
|
+
if (typography.letterSpacing) parts.push(`letter-spacing ${typography.letterSpacing}`);
|
|
1331
|
+
const desc = parts.join(", ");
|
|
1332
|
+
const styles = [];
|
|
1333
|
+
if (typography.fontFamily) styles.push(`font-family:${typography.fontFamily},sans-serif`);
|
|
1334
|
+
if (typography.fontSize) styles.push(`font-size:${typography.fontSize}`);
|
|
1335
|
+
if (typography.fontWeight != null) {
|
|
1336
|
+
styles.push(`font-weight:${formatTypographyCssValue(typography.fontWeight)}`);
|
|
1337
|
+
}
|
|
1338
|
+
if (typography.lineHeight != null) {
|
|
1339
|
+
styles.push(`line-height:${formatTypographyCssValue(typography.lineHeight)}`);
|
|
1340
|
+
}
|
|
1341
|
+
if (typography.letterSpacing) styles.push(`letter-spacing:${typography.letterSpacing}`);
|
|
1342
|
+
const isLargeFont = isLargeFontSize(typography.fontSize);
|
|
1343
|
+
return {
|
|
1344
|
+
id: `design-system:typography-${name}`,
|
|
1345
|
+
...PLUGIN_ATTRS,
|
|
1346
|
+
title: name,
|
|
1347
|
+
category,
|
|
1348
|
+
subCategory: "Typography",
|
|
1349
|
+
prompt: `Apply typography preset "${name}" from DESIGN.md: ${desc}.`,
|
|
1350
|
+
interaction: {
|
|
1351
|
+
mode: "stage-place",
|
|
1352
|
+
stagePromptTemplate: `Apply the typography preset "${name}" (${desc}) from DESIGN.md at this location.`
|
|
1353
|
+
},
|
|
1354
|
+
...isLargeFont ? { browse: { size: "large" } } : {},
|
|
1355
|
+
presentation: {
|
|
1356
|
+
type: "html-fragment",
|
|
1357
|
+
html: scopedFragment(`<div style="font-family:sans-serif;">
|
|
1358
|
+
<div style="${styles.join(";")};margin:0;">The quick brown fox</div>
|
|
1359
|
+
<div style="font-size:10px;color:#888;margin-top:6px;font-family:monospace;">${esc(name)}: ${esc(desc)}</div>
|
|
1360
|
+
</div>`)
|
|
1361
|
+
}
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
function buildColorItems(tokens, category) {
|
|
1365
|
+
return Object.entries(tokens.colors).map(
|
|
1366
|
+
([name, value]) => buildColorItem(name, value, category)
|
|
1367
|
+
);
|
|
1368
|
+
}
|
|
1369
|
+
function buildTypographyItems(tokens, category) {
|
|
1370
|
+
return Object.entries(tokens.typography).map(
|
|
1371
|
+
([name, typography]) => buildTypographyItem(name, typography, category)
|
|
1372
|
+
);
|
|
1373
|
+
}
|
|
1374
|
+
function buildSpacingItems(tokens, category) {
|
|
1375
|
+
return Object.entries(tokens.spacing).map(
|
|
1376
|
+
([name, value]) => buildSpacingItem(name, value, category)
|
|
1377
|
+
);
|
|
1378
|
+
}
|
|
1379
|
+
function buildRoundedItems(tokens, category) {
|
|
1380
|
+
return Object.entries(tokens.rounded).map(
|
|
1381
|
+
([name, value]) => buildRoundedItem(name, value, category)
|
|
1382
|
+
);
|
|
1203
1383
|
}
|
|
1204
1384
|
function buildBreakpointItems(tokens, category) {
|
|
1205
1385
|
return Object.entries(tokens.breakpoints).map(([name, value]) => ({
|
|
1206
1386
|
id: `design-system:breakpoint-${name}`,
|
|
1387
|
+
...PLUGIN_ATTRS,
|
|
1207
1388
|
title: `${name} (${value})`,
|
|
1208
1389
|
category,
|
|
1209
1390
|
subCategory: "Breakpoints",
|
|
1210
|
-
prompt: `Use breakpoint "${name}" at max-width ${value} from DESIGN.
|
|
1391
|
+
prompt: `Use breakpoint "${name}" at max-width ${value} from DESIGN.MD: @media (max-width: ${value}) { ... }`,
|
|
1392
|
+
interaction: {
|
|
1393
|
+
mode: "stage-place",
|
|
1394
|
+
stagePromptTemplate: `Add a responsive breakpoint at "${name}" (${value}) from DESIGN.md for this element.`
|
|
1395
|
+
}
|
|
1211
1396
|
}));
|
|
1212
1397
|
}
|
|
1213
1398
|
function buildAnimationItems(tokens, category) {
|
|
1214
1399
|
return Object.entries(tokens.animations).map(([name, preset]) => {
|
|
1215
1400
|
const parts = [];
|
|
1216
|
-
if (preset.duration)
|
|
1217
|
-
|
|
1218
|
-
if (preset.easing)
|
|
1219
|
-
parts.push(preset.easing);
|
|
1401
|
+
if (preset.duration) parts.push(preset.duration);
|
|
1402
|
+
if (preset.easing) parts.push(preset.easing);
|
|
1220
1403
|
const desc = parts.join(", ");
|
|
1221
1404
|
const dur = preset.duration || "300ms";
|
|
1222
1405
|
const ease = preset.easing || "ease";
|
|
1223
1406
|
return {
|
|
1224
1407
|
id: `design-system:animation-${name}`,
|
|
1408
|
+
...PLUGIN_ATTRS,
|
|
1225
1409
|
title: name,
|
|
1226
1410
|
category,
|
|
1227
1411
|
subCategory: "Animations",
|
|
1228
1412
|
prompt: `Use animation preset "${name}" from DESIGN.md: ${desc}. Apply to transition-duration and transition-timing-function.`,
|
|
1413
|
+
interaction: {
|
|
1414
|
+
mode: "stage-place",
|
|
1415
|
+
stagePromptTemplate: `Apply the animation preset "${name}" (${desc}) from DESIGN.md to this element's transitions.`
|
|
1416
|
+
},
|
|
1229
1417
|
presentation: {
|
|
1230
1418
|
type: "html-fragment",
|
|
1231
|
-
html:
|
|
1232
|
-
|
|
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;">
|
|
1419
|
+
html: scopedFragment(
|
|
1420
|
+
`<div style="display:flex;align-items:center;gap:10px;font-family:sans-serif;">
|
|
1239
1421
|
<div class="anim-preview"></div>
|
|
1240
1422
|
<div>
|
|
1241
1423
|
<div style="font-size:12px;font-weight:600;">${esc(name)}</div>
|
|
1242
1424
|
<div style="font-size:11px;color:#888;font-family:monospace;">${esc(desc)}</div>
|
|
1243
1425
|
<div style="font-size:10px;color:#aaa;">hover to preview</div>
|
|
1244
1426
|
</div>
|
|
1245
|
-
</div
|
|
1246
|
-
|
|
1427
|
+
</div>`,
|
|
1428
|
+
`.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); }`
|
|
1429
|
+
)
|
|
1247
1430
|
}
|
|
1248
1431
|
};
|
|
1249
1432
|
});
|
|
@@ -1264,63 +1447,125 @@ function buildComponentItems(tokens, category) {
|
|
|
1264
1447
|
borderColor: "border-color"
|
|
1265
1448
|
};
|
|
1266
1449
|
for (const [prop, value] of Object.entries(spec)) {
|
|
1267
|
-
if (prop === "typography")
|
|
1268
|
-
continue;
|
|
1450
|
+
if (prop === "typography") continue;
|
|
1269
1451
|
const cssProp = propertyMapping[prop] || prop;
|
|
1270
1452
|
previewStyles.push(`${cssProp}:${value}`);
|
|
1271
1453
|
}
|
|
1272
|
-
if (!spec.padding)
|
|
1273
|
-
|
|
1274
|
-
if (spec.borderColor)
|
|
1275
|
-
previewStyles.push("border:1.5px solid " + spec.borderColor);
|
|
1454
|
+
if (!spec.padding) previewStyles.push("padding:8px 16px");
|
|
1455
|
+
if (spec.borderColor) previewStyles.push("border:1.5px solid " + spec.borderColor);
|
|
1276
1456
|
return {
|
|
1277
1457
|
id: `design-system:component-${name}`,
|
|
1458
|
+
...PLUGIN_ATTRS,
|
|
1278
1459
|
title: name,
|
|
1279
1460
|
category,
|
|
1280
1461
|
subCategory: "Components",
|
|
1281
1462
|
prompt: `Apply the "${name}" component spec from DESIGN.md. Required styles: ${props}. The design-system validator will flag mismatches.`,
|
|
1463
|
+
interaction: {
|
|
1464
|
+
mode: "stage-place",
|
|
1465
|
+
stagePromptTemplate: `Apply the "${name}" component styles (${props}) from DESIGN.md to this element.`
|
|
1466
|
+
},
|
|
1282
1467
|
presentation: {
|
|
1283
1468
|
type: "html-fragment",
|
|
1284
|
-
html: `<div style="font-family:sans-serif;">
|
|
1469
|
+
html: scopedFragment(`<div style="font-family:sans-serif;">
|
|
1285
1470
|
<div style="${previewStyles.join(";")};display:inline-block;font-size:13px;min-width:80px;text-align:center;">${esc(name)}</div>
|
|
1286
1471
|
<div style="font-size:10px;color:#888;margin-top:6px;font-family:monospace;">${esc(props)}</div>
|
|
1287
|
-
</div>`
|
|
1472
|
+
</div>`)
|
|
1288
1473
|
}
|
|
1289
1474
|
};
|
|
1290
1475
|
});
|
|
1291
1476
|
}
|
|
1477
|
+
const ADD_MENU_GENERATED_REL = "agent-kit/aiditor/add-menu/design-system.generated.yaml";
|
|
1478
|
+
function readAddMenuCatalog(projectRoot) {
|
|
1479
|
+
const catalogPath = path.join(projectRoot, ADD_MENU_GENERATED_REL);
|
|
1480
|
+
if (!fs.existsSync(catalogPath)) {
|
|
1481
|
+
return [];
|
|
1482
|
+
}
|
|
1483
|
+
const parsed = load(fs.readFileSync(catalogPath, "utf-8"));
|
|
1484
|
+
if (!Array.isArray(parsed.items)) {
|
|
1485
|
+
return [];
|
|
1486
|
+
}
|
|
1487
|
+
return parsed.items;
|
|
1488
|
+
}
|
|
1489
|
+
function writeAddMenuCatalog(projectRoot, items) {
|
|
1490
|
+
const outputPath = path.join(projectRoot, ADD_MENU_GENERATED_REL);
|
|
1491
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
1492
|
+
fs.writeFileSync(outputPath, dump({ items }, { lineWidth: 120, noRefs: true }), "utf-8");
|
|
1493
|
+
return ADD_MENU_GENERATED_REL;
|
|
1494
|
+
}
|
|
1495
|
+
const AIDITOR_SETTINGS_OUTPUT_REL = "agent-kit/aiditor/settings/design-system-validator.yaml";
|
|
1496
|
+
const SETTINGS_TEMPLATE_REL = "agent-kit/aiditor/settings.template.yaml";
|
|
1497
|
+
function resolvePackagedAgentKitPath(relativePath, moduleUrl = import.meta.url) {
|
|
1498
|
+
let directory = path.dirname(fileURLToPath(moduleUrl));
|
|
1499
|
+
for (let depth = 0; depth < 4; depth++) {
|
|
1500
|
+
const candidate = path.join(directory, relativePath);
|
|
1501
|
+
if (fs.existsSync(candidate)) {
|
|
1502
|
+
return candidate;
|
|
1503
|
+
}
|
|
1504
|
+
const parent = path.dirname(directory);
|
|
1505
|
+
if (parent === directory) {
|
|
1506
|
+
break;
|
|
1507
|
+
}
|
|
1508
|
+
directory = parent;
|
|
1509
|
+
}
|
|
1510
|
+
return null;
|
|
1511
|
+
}
|
|
1512
|
+
function writeAiditorSettingsContribution(projectRoot, templatePath, force = false) {
|
|
1513
|
+
const outputPath = path.join(projectRoot, AIDITOR_SETTINGS_OUTPUT_REL);
|
|
1514
|
+
if (fs.existsSync(outputPath) && !force) {
|
|
1515
|
+
return null;
|
|
1516
|
+
}
|
|
1517
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
1518
|
+
fs.copyFileSync(templatePath, outputPath);
|
|
1519
|
+
return AIDITOR_SETTINGS_OUTPUT_REL;
|
|
1520
|
+
}
|
|
1521
|
+
function materializeDesignSystemAiditorSettings(projectRoot, force = false) {
|
|
1522
|
+
const templatePath = resolvePackagedAgentKitPath(SETTINGS_TEMPLATE_REL);
|
|
1523
|
+
if (!templatePath) {
|
|
1524
|
+
return null;
|
|
1525
|
+
}
|
|
1526
|
+
return writeAiditorSettingsContribution(projectRoot, templatePath, force);
|
|
1527
|
+
}
|
|
1292
1528
|
function findAllDesignMdFiles(projectRoot) {
|
|
1293
1529
|
const pagesRoot = path.join(projectRoot, "src", "pages");
|
|
1294
1530
|
const files = [];
|
|
1295
1531
|
const rootDesignMd = path.join(projectRoot, "DESIGN.md");
|
|
1296
|
-
if (fs.existsSync(rootDesignMd))
|
|
1297
|
-
files.push(rootDesignMd);
|
|
1532
|
+
if (fs.existsSync(rootDesignMd)) files.push(rootDesignMd);
|
|
1298
1533
|
if (fs.existsSync(pagesRoot)) {
|
|
1299
|
-
|
|
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);
|
|
1534
|
+
walkDesignMdDir(pagesRoot, files);
|
|
1309
1535
|
}
|
|
1310
1536
|
return files;
|
|
1311
1537
|
}
|
|
1538
|
+
function walkDesignMdDir(dir, files) {
|
|
1539
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
1540
|
+
if (entry.isFile() && entry.name === "DESIGN.md") {
|
|
1541
|
+
files.push(path.join(dir, entry.name));
|
|
1542
|
+
} else if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
1543
|
+
walkDesignMdDir(path.join(dir, entry.name), files);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
function categoryName(designMdPath, projectRoot, tokens) {
|
|
1548
|
+
if (tokens.name) return tokens.name;
|
|
1549
|
+
const rel = path.relative(projectRoot, designMdPath);
|
|
1550
|
+
if (rel === "DESIGN.md") return "Design System";
|
|
1551
|
+
const dir = path.dirname(rel).replace(/^src\/pages\/?/, "");
|
|
1552
|
+
return dir ? `Design System (${dir})` : "Design System";
|
|
1553
|
+
}
|
|
1312
1554
|
async function generateDesignSystemAgentKit(ctx) {
|
|
1313
1555
|
const designMdFiles = findAllDesignMdFiles(ctx.projectRoot);
|
|
1556
|
+
const settingsGenerated = materializeDesignSystemAiditorSettings(ctx.projectRoot, ctx.force);
|
|
1314
1557
|
if (designMdFiles.length === 0) {
|
|
1315
|
-
return {
|
|
1558
|
+
return {
|
|
1559
|
+
agentKitCreated: settingsGenerated ? [settingsGenerated] : [],
|
|
1560
|
+
message: settingsGenerated ? "AIditor settings materialized; no DESIGN.md found for add-menu" : "No DESIGN.md found in project"
|
|
1561
|
+
};
|
|
1316
1562
|
}
|
|
1317
1563
|
const allItems = [];
|
|
1318
1564
|
const seen = /* @__PURE__ */ new Set();
|
|
1319
1565
|
for (const filePath of designMdFiles) {
|
|
1320
1566
|
const content = fs.readFileSync(filePath, "utf-8");
|
|
1321
1567
|
const tokens = parseDesignMd(content);
|
|
1322
|
-
if (!tokens)
|
|
1323
|
-
continue;
|
|
1568
|
+
if (!tokens) continue;
|
|
1324
1569
|
const category = categoryName(filePath, ctx.projectRoot, tokens);
|
|
1325
1570
|
const items = [
|
|
1326
1571
|
...buildColorItems(tokens, category),
|
|
@@ -1339,20 +1584,244 @@ async function generateDesignSystemAgentKit(ctx) {
|
|
|
1339
1584
|
}
|
|
1340
1585
|
}
|
|
1341
1586
|
if (allItems.length === 0) {
|
|
1342
|
-
return {
|
|
1587
|
+
return {
|
|
1588
|
+
agentKitCreated: settingsGenerated ? [settingsGenerated] : [],
|
|
1589
|
+
message: "DESIGN.md found but no tokens defined"
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
writeAddMenuCatalog(ctx.projectRoot, allItems);
|
|
1593
|
+
const agentKitCreated = [ADD_MENU_GENERATED_REL];
|
|
1594
|
+
if (settingsGenerated) {
|
|
1595
|
+
agentKitCreated.push(settingsGenerated);
|
|
1343
1596
|
}
|
|
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
1597
|
return {
|
|
1348
|
-
agentKitCreated
|
|
1598
|
+
agentKitCreated,
|
|
1349
1599
|
message: `Generated ${allItems.length} add-menu items from ${designMdFiles.length} DESIGN.md file(s)`
|
|
1350
1600
|
};
|
|
1351
1601
|
}
|
|
1602
|
+
const DESIGN_VALIDATORS = [
|
|
1603
|
+
{ name: "design-tokens", run: validateTokens },
|
|
1604
|
+
{ name: "design-components", run: validateComponents },
|
|
1605
|
+
{ name: "design-structure", run: validateStructure },
|
|
1606
|
+
{ name: "design-contrast", run: validateContrast }
|
|
1607
|
+
];
|
|
1608
|
+
async function findJayHtmlFiles(scanDir) {
|
|
1609
|
+
const files = [];
|
|
1610
|
+
async function walk(dir) {
|
|
1611
|
+
let entries;
|
|
1612
|
+
try {
|
|
1613
|
+
entries = await promises.readdir(dir, { withFileTypes: true });
|
|
1614
|
+
} catch {
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
for (const entry of entries) {
|
|
1618
|
+
const fullPath = path__default.join(dir, entry.name);
|
|
1619
|
+
if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
|
|
1620
|
+
await walk(fullPath);
|
|
1621
|
+
} else if (entry.isFile() && entry.name.endsWith(JAY_EXTENSION)) {
|
|
1622
|
+
files.push(fullPath);
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
await walk(scanDir);
|
|
1627
|
+
return files;
|
|
1628
|
+
}
|
|
1629
|
+
function toAnalysisFinding(relativePath, validator, finding) {
|
|
1630
|
+
if (!finding.message) return null;
|
|
1631
|
+
return {
|
|
1632
|
+
file: relativePath,
|
|
1633
|
+
validator,
|
|
1634
|
+
severity: finding.severity === "error" ? "error" : "warning",
|
|
1635
|
+
message: finding.message,
|
|
1636
|
+
suggestion: finding.suggestion
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
1639
|
+
function buildValidationContext(projectRoot, relativePath, parsed) {
|
|
1640
|
+
return {
|
|
1641
|
+
filePath: relativePath,
|
|
1642
|
+
body: parsed.body,
|
|
1643
|
+
css: parsed.css,
|
|
1644
|
+
head: parsed.headMeta,
|
|
1645
|
+
headlessImports: parsed.headlessImports.map((imp) => ({
|
|
1646
|
+
key: imp.key,
|
|
1647
|
+
contractName: imp.contractName,
|
|
1648
|
+
contract: imp.contract ? {
|
|
1649
|
+
name: imp.contract.name,
|
|
1650
|
+
tags: imp.contract.tags,
|
|
1651
|
+
props: imp.contract.props,
|
|
1652
|
+
params: imp.contract.params
|
|
1653
|
+
} : void 0
|
|
1654
|
+
})),
|
|
1655
|
+
projectRoot
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
async function runDesignSystemAnalysis(projectRoot) {
|
|
1659
|
+
const pagesDir = path__default.join(projectRoot, "src", "pages");
|
|
1660
|
+
const componentsDir = path__default.join(projectRoot, "src", "components");
|
|
1661
|
+
const jayHtmlFiles = [
|
|
1662
|
+
...await findJayHtmlFiles(pagesDir).catch(() => []),
|
|
1663
|
+
...await findJayHtmlFiles(componentsDir).catch(() => [])
|
|
1664
|
+
];
|
|
1665
|
+
const findings = [];
|
|
1666
|
+
for (const jayHtmlPath of jayHtmlFiles) {
|
|
1667
|
+
const relativePath = path__default.relative(projectRoot, jayHtmlPath);
|
|
1668
|
+
const filename = path__default.basename(jayHtmlPath.replace(JAY_EXTENSION, ""));
|
|
1669
|
+
const dirname = path__default.dirname(jayHtmlPath);
|
|
1670
|
+
let parsed;
|
|
1671
|
+
try {
|
|
1672
|
+
const content = await promises.readFile(jayHtmlPath, "utf-8");
|
|
1673
|
+
const parseResult = await parseJayFile(
|
|
1674
|
+
content,
|
|
1675
|
+
filename,
|
|
1676
|
+
dirname,
|
|
1677
|
+
{},
|
|
1678
|
+
JAY_IMPORT_RESOLVER,
|
|
1679
|
+
projectRoot
|
|
1680
|
+
);
|
|
1681
|
+
if (!parseResult.value) continue;
|
|
1682
|
+
parsed = parseResult.value;
|
|
1683
|
+
} catch {
|
|
1684
|
+
continue;
|
|
1685
|
+
}
|
|
1686
|
+
const ctx = buildValidationContext(projectRoot, relativePath, parsed);
|
|
1687
|
+
for (const validator of DESIGN_VALIDATORS) {
|
|
1688
|
+
const validatorFindings = await Promise.resolve(validator.run(ctx));
|
|
1689
|
+
for (const finding of validatorFindings) {
|
|
1690
|
+
const mapped = toAnalysisFinding(relativePath, validator.name, finding);
|
|
1691
|
+
if (mapped) findings.push(mapped);
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
const errorCount = findings.filter((finding) => finding.severity === "error").length;
|
|
1696
|
+
const warningCount = findings.filter((finding) => finding.severity === "warning").length;
|
|
1697
|
+
return {
|
|
1698
|
+
filesScanned: jayHtmlFiles.length,
|
|
1699
|
+
findings,
|
|
1700
|
+
errorCount,
|
|
1701
|
+
warningCount
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
function countAddMenuItems(projectRoot) {
|
|
1705
|
+
return readAddMenuCatalog(projectRoot).length;
|
|
1706
|
+
}
|
|
1707
|
+
function summarizeDesignMd(projectRoot, filePath) {
|
|
1708
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
1709
|
+
const tokens = parseDesignMd(content);
|
|
1710
|
+
if (!tokens) return null;
|
|
1711
|
+
const relativePath = path.relative(projectRoot, filePath);
|
|
1712
|
+
const name = tokens.name ?? (relativePath === "DESIGN.md" ? "Design System" : `Design System (${path.dirname(relativePath)})`);
|
|
1713
|
+
return {
|
|
1714
|
+
relativePath,
|
|
1715
|
+
name,
|
|
1716
|
+
colorCount: Object.keys(tokens.colors).length,
|
|
1717
|
+
typographyCount: Object.keys(tokens.typography).length,
|
|
1718
|
+
spacingCount: Object.keys(tokens.spacing).length,
|
|
1719
|
+
componentCount: Object.keys(tokens.components).length
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
const getDesignSystemSettingsStatus = makeJayQuery(
|
|
1723
|
+
"designSystem.getDesignSystemSettingsStatus"
|
|
1724
|
+
).withHandler(async () => {
|
|
1725
|
+
const projectRoot = process.cwd();
|
|
1726
|
+
const designMdPaths = findAllDesignMdFiles(projectRoot);
|
|
1727
|
+
const designFiles = designMdPaths.map((filePath) => summarizeDesignMd(projectRoot, filePath)).filter((summary) => summary !== null);
|
|
1728
|
+
return {
|
|
1729
|
+
hasDesignMd: designFiles.length > 0,
|
|
1730
|
+
designFiles,
|
|
1731
|
+
addMenuItemCount: countAddMenuItems(projectRoot),
|
|
1732
|
+
addMenuCatalogRel: ADD_MENU_GENERATED_REL,
|
|
1733
|
+
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/."
|
|
1734
|
+
};
|
|
1735
|
+
});
|
|
1736
|
+
const runDesignSystemAnalysisAction = makeJayQuery(
|
|
1737
|
+
"designSystem.runDesignSystemAnalysis"
|
|
1738
|
+
).withHandler(async () => {
|
|
1739
|
+
const projectRoot = process.cwd();
|
|
1740
|
+
return runDesignSystemAnalysis(projectRoot);
|
|
1741
|
+
});
|
|
1742
|
+
const loadDesignSystemAddMenuCatalog = makeJayQuery(
|
|
1743
|
+
"designSystem.loadAddMenuCatalog"
|
|
1744
|
+
).withHandler(async () => {
|
|
1745
|
+
const projectRoot = process.cwd();
|
|
1746
|
+
const items = readAddMenuCatalog(projectRoot);
|
|
1747
|
+
return {
|
|
1748
|
+
items,
|
|
1749
|
+
outputRel: ADD_MENU_GENERATED_REL,
|
|
1750
|
+
itemCount: items.length
|
|
1751
|
+
};
|
|
1752
|
+
});
|
|
1753
|
+
const saveDesignSystemAddMenuCatalog = makeJayAction(
|
|
1754
|
+
"designSystem.saveAddMenuCatalog"
|
|
1755
|
+
).withHandler(async (input) => {
|
|
1756
|
+
const projectRoot = process.cwd();
|
|
1757
|
+
const outputRel = writeAddMenuCatalog(projectRoot, input.items);
|
|
1758
|
+
return {
|
|
1759
|
+
itemCount: input.items.length,
|
|
1760
|
+
outputRel,
|
|
1761
|
+
message: `Saved ${input.items.length} add-menu items to ${outputRel}.`
|
|
1762
|
+
};
|
|
1763
|
+
});
|
|
1764
|
+
const regenerateDesignSystemAddMenu = makeJayQuery(
|
|
1765
|
+
"designSystem.regenerateDesignSystemAddMenu"
|
|
1766
|
+
).withHandler(async () => {
|
|
1767
|
+
const projectRoot = process.cwd();
|
|
1768
|
+
const ctx = {
|
|
1769
|
+
projectRoot,
|
|
1770
|
+
referencesDir: path.join(projectRoot, "agent-kit", "references", "design-system-validator"),
|
|
1771
|
+
force: true
|
|
1772
|
+
};
|
|
1773
|
+
const result = await generateDesignSystemAgentKit(ctx);
|
|
1774
|
+
const itemCount = countAddMenuItems(projectRoot);
|
|
1775
|
+
return {
|
|
1776
|
+
itemCount,
|
|
1777
|
+
outputRel: ADD_MENU_GENERATED_REL,
|
|
1778
|
+
agentKitCreated: result.agentKitCreated,
|
|
1779
|
+
message: result.message
|
|
1780
|
+
};
|
|
1781
|
+
});
|
|
1782
|
+
async function renderFast() {
|
|
1783
|
+
return phaseOutput({
|
|
1784
|
+
statusMessage: "",
|
|
1785
|
+
statusTone: "info",
|
|
1786
|
+
showStatusMessage: false,
|
|
1787
|
+
catalogLoading: true,
|
|
1788
|
+
showNoCatalog: false,
|
|
1789
|
+
addMenuListRows: [],
|
|
1790
|
+
showAddMenuEditor: false,
|
|
1791
|
+
editorTitle: "",
|
|
1792
|
+
editorSubCategory: "",
|
|
1793
|
+
editorTokenValue: "",
|
|
1794
|
+
editorFontSize: "",
|
|
1795
|
+
editorFontWeight: "",
|
|
1796
|
+
editorFontFamily: "",
|
|
1797
|
+
showTypographyFields: false,
|
|
1798
|
+
showTokenValueField: false,
|
|
1799
|
+
editorReadOnly: false,
|
|
1800
|
+
editorReadOnlyReason: "",
|
|
1801
|
+
unsavedChangeCount: 0,
|
|
1802
|
+
saveButtonLabel: "Save and sync",
|
|
1803
|
+
saveDisabled: true,
|
|
1804
|
+
discardDisabled: true,
|
|
1805
|
+
showAnalysisSection: false,
|
|
1806
|
+
showReport: false,
|
|
1807
|
+
reportSummary: "",
|
|
1808
|
+
reportBody: ""
|
|
1809
|
+
}, {});
|
|
1810
|
+
}
|
|
1811
|
+
const designSystemSettingsPage = makeJayStackComponent().withProps().withFastRender(renderFast).withInteractiveMark();
|
|
1352
1812
|
export {
|
|
1813
|
+
ADD_MENU_GENERATED_REL,
|
|
1814
|
+
designSystemSettingsPage,
|
|
1815
|
+
fontFallback,
|
|
1353
1816
|
generateDesignSystemAgentKit,
|
|
1817
|
+
getDesignSystemSettingsStatus,
|
|
1818
|
+
loadDesignSystemAddMenuCatalog,
|
|
1819
|
+
regenerateDesignSystemAddMenu,
|
|
1820
|
+
runDesignSystemAnalysisAction as runDesignSystemAnalysis,
|
|
1821
|
+
saveDesignSystemAddMenuCatalog,
|
|
1354
1822
|
validateComponents,
|
|
1355
1823
|
validateContrast,
|
|
1824
|
+
validateFontFallbacks,
|
|
1356
1825
|
validateStructure,
|
|
1357
1826
|
validateTokens
|
|
1358
1827
|
};
|