vibe-design-system 2.8.20 → 2.8.22
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/package.json +1 -1
- package/vds-core-template/scan.mjs +216 -17
- package/vds-core-template/story-generator.mjs +464 -33
package/package.json
CHANGED
|
@@ -375,6 +375,62 @@ function classifyByPath(rel) {
|
|
|
375
375
|
return { group: "Components", category: "Components" };
|
|
376
376
|
}
|
|
377
377
|
|
|
378
|
+
/**
|
|
379
|
+
* Infer the reusability tier of a component from path + content signals.
|
|
380
|
+
* primitive → src/components/ui/ (shadcn atoms)
|
|
381
|
+
* component → small, focused domain component (< 200 lines, ≤ 6 local imports)
|
|
382
|
+
* feature → medium, contains state / multiple sub-parts
|
|
383
|
+
* page → full page/view — reference only, not for direct reuse
|
|
384
|
+
*/
|
|
385
|
+
function inferTier(rel, content) {
|
|
386
|
+
const normalized = rel.replace(/\\/g, "/");
|
|
387
|
+
if (normalized.startsWith("ui/") || normalized.includes("/ui/")) return "primitive";
|
|
388
|
+
|
|
389
|
+
const lines = content.split("\n").length;
|
|
390
|
+
// Count relative imports (../ ./) AND path-alias imports (@/components/) as local dependencies
|
|
391
|
+
const localImports = (content.match(/from\s+['"](?:\.\.?\/?|@\/components\/)/g) || []).length;
|
|
392
|
+
|
|
393
|
+
if (lines >= 400 || localImports >= 12) return "page";
|
|
394
|
+
if (lines >= 200 || localImports >= 7) return "feature";
|
|
395
|
+
return "component";
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Tailwind pseudo-class modifiers that appear inside class strings — never valid cva option names
|
|
399
|
+
const CVA_SKIP = new Set([
|
|
400
|
+
"hover","focus","active","disabled","dark","group","peer","placeholder",
|
|
401
|
+
"before","after","visited","checked","required","invalid","valid","not",
|
|
402
|
+
"open","closed","empty","enabled","first","last","odd","even",
|
|
403
|
+
]);
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Extract cva() variant options from a file, e.g.:
|
|
407
|
+
* variants: { variant: { default, destructive, ghost }, size: { sm, md, lg } }
|
|
408
|
+
* Returns { variant: ["default","destructive",...], size: [...] } or null.
|
|
409
|
+
* Filters out Tailwind pseudo-class modifiers that appear inside class strings.
|
|
410
|
+
*/
|
|
411
|
+
function extractCvaVariants(content) {
|
|
412
|
+
const block = content.match(/variants\s*:\s*\{([\s\S]*?)\}\s*,?\s*defaultVariants/);
|
|
413
|
+
if (!block) return null;
|
|
414
|
+
const result = {};
|
|
415
|
+
// Each top-level key is a variant dimension (variant, size, intent…)
|
|
416
|
+
// Match: variantName: { optionA: "...", optionB: "..." }
|
|
417
|
+
const keyRe = /(\w+)\s*:\s*\{([^}]+)\}/g;
|
|
418
|
+
let m;
|
|
419
|
+
while ((m = keyRe.exec(block[1])) !== null) {
|
|
420
|
+
const dimName = m[1];
|
|
421
|
+
if (CVA_SKIP.has(dimName)) continue; // skip pseudo-class keys at outer level
|
|
422
|
+
// Extract option names: only words at the start of a line (key: "value")
|
|
423
|
+
const optRe = /^\s{6,}(\w[\w-]*)\s*:/gm;
|
|
424
|
+
let om;
|
|
425
|
+
const options = [];
|
|
426
|
+
while ((om = optRe.exec(m[2])) !== null) {
|
|
427
|
+
if (!CVA_SKIP.has(om[1])) options.push(om[1]);
|
|
428
|
+
}
|
|
429
|
+
if (options.length > 0) result[dimName] = options;
|
|
430
|
+
}
|
|
431
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
432
|
+
}
|
|
433
|
+
|
|
378
434
|
function getAllComponentFiles(dir, baseDir = dir) {
|
|
379
435
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
380
436
|
const files = [];
|
|
@@ -447,30 +503,163 @@ function extractBrandAssets() {
|
|
|
447
503
|
return assets;
|
|
448
504
|
}
|
|
449
505
|
|
|
450
|
-
/** Extract
|
|
506
|
+
/** Extract Lucide icons used in app code with per-component JSX usage counts.
|
|
507
|
+
* Returns { name, total, topFiles }[] sorted by total desc.
|
|
508
|
+
* Handles aliased imports: `import { ArrowRight as Arrow }` — counted under original name. */
|
|
451
509
|
function extractLucideIconsUsed(srcDir) {
|
|
452
510
|
const allFiles = getAllTsxJsxInDir(srcDir);
|
|
453
511
|
const files = allFiles.filter((rel) => !/^stories[/\\]/.test(rel.replace(/\\/g, "/")));
|
|
454
|
-
|
|
512
|
+
|
|
513
|
+
// originalName → { total: number, topFiles: Map<componentName, count> }
|
|
514
|
+
const iconData = new Map();
|
|
455
515
|
const importRe = /import\s*\{([^}]+)\}\s*from\s*["']lucide-react["']/g;
|
|
516
|
+
|
|
456
517
|
for (const rel of files) {
|
|
457
518
|
const fullPath = path.join(srcDir, rel);
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
block.split(",").forEach((part) => {
|
|
464
|
-
const trimmed = part.trim();
|
|
465
|
-
const asMatch = trimmed.match(/^(\w+)\s+as\s+/);
|
|
466
|
-
const name = asMatch ? asMatch[1] : trimmed.split(/\s+/)[0];
|
|
467
|
-
if (name && /^[A-Z][a-zA-Z0-9]*$/.test(name)) names.add(name);
|
|
468
|
-
});
|
|
469
|
-
}
|
|
470
|
-
} catch (_) {}
|
|
519
|
+
let content;
|
|
520
|
+
try { content = fs.readFileSync(fullPath, "utf-8"); } catch (_) { continue; }
|
|
521
|
+
const componentName = path.basename(rel).replace(/\.(tsx|jsx|ts|js)$/, "");
|
|
522
|
+
|
|
523
|
+
// Pass 1 — collect local-name → original-name for this file
|
|
471
524
|
importRe.lastIndex = 0;
|
|
525
|
+
let m;
|
|
526
|
+
const localToOriginal = new Map();
|
|
527
|
+
while ((m = importRe.exec(content)) !== null) {
|
|
528
|
+
m[1].split(",").forEach((part) => {
|
|
529
|
+
const trimmed = part.trim();
|
|
530
|
+
if (!trimmed) return;
|
|
531
|
+
const asMatch = trimmed.match(/^(\w+)\s+as\s+(\w+)$/);
|
|
532
|
+
if (asMatch) {
|
|
533
|
+
const orig = asMatch[1];
|
|
534
|
+
const local = asMatch[2];
|
|
535
|
+
if (/^[A-Z][a-zA-Z0-9]*$/.test(orig)) {
|
|
536
|
+
localToOriginal.set(local, orig);
|
|
537
|
+
if (!iconData.has(orig)) iconData.set(orig, { total: 0, topFiles: new Map() });
|
|
538
|
+
}
|
|
539
|
+
} else {
|
|
540
|
+
const name = trimmed.split(/\s+/)[0];
|
|
541
|
+
if (name && /^[A-Z][a-zA-Z0-9]*$/.test(name)) {
|
|
542
|
+
localToOriginal.set(name, name);
|
|
543
|
+
if (!iconData.has(name)) iconData.set(name, { total: 0, topFiles: new Map() });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Pass 2 — count JSX usages `<LocalName ` / `<LocalName/` / `<LocalName>`
|
|
550
|
+
for (const [localName, originalName] of localToOriginal.entries()) {
|
|
551
|
+
const jsxRe = new RegExp(`<${localName}[\\s/>]`, "g");
|
|
552
|
+
let count = 0;
|
|
553
|
+
while (jsxRe.exec(content) !== null) count++;
|
|
554
|
+
if (count > 0) {
|
|
555
|
+
const data = iconData.get(originalName);
|
|
556
|
+
data.total += count;
|
|
557
|
+
data.topFiles.set(componentName, (data.topFiles.get(componentName) || 0) + count);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
472
560
|
}
|
|
473
|
-
|
|
561
|
+
|
|
562
|
+
return [...iconData.entries()]
|
|
563
|
+
.map(([name, data]) => ({
|
|
564
|
+
name,
|
|
565
|
+
total: data.total,
|
|
566
|
+
topFiles: [...data.topFiles.entries()]
|
|
567
|
+
.sort((a, b) => b[1] - a[1])
|
|
568
|
+
.slice(0, 5)
|
|
569
|
+
.map(([n]) => n),
|
|
570
|
+
}))
|
|
571
|
+
.sort((a, b) => b.total - a.total || a.name.localeCompare(b.name));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Scan app source for responsive breakpoints, grid column patterns, gaps and max-width usage.
|
|
575
|
+
* Returns { breakpoints, gridCols, gaps, maxWidths, containerCount } or null if nothing found. */
|
|
576
|
+
function extractGridSystem(srcDir) {
|
|
577
|
+
if (!fs.existsSync(srcDir)) return null;
|
|
578
|
+
const allFiles = getAllTsxJsxInDir(srcDir);
|
|
579
|
+
const files = allFiles.filter((rel) => !/^stories[/\\]/.test(rel.replace(/\\/g, "/")));
|
|
580
|
+
if (files.length === 0) return null;
|
|
581
|
+
|
|
582
|
+
const BP_NAMES = ["sm", "md", "lg", "xl", "2xl"];
|
|
583
|
+
const bpData = {};
|
|
584
|
+
for (const bp of BP_NAMES) bpData[bp] = { count: 0, topFiles: new Map() };
|
|
585
|
+
|
|
586
|
+
const colData = {}; // colValue → { count, topFiles: Map }
|
|
587
|
+
const gapCounts = {};
|
|
588
|
+
const maxWCounts = {};
|
|
589
|
+
let containerCount = 0;
|
|
590
|
+
|
|
591
|
+
for (const rel of files) {
|
|
592
|
+
let content;
|
|
593
|
+
try { content = fs.readFileSync(path.join(srcDir, rel), "utf-8"); } catch (_) { continue; }
|
|
594
|
+
const componentName = path.basename(rel).replace(/\.(tsx|jsx|ts|js)$/, "");
|
|
595
|
+
|
|
596
|
+
// Breakpoints: sm: md: lg: xl: 2xl:
|
|
597
|
+
const bpRe = /\b(2xl|xl|lg|md|sm):/g;
|
|
598
|
+
let m;
|
|
599
|
+
while ((m = bpRe.exec(content)) !== null) {
|
|
600
|
+
const name = m[1];
|
|
601
|
+
bpData[name].count++;
|
|
602
|
+
bpData[name].topFiles.set(componentName, (bpData[name].topFiles.get(componentName) || 0) + 1);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// grid-cols-{value}
|
|
606
|
+
const gcRe = /\bgrid-cols-((?:\[[\w\s\-.,/()*+]+\]|\w+))/g;
|
|
607
|
+
while ((m = gcRe.exec(content)) !== null) {
|
|
608
|
+
const val = m[1];
|
|
609
|
+
if (!colData[val]) colData[val] = { count: 0, topFiles: new Map() };
|
|
610
|
+
colData[val].count++;
|
|
611
|
+
colData[val].topFiles.set(componentName, (colData[val].topFiles.get(componentName) || 0) + 1);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// gap-{n}, gap-x-{n}, gap-y-{n}
|
|
615
|
+
const gapRe = /\bgap(?:-[xy])?-((?:\[[\w\s./]+\]|\d[\w.]*|px))\b/g;
|
|
616
|
+
while ((m = gapRe.exec(content)) !== null) {
|
|
617
|
+
const val = m[1];
|
|
618
|
+
gapCounts[val] = (gapCounts[val] || 0) + 1;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// max-w-{value}
|
|
622
|
+
const maxWRe = /\bmax-w-((?:\[[\w\s./]+\]|[\w-]+))/g;
|
|
623
|
+
while ((m = maxWRe.exec(content)) !== null) {
|
|
624
|
+
const val = m[1];
|
|
625
|
+
maxWCounts[val] = (maxWCounts[val] || 0) + 1;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// container class
|
|
629
|
+
let ctrM;
|
|
630
|
+
const ctrRe = /\bcontainer\b/g;
|
|
631
|
+
while ((ctrM = ctrRe.exec(content)) !== null) containerCount++;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const breakpoints = {};
|
|
635
|
+
for (const bp of BP_NAMES) {
|
|
636
|
+
if (bpData[bp].count > 0) {
|
|
637
|
+
breakpoints[bp] = {
|
|
638
|
+
count: bpData[bp].count,
|
|
639
|
+
topFiles: [...bpData[bp].topFiles.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([n]) => n),
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const gridCols = {};
|
|
645
|
+
for (const [val, data] of Object.entries(colData).sort((a, b) => b[1].count - a[1].count)) {
|
|
646
|
+
gridCols[val] = {
|
|
647
|
+
count: data.count,
|
|
648
|
+
topFiles: [...data.topFiles.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([n]) => n),
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const gaps = Object.fromEntries(
|
|
653
|
+
Object.entries(gapCounts).sort((a, b) => b[1] - a[1]).slice(0, 12)
|
|
654
|
+
);
|
|
655
|
+
|
|
656
|
+
const maxWidths = Object.fromEntries(
|
|
657
|
+
Object.entries(maxWCounts).sort((a, b) => b[1] - a[1]).slice(0, 10)
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
if (Object.keys(breakpoints).length === 0 && Object.keys(gridCols).length === 0) return null;
|
|
661
|
+
|
|
662
|
+
return { breakpoints, gridCols, gaps, maxWidths, containerCount };
|
|
474
663
|
}
|
|
475
664
|
|
|
476
665
|
function extractVdsTags(content) {
|
|
@@ -2018,7 +2207,11 @@ function scan() {
|
|
|
2018
2207
|
description = "";
|
|
2019
2208
|
}
|
|
2020
2209
|
const tokens = extractTailwindTokens(content);
|
|
2021
|
-
|
|
2210
|
+
const tier = inferTier(rel, content);
|
|
2211
|
+
const variants = (tier === "primitive") ? extractCvaVariants(content) : null;
|
|
2212
|
+
const lineCount = content.split("\n").length;
|
|
2213
|
+
const localImportCount = (content.match(/from\s+['"]\.\.\?\//g) || []).length;
|
|
2214
|
+
results.push({ file: rel, name, group, category, description, tokens, tier, lines: lineCount, localImports: localImportCount, ...(variants ? { variants } : {}) });
|
|
2022
2215
|
}
|
|
2023
2216
|
if (PAGES_DIR && fs.existsSync(PAGES_DIR)) {
|
|
2024
2217
|
const pageFiles = getAllComponentFiles(PAGES_DIR);
|
|
@@ -2027,6 +2220,7 @@ function scan() {
|
|
|
2027
2220
|
const content = fs.readFileSync(fullPath, "utf-8");
|
|
2028
2221
|
const name = humanizeName(rel);
|
|
2029
2222
|
const tokens = extractTailwindTokens(content);
|
|
2223
|
+
const lineCount = content.split("\n").length;
|
|
2030
2224
|
results.push({
|
|
2031
2225
|
file: path.relative(PROJECT_ROOT, PAGES_DIR).replace(/\\/g, "/") + "/" + rel,
|
|
2032
2226
|
name,
|
|
@@ -2034,6 +2228,9 @@ function scan() {
|
|
|
2034
2228
|
category: "Pages",
|
|
2035
2229
|
description: "",
|
|
2036
2230
|
tokens,
|
|
2231
|
+
tier: "page",
|
|
2232
|
+
lines: lineCount,
|
|
2233
|
+
localImports: 0,
|
|
2037
2234
|
});
|
|
2038
2235
|
}
|
|
2039
2236
|
}
|
|
@@ -2051,6 +2248,8 @@ function scan() {
|
|
|
2051
2248
|
const colorNames = Object.keys(foundations.colors || {}).filter((k) => k !== "_dark");
|
|
2052
2249
|
const colorUsage = extractColorUsage(colorNames);
|
|
2053
2250
|
if (Object.keys(colorUsage).length > 0) foundations.colorUsage = colorUsage;
|
|
2251
|
+
const gridSystem = extractGridSystem(SRC_DIR);
|
|
2252
|
+
if (gridSystem) foundations.gridSystem = gridSystem;
|
|
2054
2253
|
const componentSuggestions = extractComponentSuggestions();
|
|
2055
2254
|
const unreleasedSectionCandidates = extractUnreleasedSectionCandidates();
|
|
2056
2255
|
const output = {
|
|
@@ -1600,43 +1600,284 @@ function writeFoundationsStories(foundations) {
|
|
|
1600
1600
|
fs.writeFileSync(path.join(foundationsDir, "Brand.stories.tsx"), brandContent, "utf-8");
|
|
1601
1601
|
console.log("[VDS] Wrote " + path.relative(PROJECT_ROOT, path.join(foundationsDir, "Brand.stories.tsx")));
|
|
1602
1602
|
|
|
1603
|
-
const
|
|
1604
|
-
if (
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1603
|
+
const rawIcons = Array.isArray(foundations?.icons) ? foundations.icons : [];
|
|
1604
|
+
if (rawIcons.length > 0) {
|
|
1605
|
+
// Support both old format (string[]) and new format ({ name, total, topFiles }[])
|
|
1606
|
+
const iconData = rawIcons[0] && typeof rawIcons[0] === "object"
|
|
1607
|
+
? rawIcons
|
|
1608
|
+
: rawIcons.map((name) => ({ name, total: 0, topFiles: [] }));
|
|
1609
|
+
|
|
1610
|
+
const iconsContent = [
|
|
1611
|
+
"import type { Meta, StoryObj } from \"@storybook/react\";",
|
|
1612
|
+
"import * as Lucide from \"lucide-react\";",
|
|
1613
|
+
"",
|
|
1614
|
+
"const meta = { title: \"Foundations/Icons\" } satisfies Meta;",
|
|
1615
|
+
"export default meta;",
|
|
1616
|
+
"type Story = StoryObj;",
|
|
1617
|
+
"",
|
|
1618
|
+
`const iconData = ${JSON.stringify(iconData, null, 2)};`,
|
|
1619
|
+
"",
|
|
1620
|
+
"export const Default: Story = {",
|
|
1621
|
+
" render: () => {",
|
|
1622
|
+
" const usedIcons = iconData.filter((d) => d.total > 0);",
|
|
1623
|
+
" const unusedIcons = iconData.filter((d) => d.total === 0);",
|
|
1624
|
+
" const renderCard = (d: { name: string; total: number; topFiles: string[] }) => {",
|
|
1625
|
+
" const Icon = (Lucide as Record<string, any>)[d.name];",
|
|
1626
|
+
" if (!Icon) return null;",
|
|
1627
|
+
" return (",
|
|
1628
|
+
" <div key={d.name} style={{ display: \"flex\", flexDirection: \"column\", gap: 8, padding: 14,",
|
|
1629
|
+
" border: \"1px solid #1e293b\", borderRadius: 10, background: \"#0f172a\" }}>",
|
|
1630
|
+
" <div style={{ display: \"flex\", justifyContent: \"space-between\", alignItems: \"flex-start\" }}>",
|
|
1631
|
+
" <Icon size={22} strokeWidth={1.5} />",
|
|
1632
|
+
" {d.total > 0 && (",
|
|
1633
|
+
" <span style={{ fontSize: 11, fontWeight: 600, background: \"#1e293b\",",
|
|
1634
|
+
" color: \"#94a3b8\", padding: \"2px 7px\", borderRadius: 999 }}>",
|
|
1635
|
+
" ×{d.total}",
|
|
1636
|
+
" </span>",
|
|
1637
|
+
" )}",
|
|
1638
|
+
" </div>",
|
|
1639
|
+
" <span style={{ fontSize: 12, fontWeight: 500, color: \"#e2e8f0\" }}>{d.name}</span>",
|
|
1640
|
+
" {d.topFiles.length > 0 && (",
|
|
1641
|
+
" <div style={{ display: \"flex\", flexWrap: \"wrap\", gap: 4, marginTop: 2 }}>",
|
|
1642
|
+
" {d.topFiles.map((f) => (",
|
|
1643
|
+
" <span key={f} style={{ fontSize: 10, background: \"#1e293b\", color: \"#64748b\",",
|
|
1644
|
+
" padding: \"1px 6px\", borderRadius: 4 }}>{f}</span>",
|
|
1645
|
+
" ))}",
|
|
1646
|
+
" </div>",
|
|
1647
|
+
" )}",
|
|
1648
|
+
" </div>",
|
|
1649
|
+
" );",
|
|
1650
|
+
" };",
|
|
1651
|
+
" return (",
|
|
1652
|
+
" <div style={{ padding: 24, fontFamily: \"sans-serif\" }}>",
|
|
1653
|
+
" <div style={{ marginBottom: 24 }}>",
|
|
1654
|
+
" <p style={{ margin: 0, marginBottom: 4, fontSize: 14, color: \"#94a3b8\" }}>",
|
|
1655
|
+
" {iconData.length} icons imported from <code style={{ fontSize: 12 }}>lucide-react</code> in app code.",
|
|
1656
|
+
" </p>",
|
|
1657
|
+
" <p style={{ margin: 0, fontSize: 12, color: \"#475569\" }}>",
|
|
1658
|
+
" Sorted by usage frequency · Badge shows total JSX usages · Chips show top components.",
|
|
1659
|
+
" </p>",
|
|
1660
|
+
" </div>",
|
|
1661
|
+
" {usedIcons.length > 0 && (",
|
|
1662
|
+
" <>",
|
|
1663
|
+
" <p style={{ margin: \"0 0 12px\", fontSize: 12, fontWeight: 600, color: \"#64748b\",",
|
|
1664
|
+
" textTransform: \"uppercase\", letterSpacing: \"0.06em\" }}>",
|
|
1665
|
+
" Active — {usedIcons.length}",
|
|
1666
|
+
" </p>",
|
|
1667
|
+
" <div style={{ display: \"grid\", gridTemplateColumns: \"repeat(auto-fill, minmax(160px, 1fr))\",",
|
|
1668
|
+
" gap: 10, marginBottom: 32 }}>",
|
|
1669
|
+
" {usedIcons.map(renderCard)}",
|
|
1670
|
+
" </div>",
|
|
1671
|
+
" </>",
|
|
1672
|
+
" )}",
|
|
1673
|
+
" {unusedIcons.length > 0 && (",
|
|
1674
|
+
" <>",
|
|
1675
|
+
" <p style={{ margin: \"0 0 12px\", fontSize: 12, fontWeight: 600, color: \"#334155\",",
|
|
1676
|
+
" textTransform: \"uppercase\", letterSpacing: \"0.06em\" }}>",
|
|
1677
|
+
" Imported (not used in JSX) — {unusedIcons.length}",
|
|
1678
|
+
" </p>",
|
|
1679
|
+
" <div style={{ display: \"grid\", gridTemplateColumns: \"repeat(auto-fill, minmax(140px, 1fr))\",",
|
|
1680
|
+
" gap: 8, opacity: 0.45 }}>",
|
|
1681
|
+
" {unusedIcons.map(renderCard)}",
|
|
1682
|
+
" </div>",
|
|
1683
|
+
" </>",
|
|
1684
|
+
" )}",
|
|
1685
|
+
" </div>",
|
|
1686
|
+
" );",
|
|
1687
|
+
" },",
|
|
1688
|
+
"};",
|
|
1689
|
+
].join("\n");
|
|
1636
1690
|
fs.writeFileSync(path.join(foundationsDir, "Icons.stories.tsx"), iconsContent, "utf-8");
|
|
1637
1691
|
console.log("[VDS] Wrote " + path.relative(PROJECT_ROOT, path.join(foundationsDir, "Icons.stories.tsx")));
|
|
1638
1692
|
}
|
|
1639
1693
|
|
|
1694
|
+
const gridSystem = foundations?.gridSystem;
|
|
1695
|
+
if (gridSystem && (Object.keys(gridSystem.breakpoints || {}).length > 0 || Object.keys(gridSystem.gridCols || {}).length > 0)) {
|
|
1696
|
+
const bpEntries = Object.entries(gridSystem.breakpoints || {});
|
|
1697
|
+
const colEntries = Object.entries(gridSystem.gridCols || {});
|
|
1698
|
+
const gapEntries = Object.entries(gridSystem.gaps || {});
|
|
1699
|
+
const maxWEntries = Object.entries(gridSystem.maxWidths || {});
|
|
1700
|
+
const containerCount = gridSystem.containerCount || 0;
|
|
1701
|
+
|
|
1702
|
+
const gridContent = [
|
|
1703
|
+
"import type { Meta, StoryObj } from \"@storybook/react\";",
|
|
1704
|
+
"",
|
|
1705
|
+
"const meta = { title: \"Foundations/Grid\" } satisfies Meta;",
|
|
1706
|
+
"export default meta;",
|
|
1707
|
+
"type Story = StoryObj;",
|
|
1708
|
+
"",
|
|
1709
|
+
`const gridSystem = ${JSON.stringify(gridSystem, null, 2)};`,
|
|
1710
|
+
"",
|
|
1711
|
+
"const BP_CONFIG: Record<string, { px: number; label: string; color: string }> = {",
|
|
1712
|
+
" sm: { px: 640, label: \"Small\", color: \"#6366f1\" },",
|
|
1713
|
+
" md: { px: 768, label: \"Medium\", color: \"#8b5cf6\" },",
|
|
1714
|
+
" lg: { px: 1024, label: \"Large\", color: \"#a78bfa\" },",
|
|
1715
|
+
" xl: { px: 1280, label: \"Extra Large\", color: \"#c4b5fd\" },",
|
|
1716
|
+
" \"2xl\": { px: 1536, label: \"2× Extra Large\", color: \"#e9d5ff\" },",
|
|
1717
|
+
"};",
|
|
1718
|
+
"const ALL_BPS = [\"sm\", \"md\", \"lg\", \"xl\", \"2xl\"];",
|
|
1719
|
+
"",
|
|
1720
|
+
"export const Default: Story = {",
|
|
1721
|
+
" render: () => {",
|
|
1722
|
+
" const colEntries = Object.entries(gridSystem.gridCols || {});",
|
|
1723
|
+
" const gapEntries = Object.entries(gridSystem.gaps || {});",
|
|
1724
|
+
" const maxWEntries = Object.entries(gridSystem.maxWidths || {});",
|
|
1725
|
+
" const chip = (label: string) => (",
|
|
1726
|
+
" <span key={label} style={{ fontSize: 10, background: \"#1e293b\", color: \"#64748b\", padding: \"1px 6px\", borderRadius: 4 }}>{label}</span>",
|
|
1727
|
+
" );",
|
|
1728
|
+
" const renderColPreview = (colVal: string) => {",
|
|
1729
|
+
" const n = parseInt(colVal, 10);",
|
|
1730
|
+
" if (!isNaN(n) && n >= 1 && n <= 12) {",
|
|
1731
|
+
" return (",
|
|
1732
|
+
" <div style={{ display: \"grid\", gridTemplateColumns: `repeat(${n}, 1fr)`, gap: 2, marginBottom: 8 }}>",
|
|
1733
|
+
" {Array.from({ length: n }).map((_, i) => (",
|
|
1734
|
+
" <div key={i} style={{ height: 20, borderRadius: 3, background: \"#1e3a5f\" }} />",
|
|
1735
|
+
" ))}",
|
|
1736
|
+
" </div>",
|
|
1737
|
+
" );",
|
|
1738
|
+
" }",
|
|
1739
|
+
" return (",
|
|
1740
|
+
" <div style={{ fontFamily: \"monospace\", fontSize: 10, color: \"#475569\", marginBottom: 8, background: \"#0a0f1a\", padding: \"4px 6px\", borderRadius: 4 }}>",
|
|
1741
|
+
" grid-cols-{colVal}",
|
|
1742
|
+
" </div>",
|
|
1743
|
+
" );",
|
|
1744
|
+
" };",
|
|
1745
|
+
" return (",
|
|
1746
|
+
" <div style={{ padding: 24, fontFamily: \"sans-serif\", maxWidth: 900 }}>",
|
|
1747
|
+
"",
|
|
1748
|
+
" {/* ── Breakpoints ── */}",
|
|
1749
|
+
" <section style={{ marginBottom: 40 }}>",
|
|
1750
|
+
" <p style={{ margin: \"0 0 4px\", fontSize: 12, fontWeight: 600, color: \"#64748b\", textTransform: \"uppercase\", letterSpacing: \"0.06em\" }}>",
|
|
1751
|
+
" Responsive Breakpoints",
|
|
1752
|
+
" </p>",
|
|
1753
|
+
" <p style={{ margin: \"0 0 14px\", fontSize: 12, color: \"#475569\" }}>",
|
|
1754
|
+
" Tailwind CSS breakpoint prefixes detected in source.",
|
|
1755
|
+
" </p>",
|
|
1756
|
+
" <div style={{ position: \"relative\", height: 28, marginBottom: 14, background: \"#0a0f1a\", borderRadius: 8, border: \"1px solid #1e293b\", overflow: \"hidden\" }}>",
|
|
1757
|
+
" {ALL_BPS.map((bp) => {",
|
|
1758
|
+
" const cfg = BP_CONFIG[bp];",
|
|
1759
|
+
" const used = !!(gridSystem.breakpoints as any)?.[bp];",
|
|
1760
|
+
" const leftPct = (cfg.px / 1600) * 100;",
|
|
1761
|
+
" return (",
|
|
1762
|
+
" <div key={bp} style={{ position: \"absolute\", left: `${leftPct}%`, top: 0, bottom: 0,",
|
|
1763
|
+
" borderLeft: `2px solid ${used ? cfg.color : \"#1e293b\"}`,",
|
|
1764
|
+
" display: \"flex\", alignItems: \"center\", paddingLeft: 4 }}>",
|
|
1765
|
+
" <span style={{ fontSize: 9, color: used ? cfg.color : \"#334155\",",
|
|
1766
|
+
" whiteSpace: \"nowrap\", fontWeight: 600 }}>{bp}</span>",
|
|
1767
|
+
" </div>",
|
|
1768
|
+
" );",
|
|
1769
|
+
" })}",
|
|
1770
|
+
" </div>",
|
|
1771
|
+
" <div style={{ borderRadius: 8, border: \"1px solid #1e293b\", overflow: \"hidden\" }}>",
|
|
1772
|
+
" <table style={{ width: \"100%\", borderCollapse: \"collapse\", fontSize: 12 }}>",
|
|
1773
|
+
" <thead>",
|
|
1774
|
+
" <tr style={{ background: \"#0a0f1a\", borderBottom: \"1px solid #1e293b\" }}>",
|
|
1775
|
+
" <th style={{ textAlign: \"left\", padding: \"8px 12px\", color: \"#64748b\", fontWeight: 600 }}>Prefix</th>",
|
|
1776
|
+
" <th style={{ textAlign: \"left\", padding: \"8px 12px\", color: \"#64748b\", fontWeight: 600 }}>Min-width</th>",
|
|
1777
|
+
" <th style={{ textAlign: \"left\", padding: \"8px 12px\", color: \"#64748b\", fontWeight: 600 }}>Usages</th>",
|
|
1778
|
+
" <th style={{ textAlign: \"left\", padding: \"8px 12px\", color: \"#64748b\", fontWeight: 600 }}>Top components</th>",
|
|
1779
|
+
" </tr>",
|
|
1780
|
+
" </thead>",
|
|
1781
|
+
" <tbody>",
|
|
1782
|
+
" {ALL_BPS.map((bp, i) => {",
|
|
1783
|
+
" const cfg = BP_CONFIG[bp];",
|
|
1784
|
+
" const data = (gridSystem.breakpoints as any)?.[bp];",
|
|
1785
|
+
" return (",
|
|
1786
|
+
" <tr key={bp} style={{ borderBottom: i < 4 ? \"1px solid #1e293b\" : \"none\", opacity: data ? 1 : 0.3 }}>",
|
|
1787
|
+
" <td style={{ padding: \"8px 12px\" }}>",
|
|
1788
|
+
" <code style={{ fontSize: 12, fontWeight: 700, color: data ? cfg.color : \"#475569\" }}>{bp}:</code>",
|
|
1789
|
+
" </td>",
|
|
1790
|
+
" <td style={{ padding: \"8px 12px\", color: \"#94a3b8\", fontFamily: \"monospace\" }}>{cfg.px}px</td>",
|
|
1791
|
+
" <td style={{ padding: \"8px 12px\", color: data ? \"#e2e8f0\" : \"#334155\" }}>",
|
|
1792
|
+
" {data ? `×${data.count}` : \"—\"}",
|
|
1793
|
+
" </td>",
|
|
1794
|
+
" <td style={{ padding: \"8px 12px\" }}>",
|
|
1795
|
+
" <div style={{ display: \"flex\", flexWrap: \"wrap\", gap: 4 }}>",
|
|
1796
|
+
" {(data?.topFiles || []).map(chip)}",
|
|
1797
|
+
" </div>",
|
|
1798
|
+
" </td>",
|
|
1799
|
+
" </tr>",
|
|
1800
|
+
" );",
|
|
1801
|
+
" })}",
|
|
1802
|
+
" </tbody>",
|
|
1803
|
+
" </table>",
|
|
1804
|
+
" </div>",
|
|
1805
|
+
" </section>",
|
|
1806
|
+
"",
|
|
1807
|
+
" {/* ── Grid Columns ── */}",
|
|
1808
|
+
" {colEntries.length > 0 && (",
|
|
1809
|
+
" <section style={{ marginBottom: 40 }}>",
|
|
1810
|
+
" <p style={{ margin: \"0 0 4px\", fontSize: 12, fontWeight: 600, color: \"#64748b\", textTransform: \"uppercase\", letterSpacing: \"0.06em\" }}>",
|
|
1811
|
+
" Grid Columns",
|
|
1812
|
+
" </p>",
|
|
1813
|
+
" <p style={{ margin: \"0 0 14px\", fontSize: 12, color: \"#475569\" }}>",
|
|
1814
|
+
" grid-cols-* patterns detected in source, sorted by usage.",
|
|
1815
|
+
" </p>",
|
|
1816
|
+
" <div style={{ display: \"grid\", gridTemplateColumns: \"repeat(auto-fill, minmax(180px, 1fr))\", gap: 12 }}>",
|
|
1817
|
+
" {(colEntries as [string, { count: number; topFiles: string[] }][]).map(([val, data]) => (",
|
|
1818
|
+
" <div key={val} style={{ background: \"#0a0f1a\", border: \"1px solid #1e293b\", borderRadius: 8, padding: 12 }}>",
|
|
1819
|
+
" {renderColPreview(val)}",
|
|
1820
|
+
" <div style={{ display: \"flex\", justifyContent: \"space-between\", alignItems: \"center\", marginBottom: 6 }}>",
|
|
1821
|
+
" <code style={{ fontSize: 11, color: \"#a78bfa\" }}>grid-cols-{val}</code>",
|
|
1822
|
+
" <span style={{ fontSize: 11, fontWeight: 600, background: \"#1e293b\",",
|
|
1823
|
+
" color: \"#94a3b8\", padding: \"1px 6px\", borderRadius: 999 }}>×{data.count}</span>",
|
|
1824
|
+
" </div>",
|
|
1825
|
+
" <div style={{ display: \"flex\", flexWrap: \"wrap\", gap: 4 }}>",
|
|
1826
|
+
" {data.topFiles.map(chip)}",
|
|
1827
|
+
" </div>",
|
|
1828
|
+
" </div>",
|
|
1829
|
+
" ))}",
|
|
1830
|
+
" </div>",
|
|
1831
|
+
" </section>",
|
|
1832
|
+
" )}",
|
|
1833
|
+
"",
|
|
1834
|
+
" {/* ── Gaps ── */}",
|
|
1835
|
+
" {gapEntries.length > 0 && (",
|
|
1836
|
+
" <section style={{ marginBottom: 40 }}>",
|
|
1837
|
+
" <p style={{ margin: \"0 0 4px\", fontSize: 12, fontWeight: 600, color: \"#64748b\", textTransform: \"uppercase\", letterSpacing: \"0.06em\" }}>",
|
|
1838
|
+
" Common Gaps",
|
|
1839
|
+
" </p>",
|
|
1840
|
+
" <p style={{ margin: \"0 0 12px\", fontSize: 12, color: \"#475569\" }}>gap-* values across all components.</p>",
|
|
1841
|
+
" <div style={{ display: \"flex\", flexWrap: \"wrap\", gap: 8 }}>",
|
|
1842
|
+
" {(gapEntries as [string, number][]).map(([val, count]) => (",
|
|
1843
|
+
" <span key={val} style={{ display: \"flex\", alignItems: \"center\", gap: 6,",
|
|
1844
|
+
" background: \"#0a0f1a\", border: \"1px solid #1e293b\", borderRadius: 6, padding: \"5px 10px\", fontSize: 12 }}>",
|
|
1845
|
+
" <code style={{ color: \"#67e8f9\" }}>gap-{val}</code>",
|
|
1846
|
+
" <span style={{ color: \"#475569\", fontSize: 11 }}>×{count}</span>",
|
|
1847
|
+
" </span>",
|
|
1848
|
+
" ))}",
|
|
1849
|
+
" </div>",
|
|
1850
|
+
" </section>",
|
|
1851
|
+
" )}",
|
|
1852
|
+
"",
|
|
1853
|
+
" {/* ── Container / Max-widths ── */}",
|
|
1854
|
+
" {maxWEntries.length > 0 && (",
|
|
1855
|
+
" <section>",
|
|
1856
|
+
" <p style={{ margin: \"0 0 4px\", fontSize: 12, fontWeight: 600, color: \"#64748b\", textTransform: \"uppercase\", letterSpacing: \"0.06em\" }}>",
|
|
1857
|
+
" Container Widths",
|
|
1858
|
+
" </p>",
|
|
1859
|
+
` <p style={{ margin: "0 0 12px", fontSize: 12, color: "#475569" }}>max-w-* usage${containerCount > 0 ? ` · container ×${containerCount}` : ""}.</p>`,
|
|
1860
|
+
" <div style={{ display: \"flex\", flexWrap: \"wrap\", gap: 8 }}>",
|
|
1861
|
+
" {(maxWEntries as [string, number][]).map(([val, count]) => (",
|
|
1862
|
+
" <span key={val} style={{ display: \"flex\", alignItems: \"center\", gap: 6,",
|
|
1863
|
+
" background: \"#0a0f1a\", border: \"1px solid #1e293b\", borderRadius: 6, padding: \"5px 10px\", fontSize: 12 }}>",
|
|
1864
|
+
" <code style={{ color: \"#4ade80\" }}>max-w-{val}</code>",
|
|
1865
|
+
" <span style={{ color: \"#475569\", fontSize: 11 }}>×{count}</span>",
|
|
1866
|
+
" </span>",
|
|
1867
|
+
" ))}",
|
|
1868
|
+
" </div>",
|
|
1869
|
+
" </section>",
|
|
1870
|
+
" )}",
|
|
1871
|
+
"",
|
|
1872
|
+
" </div>",
|
|
1873
|
+
" );",
|
|
1874
|
+
" },",
|
|
1875
|
+
"};",
|
|
1876
|
+
].join("\n");
|
|
1877
|
+
fs.writeFileSync(path.join(foundationsDir, "Grid.stories.tsx"), gridContent, "utf-8");
|
|
1878
|
+
console.log("[VDS] Wrote " + path.relative(PROJECT_ROOT, path.join(foundationsDir, "Grid.stories.tsx")));
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1640
1881
|
const buttonUsage = foundations?.buttonUsage;
|
|
1641
1882
|
if (buttonUsage && Array.isArray(buttonUsage.combos) && buttonUsage.combos.length > 0) {
|
|
1642
1883
|
const combos = buttonUsage.combos.map((c) => ({
|
|
@@ -2245,6 +2486,195 @@ function writeChangelogStory(changelog) {
|
|
|
2245
2486
|
console.log("[VDS] Wrote " + path.relative(PROJECT_ROOT, path.join(foundationsDir, "Changelog.stories.tsx")));
|
|
2246
2487
|
}
|
|
2247
2488
|
|
|
2489
|
+
/** Generate .cursor/rules — structured design system context for AI agents.
|
|
2490
|
+
* Components are split into tiers so the agent knows what to reuse vs reference.
|
|
2491
|
+
* Foundations (colors, icons, spacing, breakpoints) are inlined as tables/lists.
|
|
2492
|
+
* The file is written to PROJECT_ROOT/.cursor/rules so Cursor injects it automatically. */
|
|
2493
|
+
function writeCursorRules(components, foundations) {
|
|
2494
|
+
const lines = [];
|
|
2495
|
+
const now = new Date().toISOString().slice(0, 10);
|
|
2496
|
+
|
|
2497
|
+
lines.push(`# VDS Design System — Agent Context`);
|
|
2498
|
+
lines.push(`> Auto-generated by VDS on ${now}. Do not edit — regenerated on every \`npm run vds\`.`);
|
|
2499
|
+
lines.push(``);
|
|
2500
|
+
|
|
2501
|
+
// ── Ground rules ──────────────────────────────────────────────────────────
|
|
2502
|
+
const gaps = foundations?.gridSystem?.gaps || {};
|
|
2503
|
+
const topGaps = Object.entries(gaps).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([v]) => `gap-${v}`).join(", ");
|
|
2504
|
+
const bpUsed = Object.keys(foundations?.gridSystem?.breakpoints || {});
|
|
2505
|
+
const bpAll = ["sm","md","lg","xl","2xl"];
|
|
2506
|
+
const bpUnused = bpAll.filter(b => !bpUsed.includes(b));
|
|
2507
|
+
|
|
2508
|
+
lines.push(`## ⚙️ Rules`);
|
|
2509
|
+
lines.push(`1. Before creating a new component, check the component lists below.`);
|
|
2510
|
+
lines.push(`2. **Never hardcode colors or hex values** — use the token table.`);
|
|
2511
|
+
lines.push(`3. **Always import icons from \`lucide-react\`** — never other icon libraries.`);
|
|
2512
|
+
if (topGaps) lines.push(`4. **Spacing convention**: ${topGaps} are the standard gaps in this project.`);
|
|
2513
|
+
if (bpUsed.length) lines.push(`5. **Breakpoints in use**: ${bpUsed.map(b=>`\`${b}:\``).join(" ")} — prefer these.`);
|
|
2514
|
+
if (bpUnused.length) lines.push(`6. **Breakpoints NOT used**: ${bpUnused.map(b=>`\`${b}:\``).join(" ")} — avoid unless explicitly needed.`);
|
|
2515
|
+
lines.push(``);
|
|
2516
|
+
lines.push(`---`);
|
|
2517
|
+
lines.push(``);
|
|
2518
|
+
|
|
2519
|
+
// ── Component tiers ───────────────────────────────────────────────────────
|
|
2520
|
+
const byTier = { primitive: [], component: [], feature: [], page: [] };
|
|
2521
|
+
for (const c of components) {
|
|
2522
|
+
const t = c.tier || "component";
|
|
2523
|
+
if (byTier[t]) byTier[t].push(c);
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
// Tier 1 — Primitives
|
|
2527
|
+
if (byTier.primitive.length > 0) {
|
|
2528
|
+
lines.push(`## 🧱 Primitives — Use first for all UI elements`);
|
|
2529
|
+
lines.push(`> Atomic components from \`src/components/ui/\`. Always prefer over building from scratch.`);
|
|
2530
|
+
lines.push(``);
|
|
2531
|
+
for (const c of byTier.primitive.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
2532
|
+
let entry = `- **${c.name}** \`${c.file}\``;
|
|
2533
|
+
if (c.variants) {
|
|
2534
|
+
const parts = Object.entries(c.variants).map(([k, vs]) => `${k}: ${vs.join(" | ")}`);
|
|
2535
|
+
entry += ` — ${parts.join(" · ")}`;
|
|
2536
|
+
}
|
|
2537
|
+
lines.push(entry);
|
|
2538
|
+
}
|
|
2539
|
+
lines.push(``);
|
|
2540
|
+
lines.push(`---`);
|
|
2541
|
+
lines.push(``);
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
// Tier 2 — Components
|
|
2545
|
+
if (byTier.component.length > 0) {
|
|
2546
|
+
lines.push(`## 🔷 Components — Reusable domain components`);
|
|
2547
|
+
lines.push(`> Project-specific, small, focused. Use before creating new ones.`);
|
|
2548
|
+
lines.push(``);
|
|
2549
|
+
for (const c of byTier.component.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
2550
|
+
let entry = `- **${c.name}** \`${c.file}\``;
|
|
2551
|
+
if (c.description) entry += ` — ${c.description}`;
|
|
2552
|
+
lines.push(entry);
|
|
2553
|
+
}
|
|
2554
|
+
lines.push(``);
|
|
2555
|
+
lines.push(`---`);
|
|
2556
|
+
lines.push(``);
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
// Tier 3 — Features
|
|
2560
|
+
if (byTier.feature.length > 0) {
|
|
2561
|
+
lines.push(`## ⚙️ Feature Components — Extract patterns, don't import whole`);
|
|
2562
|
+
lines.push(`> Complex components with internal state. Reuse sub-patterns, not the whole file.`);
|
|
2563
|
+
lines.push(``);
|
|
2564
|
+
for (const c of byTier.feature.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
2565
|
+
lines.push(`- **${c.name}** \`${c.file}\``);
|
|
2566
|
+
}
|
|
2567
|
+
lines.push(``);
|
|
2568
|
+
lines.push(`---`);
|
|
2569
|
+
lines.push(``);
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
// Tier 4 — Pages (just names, no need to list file paths)
|
|
2573
|
+
if (byTier.page.length > 0) {
|
|
2574
|
+
lines.push(`## 📄 Pages & Views — Reference only`);
|
|
2575
|
+
lines.push(`> Full page views. Study for patterns but do not import into new features.`);
|
|
2576
|
+
lines.push(``);
|
|
2577
|
+
lines.push(byTier.page.map(c => c.name).sort().join(", "));
|
|
2578
|
+
lines.push(``);
|
|
2579
|
+
lines.push(`---`);
|
|
2580
|
+
lines.push(``);
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
// ── Color tokens ──────────────────────────────────────────────────────────
|
|
2584
|
+
const colors = foundations?.colors || {};
|
|
2585
|
+
const colorUsage = foundations?.colorUsage || {};
|
|
2586
|
+
const colorEntries = Object.entries(colors)
|
|
2587
|
+
.filter(([k]) => k !== "_dark" && !k.startsWith("arbitrary-") && !k.startsWith("inline-"))
|
|
2588
|
+
.sort((a, b) => {
|
|
2589
|
+
const ua = colorUsage[a[0]]?.total || 0;
|
|
2590
|
+
const ub = colorUsage[b[0]]?.total || 0;
|
|
2591
|
+
return ub - ua;
|
|
2592
|
+
})
|
|
2593
|
+
.slice(0, 20);
|
|
2594
|
+
|
|
2595
|
+
if (colorEntries.length > 0) {
|
|
2596
|
+
lines.push(`## 🎨 Color Tokens — Never hardcode hex values`);
|
|
2597
|
+
lines.push(`> Use Tailwind utility classes: \`bg-{token}\`, \`text-{token}\`, \`border-{token}\``);
|
|
2598
|
+
lines.push(``);
|
|
2599
|
+
lines.push(`| Token | Value | Usages |`);
|
|
2600
|
+
lines.push(`|-------|-------|--------|`);
|
|
2601
|
+
for (const [token, val] of colorEntries) {
|
|
2602
|
+
const hex = typeof val === "string" ? val : (val?.DEFAULT || val?.value || "");
|
|
2603
|
+
const usage = colorUsage[token]?.total || 0;
|
|
2604
|
+
lines.push(`| \`${token}\` | ${hex} | ${usage > 0 ? `×${usage}` : "—"} |`);
|
|
2605
|
+
}
|
|
2606
|
+
lines.push(``);
|
|
2607
|
+
lines.push(`---`);
|
|
2608
|
+
lines.push(``);
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
// ── Typography ────────────────────────────────────────────────────────────
|
|
2612
|
+
const typo = foundations?.typography || {};
|
|
2613
|
+
if (typo.fontFamily && Object.keys(typo.fontFamily).length > 0) {
|
|
2614
|
+
lines.push(`## 🔤 Typography`);
|
|
2615
|
+
const families = Object.entries(typo.fontFamily).map(([k, v]) => `${k}: ${Array.isArray(v) ? v[0] : v}`).join(" · ");
|
|
2616
|
+
lines.push(`Font: ${families}`);
|
|
2617
|
+
lines.push(``);
|
|
2618
|
+
lines.push(`---`);
|
|
2619
|
+
lines.push(``);
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
// ── Spacing / Grid ────────────────────────────────────────────────────────
|
|
2623
|
+
const gridSystem = foundations?.gridSystem;
|
|
2624
|
+
if (gridSystem) {
|
|
2625
|
+
lines.push(`## 📐 Spacing & Layout (detected in this project)`);
|
|
2626
|
+
if (Object.keys(gaps).length > 0) {
|
|
2627
|
+
const gapLine = Object.entries(gaps).sort((a,b)=>b[1]-a[1]).slice(0,8)
|
|
2628
|
+
.map(([v, c]) => `\`gap-${v}\` ×${c}`).join(" · ");
|
|
2629
|
+
lines.push(`**Common gaps:** ${gapLine}`);
|
|
2630
|
+
}
|
|
2631
|
+
if (Object.keys(gridSystem.gridCols || {}).length > 0) {
|
|
2632
|
+
const colLine = Object.entries(gridSystem.gridCols).sort((a,b)=>b[1].count-a[1].count).slice(0,6)
|
|
2633
|
+
.map(([v, d]) => `\`grid-cols-${v}\` ×${d.count}`).join(" · ");
|
|
2634
|
+
lines.push(`**Grid columns:** ${colLine}`);
|
|
2635
|
+
}
|
|
2636
|
+
if (Object.keys(gridSystem.maxWidths || {}).length > 0) {
|
|
2637
|
+
const mwLine = Object.entries(gridSystem.maxWidths).slice(0,5)
|
|
2638
|
+
.map(([v, c]) => `\`max-w-${v}\` ×${c}`).join(" · ");
|
|
2639
|
+
lines.push(`**Container widths:** ${mwLine}`);
|
|
2640
|
+
}
|
|
2641
|
+
if (bpUsed.length > 0) {
|
|
2642
|
+
const bpLine = bpUsed.map(bp => {
|
|
2643
|
+
const d = gridSystem.breakpoints[bp];
|
|
2644
|
+
return `\`${bp}:\` ×${d.count}`;
|
|
2645
|
+
}).join(" · ");
|
|
2646
|
+
lines.push(`**Active breakpoints:** ${bpLine}`);
|
|
2647
|
+
}
|
|
2648
|
+
lines.push(``);
|
|
2649
|
+
lines.push(`---`);
|
|
2650
|
+
lines.push(``);
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
// ── Icons ─────────────────────────────────────────────────────────────────
|
|
2654
|
+
const icons = Array.isArray(foundations?.icons) ? foundations.icons : [];
|
|
2655
|
+
const activeIcons = icons.filter(i => i && typeof i === "object" ? i.total > 0 : true);
|
|
2656
|
+
if (activeIcons.length > 0) {
|
|
2657
|
+
lines.push(`## 🎯 Icons — \`import { Name } from "lucide-react"\``);
|
|
2658
|
+
lines.push(`> ${activeIcons.length} icons active in this project. See Foundations/Icons in Storybook for full list.`);
|
|
2659
|
+
lines.push(``);
|
|
2660
|
+
const top = activeIcons.slice(0, 16);
|
|
2661
|
+
const iconLine = top.map(i => {
|
|
2662
|
+
const n = typeof i === "string" ? i : i.name;
|
|
2663
|
+
const t = typeof i === "object" ? i.total : 0;
|
|
2664
|
+
return t > 0 ? `\`${n}\` ×${t}` : `\`${n}\``;
|
|
2665
|
+
}).join(" · ");
|
|
2666
|
+
lines.push(`**Most used:** ${iconLine}`);
|
|
2667
|
+
if (icons.length > top.length) lines.push(`*(+ ${icons.length - top.length} more)*`);
|
|
2668
|
+
lines.push(``);
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
const cursorDir = path.join(PROJECT_ROOT, ".cursor");
|
|
2672
|
+
if (!fs.existsSync(cursorDir)) fs.mkdirSync(cursorDir, { recursive: true });
|
|
2673
|
+
const outPath = path.join(cursorDir, "rules");
|
|
2674
|
+
fs.writeFileSync(outPath, lines.join("\n"), "utf-8");
|
|
2675
|
+
console.log("[VDS] Wrote " + path.relative(PROJECT_ROOT, outPath));
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2248
2678
|
function main() {
|
|
2249
2679
|
if (!fs.existsSync(VDS_OUTPUT)) {
|
|
2250
2680
|
console.error("[VDS] vds-output.json not found. Run `npm run vds` first.");
|
|
@@ -2262,6 +2692,7 @@ function main() {
|
|
|
2262
2692
|
ensureDir(STORIES_DIR);
|
|
2263
2693
|
ensureDir(path.join(STORIES_DIR, "foundations"));
|
|
2264
2694
|
writeFoundationsStories(foundations);
|
|
2695
|
+
writeCursorRules(components, foundations);
|
|
2265
2696
|
const componentSuggestions = data.componentSuggestions;
|
|
2266
2697
|
if (componentSuggestions?.length) {
|
|
2267
2698
|
writeComponentSuggestionsStory(componentSuggestions);
|