foundry-design 0.2.0-beta.2 → 0.2.0-beta.21

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.
Files changed (48) hide show
  1. package/README.md +164 -17
  2. package/dist/companion.d.ts +24 -0
  3. package/dist/companion.d.ts.map +1 -0
  4. package/dist/companion.js +89 -0
  5. package/dist/companion.js.map +1 -0
  6. package/dist/delivery-export.d.ts +27 -0
  7. package/dist/delivery-export.d.ts.map +1 -0
  8. package/dist/delivery-export.js +200 -0
  9. package/dist/delivery-export.js.map +1 -0
  10. package/dist/doctor.d.ts +24 -0
  11. package/dist/doctor.d.ts.map +1 -0
  12. package/dist/doctor.js +206 -0
  13. package/dist/doctor.js.map +1 -0
  14. package/dist/index.js +583 -124
  15. package/dist/index.js.map +1 -1
  16. package/dist/indexer.d.ts.map +1 -1
  17. package/dist/indexer.js +690 -30
  18. package/dist/indexer.js.map +1 -1
  19. package/dist/installer.d.ts +46 -0
  20. package/dist/installer.d.ts.map +1 -1
  21. package/dist/installer.js +551 -100
  22. package/dist/installer.js.map +1 -1
  23. package/dist/project.d.ts +9 -1
  24. package/dist/project.d.ts.map +1 -1
  25. package/dist/project.js +50 -2
  26. package/dist/project.js.map +1 -1
  27. package/dist/proxy.d.ts +7 -0
  28. package/dist/proxy.d.ts.map +1 -0
  29. package/dist/proxy.js +106 -0
  30. package/dist/proxy.js.map +1 -0
  31. package/dist/release.d.ts +5 -0
  32. package/dist/release.d.ts.map +1 -0
  33. package/dist/release.js +12 -0
  34. package/dist/release.js.map +1 -0
  35. package/dist/skill/foundry-design-control/SKILL.md +41 -15
  36. package/dist/skill/foundry-design-control/references/apply-run-contract.md +13 -7
  37. package/dist/skill/foundry-design-control/references/change-contract.md +9 -6
  38. package/dist/skill/foundry-design-control/references/component-workshop.md +46 -0
  39. package/dist/skill/foundry-design-control/references/design-branches.md +47 -0
  40. package/dist/skill/foundry-design-control/references/design-system-intelligence.md +40 -0
  41. package/dist/skill/foundry-design-control/references/motion-studio.md +101 -0
  42. package/dist/skill/foundry-design-control/references/portable-branch-records.md +38 -0
  43. package/dist/skill/foundry-design-control/references/responsive-design-lab.md +34 -0
  44. package/dist/skill/foundry-design-control/references/typography-studio.md +14 -0
  45. package/dist/skill/foundry-design-control/references/visual-agent-conversation.md +14 -0
  46. package/dist/skill/foundry-design-control/references/visual-workbench.md +27 -4
  47. package/dist/skill/foundry-design-control/scripts/foundry.sh +1 -5
  48. 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,48 +382,137 @@ 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();
