roast-my-design-system 3.4.0 โ†’ 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,11 +24,11 @@ Your AI agent (Claude, Cursor, Copilot) builds UI by imitating what's already in
24
24
 
25
25
  The full report for vercel/ai-chatbot, top to bottom:
26
26
 
27
- ![The full diagnosis report for vercel/ai-chatbot in dark mode: health score, three-yardstick tiles, palette forensics, spacing receipts, typography, offenders, duplicates, and the where-to-start close](https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/report-full-dark.png?v=3.4.0)
27
+ ![The full diagnosis report for vercel/ai-chatbot in dark mode: health score, three-yardstick tiles, palette forensics, spacing receipts, typography, offenders, duplicates, and the where-to-start close](https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/report-full-dark.png?v=3.5.0)
28
28
 
29
29
  The same report in light mode (one file, built-in toggle):
30
30
 
31
- ![The diagnosis report in light mode](https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/report-light-hero.png?v=3.4.0)
31
+ ![The diagnosis report in light mode](https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/report-light-hero.png?v=3.5.0)
32
32
 
33
33
  ## What makes the numbers trustworthy
34
34
 
@@ -96,8 +96,9 @@ You get the roast in chat plus `design-system-roast.html` at your repo root: a s
96
96
  - a **light/dark theme toggle** in one file
97
97
  - the usage-weighted palette bar, the grey ramp, the off-scale spacing receipts, the duplicate-component receipts with clickable file paths, and the worst-offenders ledger
98
98
  - a **Where to start** close: up to three moves derived from your repo's own numbers, each with a file-path receipt
99
+ - a **present** ๐ŸŽ below it: you sat through the roast, so `design-system-rules.md` is wrapped inside the report itself. Unwrap, then copy or download the agent rules generated from your scan.
99
100
 
100
- After the roast, the skill offers `design-system-rules.md`: the agent-rules file generated from the same scan, ready for your CLAUDE.md, `.cursor/rules` or AGENTS.md.
101
+ After the roast, the skill also offers to write `design-system-rules.md` to disk and merge it into your CLAUDE.md, `.cursor/rules` or AGENTS.md.
101
102
 
102
103
  ## Live examples
103
104
 
@@ -126,4 +127,4 @@ Yes, the median repo is already a mess. That's the point.
126
127
 
127
128
  MIT
128
129
 
