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.
Files changed (48) hide show
  1. package/README.md +163 -16
  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 +442 -120
  15. package/dist/index.js.map +1 -1
  16. package/dist/indexer.d.ts.map +1 -1
  17. package/dist/indexer.js +649 -27
  18. package/dist/indexer.js.map +1 -1
  19. package/dist/installer.d.ts +31 -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 +8 -0
  24. package/dist/project.d.ts.map +1 -1
  25. package/dist/project.js +46 -1
  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 +34 -11
  36. package/dist/skill/foundry-design-control/references/apply-run-contract.md +9 -4
  37. package/dist/skill/foundry-design-control/references/change-contract.md +2 -0
  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,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
- 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,
92
- source: { file, line: lineAt(content, match.index ?? 0) },
93
- confidence: 'instrumented',
94
- evidence: ['CSS custom property'],
95
- });
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) },
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
  }