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