77
- const themes = new Set();
385
+ const containerQueries = new Map();
386
+ const themes = new Map();
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
- for (const match of content.matchAll(/(--[\w-]+)\s*:\s*([^;}\n]+)/g)) {
84
- const name = match[1];
85
- const value = match[2].trim();
86
- tokens.set(name, {
87
- id: stableId('tok', name),
88
- name,
89
- value,
90
- category: tokenCategory(name, value),
91
- cssVariable: name,
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
+ ],
472
+ });
473
+ }
474
+ }
475
+ for (const match of content.matchAll(/\[data-theme\s*=\s*["']?([\w-]+)["']?\]/g)) {
476
+ const id = match[1];
477
+ themes.set(id, {
478
+ id,
479
+ label: id[0].toUpperCase() + id.slice(1),
480
+ selector: `[data-theme="${id}"]`,
481
+ attribute: 'data-theme',
482
+ value: id,
92
483
  source: { file, line: lineAt(content, match.index ?? 0) },
93
484
  confidence: 'instrumented',
94
- evidence: ['CSS custom property'],
485
+ evidence: ['Indexed CSS data-theme selector'],
95
486
  });
96
- if (/duration|motion/.test(name)) {
97
- const duration = /([\d.]+)m?s/.exec(value);
98
- motion.set(name, {
99
- id: stableId('mot', name),
100
- label: name.replace(/^--/, ''),
101
- duration: duration ? Number(duration[1]) * (value.includes('ms') ? 1 : 1000) : undefined,
102
- source: { file, line: lineAt(content, match.index ?? 0) },
103
- });
104
- }
105
487
  }
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,
488
+ for (const match of content.matchAll(/\bdata-theme\s*=\s*["']([\w-]+)["']/g)) {
489
+ const id = match[1];
490
+ if (themes.has(id))
491
+ continue;
492
+ themes.set(id, {
493
+ id,
494
+ label: id[0].toUpperCase() + id.slice(1),
495
+ attribute: 'data-theme',
496
+ value: id,
110
497
  source: { file, line: lineAt(content, match.index ?? 0) },
498
+ confidence: 'instrumented',
499
+ evidence: ['Indexed HTML data-theme attribute'],
111
500
  });
112
501
  }
113
- for (const match of content.matchAll(/(?:data-theme=["']|\[data-theme=["'])([\w-]+)/g)) {
114
- themes.add(match[1]);
502
+ for (const match of content.matchAll(/(?:^|\s)(\.dark)(?=\s|[{,:])/gm)) {
503
+ if (themes.has('dark'))
504
+ continue;
505
+ themes.set('dark', {
506
+ id: 'dark',
507
+ label: 'Dark',
508
+ selector: '.dark',
509
+ source: { file, line: lineAt(content, match.index ?? 0) },
510
+ confidence: 'instrumented',
511
+ evidence: ['Indexed CSS class theme selector'],
512
+ });
115
513
  }
116
- if (/(?:^|\s)\.dark(?:\s|[{,:])/.test(content))
117
- themes.add('dark');
118
514
  if (/\.(?:tsx?|jsx?|mjs|cjs)$/.test(path)) {
515
+ axesByFile.set(file, sourceVariantAxes(file, content));
119
516
  if (/\.stories\.[cm]?[jt]sx?$/.test(path)) {
120
517
  const componentName = basename(path).replace(/\.stories\.[cm]?[jt]sx?$/, '');
121
518
  const variants = [...content.matchAll(/export\s+const\s+([A-Z][A-Za-z0-9_]*)/g)].map((story) => ({
@@ -123,7 +520,10 @@ export async function indexProjectDesign(root, config, revision) {
123
520
  label: story[1],
124
521
  property: 'story',
125
522
  value: story[1],
523
+ props: { story: story[1] },
126
524
  source: { file, line: lineAt(content, story.index ?? 0) },
525
+ adapter: 'storybook',
526
+ sourceProperty: story[1],
127
527
  }));
128
528
  storyVariants.set(componentName, variants);
129
529
  continue;
@@ -137,6 +537,7 @@ export async function indexProjectDesign(root, config, revision) {
137
537
  source: { file, line: lineAt(content, match.index ?? 0) },
138
538
  instances: 0,
139
539
  variants: [],
540
+ variantAxes: [],
140
541
  evidence: ['exported component'],
141
542
  };
142
543
  components.set(id, component);
@@ -147,6 +548,17 @@ export async function indexProjectDesign(root, config, revision) {
147
548
  const component = [...components.values()].find((item) => item.name === componentName);
148
549
  if (component) {
149
550
  component.variants = variants;
551
+ component.variantAxes.push({
552
+ id: stableId('axis', `${variants[0]?.source?.file}:storybook:story`),
553
+ label: 'Story',
554
+ property: 'story',
555
+ values: variants.map((variant) => variant.value),
556
+ adapter: 'storybook',
557
+ source: variants[0].source,
558
+ sourceProperty: 'named export',
559
+ canCreate: true,
560
+ evidence: ['Storybook named exports', 'Writable story definition'],
561
+ });
150
562
  component.evidence.push('Storybook story');
151
563
  }
152
564
  else if (variants[0]?.source) {
@@ -157,10 +569,250 @@ export async function indexProjectDesign(root, config, revision) {
157
569
  source: variants[0].source,
158
570
  instances: 0,
159
571
  variants,
572
+ variantAxes: [
573
+ {
574
+ id: stableId('axis', `${variants[0].source.file}:storybook:story`),
575
+ label: 'Story',
576
+ property: 'story',
577
+ values: variants.map((variant) => variant.value),
578
+ adapter: 'storybook',
579
+ source: variants[0].source,
580
+ sourceProperty: 'named export',
581
+ canCreate: true,
582
+ evidence: ['Storybook named exports', 'Writable story definition'],
583
+ },
584
+ ],
160
585
  evidence: ['Storybook story'],
161
586
  });
162
587
  }
163
588
  }
589
+ for (const component of components.values()) {
590
+ const nativeAxes = axesByFile.get(component.source?.file ?? '') ?? [];
591
+ const known = new Set(component.variantAxes.map((axis) => axis.property));
592
+ component.variantAxes.push(...nativeAxes.filter((axis) => !known.has(axis.property)));
593
+ for (const axis of nativeAxes) {
594
+ for (const value of axis.values) {
595
+ const id = stableId('var', `${component.id}:${axis.property}:${String(value)}`);
596
+ if (component.variants.some((variant) => variant.id === id))
597
+ continue;
598
+ component.variants.push({
599
+ id,
600
+ label: String(value),
601
+ property: axis.property,
602
+ value,
603
+ props: { [axis.property]: value },
604
+ source: axis.source,
605
+ adapter: axis.adapter,
606
+ sourceProperty: `${axis.sourceProperty}.${String(value)}`,
607
+ });
608
+ }
609
+ }
610
+ if (nativeAxes.length)
611
+ component.evidence.push('Source-backed variant axis');
612
+ }
613
+ resolveTokenAliases(tokens);
614
+ const tokenList = [...tokens.values()];
615
+ const tokenUsages = [];
616
+ const findings = [];
617
+ const recurringLiterals = new Map();
618
+ const componentForSource = (file, line) => [...components.values()]
619
+ .filter((component) => component.source?.file === file && (component.source.line ?? 0) <= line)
620
+ .sort((a, b) => (b.source?.line ?? 0) - (a.source?.line ?? 0))[0];
621
+ for (const { file, content, stylesheets } of documents) {
622
+ for (const stylesheet of stylesheets) {
623
+ for (const match of stylesheet.content.matchAll(/var\(\s*(--[\w-]+)(?:\s*,[^)]*)?\)/g)) {
624
+ const token = tokens.get(match[1]);
625
+ if (!token)
626
+ continue;
627
+ const index = stylesheet.offset + (match.index ?? 0);
628
+ const line = lineAt(content, index);
629
+ const lineStart = content.lastIndexOf('\n', index) + 1;
630
+ const declarationPrefix = content.slice(lineStart, index);
631
+ const alias = /--[\w-]+\s*:\s*$/.test(declarationPrefix);
632
+ const component = componentForSource(file, line);
633
+ tokenUsages.push({
634
+ id: stableId('use', `${file}:${index}:${token.id}`),
635
+ tokenId: token.id,
636
+ tokenName: token.name,
637
+ value: token.value,
638
+ category: token.category,
639
+ kind: alias ? 'alias' : 'reference',
640
+ componentId: component?.id,
641
+ source: { file, line },
642
+ evidence: [alias ? 'Token aliases this project value' : 'CSS variable reference'],
643
+ });
644
+ }
645
+ for (const match of stylesheet.content.matchAll(/([a-zA-Z][\w-]*)\s*:\s*([^;\n},]+)[;},]/g)) {
646
+ const property = match[1];
647
+ const value = match[2].trim();
648
+ if (property.startsWith('--') || value.includes('var('))
649
+ continue;
650
+ const category = declarationCategory(property, value);
651
+ if (!category)
652
+ continue;
653
+ const categoryTokens = tokenList.filter((token) => token.category === category);
654
+ const exact = categoryTokens
655
+ .filter((token) => comparable(tokenComparableValue(token)) === comparable(value))
656
+ .sort((a, b) => (b.aliasChain?.length ?? 0) - (a.aliasChain?.length ?? 0) ||
657
+ a.name.localeCompare(b.name))[0];
658
+ const suggested = exact ??
659
+ closeNumericToken(value, categoryTokens.map((token) => ({ ...token, value: tokenComparableValue(token) })));
660
+ const index = stylesheet.offset + (match.index ?? 0);
661
+ const line = lineAt(content, index);
662
+ const component = componentForSource(file, line);
663
+ const recurringKey = `${category}:${comparable(value)}`;
664
+ const recurring = recurringLiterals.get(recurringKey) ?? {
665
+ value,
666
+ category,
667
+ properties: [],
668
+ sources: [],
669
+ componentIds: [],
670
+ };
671
+ recurring.properties.push(property);
672
+ recurring.sources.push({ file, line });
673
+ if (component?.id)
674
+ recurring.componentIds.push(component.id);
675
+ recurringLiterals.set(recurringKey, recurring);
676
+ if (!suggested)
677
+ continue;
678
+ const usage = {
679
+ id: stableId('use', `${file}:${index}:${suggested.id}:literal`),
680
+ tokenId: suggested.id,
681
+ tokenName: suggested.name,
682
+ value,
683
+ category,
684
+ kind: 'literal',
685
+ property,
686
+ componentId: component?.id,
687
+ source: { file, line },
688
+ evidence: [
689
+ exact
690
+ ? 'Literal equals an existing project token'
691
+ : 'Literal is close to a project token',
692
+ ],
693
+ };
694
+ tokenUsages.push(usage);
695
+ findings.push({
696
+ id: stableId('finding', usage.id),
697
+ kind: component ? 'component-drift' : 'literal-drift',
698
+ severity: exact ? 'info' : 'warning',
699
+ title: exact ? `Use ${suggested.name}` : `${value} is close to ${suggested.name}`,
700
+ detail: exact
701
+ ? `${property} repeats ${suggested.value} as a literal instead of its project token.`
702
+ : `${property} uses ${value}; the nearest ${category} token is ${suggested.name} at ${suggested.value}.`,
703
+ category,
704
+ tokenIds: [suggested.id],
705
+ usageIds: [usage.id],
706
+ componentIds: component ? [component.id] : [],
707
+ suggestedTokenId: suggested.id,
708
+ source: usage.source,
709
+ evidence: usage.evidence,
710
+ });
711
+ }
712
+ }
713
+ }
714
+ const tokenPromotions = [...recurringLiterals.entries()]
715
+ .filter(([, recurring]) => recurring.sources.length >= 2)
716
+ .map(([key, recurring]) => {
717
+ const categoryTokens = tokenList.filter((token) => token.category === recurring.category);
718
+ const exact = categoryTokens
719
+ .filter((token) => comparable(tokenComparableValue(token)) === comparable(recurring.value))
720
+ .sort((a, b) => (b.aliasChain?.length ?? 0) - (a.aliasChain?.length ?? 0) ||
721
+ a.name.localeCompare(b.name))[0];
722
+ const near = exact
723
+ ? undefined
724
+ : closeNumericToken(recurring.value, categoryTokens.map((token) => ({ ...token, value: tokenComparableValue(token) })));
725
+ const suggested = exact ?? near;
726
+ const property = [...recurring.properties].sort((a, b) => recurring.properties.filter((item) => item === b).length -
727
+ recurring.properties.filter((item) => item === a).length || a.localeCompare(b))[0];
728
+ const sources = recurring.sources.filter((source, index, all) => all.findIndex((candidate) => candidate.file === source.file && candidate.line === source.line) === index);
729
+ const relation = exact ? 'exact' : near ? 'near' : 'new';
730
+ const proposedTokenName = promotionTokenName(recurring.category, property, recurring.value);
731
+ const suggestedTokenName = suggested?.name ??
732
+ (tokens.has(proposedTokenName)
733
+ ? `${proposedTokenName}-${stableId('token-name', key).slice(-4)}`
734
+ : proposedTokenName);
735
+ return {
736
+ id: stableId('promotion', key),
737
+ value: recurring.value,
738
+ category: recurring.category,
739
+ property,
740
+ occurrenceCount: sources.length,
741
+ sources,
742
+ componentIds: [...new Set(recurring.componentIds)],
743
+ recommendation: suggested ? 'use-existing' : 'create-token',
744
+ relation,
745
+ suggestedTokenId: suggested?.id,
746
+ suggestedTokenName,
747
+ suggestedValue: suggested ? `var(${suggested.name})` : recurring.value,
748
+ aliasChain: suggested?.aliasChain ?? [],
749
+ canStage: sources.length > 0,
750
+ blockers: [],
751
+ evidence: [
752
+ `${sources.length} authored literal occurrences`,
753
+ ...(suggested
754
+ ? [
755
+ relation === 'exact'
756
+ ? 'Resolved value matches an existing project token'
757
+ : 'Nearest compatible project token requires explicit review',
758
+ ]
759
+ : ['No compatible project token exists; create one before replacing usages']),
760
+ ...(suggested?.aliasStatus === 'resolved'
761
+ ? [`Preserves alias chain ${(suggested.aliasChain ?? []).join(' → ')}`]
762
+ : []),
763
+ ],
764
+ };
765
+ })
766
+ .sort((a, b) => b.occurrenceCount - a.occurrenceCount || a.value.localeCompare(b.value));
767
+ for (let index = 0; index < tokenList.length; index += 1) {
768
+ const token = tokenList[index];
769
+ const near = tokenList.slice(index + 1).find((candidate) => {
770
+ if (candidate.category !== token.category)
771
+ return false;
772
+ const first = numericValue(token.value);
773
+ const second = numericValue(candidate.value);
774
+ return Boolean(first &&
775
+ second &&
776
+ first.unit === second.unit &&
777
+ Math.abs(first.amount - second.amount) > 0 &&
778
+ Math.abs(first.amount - second.amount) <= Math.max(2, Math.abs(first.amount) * 0.2));
779
+ });
780
+ if (near) {
781
+ const relatedUsages = tokenUsages.filter((usage) => usage.tokenId === token.id || usage.tokenId === near.id);
782
+ findings.push({
783
+ id: stableId('finding', `near:${token.id}:${near.id}`),
784
+ kind: 'near-duplicate',
785
+ severity: 'warning',
786
+ title: `${token.name} and ${near.name} are unusually close`,
787
+ detail: `${token.value} and ${near.value} may represent the same ${token.category} decision.`,
788
+ category: token.category,
789
+ tokenIds: [token.id, near.id],
790
+ usageIds: relatedUsages.map((usage) => usage.id),
791
+ componentIds: [
792
+ ...new Set(relatedUsages
793
+ .map((usage) => usage.componentId)
794
+ .filter((componentId) => Boolean(componentId))),
795
+ ],
796
+ source: near.source ?? token.source,
797
+ evidence: ['Same category', 'Values fall within the project drift threshold'],
798
+ });
799
+ }
800
+ if (!tokenUsages.some((usage) => usage.tokenId === token.id)) {
801
+ findings.push({
802
+ id: stableId('finding', `unused:${token.id}`),
803
+ kind: 'unused-token',
804
+ severity: 'info',
805
+ title: `${token.name} has no indexed references`,
806
+ detail: 'Confirm whether this token is intentionally reserved before removing it.',
807
+ category: token.category,
808
+ tokenIds: [token.id],
809
+ usageIds: [],
810
+ componentIds: [],
811
+ source: token.source,
812
+ evidence: ['No references were found in indexed project files'],
813
+ });
814
+ }
815
+ }
164
816
  const configuredViewports = config?.design?.viewports ?? [];
165
817
  const graphBreakpoints = configuredViewports.length
166
818
  ? configuredViewports.map((item) => ({ ...item, height: item.height ?? 900 }))
@@ -182,8 +834,12 @@ export async function indexProjectDesign(root, config, revision) {
182
834
  ];
183
835
  const configuredThemes = config?.design?.themes ?? [];
184
836
  const graphThemes = configuredThemes.length
185
- ? configuredThemes
186
- : [...themes].map((id) => ({ id, label: id[0].toUpperCase() + id.slice(1) }));
837
+ ? configuredThemes.map((theme) => ({
838
+ ...theme,
839
+ confidence: theme.confidence ?? 'instrumented',
840
+ evidence: theme.evidence ?? ['Configured Foundry theme hook'],
841
+ }))
842
+ : [...themes.values()];
187
843
  return projectDesignGraphSchema.parse({
188
844
  protocolVersion: PROTOCOL_VERSION,
189
845
  projectRoot: root,
@@ -191,9 +847,13 @@ export async function indexProjectDesign(root, config, revision) {
191
847
  tokens: [...tokens.values()],
192
848
  components: [...components.values()],
193
849
  breakpoints: graphBreakpoints,
850
+ containerQueries: [...containerQueries.values()].sort((a, b) => (a.minWidth ?? a.maxWidth ?? 0) - (b.minWidth ?? b.maxWidth ?? 0)),
194
851
  themes: graphThemes,
195
852
  states: config?.design?.states ?? [],
196
853
  motionPresets: [...motion.values()],
854
+ tokenUsages,
855
+ designSystemFindings: findings,
856
+ tokenPromotions,
197
857
  indexedAt: new Date().toISOString(),
198
858
  });
199
859
  }