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
|
-

|
|
28
28
|
|
|
29
29
|
The same report in light mode (one file, built-in toggle):
|
|
30
30
|
|
|
31
|
-

|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
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}`);
|