129
- Built and designed by [Greg Kozakiewicz](https://gregkozakiewicz.com).
130
+ Built and designed by <a href="https://gregkozakiewicz.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/gk-mark-dark.png?v=3.5.0"><img src="https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/gk-mark.png?v=3.5.0" height="15" alt="GK mark"></picture> Greg Kozakiewicz</a>.
package/bin/roast.mjs CHANGED
@@ -103,6 +103,9 @@ if (asJson) {
103
103
  if (wantRules && !asJson) {
104
104
  console.log(`\n design-system-rules.md is ready: paste it into CLAUDE.md or .cursor/rules
105
105
  so your AI agent stops repeating this repo's mistakes.`);
106
+ } else if (!asJson) {
107
+ console.log(`\n there is a present wrapped inside the report: your agent rules file,
108
+ generated from this scan. Or run with --rules to write it straight to disk.`);
106
109
  }
107
110
 
108
111
  say(`\nWant the fixes, not just the roast? The free Claude Code skill runs this same
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "Roast your design system with real data. A deterministic, zero-dependency scanner that counts every colour, grey, spacing value, typeface, duplicated component, inline style and arbitrary Tailwind value in your repo, scores it 0-100 against Ideal Design System norms and a 30-repo benchmark, generates a shareable HTML diagnosis, and with --rules writes the agent rules file (CLAUDE.md, .cursor/rules) that stops the mess coming back.",
5
5
  "keywords": [
6
6
  "design-system",
@@ -11,6 +11,7 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
11
11
  import { resolve, basename, join, dirname } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { distinctTypefaces } from '../lib/typefaces.mjs';
14
+ import { rulesMarkdown } from '../rules/build.mjs';
14
15
 
15
16
  // The benchmark (Ideal-2026 norms + scanned-repo stats) ships next to the
16
17
  // code so the page works offline; degrade gracefully when absent.
@@ -33,7 +34,7 @@ const GK_MASK = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqa
33
34
  const GK_MARK = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAACXBIWXMAAAAAAAAAAQCEeRdzAAACPklEQVR4nOVWTYh5URR/PlJYEKV8LKwkZalsmI2dZmMnK8pKmaZ87JSanexEkayUhSxsZyfJNBQ7UYqVhSShfM+v/6mXmtU81+p/Frfze+/d3++ce88993Hcf2cikehZ1GazWafTPUVDLBZjLBQKnU6HMTVvcrl8Op3ebrdsNgsokUiYUVP4Pp/v9s96vd79cwZGK95sNkkgGAwC2u12lhrY3u12C/b1ei2TyV5eXjabjV6vZ6BBa51MJin8arUKWKlU4H99fSkUCo5JUQ2HQxJwuVzIYLVawcdIVSs8CQoNpMQ+Go0Aw+EwwXw+D+h2u4Vr0LRSqUSMqVQKsN1uE3Q4HEql8ng8BgIBTnDhqlSqxWIBuvP5bDAYLBbL5XIBHAwGeBuJRODvdjun0/nnPOjraDRK8TYaDcBMJkMwFosBfn9/E0wkEn9OgjYgnU6fTidQwAGcTCbw9/u9VqvFUYB/vV5RskajkRNcTiaTKR6PI9j5fE7xlstlPM/lcgTr9ToneJ/5oOB4PB70O8Tb7/f9fv9sNqPkXl9fhQsQ9f1ktVqNvUVCxA6ZRxsf5pMAxnsuVBSOwvv7+6Phk0PsBNn0OOLC9haLReqdvBL/wUNKNPnt7Q0LjeVutVqhUEij0TwU9W8BVA4JUEUul8tarcZGhpbIarWiSeA0HQ4Hkvn4+GB56VMvov6DA+z1eplR89btdsE+Ho9tNhv3jOv+8/MTFz06M3ypVMqMnTfcwPhn4djG/tue+Mf4RGom9gMt6lAx16huIwAAAABJRU5ErkJggg==';
34
35
 
35
36
  // Shown in the report footer; keep in step with plugin.json when releasing.
36
- const VERSION = '3.4.0';
37
+ const VERSION = '3.5.0';
37
38
 
38
39
  // Two shipped skins, same layout: 'dark' (navy glass, mint accent) and
39
40
  // 'light' (lilac wash, white glass, violet accent). --theme picks one.
@@ -483,6 +484,38 @@ function componentsSection() {
483
484
  // "Where to start" โ€” at most three moves, every one derived from this repo's
484
485
  // own numbers with a receipt. Deliberately shallow: a starting push, not a
485
486
  // remediation plan.
487
+ // The present. The report ends the diagnosis arc with a gift: the agent
488
+ // rules file generated from this same scan, wrapped behind one click.
489
+ // Embedded so the report stays a single self-contained shareable file.
490
+ function giftSection() {
491
+ const { text: rulesText, ruleCount } = rulesMarkdown(h);
492
+ return `<section class="glass pad gift-sec">
493
+ ${sectionHead('You sat through the roast', 'so you get a present.')}
494
+ <div class="gift-stage">
495
+ <button class="gift" id="gift" aria-label="Unwrap your generated agent rules file">
496
+ <svg viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
497
+ <rect x="10" y="26" width="44" height="30" rx="4"/>
498
+ <rect x="6" y="17" width="52" height="9" rx="3"/>
499
+ <path class="ribbon" d="M32 26 v30"/>
500
+ <path class="ribbon" d="M32 17 c-9 1 -13 -8 -7 -11 c5 -2 8 4 7 11 c-1 -7 2 -13 7 -11 c6 3 2 12 -7 11z"/>
501
+ </svg>
502
+ <span class="gift-hint">unwrap</span>
503
+ </button>
504
+ <div class="gift-pop" id="gift-pop" aria-hidden="true"></div>
505
+ <div class="gift-reveal" id="gift-reveal" hidden>
506
+ <div class="gift-head"><span class="mono strong">design-system-rules.md</span>
507
+ <span class="gift-count">${ruleCount} rules generated from this scan, every one with a receipt</span></div>
508
+ <pre class="gift-md" id="gift-md">${esc(rulesText)}</pre>
509
+ <div class="gift-actions">
510
+ <button class="gbtn" id="gift-copy">Copy the rules</button>
511
+ <button class="gbtn ghost" id="gift-dl">Download the file</button>
512
+ </div>
513
+ <div class="gift-sub">Paste into CLAUDE.md, .cursor/rules or AGENTS.md. From then on your AI agent follows your system instead of guessing at it.</div>
514
+ </div>
515
+ </div>
516
+ </section>`;
517
+ }
518
+
486
519
  function whereToStartSection() {
487
520
  const c = [];
488
521
  if (agentFiles.length === 0) c.push({ score: 60, title: 'Write the agent rules file',
@@ -797,6 +830,36 @@ const html = `<!doctype html>
797
830
  footer .brand { color:var(--accent); font-weight:700; text-decoration:none; }
798
831
  footer .brand:hover { text-decoration:underline; }
799
832
  footer .creds { display:flex; gap:16px; font:700 9.5px/1.6 var(--sans); letter-spacing:.16em; text-transform:uppercase; color:var(--dim2); }
833
+ .gift-sec { margin-top:16px; }
834
+ .gift-stage { position:relative; display:grid; place-items:center; min-height:120px; }
835
+ .gift-stage.open { min-height:0; }
836
+ .gift[hidden] { display:none; }
837
+ .gift { background:none; border:0; cursor:pointer; display:grid; place-items:center; gap:9px; padding:14px; color:var(--dim);
838
+ transition:transform .25s ease, opacity .25s ease, filter .25s ease; }
839
+ .gift svg { width:72px; height:72px; }
840
+ .gift .ribbon { stroke:var(--accent); }
841
+ .gift:hover { transform:translateY(-3px) scale(1.04); color:var(--text); }
842
+ .gift.gone { transform:scale(.5); opacity:0; filter:blur(5px); }
843
+ .gift-hint { font:700 10px/1 var(--sans); letter-spacing:.18em; text-transform:uppercase; }
844
+ .gift-pop { position:absolute; inset:0; pointer-events:none; }
845
+ .gift-pop i { position:absolute; left:50%; top:42%; width:7px; height:10px; border-radius:2px; opacity:0;
846
+ animation:gpop .85s cubic-bezier(.16,.8,.32,1) forwards; }
847
+ @keyframes gpop { 0% { opacity:1; transform:translate(0,0) rotate(0); } 100% { opacity:0; transform:translate(var(--dx),var(--dy)) rotate(var(--rot)); } }
848
+ .gift-reveal { width:100%; }
849
+ .gift-reveal.in { animation:greveal .5s ease both; }
850
+ @keyframes greveal { from { opacity:0; transform:translateY(8px) scale(.985); } to { opacity:1; transform:none; } }
851
+ .gift-head { display:flex; align-items:baseline; gap:12px; flex-wrap:wrap; margin-bottom:10px; }
852
+ .gift-count { color:var(--dim2); font-size:12.5px; }
853
+ .gift-md { max-height:340px; overflow:auto; background:var(--chip-bg); border:1px solid var(--line-soft); border-radius:12px;
854
+ padding:16px 18px; font:12px/1.7 var(--mono); color:var(--dim); white-space:pre-wrap; }
855
+ .gift-actions { display:flex; gap:10px; margin-top:12px; flex-wrap:wrap; }
856
+ .gbtn { font:700 12.5px/1 var(--sans); padding:11px 16px; border-radius:10px; border:1px solid var(--accent);
857
+ background:var(--accent); color:var(--card-solid); cursor:pointer; }
858
+ .gbtn:hover { filter:brightness(1.08); }
859
+ .gbtn.ghost { background:transparent; color:var(--accent); }
860
+ .gift-sub { color:var(--dim2); font-size:12.5px; margin-top:10px; }
861
+ @media print { .gift, .gift-pop { display:none !important; } .gift-reveal[hidden] { display:block !important; } }
862
+ @media (prefers-reduced-motion: reduce) { .gift, .gift-reveal.in, .gift-pop i { transition:none !important; animation:none !important; } }
800
863
  .author { color:var(--accent); font-weight:700; text-decoration:none; }
801
864
  .gk-mark { display:inline-block; width:15px; height:15px; margin-right:5px; vertical-align:-3px;
802
865
  background:currentColor; -webkit-mask:url(${GK_MASK}) center/contain no-repeat; mask:url(${GK_MASK}) center/contain no-repeat; }
@@ -829,6 +892,8 @@ const html = `<!doctype html>
829
892
 
830
893
  ${whereToStartSection()}
831
894
 
895
+ ${giftSection()}
896
+
832
897
  <div class="stats">${bigStats.map((s) => statTile(s)).join('')}</div>
833
898
 
834
899
  ${agentSection()}
@@ -851,6 +916,47 @@ ${componentsSection()}
851
916
  </div>
852
917
  <span class="creds"><span>Non-destructive scan</span><span>Read-only</span><span>Paths are real</span></span>
853
918
  </footer>
919
+ <script>
920
+ (function(){
921
+ var gift=document.getElementById('gift'); if(!gift) return;
922
+ var reveal=document.getElementById('gift-reveal'), pop=document.getElementById('gift-pop');
923
+ var reduce=window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
924
+ gift.addEventListener('click', function(){
925
+ if(!reduce && pop){
926
+ var colors=['var(--accent)','var(--coral)','var(--amber)','var(--text)'];
927
+ for(var i=0;i<26;i++){
928
+ var f=document.createElement('i');
929
+ var a=Math.random()*Math.PI*2, v=46+Math.random()*84;
930
+ f.style.setProperty('--dx',(Math.cos(a)*v)+'px');
931
+ f.style.setProperty('--dy',(Math.sin(a)*v-56)+'px');
932
+ f.style.setProperty('--rot',(Math.random()*540-270)+'deg');
933
+ f.style.background=colors[i%4];
934
+ f.style.animationDelay=(Math.random()*80)+'ms';
935
+ pop.appendChild(f);
936
+ }
937
+ setTimeout(function(){ pop.innerHTML=''; },1000);
938
+ }
939
+ gift.classList.add('gone');
940
+ setTimeout(function(){ gift.hidden=true; gift.parentNode.classList.add('open'); reveal.hidden=false; reveal.classList.add('in'); }, reduce?0:240);
941
+ },{once:true});
942
+ var copyBtn=document.getElementById('gift-copy');
943
+ function rulesText(){ return document.getElementById('gift-md').textContent; }
944
+ copyBtn.addEventListener('click', function(){
945
+ var done=function(){ copyBtn.textContent='Copied'; setTimeout(function(){ copyBtn.textContent='Copy the rules'; },1600); };
946
+ var fallback=function(){ var ta=document.createElement('textarea'); ta.value=rulesText(); document.body.appendChild(ta);
947
+ ta.select(); try{ document.execCommand('copy'); }catch(e){} ta.remove(); };
948
+ if(navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(rulesText()).then(done, function(){ fallback(); done(); }); }
949
+ else { fallback(); done(); }
950
+ });
951
+ document.getElementById('gift-dl').addEventListener('click', function(){
952
+ var a=document.createElement('a');
953
+ a.href=URL.createObjectURL(new Blob([rulesText()],{type:'text/markdown'}));
954
+ a.download='design-system-rules.md';
955
+ document.body.appendChild(a); a.click();
956
+ setTimeout(function(){ URL.revokeObjectURL(a.href); a.remove(); },400);
957
+ });
958
+ })();
959
+ </script>
854
960
  </div></body></html>`;
855
961
 
856
962
  writeFileSync(outPath, html);
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Rules builder โ€” shared by the rules CLI and the diagnose page (which embeds
3
+ * the generated markdown behind the gift reveal). Input: a harvest object.
4
+ * Output: { text, ruleCount } โ€” paste-ready agent-rules markdown, every rule
5
+ * with a receipt from the scanned repo.
6
+ */
7
+ import { distinctTypefaces } from '../lib/typefaces.mjs';
8
+
9
+ export function rulesMarkdown(h) {
10
+ const repoName = h.profile?.name ?? 'this repo';
11
+ const t = h.tokens ?? {};
12
+ const reusable = (h.components ?? []).filter((c) => !c.isPage);
13
+ const scanDate = (h.harvestedAt ?? '').slice(0, 10);
14
+
15
+ const lines = [];
16
+ const rule = (s) => lines.push(`- ${s}`);
17
+ const section = (title) => lines.push('', `### ${title}`, '');
18
+
19
+ lines.push('## Design system rules');
20
+ lines.push('');
21
+ lines.push(`<!-- Generated by roast-my-design-system from a scan of ${repoName} on ${scanDate}.`);
22
+ lines.push(' Paste into CLAUDE.md, .cursor/rules or AGENTS.md. Regenerate after big refactors:');
23
+ lines.push(' npx roast-my-design-system --rules -->');
24
+ lines.push('');
25
+ lines.push('Follow these rules when writing or editing UI in this repo. Every rule below was derived from a scan of this codebase, with real paths and usage counts.');
26
+
27
+ // ---------- tokens ----------
28
+ const strays = (t.colors ?? []).filter((c) => !c.isToken);
29
+ const tokenCount = (t.colors ?? []).filter((c) => c.isToken).length;
30
+ if (t.tokenFile || (t.colors ?? []).length) {
31
+ section('Colours and tokens');
32
+ if (t.tokenFile) {
33
+ rule(`Design tokens live in \`${t.tokenFile}\`. Reach for an existing token before inventing any value.`);
34
+ if (tokenCount && strays.length) rule(`Never hardcode colour values in components. The palette already has ${tokenCount} tokens; the scan still found ${strays.length} hardcoded colours sitting next to them. Do not add more.`);
35
+ else rule('Never hardcode colour values in components; add a token first if one is genuinely missing.');
36
+ } else if ((t.colors ?? []).length) {
37
+ rule(`There is no token file yet. Until one exists, reuse the colours already in the codebase instead of introducing new ones (${t.colors.length} distinct colours are already in play).`);
38
+ }
39
+ const ds = h.profile?.designSystem;
40
+ if (ds?.kind && !['none', 'custom'].includes(ds.kind) && ds.confidence !== 'low') {
41
+ rule(`This repo uses ${ds.name ?? ds.kind}${h.profile.uiDir ? `; its components live in \`${h.profile.uiDir}\`` : ''}. Prefer extending it over building parallel pieces.`);
42
+ }
43
+ }
44
+
45
+ // ---------- canonical components ----------
46
+ // Skip names that shadow framework components (their usage counts absorb the
47
+ // framework's) and names defined in more than one file (ambiguous, and the
48
+ // duplicates section already handles them).
49
+ const FRAMEWORK_NAMES = new Set(['Link', 'Image', 'Head', 'Script', 'Form']);
50
+ const nameCounts = new Map();
51
+ for (const c of reusable) nameCounts.set(c.name, (nameCounts.get(c.name) ?? 0) + 1);
52
+ const top = reusable
53
+ .filter((c) => c.usageCount > 0 && !FRAMEWORK_NAMES.has(c.name) && nameCounts.get(c.name) === 1)
54
+ .sort((a, b) => b.usageCount - a.usageCount).slice(0, 8);
55
+ if (top.length) {
56
+ section('Canonical components');
57
+ rule('Use these existing components instead of writing new ones:');
58
+ for (const c of top) {
59
+ const props = c.propsHint?.named?.length ? ` ยท props: ${c.propsHint.named.slice(0, 4).join(', ')}` : '';
60
+ lines.push(` - \`<${c.name}>\` from \`${c.file}\` (used ${c.usageCount}x${props})`);
61
+ }
62
+ }
63
+
64
+ // ---------- duplicates to avoid ----------
65
+ const dupes = h.duplicates?.exactDuplicates ?? [];
66
+ const icons = h.duplicates?.iconCollisions ?? [];
67
+ if (dupes.length || icons.length) {
68
+ section('Known duplicates: do not make it worse');
69
+ // No per-file usage data here, so rank by how shared the location looks;
70
+ // when nothing stands out, do not guess a canonical.
71
+ const sharedScore = (f) => (f.includes('packages/') ? 4 : 0) + (/\/ui\//.test(f) ? 2 : 0) + (f.includes('components/') ? 1 : 0) - (/\bapp\//.test(f) ? 2 : 0) - (f.includes('/icons/') ? 4 : 0);
72
+ for (const d of dupes.slice(0, 6)) {
73
+ const files = d.files.map((f) => (typeof f === 'string' ? f : f.file));
74
+ const ranked = [...files].sort((a, b) => sharedScore(b) - sharedScore(a));
75
+ const clear = sharedScore(ranked[0]) > sharedScore(ranked[1]);
76
+ if (d.wrapped) {
77
+ rule(`\`<${d.name}>\` is defined twice and one wraps the other${clear ? `. Import \`${ranked[0]}\`` : ''}; do not create a third.`);
78
+ } else if (clear) {
79
+ rule(`\`<${d.name}>\` exists in ${files.length} places. Treat \`${ranked[0]}\` as canonical; do not import the other ${files.length === 2 ? 'copy' : 'copies'}, and never create another.`);
80
+ } else {
81
+ rule(`\`<${d.name}>\` exists in ${files.length} places (${files.slice(0, 2).map((f) => `\`${f}\``).join(', ')}). Match whichever the surrounding code already imports, and never create another.`);
82
+ }
83
+ }
84
+ if (icons.length) rule(`Two icon sets collide on ${icons.length} name${icons.length === 1 ? '' : 's'}. Before adding any icon, check which set the surrounding file already imports and stay with it.`);
85
+ }
86
+
87
+ // ---------- spacing ----------
88
+ const arbitrary = t.tailwind?.arbitrary ?? [];
89
+ const arbCount = arbitrary.reduce((s, a) => s + a.count, 0);
90
+ const offScale = (t.spacing ?? []).length;
91
+ if (t.tailwind?.spacing?.length || offScale || arbCount) {
92
+ section('Spacing and sizing');
93
+ if (t.tailwind?.spacing?.length) rule('Stay on the Tailwind spacing scale. If a gap looks wrong on a scale step, flag it instead of nudging by a pixel.');
94
+ if (arbCount) rule(`No new arbitrary bracket values (\`p-[13px]\`, \`text-[10px]\`). The scan found ${arbCount} already. If a value repeats, it is a decision: name it as a token instead of writing the bracket again.`);
95
+ if (offScale) rule(`Avoid new one-off CSS spacing values; ${offScale} off-scale value${offScale === 1 ? ' is' : 's are'} already in play.`);
96
+ }
97
+
98
+ // ---------- typography ----------
99
+ const faces = distinctTypefaces(t.fontFamilies ?? []);
100
+ if (faces.length) {
101
+ section('Typography');
102
+ rule(`The repo uses ${faces.length} typeface${faces.length === 1 ? '' : 's'}: ${faces.slice(0, 4).join(', ')}. Do not introduce another, and do not re-declare font stacks by hand; use the existing setup.`);
103
+ }
104
+
105
+ // ---------- styling discipline ----------
106
+ section('Styling discipline');
107
+ rule(`Never write ${reusable.length ? '`style={{ ... }}`' : 'inline `style="..."` attributes'} for static values; styling belongs to classes and tokens where the system can see it.`);
108
+ if ((t.inlineStyles?.count ?? 0) > 0) lines.push(` (${t.inlineStyles.count} static inline block${t.inlineStyles.count === 1 ? '' : 's'} already exist; do not add to them.)`);
109
+ rule('Before styling anything new, look at a neighbouring component and match how it does it. Consistency with the repo beats personal preference.');
110
+
111
+ lines.push('');
112
+ lines.push('---');
113
+ lines.push('*Generated by [roast-my-design-system](https://github.com/pencilrebel/roast-my-design-system). Rescan after refactors to keep these rules honest.*');
114
+ lines.push('');
115
+ const text = lines.join('\n');
116
+ const ruleCount = lines.filter((l) => l.startsWith('- ')).length;
117
+ return { text, ruleCount };
118
+ }
@@ -4,12 +4,13 @@
4
4
  * agent-rules section (CLAUDE.md, .cursor/rules, AGENTS.md) generated from
5
5
  * what the scan actually measured. The report diagnoses the past; this file
6
6
  * protects the future โ€” every rule carries a receipt from this repo.
7
+ * (The report also embeds this same markdown behind the gift reveal.)
7
8
  *
8
9
  * node src/rules/index.mjs <harvest.json> [--out design-system-rules.md]
9
10
  */
10
11
  import { readFileSync, writeFileSync } from 'node:fs';
11
12
  import { resolve } from 'node:path';
12
- import { distinctTypefaces } from '../lib/typefaces.mjs';
13
+ import { rulesMarkdown } from './build.mjs';
13
14
 
14
15
  function arg(name, fallback) {
15
16
  const i = process.argv.indexOf(`--${name}`);
@@ -20,113 +21,7 @@ if (!inPath) { console.error('Usage: node src/rules/index.mjs <harvest.json> [--
20
21
  const outPath = resolve(arg('out', 'design-system-rules.md'));
21
22
 
22
23
  const h = JSON.parse(readFileSync(inPath, 'utf8'));
23
- const repoName = h.profile?.name ?? 'this repo';
24
- const t = h.tokens ?? {};
25
- const reusable = (h.components ?? []).filter((c) => !c.isPage);
26
- const scanDate = (h.harvestedAt ?? '').slice(0, 10);
27
-
28
- const lines = [];
29
- const rule = (s) => lines.push(`- ${s}`);
30
- const section = (title) => lines.push('', `### ${title}`, '');
31
-
32
- lines.push('## Design system rules');
33
- lines.push('');
34
- lines.push(`<!-- Generated by roast-my-design-system from a scan of ${repoName} on ${scanDate}.`);
35
- lines.push(' Paste into CLAUDE.md, .cursor/rules or AGENTS.md. Regenerate after big refactors:');
36
- lines.push(' npx roast-my-design-system --rules -->');
37
- lines.push('');
38
- lines.push('Follow these rules when writing or editing UI in this repo. Every rule below was derived from a scan of this codebase, with real paths and usage counts.');
39
-
40
- // ---------- tokens ----------
41
- const strays = (t.colors ?? []).filter((c) => !c.isToken);
42
- const tokenCount = (t.colors ?? []).filter((c) => c.isToken).length;
43
- if (t.tokenFile || (t.colors ?? []).length) {
44
- section('Colours and tokens');
45
- if (t.tokenFile) {
46
- rule(`Design tokens live in \`${t.tokenFile}\`. Reach for an existing token before inventing any value.`);
47
- if (tokenCount && strays.length) rule(`Never hardcode colour values in components. The palette already has ${tokenCount} tokens; the scan still found ${strays.length} hardcoded colours sitting next to them. Do not add more.`);
48
- else rule('Never hardcode colour values in components; add a token first if one is genuinely missing.');
49
- } else if ((t.colors ?? []).length) {
50
- rule(`There is no token file yet. Until one exists, reuse the colours already in the codebase instead of introducing new ones (${t.colors.length} distinct colours are already in play).`);
51
- }
52
- const ds = h.profile?.designSystem;
53
- if (ds?.kind && !['none', 'custom'].includes(ds.kind) && ds.confidence !== 'low') {
54
- rule(`This repo uses ${ds.name ?? ds.kind}${h.profile.uiDir ? `; its components live in \`${h.profile.uiDir}\`` : ''}. Prefer extending it over building parallel pieces.`);
55
- }
56
- }
57
-
58
- // ---------- canonical components ----------
59
- // Skip names that shadow framework components (their usage counts absorb the
60
- // framework's) and names defined in more than one file (ambiguous, and the
61
- // duplicates section already handles them).
62
- const FRAMEWORK_NAMES = new Set(['Link', 'Image', 'Head', 'Script', 'Form']);
63
- const nameCounts = new Map();
64
- for (const c of reusable) nameCounts.set(c.name, (nameCounts.get(c.name) ?? 0) + 1);
65
- const top = reusable
66
- .filter((c) => c.usageCount > 0 && !FRAMEWORK_NAMES.has(c.name) && nameCounts.get(c.name) === 1)
67
- .sort((a, b) => b.usageCount - a.usageCount).slice(0, 8);
68
- if (top.length) {
69
- section('Canonical components');
70
- rule('Use these existing components instead of writing new ones:');
71
- for (const c of top) {
72
- const props = c.propsHint?.named?.length ? ` ยท props: ${c.propsHint.named.slice(0, 4).join(', ')}` : '';
73
- lines.push(` - \`<${c.name}>\` from \`${c.file}\` (used ${c.usageCount}x${props})`);
74
- }
75
- }
76
-
77
- // ---------- duplicates to avoid ----------
78
- const dupes = h.duplicates?.exactDuplicates ?? [];
79
- const icons = h.duplicates?.iconCollisions ?? [];
80
- if (dupes.length || icons.length) {
81
- section('Known duplicates: do not make it worse');
82
- // No per-file usage data here, so rank by how shared the location looks;
83
- // when nothing stands out, do not guess a canonical.
84
- const sharedScore = (f) => (f.includes('packages/') ? 4 : 0) + (/\/ui\//.test(f) ? 2 : 0) + (f.includes('components/') ? 1 : 0) - (/\bapp\//.test(f) ? 2 : 0) - (f.includes('/icons/') ? 4 : 0);
85
- for (const d of dupes.slice(0, 6)) {
86
- const files = d.files.map((f) => (typeof f === 'string' ? f : f.file));
87
- const ranked = [...files].sort((a, b) => sharedScore(b) - sharedScore(a));
88
- const clear = sharedScore(ranked[0]) > sharedScore(ranked[1]);
89
- if (d.wrapped) {
90
- rule(`\`<${d.name}>\` is defined twice and one wraps the other${clear ? `. Import \`${ranked[0]}\`` : ''}; do not create a third.`);
91
- } else if (clear) {
92
- rule(`\`<${d.name}>\` exists in ${files.length} places. Treat \`${ranked[0]}\` as canonical; do not import the other ${files.length === 2 ? 'copy' : 'copies'}, and never create another.`);
93
- } else {
94
- rule(`\`<${d.name}>\` exists in ${files.length} places (${files.slice(0, 2).map((f) => `\`${f}\``).join(', ')}). Match whichever the surrounding code already imports, and never create another.`);
95
- }
96
- }
97
- if (icons.length) rule(`Two icon sets collide on ${icons.length} name${icons.length === 1 ? '' : 's'}. Before adding any icon, check which set the surrounding file already imports and stay with it.`);
98
- }
99
-
100
- // ---------- spacing ----------
101
- const arbitrary = t.tailwind?.arbitrary ?? [];
102
- const arbCount = arbitrary.reduce((s, a) => s + a.count, 0);
103
- const offScale = (t.spacing ?? []).length;
104
- if (t.tailwind?.spacing?.length || offScale || arbCount) {
105
- section('Spacing and sizing');
106
- if (t.tailwind?.spacing?.length) rule('Stay on the Tailwind spacing scale. If a gap looks wrong on a scale step, flag it instead of nudging by a pixel.');
107
- if (arbCount) rule(`No new arbitrary bracket values (\`p-[13px]\`, \`text-[10px]\`). The scan found ${arbCount} already. If a value repeats, it is a decision: name it as a token instead of writing the bracket again.`);
108
- if (offScale) rule(`Avoid new one-off CSS spacing values; ${offScale} off-scale value${offScale === 1 ? ' is' : 's are'} already in play.`);
109
- }
110
-
111
- // ---------- typography ----------
112
- const faces = distinctTypefaces(t.fontFamilies ?? []);
113
- if (faces.length) {
114
- section('Typography');
115
- rule(`The repo uses ${faces.length} typeface${faces.length === 1 ? '' : 's'}: ${faces.slice(0, 4).join(', ')}. Do not introduce another, and do not re-declare font stacks by hand; use the existing setup.`);
116
- }
117
-
118
- // ---------- styling discipline ----------
119
- section('Styling discipline');
120
- rule(`Never write ${reusable.length ? '`style={{ ... }}`' : 'inline `style="..."` attributes'} for static values; styling belongs to classes and tokens where the system can see it.`);
121
- if ((t.inlineStyles?.count ?? 0) > 0) lines.push(` (${t.inlineStyles.count} static inline block${t.inlineStyles.count === 1 ? '' : 's'} already exist; do not add to them.)`);
122
- rule('Before styling anything new, look at a neighbouring component and match how it does it. Consistency with the repo beats personal preference.');
123
-
124
- lines.push('');
125
- lines.push('---');
126
- lines.push('*Generated by [roast-my-design-system](https://github.com/pencilrebel/roast-my-design-system). Rescan after refactors to keep these rules honest.*');
127
- lines.push('');
128
-
129
- writeFileSync(outPath, lines.join('\n'));
130
- const ruleCount = lines.filter((l) => l.startsWith('- ')).length;
131
- console.log(`โœ“ Agent rules for ${repoName}: ${ruleCount} rules, all with receipts`);
24
+ const { text, ruleCount } = rulesMarkdown(h);
25
+ writeFileSync(outPath, text);
26
+ console.log(`โœ“ Agent rules for ${h.profile?.name ?? 'this repo'}: ${ruleCount} rules, all with receipts`);
132
27
  console.log(` โ†’ ${outPath}`);