foundry-design 0.2.0-beta.2 → 0.2.0-beta.20
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/README.md +163 -16
- package/dist/companion.d.ts +24 -0
- package/dist/companion.d.ts.map +1 -0
- package/dist/companion.js +89 -0
- package/dist/companion.js.map +1 -0
- package/dist/delivery-export.d.ts +27 -0
- package/dist/delivery-export.d.ts.map +1 -0
- package/dist/delivery-export.js +200 -0
- package/dist/delivery-export.js.map +1 -0
- package/dist/doctor.d.ts +24 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +206 -0
- package/dist/doctor.js.map +1 -0
- package/dist/index.js +442 -120
- package/dist/index.js.map +1 -1
- package/dist/indexer.d.ts.map +1 -1
- package/dist/indexer.js +649 -27
- package/dist/indexer.js.map +1 -1
- package/dist/installer.d.ts +31 -0
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js +551 -100
- package/dist/installer.js.map +1 -1
- package/dist/project.d.ts +8 -0
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +46 -1
- package/dist/project.js.map +1 -1
- package/dist/proxy.d.ts +7 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/proxy.js +106 -0
- package/dist/proxy.js.map +1 -0
- package/dist/release.d.ts +5 -0
- package/dist/release.d.ts.map +1 -0
- package/dist/release.js +12 -0
- package/dist/release.js.map +1 -0
- package/dist/skill/foundry-design-control/SKILL.md +34 -11
- package/dist/skill/foundry-design-control/references/apply-run-contract.md +9 -4
- package/dist/skill/foundry-design-control/references/change-contract.md +2 -0
- package/dist/skill/foundry-design-control/references/component-workshop.md +46 -0
- package/dist/skill/foundry-design-control/references/design-branches.md +47 -0
- package/dist/skill/foundry-design-control/references/design-system-intelligence.md +40 -0
- package/dist/skill/foundry-design-control/references/motion-studio.md +101 -0
- package/dist/skill/foundry-design-control/references/portable-branch-records.md +38 -0
- package/dist/skill/foundry-design-control/references/responsive-design-lab.md +34 -0
- package/dist/skill/foundry-design-control/references/typography-studio.md +14 -0
- package/dist/skill/foundry-design-control/references/visual-agent-conversation.md +14 -0
- package/dist/skill/foundry-design-control/references/visual-workbench.md +27 -4
- package/dist/skill/foundry-design-control/scripts/foundry.sh +1 -5
- package/package.json +3 -3
package/dist/indexer.js
CHANGED
|
@@ -14,7 +14,31 @@ const SOURCE_EXTENSIONS = new Set([
|
|
|
14
14
|
'.mjs',
|
|
15
15
|
'.cjs',
|
|
16
16
|
'.json',
|
|
17
|
+
'.html',
|
|
17
18
|
]);
|
|
19
|
+
const STYLESHEET_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less']);
|
|
20
|
+
function stylesheetSegments(file, content) {
|
|
21
|
+
const extension = extname(file).toLowerCase();
|
|
22
|
+
if (STYLESHEET_EXTENSIONS.has(extension))
|
|
23
|
+
return [{ content, offset: 0 }];
|
|
24
|
+
const segments = [];
|
|
25
|
+
const appendMatches = (pattern) => {
|
|
26
|
+
for (const match of content.matchAll(pattern)) {
|
|
27
|
+
const body = match[1];
|
|
28
|
+
if (!body || match.index == null)
|
|
29
|
+
continue;
|
|
30
|
+
const bodyOffset = match[0].indexOf(body);
|
|
31
|
+
segments.push({ content: body, offset: match.index + Math.max(0, bodyOffset) });
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
if (extension === '.html') {
|
|
35
|
+
appendMatches(/<style\b[^>]*>([\s\S]*?)<\/style>/gi);
|
|
36
|
+
}
|
|
37
|
+
else if (/\.[cm]?[jt]sx?$/.test(file)) {
|
|
38
|
+
appendMatches(/\b(?:css|createGlobalStyle|styled(?:\.[A-Za-z_$][\w$]*|\([^)]*\)))\s*`([\s\S]*?)`/g);
|
|
39
|
+
}
|
|
40
|
+
return segments;
|
|
41
|
+
}
|
|
18
42
|
function stableId(prefix, value) {
|
|
19
43
|
return `${prefix}_${createHash('sha1').update(value).digest('hex').slice(0, 12)}`;
|
|
20
44
|
}
|
|
@@ -39,6 +63,290 @@ function tokenCategory(name, value) {
|
|
|
39
63
|
return 'size';
|
|
40
64
|
return 'other';
|
|
41
65
|
}
|
|
66
|
+
function declarationCategory(property, value) {
|
|
67
|
+
const name = property.toLowerCase();
|
|
68
|
+
if (/shadow/.test(name))
|
|
69
|
+
return 'shadow';
|
|
70
|
+
if (/border.*radius|radius/.test(name))
|
|
71
|
+
return 'radius';
|
|
72
|
+
if (/font|line-height|letter-spacing|tracking/.test(name))
|
|
73
|
+
return 'typography';
|
|
74
|
+
if (/transition|animation|duration|easing/.test(name))
|
|
75
|
+
return 'motion';
|
|
76
|
+
if (/gap|padding|margin|inset/.test(name))
|
|
77
|
+
return 'spacing';
|
|
78
|
+
if (/color|background|fill|stroke/.test(name) && /#|rgb|hsl|oklch|color\(/i.test(value))
|
|
79
|
+
return 'color';
|
|
80
|
+
if (/width|height|size/.test(name) && /-?[\d.]+(?:px|rem|em|%|vh|vw)/i.test(value))
|
|
81
|
+
return 'size';
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
function comparable(value) {
|
|
85
|
+
return value
|
|
86
|
+
.trim()
|
|
87
|
+
.toLowerCase()
|
|
88
|
+
.replace(/\s*!important\s*$/, '')
|
|
89
|
+
.replaceAll(' ', '');
|
|
90
|
+
}
|
|
91
|
+
function numericValue(value) {
|
|
92
|
+
const match = /^(-?[\d.]+)(px|rem|em|ms|s|%)$/i.exec(comparable(value));
|
|
93
|
+
return match ? { amount: Number(match[1]), unit: match[2].toLowerCase() } : null;
|
|
94
|
+
}
|
|
95
|
+
function closeNumericToken(value, tokens) {
|
|
96
|
+
const parsed = numericValue(value);
|
|
97
|
+
if (!parsed)
|
|
98
|
+
return undefined;
|
|
99
|
+
return tokens
|
|
100
|
+
.map((token) => ({ token, parsed: numericValue(token.value) }))
|
|
101
|
+
.filter((entry) => entry.parsed?.unit === parsed.unit)
|
|
102
|
+
.map((entry) => ({ ...entry, distance: Math.abs(entry.parsed.amount - parsed.amount) }))
|
|
103
|
+
.filter((entry) => entry.distance > 0 && entry.distance <= Math.max(2, Math.abs(parsed.amount) * 0.25))
|
|
104
|
+
.sort((a, b) => a.distance - b.distance)[0]?.token;
|
|
105
|
+
}
|
|
106
|
+
function tokenComparableValue(token) {
|
|
107
|
+
return token.resolvedValue ?? token.value;
|
|
108
|
+
}
|
|
109
|
+
function aliasTarget(value) {
|
|
110
|
+
return /^var\(\s*(--[\w-]+)(?:\s*,[^)]*)?\s*\)$/.exec(value.trim())?.[1];
|
|
111
|
+
}
|
|
112
|
+
function resolveTokenAliases(tokens) {
|
|
113
|
+
const resolve = (token, trail = []) => {
|
|
114
|
+
const targetName = aliasTarget(token.value);
|
|
115
|
+
if (!targetName)
|
|
116
|
+
return { aliasChain: [token.name], aliasStatus: 'direct', resolvedValue: token.value };
|
|
117
|
+
if (trail.includes(token.name)) {
|
|
118
|
+
return { aliasChain: [...trail, token.name], aliasStatus: 'circular' };
|
|
119
|
+
}
|
|
120
|
+
const target = tokens.get(targetName);
|
|
121
|
+
if (!target)
|
|
122
|
+
return { aliasChain: [token.name, targetName], aliasStatus: 'broken' };
|
|
123
|
+
const resolved = resolve(target, [...trail, token.name]);
|
|
124
|
+
return {
|
|
125
|
+
aliasChain: [token.name, ...resolved.aliasChain.filter((name) => name !== token.name)],
|
|
126
|
+
aliasStatus: resolved.aliasStatus === 'direct' ? 'resolved' : resolved.aliasStatus,
|
|
127
|
+
resolvedValue: resolved.resolvedValue,
|
|
128
|
+
};
|
|
129
|
+
};
|
|
130
|
+
for (const token of tokens.values()) {
|
|
131
|
+
const targetName = aliasTarget(token.value);
|
|
132
|
+
if (targetName) {
|
|
133
|
+
token.aliasOfTokenName = targetName;
|
|
134
|
+
const target = tokens.get(targetName);
|
|
135
|
+
token.aliasOfTokenId = target?.id;
|
|
136
|
+
if (token.category === 'other' && target)
|
|
137
|
+
token.category = target.category;
|
|
138
|
+
}
|
|
139
|
+
Object.assign(token, resolve(token));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function promotionTokenName(category, property, value) {
|
|
143
|
+
const propertySlug = property
|
|
144
|
+
.toLowerCase()
|
|
145
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
146
|
+
.replace(/^-|-$/g, '');
|
|
147
|
+
const valueSlug = value
|
|
148
|
+
.toLowerCase()
|
|
149
|
+
.replace(/[^a-z0-9.]+/g, '-')
|
|
150
|
+
.replaceAll('.', '-')
|
|
151
|
+
.replace(/^-|-$/g, '')
|
|
152
|
+
.slice(0, 24);
|
|
153
|
+
return `--${category}-${propertySlug || 'value'}${valueSlug ? `-${valueSlug}` : ''}`;
|
|
154
|
+
}
|
|
155
|
+
function objectNumber(source, property) {
|
|
156
|
+
const match = new RegExp(`\\b${property}\\s*:\\s*(-?[\\d.]+)`).exec(source);
|
|
157
|
+
const value = Number(match?.[1]);
|
|
158
|
+
return Number.isFinite(value) ? value : undefined;
|
|
159
|
+
}
|
|
160
|
+
function objectString(source, property) {
|
|
161
|
+
const quoted = new RegExp(`\\b${property}\\s*:\\s*['\"]([^'\"]+)['\"]`).exec(source);
|
|
162
|
+
if (quoted?.[1])
|
|
163
|
+
return quoted[1];
|
|
164
|
+
const array = new RegExp(`\\b${property}\\s*:\\s*\\[([^\\]]+)\\]`).exec(source);
|
|
165
|
+
if (!array?.[1])
|
|
166
|
+
return undefined;
|
|
167
|
+
const values = array[1]
|
|
168
|
+
.split(',')
|
|
169
|
+
.map((value) => Number(value.trim()))
|
|
170
|
+
.filter(Number.isFinite);
|
|
171
|
+
return values.length === 4 ? `cubic-bezier(${values.join(', ')})` : undefined;
|
|
172
|
+
}
|
|
173
|
+
function nativeMotionPresets(file, content) {
|
|
174
|
+
const presets = [];
|
|
175
|
+
const push = (adapter, label, sourceProperty, index, body) => {
|
|
176
|
+
const seconds = adapter !== 'react-spring';
|
|
177
|
+
const duration = objectNumber(body, 'duration');
|
|
178
|
+
const delay = objectNumber(body, 'delay');
|
|
179
|
+
const easing = objectString(body, adapter === 'gsap' ? 'ease' : 'ease') ?? objectString(body, 'easing');
|
|
180
|
+
const configuration = Object.fromEntries([
|
|
181
|
+
'type',
|
|
182
|
+
'mass',
|
|
183
|
+
'stiffness',
|
|
184
|
+
'damping',
|
|
185
|
+
'tension',
|
|
186
|
+
'friction',
|
|
187
|
+
'velocity',
|
|
188
|
+
'repeat',
|
|
189
|
+
'repeatType',
|
|
190
|
+
'yoyo',
|
|
191
|
+
]
|
|
192
|
+
.map((property) => [property, objectNumber(body, property) ?? objectString(body, property)])
|
|
193
|
+
.filter((entry) => entry[1] != null));
|
|
194
|
+
presets.push({
|
|
195
|
+
id: stableId('mot', `${file}:${index}:${adapter}`),
|
|
196
|
+
label,
|
|
197
|
+
duration: duration == null ? undefined : seconds ? duration * 1000 : duration,
|
|
198
|
+
delay: delay == null ? undefined : seconds ? delay * 1000 : delay,
|
|
199
|
+
easing,
|
|
200
|
+
adapter,
|
|
201
|
+
sourceProperty,
|
|
202
|
+
configuration,
|
|
203
|
+
source: { file, line: lineAt(content, index) },
|
|
204
|
+
evidence: [`${adapter} source import`, `${sourceProperty} authoring site`],
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
if (/from\s+['\"](?:motion\/react|framer-motion)['\"]/.test(content)) {
|
|
208
|
+
for (const match of content.matchAll(/<motion\.([A-Za-z][\w]*)\b([\s\S]*?)>/g)) {
|
|
209
|
+
const body = match[2] ?? '';
|
|
210
|
+
if (!/\b(?:animate|transition|variants)\s*=/.test(body))
|
|
211
|
+
continue;
|
|
212
|
+
push('motion', `Motion ${match[1]}`, 'transition', match.index ?? 0, body);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (/from\s+['\"]gsap['\"]|require\(['\"]gsap['\"]\)/.test(content)) {
|
|
216
|
+
for (const match of content.matchAll(/gsap\.(to|from|fromTo|set)\s*\(([\s\S]{0,1200}?)\)\s*[;,]/g)) {
|
|
217
|
+
push('gsap', `GSAP ${match[1]} tween`, 'vars', match.index ?? 0, match[2] ?? '');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (/from\s+['\"](?:@react-spring\/web|react-spring)['\"]/.test(content)) {
|
|
221
|
+
for (const match of content.matchAll(/\b(useSpring|useTransition|useTrail)\s*\(([\s\S]{0,1200}?)\)\s*[;,]/g)) {
|
|
222
|
+
push('react-spring', `React Spring ${match[1]}`, match[1] === 'useSpring' ? 'config' : (match[1] ?? 'config'), match.index ?? 0, match[2] ?? '');
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return presets;
|
|
226
|
+
}
|
|
227
|
+
function balancedObject(content, openIndex) {
|
|
228
|
+
if (content[openIndex] !== '{')
|
|
229
|
+
return null;
|
|
230
|
+
let depth = 0;
|
|
231
|
+
let quote = '';
|
|
232
|
+
let escaped = false;
|
|
233
|
+
for (let index = openIndex; index < content.length; index += 1) {
|
|
234
|
+
const char = content[index];
|
|
235
|
+
if (quote) {
|
|
236
|
+
if (escaped)
|
|
237
|
+
escaped = false;
|
|
238
|
+
else if (char === '\\')
|
|
239
|
+
escaped = true;
|
|
240
|
+
else if (char === quote)
|
|
241
|
+
quote = '';
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
245
|
+
quote = char;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (char === '{')
|
|
249
|
+
depth += 1;
|
|
250
|
+
if (char === '}')
|
|
251
|
+
depth -= 1;
|
|
252
|
+
if (depth === 0)
|
|
253
|
+
return { body: content.slice(openIndex + 1, index), start: openIndex, end: index };
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
function objectProperty(content, property, offset = 0) {
|
|
258
|
+
const match = new RegExp(`(?:^|[,\\s])${property}\\s*:\\s*\\{`, 'm').exec(content.slice(offset));
|
|
259
|
+
if (!match)
|
|
260
|
+
return null;
|
|
261
|
+
const openIndex = offset + match.index + match[0].lastIndexOf('{');
|
|
262
|
+
return balancedObject(content, openIndex);
|
|
263
|
+
}
|
|
264
|
+
function topLevelObjectKeys(content) {
|
|
265
|
+
const keys = [];
|
|
266
|
+
let depth = 0;
|
|
267
|
+
let quote = '';
|
|
268
|
+
let escaped = false;
|
|
269
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
270
|
+
const char = content[index];
|
|
271
|
+
if (quote) {
|
|
272
|
+
if (escaped)
|
|
273
|
+
escaped = false;
|
|
274
|
+
else if (char === '\\')
|
|
275
|
+
escaped = true;
|
|
276
|
+
else if (char === quote)
|
|
277
|
+
quote = '';
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (char === '"' || char === "'" || char === '`') {
|
|
281
|
+
quote = char;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (char === '{' || char === '[' || char === '(')
|
|
285
|
+
depth += 1;
|
|
286
|
+
if (char === '}' || char === ']' || char === ')')
|
|
287
|
+
depth -= 1;
|
|
288
|
+
if (depth !== 0)
|
|
289
|
+
continue;
|
|
290
|
+
const match = /^\s*(?:['"]([^'"]+)['"]|([A-Za-z_$][\w$-]*))\s*:/.exec(content.slice(index));
|
|
291
|
+
const key = match?.[1] ?? match?.[2];
|
|
292
|
+
if (!key || keys.some((item) => item.key === key))
|
|
293
|
+
continue;
|
|
294
|
+
keys.push({ key, index });
|
|
295
|
+
index += (match?.[0].length ?? 1) - 1;
|
|
296
|
+
}
|
|
297
|
+
return keys;
|
|
298
|
+
}
|
|
299
|
+
function sourceVariantAxes(file, content) {
|
|
300
|
+
const axes = [];
|
|
301
|
+
for (const cva of content.matchAll(/\bcva\s*\(/g)) {
|
|
302
|
+
const configOpen = content.indexOf('{', cva.index ?? 0);
|
|
303
|
+
const config = configOpen >= 0 ? balancedObject(content, configOpen) : null;
|
|
304
|
+
if (!config)
|
|
305
|
+
continue;
|
|
306
|
+
const variants = objectProperty(config.body, 'variants');
|
|
307
|
+
if (!variants)
|
|
308
|
+
continue;
|
|
309
|
+
for (const axisKey of topLevelObjectKeys(variants.body)) {
|
|
310
|
+
const axisOpen = variants.body.indexOf('{', axisKey.index);
|
|
311
|
+
const axis = axisOpen >= 0 ? balancedObject(variants.body, axisOpen) : null;
|
|
312
|
+
if (!axis)
|
|
313
|
+
continue;
|
|
314
|
+
const values = topLevelObjectKeys(axis.body).map((item) => item.key);
|
|
315
|
+
if (!values.length)
|
|
316
|
+
continue;
|
|
317
|
+
const absolute = config.start + 1 + variants.start + 1 + axisOpen;
|
|
318
|
+
axes.push({
|
|
319
|
+
id: stableId('axis', `${file}:cva:${axisKey.key}`),
|
|
320
|
+
label: axisKey.key,
|
|
321
|
+
property: axisKey.key,
|
|
322
|
+
values,
|
|
323
|
+
adapter: 'cva',
|
|
324
|
+
source: { file, line: lineAt(content, absolute) },
|
|
325
|
+
sourceProperty: `variants.${axisKey.key}`,
|
|
326
|
+
canCreate: true,
|
|
327
|
+
evidence: ['CVA variants object', `Writable ${axisKey.key} option map`],
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
for (const match of content.matchAll(/\b([A-Za-z_$][\w$]*)\??\s*:\s*((?:['"][^'"]+['"]\s*\|\s*)+['"][^'"]+['"])/g)) {
|
|
332
|
+
const property = match[1];
|
|
333
|
+
const values = [...match[2].matchAll(/['"]([^'"]+)['"]/g)].map((item) => item[1]);
|
|
334
|
+
if (values.length < 2 || axes.some((axis) => axis.property === property))
|
|
335
|
+
continue;
|
|
336
|
+
axes.push({
|
|
337
|
+
id: stableId('axis', `${file}:typescript:${property}`),
|
|
338
|
+
label: property,
|
|
339
|
+
property,
|
|
340
|
+
values,
|
|
341
|
+
adapter: 'typescript',
|
|
342
|
+
source: { file, line: lineAt(content, match.index ?? 0) },
|
|
343
|
+
sourceProperty: property,
|
|
344
|
+
canCreate: true,
|
|
345
|
+
evidence: ['TypeScript string union', `Writable ${property} prop type`],
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
return axes;
|
|
349
|
+
}
|
|
42
350
|
async function sourceFiles(root, exclusions) {
|
|
43
351
|
const files = [];
|
|
44
352
|
const excluded = new Set([
|
|
@@ -74,41 +382,95 @@ export async function indexProjectDesign(root, config, revision) {
|
|
|
74
382
|
const tokens = new Map();
|
|
75
383
|
const components = new Map();
|
|
76
384
|
const breakpoints = new Map();
|
|
385
|
+
const containerQueries = new Map();
|
|
77
386
|
const themes = new Set();
|
|
78
387
|
const motion = new Map();
|
|
79
388
|
const storyVariants = new Map();
|
|
389
|
+
const axesByFile = new Map();
|
|
390
|
+
const documents = [];
|
|
80
391
|
for (const path of files) {
|
|
81
392
|
const content = await readFile(path, 'utf8').catch(() => '');
|
|
82
393
|
const file = relative(root, path).replaceAll('\\', '/');
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
394
|
+
const stylesheets = stylesheetSegments(file, content);
|
|
395
|
+
documents.push({ file, content, stylesheets });
|
|
396
|
+
for (const preset of nativeMotionPresets(file, content))
|
|
397
|
+
motion.set(preset.id, preset);
|
|
398
|
+
for (const stylesheet of stylesheets) {
|
|
399
|
+
for (const match of stylesheet.content.matchAll(/(--[\w-]+)\s*:\s*([^;}\n]+)/g)) {
|
|
400
|
+
const name = match[1];
|
|
401
|
+
const value = match[2].trim();
|
|
402
|
+
if (!value || value.includes('${') || value.includes('\\s*'))
|
|
403
|
+
continue;
|
|
404
|
+
const index = stylesheet.offset + (match.index ?? 0);
|
|
405
|
+
tokens.set(name, {
|
|
406
|
+
id: stableId('tok', name),
|
|
407
|
+
name,
|
|
408
|
+
value,
|
|
409
|
+
category: tokenCategory(name, value),
|
|
410
|
+
cssVariable: name,
|
|
411
|
+
source: { file, line: lineAt(content, index) },
|
|
412
|
+
confidence: 'instrumented',
|
|
413
|
+
evidence: ['CSS custom property'],
|
|
414
|
+
});
|
|
415
|
+
if (/duration|motion/.test(name)) {
|
|
416
|
+
const duration = /([\d.]+)m?s/.exec(value);
|
|
417
|
+
motion.set(name, {
|
|
418
|
+
id: stableId('mot', name),
|
|
419
|
+
label: name.replace(/^--/, ''),
|
|
420
|
+
duration: duration
|
|
421
|
+
? Number(duration[1]) * (value.includes('ms') ? 1 : 1000)
|
|
422
|
+
: undefined,
|
|
423
|
+
adapter: 'css',
|
|
424
|
+
configuration: {},
|
|
425
|
+
source: { file, line: lineAt(content, index) },
|
|
426
|
+
evidence: ['CSS custom property'],
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
for (const match of stylesheet.content.matchAll(/@media\s*\([^)]*(?:min|max)-width\s*:\s*(\d+)px[^)]*\)/g)) {
|
|
431
|
+
const index = stylesheet.offset + (match.index ?? 0);
|
|
432
|
+
const width = Number(match[1]);
|
|
433
|
+
breakpoints.set(width, {
|
|
434
|
+
width,
|
|
435
|
+
source: { file, line: lineAt(content, index) },
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
for (const match of stylesheet.content.matchAll(/@container(?:\s+([\w-]+))?\s*\(([^)]*)\)/g)) {
|
|
439
|
+
const name = match[1];
|
|
440
|
+
const condition = match[2].trim();
|
|
441
|
+
const min = /min-(?:width|inline-size)\s*:\s*([\d.]+)px/i.exec(condition)?.[1] ??
|
|
442
|
+
/(?:width|inline-size)\s*>=?\s*([\d.]+)px/i.exec(condition)?.[1];
|
|
443
|
+
const max = /max-(?:width|inline-size)\s*:\s*([\d.]+)px/i.exec(condition)?.[1] ??
|
|
444
|
+
/(?:width|inline-size)\s*<=?\s*([\d.]+)px/i.exec(condition)?.[1];
|
|
445
|
+
if (!min && !max)
|
|
446
|
+
continue;
|
|
447
|
+
const axis = /block-size|height/i.test(condition)
|
|
448
|
+
? 'block-size'
|
|
449
|
+
: /\bsize\b/i.test(condition) && !/inline-size/i.test(condition)
|
|
450
|
+
? 'size'
|
|
451
|
+
: 'inline-size';
|
|
452
|
+
const id = stableId('container', `${file}:${name ?? 'anonymous'}:${condition}`);
|
|
453
|
+
const index = stylesheet.offset + (match.index ?? 0);
|
|
454
|
+
containerQueries.set(id, {
|
|
455
|
+
id,
|
|
456
|
+
label: `${name ?? 'Anonymous container'} · ${condition}`,
|
|
457
|
+
...(name ? { name } : {}),
|
|
458
|
+
condition,
|
|
459
|
+
axis,
|
|
460
|
+
...(min ? { minWidth: Number(min) } : {}),
|
|
461
|
+
...(max ? { maxWidth: Number(max) } : {}),
|
|
462
|
+
source: { file, line: lineAt(content, index) },
|
|
463
|
+
evidence: [
|
|
464
|
+
'CSS @container rule',
|
|
465
|
+
...(stylesheet.content.includes('container-type')
|
|
466
|
+
? ['CSS container-type declaration']
|
|
467
|
+
: []),
|
|
468
|
+
...(name && stylesheet.content.includes('container-name')
|
|
469
|
+
? ['CSS container-name declaration']
|
|
470
|
+
: []),
|
|
471
|
+
],
|
|
103
472
|
});
|
|
104
473
|
}
|
|
105
|
-
}
|
|
106
|
-
for (const match of content.matchAll(/@media\s*\([^)]*(?:min|max)-width\s*:\s*(\d+)px[^)]*\)/g)) {
|
|
107
|
-
const width = Number(match[1]);
|
|
108
|
-
breakpoints.set(width, {
|
|
109
|
-
width,
|
|
110
|
-
source: { file, line: lineAt(content, match.index ?? 0) },
|
|
111
|
-
});
|
|
112
474
|
}
|
|
113
475
|
for (const match of content.matchAll(/(?:data-theme=["']|\[data-theme=["'])([\w-]+)/g)) {
|
|
114
476
|
themes.add(match[1]);
|
|
@@ -116,6 +478,7 @@ export async function indexProjectDesign(root, config, revision) {
|
|
|
116
478
|
if (/(?:^|\s)\.dark(?:\s|[{,:])/.test(content))
|
|
117
479
|
themes.add('dark');
|
|
118
480
|
if (/\.(?:tsx?|jsx?|mjs|cjs)$/.test(path)) {
|
|
481
|
+
axesByFile.set(file, sourceVariantAxes(file, content));
|
|
119
482
|
if (/\.stories\.[cm]?[jt]sx?$/.test(path)) {
|
|
120
483
|
const componentName = basename(path).replace(/\.stories\.[cm]?[jt]sx?$/, '');
|
|
121
484
|
const variants = [...content.matchAll(/export\s+const\s+([A-Z][A-Za-z0-9_]*)/g)].map((story) => ({
|
|
@@ -123,7 +486,10 @@ export async function indexProjectDesign(root, config, revision) {
|
|
|
123
486
|
label: story[1],
|
|
124
487
|
property: 'story',
|
|
125
488
|
value: story[1],
|
|
489
|
+
props: { story: story[1] },
|
|
126
490
|
source: { file, line: lineAt(content, story.index ?? 0) },
|
|
491
|
+
adapter: 'storybook',
|
|
492
|
+
sourceProperty: story[1],
|
|
127
493
|
}));
|
|
128
494
|
storyVariants.set(componentName, variants);
|
|
129
495
|
continue;
|
|
@@ -137,6 +503,7 @@ export async function indexProjectDesign(root, config, revision) {
|
|
|
137
503
|
source: { file, line: lineAt(content, match.index ?? 0) },
|
|
138
504
|
instances: 0,
|
|
139
505
|
variants: [],
|
|
506
|
+
variantAxes: [],
|
|
140
507
|
evidence: ['exported component'],
|
|
141
508
|
};
|
|
142
509
|
components.set(id, component);
|
|
@@ -147,6 +514,17 @@ export async function indexProjectDesign(root, config, revision) {
|
|
|
147
514
|
const component = [...components.values()].find((item) => item.name === componentName);
|
|
148
515
|
if (component) {
|
|
149
516
|
component.variants = variants;
|
|
517
|
+
component.variantAxes.push({
|
|
518
|
+
id: stableId('axis', `${variants[0]?.source?.file}:storybook:story`),
|
|
519
|
+
label: 'Story',
|
|
520
|
+
property: 'story',
|
|
521
|
+
values: variants.map((variant) => variant.value),
|
|
522
|
+
adapter: 'storybook',
|
|
523
|
+
source: variants[0].source,
|
|
524
|
+
sourceProperty: 'named export',
|
|
525
|
+
canCreate: true,
|
|
526
|
+
evidence: ['Storybook named exports', 'Writable story definition'],
|
|
527
|
+
});
|
|
150
528
|
component.evidence.push('Storybook story');
|
|
151
529
|
}
|
|
152
530
|
else if (variants[0]?.source) {
|
|
@@ -157,10 +535,250 @@ export async function indexProjectDesign(root, config, revision) {
|
|
|
157
535
|
source: variants[0].source,
|
|
158
536
|
instances: 0,
|
|
159
537
|
variants,
|
|
538
|
+
variantAxes: [
|
|
539
|
+
{
|
|
540
|
+
id: stableId('axis', `${variants[0].source.file}:storybook:story`),
|
|
541
|
+
label: 'Story',
|
|
542
|
+
property: 'story',
|
|
543
|
+
values: variants.map((variant) => variant.value),
|
|
544
|
+
adapter: 'storybook',
|
|
545
|
+
source: variants[0].source,
|
|
546
|
+
sourceProperty: 'named export',
|
|
547
|
+
canCreate: true,
|
|
548
|
+
evidence: ['Storybook named exports', 'Writable story definition'],
|
|
549
|
+
},
|
|
550
|
+
],
|
|
160
551
|
evidence: ['Storybook story'],
|
|
161
552
|
});
|
|
162
553
|
}
|
|
163
554
|
}
|
|
555
|
+
for (const component of components.values()) {
|
|
556
|
+
const nativeAxes = axesByFile.get(component.source?.file ?? '') ?? [];
|
|
557
|
+
const known = new Set(component.variantAxes.map((axis) => axis.property));
|
|
558
|
+
component.variantAxes.push(...nativeAxes.filter((axis) => !known.has(axis.property)));
|
|
559
|
+
for (const axis of nativeAxes) {
|
|
560
|
+
for (const value of axis.values) {
|
|
561
|
+
const id = stableId('var', `${component.id}:${axis.property}:${String(value)}`);
|
|
562
|
+
if (component.variants.some((variant) => variant.id === id))
|
|
563
|
+
continue;
|
|
564
|
+
component.variants.push({
|
|
565
|
+
id,
|
|
566
|
+
label: String(value),
|
|
567
|
+
property: axis.property,
|
|
568
|
+
value,
|
|
569
|
+
props: { [axis.property]: value },
|
|
570
|
+
source: axis.source,
|
|
571
|
+
adapter: axis.adapter,
|
|
572
|
+
sourceProperty: `${axis.sourceProperty}.${String(value)}`,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
if (nativeAxes.length)
|
|
577
|
+
component.evidence.push('Source-backed variant axis');
|
|
578
|
+
}
|
|
579
|
+
resolveTokenAliases(tokens);
|
|
580
|
+
const tokenList = [...tokens.values()];
|
|
581
|
+
const tokenUsages = [];
|
|
582
|
+
const findings = [];
|
|
583
|
+
const recurringLiterals = new Map();
|
|
584
|
+
const componentForSource = (file, line) => [...components.values()]
|
|
585
|
+
.filter((component) => component.source?.file === file && (component.source.line ?? 0) <= line)
|
|
586
|
+
.sort((a, b) => (b.source?.line ?? 0) - (a.source?.line ?? 0))[0];
|
|
587
|
+
for (const { file, content, stylesheets } of documents) {
|
|
588
|
+
for (const stylesheet of stylesheets) {
|
|
589
|
+
for (const match of stylesheet.content.matchAll(/var\(\s*(--[\w-]+)(?:\s*,[^)]*)?\)/g)) {
|
|
590
|
+
const token = tokens.get(match[1]);
|
|
591
|
+
if (!token)
|
|
592
|
+
continue;
|
|
593
|
+
const index = stylesheet.offset + (match.index ?? 0);
|
|
594
|
+
const line = lineAt(content, index);
|
|
595
|
+
const lineStart = content.lastIndexOf('\n', index) + 1;
|
|
596
|
+
const declarationPrefix = content.slice(lineStart, index);
|
|
597
|
+
const alias = /--[\w-]+\s*:\s*$/.test(declarationPrefix);
|
|
598
|
+
const component = componentForSource(file, line);
|
|
599
|
+
tokenUsages.push({
|
|
600
|
+
id: stableId('use', `${file}:${index}:${token.id}`),
|
|
601
|
+
tokenId: token.id,
|
|
602
|
+
tokenName: token.name,
|
|
603
|
+
value: token.value,
|
|
604
|
+
category: token.category,
|
|
605
|
+
kind: alias ? 'alias' : 'reference',
|
|
606
|
+
componentId: component?.id,
|
|
607
|
+
source: { file, line },
|
|
608
|
+
evidence: [alias ? 'Token aliases this project value' : 'CSS variable reference'],
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
for (const match of stylesheet.content.matchAll(/([a-zA-Z][\w-]*)\s*:\s*([^;\n},]+)[;},]/g)) {
|
|
612
|
+
const property = match[1];
|
|
613
|
+
const value = match[2].trim();
|
|
614
|
+
if (property.startsWith('--') || value.includes('var('))
|
|
615
|
+
continue;
|
|
616
|
+
const category = declarationCategory(property, value);
|
|
617
|
+
if (!category)
|
|
618
|
+
continue;
|
|
619
|
+
const categoryTokens = tokenList.filter((token) => token.category === category);
|
|
620
|
+
const exact = categoryTokens
|
|
621
|
+
.filter((token) => comparable(tokenComparableValue(token)) === comparable(value))
|
|
622
|
+
.sort((a, b) => (b.aliasChain?.length ?? 0) - (a.aliasChain?.length ?? 0) ||
|
|
623
|
+
a.name.localeCompare(b.name))[0];
|
|
624
|
+
const suggested = exact ??
|
|
625
|
+
closeNumericToken(value, categoryTokens.map((token) => ({ ...token, value: tokenComparableValue(token) })));
|
|
626
|
+
const index = stylesheet.offset + (match.index ?? 0);
|
|
627
|
+
const line = lineAt(content, index);
|
|
628
|
+
const component = componentForSource(file, line);
|
|
629
|
+
const recurringKey = `${category}:${comparable(value)}`;
|
|
630
|
+
const recurring = recurringLiterals.get(recurringKey) ?? {
|
|
631
|
+
value,
|
|
632
|
+
category,
|
|
633
|
+
properties: [],
|
|
634
|
+
sources: [],
|
|
635
|
+
componentIds: [],
|
|
636
|
+
};
|
|
637
|
+
recurring.properties.push(property);
|
|
638
|
+
recurring.sources.push({ file, line });
|
|
639
|
+
if (component?.id)
|
|
640
|
+
recurring.componentIds.push(component.id);
|
|
641
|
+
recurringLiterals.set(recurringKey, recurring);
|
|
642
|
+
if (!suggested)
|
|
643
|
+
continue;
|
|
644
|
+
const usage = {
|
|
645
|
+
id: stableId('use', `${file}:${index}:${suggested.id}:literal`),
|
|
646
|
+
tokenId: suggested.id,
|
|
647
|
+
tokenName: suggested.name,
|
|
648
|
+
value,
|
|
649
|
+
category,
|
|
650
|
+
kind: 'literal',
|
|
651
|
+
property,
|
|
652
|
+
componentId: component?.id,
|
|
653
|
+
source: { file, line },
|
|
654
|
+
evidence: [
|
|
655
|
+
exact
|
|
656
|
+
? 'Literal equals an existing project token'
|
|
657
|
+
: 'Literal is close to a project token',
|
|
658
|
+
],
|
|
659
|
+
};
|
|
660
|
+
tokenUsages.push(usage);
|
|
661
|
+
findings.push({
|
|
662
|
+
id: stableId('finding', usage.id),
|
|
663
|
+
kind: component ? 'component-drift' : 'literal-drift',
|
|
664
|
+
severity: exact ? 'info' : 'warning',
|
|
665
|
+
title: exact ? `Use ${suggested.name}` : `${value} is close to ${suggested.name}`,
|
|
666
|
+
detail: exact
|
|
667
|
+
? `${property} repeats ${suggested.value} as a literal instead of its project token.`
|
|
668
|
+
: `${property} uses ${value}; the nearest ${category} token is ${suggested.name} at ${suggested.value}.`,
|
|
669
|
+
category,
|
|
670
|
+
tokenIds: [suggested.id],
|
|
671
|
+
usageIds: [usage.id],
|
|
672
|
+
componentIds: component ? [component.id] : [],
|
|
673
|
+
suggestedTokenId: suggested.id,
|
|
674
|
+
source: usage.source,
|
|
675
|
+
evidence: usage.evidence,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
const tokenPromotions = [...recurringLiterals.entries()]
|
|
681
|
+
.filter(([, recurring]) => recurring.sources.length >= 2)
|
|
682
|
+
.map(([key, recurring]) => {
|
|
683
|
+
const categoryTokens = tokenList.filter((token) => token.category === recurring.category);
|
|
684
|
+
const exact = categoryTokens
|
|
685
|
+
.filter((token) => comparable(tokenComparableValue(token)) === comparable(recurring.value))
|
|
686
|
+
.sort((a, b) => (b.aliasChain?.length ?? 0) - (a.aliasChain?.length ?? 0) ||
|
|
687
|
+
a.name.localeCompare(b.name))[0];
|
|
688
|
+
const near = exact
|
|
689
|
+
? undefined
|
|
690
|
+
: closeNumericToken(recurring.value, categoryTokens.map((token) => ({ ...token, value: tokenComparableValue(token) })));
|
|
691
|
+
const suggested = exact ?? near;
|
|
692
|
+
const property = [...recurring.properties].sort((a, b) => recurring.properties.filter((item) => item === b).length -
|
|
693
|
+
recurring.properties.filter((item) => item === a).length || a.localeCompare(b))[0];
|
|
694
|
+
const sources = recurring.sources.filter((source, index, all) => all.findIndex((candidate) => candidate.file === source.file && candidate.line === source.line) === index);
|
|
695
|
+
const relation = exact ? 'exact' : near ? 'near' : 'new';
|
|
696
|
+
const proposedTokenName = promotionTokenName(recurring.category, property, recurring.value);
|
|
697
|
+
const suggestedTokenName = suggested?.name ??
|
|
698
|
+
(tokens.has(proposedTokenName)
|
|
699
|
+
? `${proposedTokenName}-${stableId('token-name', key).slice(-4)}`
|
|
700
|
+
: proposedTokenName);
|
|
701
|
+
return {
|
|
702
|
+
id: stableId('promotion', key),
|
|
703
|
+
value: recurring.value,
|
|
704
|
+
category: recurring.category,
|
|
705
|
+
property,
|
|
706
|
+
occurrenceCount: sources.length,
|
|
707
|
+
sources,
|
|
708
|
+
componentIds: [...new Set(recurring.componentIds)],
|
|
709
|
+
recommendation: suggested ? 'use-existing' : 'create-token',
|
|
710
|
+
relation,
|
|
711
|
+
suggestedTokenId: suggested?.id,
|
|
712
|
+
suggestedTokenName,
|
|
713
|
+
suggestedValue: suggested ? `var(${suggested.name})` : recurring.value,
|
|
714
|
+
aliasChain: suggested?.aliasChain ?? [],
|
|
715
|
+
canStage: sources.length > 0,
|
|
716
|
+
blockers: [],
|
|
717
|
+
evidence: [
|
|
718
|
+
`${sources.length} authored literal occurrences`,
|
|
719
|
+
...(suggested
|
|
720
|
+
? [
|
|
721
|
+
relation === 'exact'
|
|
722
|
+
? 'Resolved value matches an existing project token'
|
|
723
|
+
: 'Nearest compatible project token requires explicit review',
|
|
724
|
+
]
|
|
725
|
+
: ['No compatible project token exists; create one before replacing usages']),
|
|
726
|
+
...(suggested?.aliasStatus === 'resolved'
|
|
727
|
+
? [`Preserves alias chain ${(suggested.aliasChain ?? []).join(' → ')}`]
|
|
728
|
+
: []),
|
|
729
|
+
],
|
|
730
|
+
};
|
|
731
|
+
})
|
|
732
|
+
.sort((a, b) => b.occurrenceCount - a.occurrenceCount || a.value.localeCompare(b.value));
|
|
733
|
+
for (let index = 0; index < tokenList.length; index += 1) {
|
|
734
|
+
const token = tokenList[index];
|
|
735
|
+
const near = tokenList.slice(index + 1).find((candidate) => {
|
|
736
|
+
if (candidate.category !== token.category)
|
|
737
|
+
return false;
|
|
738
|
+
const first = numericValue(token.value);
|
|
739
|
+
const second = numericValue(candidate.value);
|
|
740
|
+
return Boolean(first &&
|
|
741
|
+
second &&
|
|
742
|
+
first.unit === second.unit &&
|
|
743
|
+
Math.abs(first.amount - second.amount) > 0 &&
|
|
744
|
+
Math.abs(first.amount - second.amount) <= Math.max(2, Math.abs(first.amount) * 0.2));
|
|
745
|
+
});
|
|
746
|
+
if (near) {
|
|
747
|
+
const relatedUsages = tokenUsages.filter((usage) => usage.tokenId === token.id || usage.tokenId === near.id);
|
|
748
|
+
findings.push({
|
|
749
|
+
id: stableId('finding', `near:${token.id}:${near.id}`),
|
|
750
|
+
kind: 'near-duplicate',
|
|
751
|
+
severity: 'warning',
|
|
752
|
+
title: `${token.name} and ${near.name} are unusually close`,
|
|
753
|
+
detail: `${token.value} and ${near.value} may represent the same ${token.category} decision.`,
|
|
754
|
+
category: token.category,
|
|
755
|
+
tokenIds: [token.id, near.id],
|
|
756
|
+
usageIds: relatedUsages.map((usage) => usage.id),
|
|
757
|
+
componentIds: [
|
|
758
|
+
...new Set(relatedUsages
|
|
759
|
+
.map((usage) => usage.componentId)
|
|
760
|
+
.filter((componentId) => Boolean(componentId))),
|
|
761
|
+
],
|
|
762
|
+
source: near.source ?? token.source,
|
|
763
|
+
evidence: ['Same category', 'Values fall within the project drift threshold'],
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
if (!tokenUsages.some((usage) => usage.tokenId === token.id)) {
|
|
767
|
+
findings.push({
|
|
768
|
+
id: stableId('finding', `unused:${token.id}`),
|
|
769
|
+
kind: 'unused-token',
|
|
770
|
+
severity: 'info',
|
|
771
|
+
title: `${token.name} has no indexed references`,
|
|
772
|
+
detail: 'Confirm whether this token is intentionally reserved before removing it.',
|
|
773
|
+
category: token.category,
|
|
774
|
+
tokenIds: [token.id],
|
|
775
|
+
usageIds: [],
|
|
776
|
+
componentIds: [],
|
|
777
|
+
source: token.source,
|
|
778
|
+
evidence: ['No references were found in indexed project files'],
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
}
|
|
164
782
|
const configuredViewports = config?.design?.viewports ?? [];
|
|
165
783
|
const graphBreakpoints = configuredViewports.length
|
|
166
784
|
? configuredViewports.map((item) => ({ ...item, height: item.height ?? 900 }))
|
|
@@ -191,9 +809,13 @@ export async function indexProjectDesign(root, config, revision) {
|
|
|
191
809
|
tokens: [...tokens.values()],
|
|
192
810
|
components: [...components.values()],
|
|
193
811
|
breakpoints: graphBreakpoints,
|
|
812
|
+
containerQueries: [...containerQueries.values()].sort((a, b) => (a.minWidth ?? a.maxWidth ?? 0) - (b.minWidth ?? b.maxWidth ?? 0)),
|
|
194
813
|
themes: graphThemes,
|
|
195
814
|
states: config?.design?.states ?? [],
|
|
196
815
|
motionPresets: [...motion.values()],
|
|
816
|
+
tokenUsages,
|
|
817
|
+
designSystemFindings: findings,
|
|
818
|
+
tokenPromotions,
|
|
197
819
|
indexedAt: new Date().toISOString(),
|
|
198
820
|
});
|
|
199
821
|
}
|