roast-my-design-system 3.7.1 → 3.8.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
|
|
|
@@ -132,4 +132,4 @@ MIT. The code is yours to fork, modify and redistribute; the copyright notice tr
|
|
|
132
132
|
|
|
133
133
|
**roast-my-design-system**™ and the GK mark are trademarks of Greg Kozakiewicz. Forking is welcome, republishing under this name is not: see [brand and attribution](https://pencilrebel.github.io/roast-my-design-system/brand.html).
|
|
134
134
|
|
|
135
|
-
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.
|
|
135
|
+
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.8.0"><img src="https://raw.githubusercontent.com/pencilrebel/roast-my-design-system/main/assets/gk-mark.png?v=3.8.0" height="15" alt="GK mark"></picture> Greg Kozakiewicz</a>.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roast-my-design-system",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
4
4
|
"description": "Roast your design system with real data. A deterministic, zero-dependency scanner that counts everything that betrays a design system, from colours and their near-identical twins to greys, spacing values, typefaces, duplicated and never-imported components, inline styles, !important and arbitrary Tailwind values, 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",
|
|
@@ -36,7 +36,7 @@ const GK_MASK = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqa
|
|
|
36
36
|
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==';
|
|
37
37
|
|
|
38
38
|
// Shown in the report footer; keep in step with plugin.json when releasing.
|
|
39
|
-
const VERSION = '3.
|
|
39
|
+
const VERSION = '3.8.0';
|
|
40
40
|
|
|
41
41
|
// Report identity. The scan id is a stable digest of this scan's shape, salted
|
|
42
42
|
// with the report namespace, so the same repo scanned twice reads the same and
|
|
@@ -321,7 +321,7 @@ function tile(value, label, metric, fallbackTarget, healthValue = value) {
|
|
|
321
321
|
const av = displayAvg(metric);
|
|
322
322
|
if (av !== null) rows.push(row('Avg Design System', `${n(av)}${avgNote}`, av, value));
|
|
323
323
|
if (rm !== null && (rm > 0 || ZERO_IDEAL.has(metric) || metric === 'arbitrary')) rows.push(row('Reputable systems', n(rm), rm, value));
|
|
324
|
-
return { num: n(value), label, health, rows };
|
|
324
|
+
return { num: n(value), label, health, rows, metric, healthValue };
|
|
325
325
|
}
|
|
326
326
|
const bigStats = [
|
|
327
327
|
tile(colors.length, 'distinct colours', 'colors', 'a system needs ~24', tokenLed ? colorStrays : colors.length),
|
|
@@ -342,6 +342,42 @@ const healthScore = scoredTiles.length
|
|
|
342
342
|
? Math.round(scoredTiles.reduce((sum, s) => sum + SCORE_OF[s.health], 0) / scoredTiles.length)
|
|
343
343
|
: null;
|
|
344
344
|
|
|
345
|
+
// ---------- what a fix is worth ----------
|
|
346
|
+
// The score is the average of the scored tiles, so moving one tile across a
|
|
347
|
+
// band is worth an exact number of points: red to green 10, red to amber 5,
|
|
348
|
+
// amber to green 5 (on nine tiles). Deltas are only claimed when a move
|
|
349
|
+
// actually crosses a band — a cleanup that lands inside the same band is real
|
|
350
|
+
// work worth zero points, and saying otherwise would be a lie.
|
|
351
|
+
const TILE_COUNT = scoredTiles.length || 1;
|
|
352
|
+
const scoreOfHealth = (hh) => SCORE_OF[hh] ?? 0;
|
|
353
|
+
function bandPoints(metric, from, to) {
|
|
354
|
+
const a = healthOf(metric, from), b = healthOf(metric, to);
|
|
355
|
+
if (!(a in SCORE_OF) || !(b in SCORE_OF)) return 0;
|
|
356
|
+
return Math.round((scoreOfHealth(b) - scoreOfHealth(a)) / TILE_COUNT);
|
|
357
|
+
}
|
|
358
|
+
// The nearest number that moves this metric up a band, and what it pays.
|
|
359
|
+
function nextBand(metric, value) {
|
|
360
|
+
const iv = ideal(metric);
|
|
361
|
+
if (iv === null) return null;
|
|
362
|
+
const cur = healthOf(metric, value);
|
|
363
|
+
if (cur === 'good') return null;
|
|
364
|
+
if (cur === 'warn') {
|
|
365
|
+
const target = ZERO_IDEAL.has(metric) ? 0 : iv;
|
|
366
|
+
return { target, gain: bandPoints(metric, value, target) };
|
|
367
|
+
}
|
|
368
|
+
const warnTarget = ZERO_IDEAL.has(metric) ? WARN_TOLERANCE[metric] : Math.min(median(metric) ?? iv * 1.5, iv * 1.5);
|
|
369
|
+
return { target: Math.round(warnTarget), gain: bandPoints(metric, value, Math.round(warnTarget)) };
|
|
370
|
+
}
|
|
371
|
+
// The score this repo would have with a set of metrics at new values.
|
|
372
|
+
function projectedScore(applied) {
|
|
373
|
+
const scored = bigStats.filter((st) => st.health in SCORE_OF);
|
|
374
|
+
if (!scored.length) return healthScore;
|
|
375
|
+
return Math.round(scored.reduce((sum, st) => {
|
|
376
|
+
const v = applied.has(st.metric) ? applied.get(st.metric) : st.healthValue;
|
|
377
|
+
return sum + scoreOfHealth(healthOf(st.metric, v));
|
|
378
|
+
}, 0) / scored.length);
|
|
379
|
+
}
|
|
380
|
+
|
|
345
381
|
// A repo with essentially no colour/spacing signal most likely has no design
|
|
346
382
|
// system in it at all; say that up front instead of quietly scoring zeros.
|
|
347
383
|
const noSystemLikely = colors.length === 0 || (colors.length < 3 && spacingTotal === 0);
|
|
@@ -459,6 +495,20 @@ function duplicatesSection() {
|
|
|
459
495
|
<div class="fams">${iconCard}${dupeCards}${famCards}</div></div>`;
|
|
460
496
|
}
|
|
461
497
|
|
|
498
|
+
// Scanned values get injected into style attributes below (a radius, a shadow,
|
|
499
|
+
// a font size from someone else's repo). Allow only a conservative CSS charset
|
|
500
|
+
// so a malformed or hostile value cannot break out of the attribute.
|
|
501
|
+
const cssSafe = (v) => (typeof v === 'string' && /^[-#0-9a-z%.,()\s\/]+$/i.test(v) && !/[;{}<>"']/.test(v) ? v.trim() : null);
|
|
502
|
+
// px equivalent of a length, for sorting and for rendering type at real size.
|
|
503
|
+
function toPx(v) {
|
|
504
|
+
const m = /^(-?\d*\.?\d+)\s*(px|rem|em|pt)?$/i.exec(String(v).trim());
|
|
505
|
+
if (!m) return null;
|
|
506
|
+
const num = parseFloat(m[1]);
|
|
507
|
+
const unit = (m[2] ?? 'px').toLowerCase();
|
|
508
|
+
if (!Number.isFinite(num) || num <= 0) return null;
|
|
509
|
+
return unit === 'px' ? num : unit === 'pt' ? num * 1.333 : num * 16;
|
|
510
|
+
}
|
|
511
|
+
|
|
462
512
|
function typographySection() {
|
|
463
513
|
if (!fontFamilies.length && !fontSizeTotal && !radiiTotal) return '';
|
|
464
514
|
const mini = [
|
|
@@ -475,13 +525,59 @@ function typographySection() {
|
|
|
475
525
|
.sort((a, b) => b.count - a.count).slice(0, 14);
|
|
476
526
|
const receipts = (title, chips) => chips.length < 2 ? '' :
|
|
477
527
|
`<div class="receipts">${eyebrow(title)}<div class="chips-row">${chips.map((c) => `<span class="vchip">${esc(String(c.label))} ×${c.count}</span>`).join('')}</div></div>`;
|
|
528
|
+
|
|
529
|
+
// ---------- specimens: the values rendered, not counted ----------
|
|
530
|
+
// Counting says "47 font sizes". Rendering them says it better: the eye
|
|
531
|
+
// finds the near-duplicates on its own, the way the grey ramp works.
|
|
532
|
+
const typeSpecimens = (() => {
|
|
533
|
+
const rows = (h.tokens.fontSizes ?? [])
|
|
534
|
+
.map((f) => ({ label: f.value, count: f.count, px: toPx(f.value) }))
|
|
535
|
+
.filter((f) => f.px && f.px >= 6 && f.px <= 200)
|
|
536
|
+
.sort((a, b) => b.px - a.px);
|
|
537
|
+
if (rows.length < 4) return '';
|
|
538
|
+
const shown = rows.slice(0, 16);
|
|
539
|
+
const capped = shown.some((r) => r.px > 52);
|
|
540
|
+
return `<div class="spec">${eyebrow(`the type scale, at its real sizes${capped ? ' · the largest are capped to fit' : ''} · how many of these are the same decision?`)}
|
|
541
|
+
<div class="spec-rows">${shown.map((r) => `
|
|
542
|
+
<div class="spec-type">
|
|
543
|
+
<span class="spec-val">${esc(r.label)} ×${r.count}</span>
|
|
544
|
+
<span class="spec-sample" style="font-size:${Math.min(r.px, 52)}px">Almost but not quite</span>
|
|
545
|
+
</div>`).join('')}</div>
|
|
546
|
+
${rows.length > 16 ? `<div class="spec-more">and ${rows.length - 16} more</div>` : ''}</div>`;
|
|
547
|
+
})();
|
|
548
|
+
|
|
549
|
+
const radiiSpecimens = (() => {
|
|
550
|
+
const cells = (h.tokens.radii ?? [])
|
|
551
|
+
.map((r) => ({ label: r.value, count: r.count, css: cssSafe(r.value) }))
|
|
552
|
+
.filter((r) => r.css && !/^0$|^0px$/.test(r.css) && !/var\(|calc\(/i.test(r.css))
|
|
553
|
+
.slice(0, 14);
|
|
554
|
+
if (cells.length < 3) return '';
|
|
555
|
+
return `<div class="spec">${eyebrow(`the corners, drawn · ${n(radiiTotal)} radii in one interface`)}
|
|
556
|
+
<div class="spec-grid">${cells.map((r) => `
|
|
557
|
+
<div class="spec-cell"><div class="spec-box" style="border-radius:${r.css}"></div>
|
|
558
|
+
<span class="spec-cap">${esc(r.label)} ×${r.count}</span></div>`).join('')}</div></div>`;
|
|
559
|
+
})();
|
|
560
|
+
|
|
561
|
+
const shadowSpecimens = (() => {
|
|
562
|
+
const cells = (h.tokens.shadows ?? [])
|
|
563
|
+
.map((sh) => ({ label: sh.value, count: sh.count, css: cssSafe(sh.value) }))
|
|
564
|
+
.filter((sh) => sh.css && !/^(none|initial|inherit|unset|revert)$/i.test(sh.css) && !/var\(/i.test(sh.css))
|
|
565
|
+
.slice(0, 10);
|
|
566
|
+
if (cells.length < 2) return '';
|
|
567
|
+
const short = (v) => (v.length > 20 ? `${v.slice(0, 19)}…` : v);
|
|
568
|
+
return `<div class="spec">${eyebrow(`the elevations, cast${cells.length < shadows.length ? `, ${cells.length} of ${n(shadows.length)}` : ` · ${n(shadows.length)} shadow styles`} · on the light surface they were drawn for`)}
|
|
569
|
+
<div class="spec-grid spec-shadows">${cells.map((sh) => `
|
|
570
|
+
<div class="spec-cell"><div class="spec-card" style="box-shadow:${sh.css}"></div>
|
|
571
|
+
<span class="spec-cap" title="${esc(sh.label)}">${esc(short(sh.label))} ×${sh.count}</span></div>`).join('')}</div></div>`;
|
|
572
|
+
})();
|
|
478
573
|
const fams = fontFamilies.slice(0, 8).map((f) =>
|
|
479
574
|
`<div class="mini-card"><span class="mc-path">${esc(f.value.slice(0, 70))}</span><span class="pill${f.count > 2 ? ' pill-coral' : ' pill-mint'}">×${f.count}</span></div>`).join('');
|
|
480
575
|
return `<section>
|
|
481
576
|
${sectionHead('Typography & shape', '')}
|
|
482
577
|
<div class="stats minis">${mini.map((s) => statTile(s)).join('')}</div>
|
|
483
|
-
${receipts('the font sizes, by use', sizeChips)}
|
|
484
|
-
${receipts('the radii, by use', radiiChips)}
|
|
578
|
+
${typeSpecimens || receipts('the font sizes, by use', sizeChips)}
|
|
579
|
+
${radiiSpecimens || receipts('the radii, by use', radiiChips)}
|
|
580
|
+
${shadowSpecimens}
|
|
485
581
|
${fontFamilies.length > 1 ? `<div class="glass pad" style="margin-top:16px">${sectionHead(`${typefaces.length} typeface${typefaces.length === 1 ? '' : 's'}, declared ${fontFamilies.length} different ways`, 'every distinct declaration is a chance for the next one to be wrong')}<div class="fam-rows">${fams}</div></div>` : ''}
|
|
486
582
|
</section>`;
|
|
487
583
|
}
|
|
@@ -572,7 +668,7 @@ function giftSection() {
|
|
|
572
668
|
|
|
573
669
|
function whereToStartSection() {
|
|
574
670
|
const c = [];
|
|
575
|
-
if (agentFiles.length === 0) c.push({ score: 60, title: 'Write the agent rules file',
|
|
671
|
+
if (agentFiles.length === 0) c.push({ score: 60, metric: null, title: 'Write the agent rules file',
|
|
576
672
|
sub: `No CLAUDE.md, no AGENTS.md. One page naming the canonical components and the tokens file stops your agent guessing on every UI change. Cheapest fix on this list.` });
|
|
577
673
|
if (hardDupes.length > 0) {
|
|
578
674
|
// Pick the pair with the strongest copy-paste evidence: sibling files in
|
|
@@ -588,10 +684,11 @@ function whereToStartSection() {
|
|
|
588
684
|
return sc;
|
|
589
685
|
};
|
|
590
686
|
const d = [...hardDupes].sort((a, b) => strength(b) - strength(a))[0];
|
|
591
|
-
c.push({ score: 25 + hardDupes.length * 4,
|
|
687
|
+
c.push({ score: 25 + hardDupes.length * 4, metric: 'exactDuplicates', after: hardDupes.length - 1,
|
|
688
|
+
title: `Crown the canonical <${esc(d.name)}>`,
|
|
592
689
|
sub: `${hardDupes.length} component name${hardDupes.length > 1 ? 's have' : ' has'} competing implementations. Start with <${esc(d.name)}>: ${esc(d.files[0])} vs ${esc(d.files[1] ?? '')}. Decide which one is canonical, re-export it from one home, and rename or fold in the other.` });
|
|
593
690
|
}
|
|
594
|
-
if (iconCollisions.length >= 5) c.push({ score: 20 + iconCollisions.length * 3, title: 'Merge the two icon sets',
|
|
691
|
+
if (iconCollisions.length >= 5) c.push({ score: 20 + iconCollisions.length * 3, metric: null, title: 'Merge the two icon sets',
|
|
595
692
|
sub: `${iconCollisions.length} icon names exist in both sets, so every import is a coin flip. Pick one home and rename or delete the rest. Receipt: ${esc(iconCollisions[0].files[0])} vs ${esc(iconCollisions[0].files[1])}.` });
|
|
596
693
|
const strayOffender = offenders.find((o) => o.strayColors > 0);
|
|
597
694
|
if (colorStrays > 12) {
|
|
@@ -601,13 +698,14 @@ function whereToStartSection() {
|
|
|
601
698
|
const besideTokens = strayOffender && tf && (strayOffender.file === tf
|
|
602
699
|
|| strayOffender.file.split('/').slice(0, -1).join('/') === tf.split('/').slice(0, -1).join('/')
|
|
603
700
|
|| /variables|tokens|theme/i.test(strayOffender.file));
|
|
604
|
-
c.push({ score: colorStrays, title: `Tokenise the ${n(colorStrays)} stray colours`,
|
|
701
|
+
c.push({ score: colorStrays, metric: 'colors', after: 0, title: `Tokenise the ${n(colorStrays)} stray colours`,
|
|
605
702
|
sub: `They are hardcoded where no token names them${strayOffender ? (besideTokens
|
|
606
703
|
? `, including ${strayOffender.strayColors} in ${esc(strayOffender.file)}, sitting right next to the token definitions. Those are the cheapest wins on this page`
|
|
607
704
|
: `; ${esc(strayOffender.file)} alone carries ${strayOffender.strayColors}`) : ''}. Every one is a value your agent will happily copy.` });
|
|
608
705
|
}
|
|
609
706
|
if (inline.count > 10) { const worst = inline.files[0];
|
|
610
|
-
c.push({ score: inline.count / 2,
|
|
707
|
+
c.push({ score: inline.count / 2, metric: 'inlineStyles', after: 0,
|
|
708
|
+
title: `Fold ${n(inline.count)} inline style blocks back into the system`,
|
|
611
709
|
sub: `These are static values written as style attributes${worst ? `; start with ${esc(worst.file)} (${worst.count} blocks)` : ''}. Dynamic positioning was already excluded, so all of these could be classes or tokens today.` }); }
|
|
612
710
|
if (arbitraryCount >= 20) {
|
|
613
711
|
// Repeated brackets read as decisions; singletons are worth a pass. Small
|
|
@@ -618,7 +716,10 @@ function whereToStartSection() {
|
|
|
618
716
|
const singles = arbitrary.filter((a) => a.count === 1).length;
|
|
619
717
|
const top = arbitrary[0];
|
|
620
718
|
const tokenFile = h.tokens.tokenFile;
|
|
621
|
-
|
|
719
|
+
// naming the repeats removes their counts; the one-offs are left alone on purpose
|
|
720
|
+
const afterNaming = arbitraryCount - repeats.reduce((sum, a) => sum + a.count, 0);
|
|
721
|
+
c.push({ score: arbitraryCount / 3, metric: 'arbitrary', after: afterNaming,
|
|
722
|
+
title: `Give your repeated bracket values names`,
|
|
622
723
|
sub: repeats.length
|
|
623
724
|
? `${esc(top.value)} appears ${top.count} times, which reads as a decision, not drift. If it is one, name it${tokenFile ? ` in ${esc(tokenFile)}` : ' in the scale'} so the next component (and your agent) can reach for it${repeats.length > 1 ? `; ${repeats.length - 1} more repeated value${repeats.length > 2 ? 's' : ''} deserve the same look` : ''}. The ${singles} values used once are worth a pass: keep the deliberate ones (optical nudges${nudges ? ` like the ${nudges} under 4px` : ''}, one-off layout widths) and round the accidents to a neighbouring step.`
|
|
624
725
|
: `${n(arbitraryCount)} bracket values sit outside the scale, almost all used once. Keep the deliberate ones (optical nudges, one-off widths) and round the rest to a neighbouring step.` });
|
|
@@ -628,21 +729,66 @@ function whereToStartSection() {
|
|
|
628
729
|
// the first move is to define it, next to the tokens the repo already has.
|
|
629
730
|
const hasScale = twSpacing.length >= 5;
|
|
630
731
|
const tf = h.tokens.tokenFile;
|
|
631
|
-
c.push({ score: spacingTotal / 2, title: hasScale
|
|
732
|
+
c.push({ score: spacingTotal / 2, metric: 'spacing', title: hasScale
|
|
632
733
|
? `Fold ${n(spacingTotal)} off-scale spacing values back to the scale`
|
|
633
734
|
: `Define a spacing scale, then fold ${n(spacingTotal)} values into it`,
|
|
634
735
|
sub: hasScale
|
|
635
736
|
? `${n(spacingTotal)} values sit outside the scale you already use. Keep the deliberate exceptions and round the accidents to a neighbouring step.`
|
|
636
737
|
: `${n(spacingTotal)} distinct spacing values and no named scale to hold them. Define ~8 steps${tf ? ` in ${esc(tf)}, next to the tokens you already keep there` : ''}, then migrate values as you touch each file. No mass rounding.` });
|
|
637
738
|
}
|
|
638
|
-
|
|
739
|
+
if (nearPairs.length >= 3) {
|
|
740
|
+
const p0 = nearPairs[0];
|
|
741
|
+
c.push({ score: 30 + nearPairs.length * 2, metric: 'nearPairs', after: 0,
|
|
742
|
+
title: `Collapse the ${n(nearPairs.length)} near-identical colours`,
|
|
743
|
+
sub: `${esc(p0.a.value)} and ${esc(p0.b.value)} are the same colour to any eye${p0.a.count + p0.b.count > 3 ? `, and they are used ${n(p0.a.count + p0.b.count)} times between them` : ''}. Nobody chose to have both. Pick the one that is already a token, point the other at it, and the pair stops multiplying.` });
|
|
744
|
+
}
|
|
745
|
+
if (important.count >= 10) {
|
|
746
|
+
const worst = important.files[0];
|
|
747
|
+
c.push({ score: 20 + important.count, metric: 'important', after: 0,
|
|
748
|
+
title: `Unwind the ${n(important.count)} !important declarations`,
|
|
749
|
+
sub: `Each one is a selector losing an argument with another selector${worst ? `; ${esc(basename(worst.file))} alone carries ${worst.count}` : ''}. Fix the specificity at the source and they stop being necessary.` });
|
|
750
|
+
}
|
|
751
|
+
if (neverImported.length >= 3) {
|
|
752
|
+
c.push({ score: 15 + neverImported.length, metric: 'neverImported', after: 0,
|
|
753
|
+
title: `Decide about the ${n(neverImported.length)} components nobody imports`,
|
|
754
|
+
sub: `${neverImported.slice(0, 3).map((x) => `<${esc(x.name)}>`).join(', ')}${neverImported.length > 3 ? ' and others' : ''} sit in the system with no callers. Adopt them or delete them: either answer is better than a system with rooms nobody enters.` });
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// What each move is actually worth, then rank by payoff for real.
|
|
758
|
+
for (const item of c) {
|
|
759
|
+
item.delta = 0; item.target = null;
|
|
760
|
+
if (!item.metric) continue;
|
|
761
|
+
const now = bigStats.find((st) => st.metric === item.metric)?.healthValue;
|
|
762
|
+
if (now === undefined) continue;
|
|
763
|
+
if (item.after !== undefined) {
|
|
764
|
+
const gain = bandPoints(item.metric, now, item.after);
|
|
765
|
+
if (gain > 0) { item.delta = gain; continue; }
|
|
766
|
+
}
|
|
767
|
+
const nb = nextBand(item.metric, now);
|
|
768
|
+
if (nb && nb.gain > 0) item.target = nb;
|
|
769
|
+
}
|
|
770
|
+
const top3 = c.sort((a, b) => (b.delta - a.delta) || (b.score - a.score)).slice(0, 3);
|
|
639
771
|
if (!top3.length) return '';
|
|
772
|
+
// The headline is the score with every move applied together, not the sum of
|
|
773
|
+
// the parts: two moves on the same tile must not be counted twice.
|
|
774
|
+
const applied = new Map();
|
|
775
|
+
for (const item of top3) if (item.metric && item.after !== undefined && item.delta > 0) applied.set(item.metric, item.after);
|
|
776
|
+
const after = projectedScore(applied);
|
|
777
|
+
const words = ['One tweak', 'Two tweaks', 'Three tweaks'][top3.length - 1];
|
|
778
|
+
const head = healthScore !== null && after > healthScore
|
|
779
|
+
? `${words} · <b class="proj">${healthScore} → ${after}</b>`
|
|
780
|
+
: `${words} to increase your score.`;
|
|
781
|
+
const chip = (item) => item.delta > 0
|
|
782
|
+
? `<span class="delta">+${item.delta}</span>`
|
|
783
|
+
: item.target
|
|
784
|
+
? `<span class="delta delta-target">${item.target.target === 0 ? 'clear them all' : `under ${n(item.target.target)}`} · +${item.target.gain}</span>`
|
|
785
|
+
: '';
|
|
640
786
|
return `<section class="glass pad">
|
|
641
|
-
${sectionHead('Where to start',
|
|
787
|
+
${sectionHead('Where to start', head)}
|
|
642
788
|
<div class="ledger">${top3.map((item, i) => `
|
|
643
789
|
<div class="ledger-row start-row">
|
|
644
790
|
<span class="ledger-idx">${String(i + 1).padStart(2, '0')}</span>
|
|
645
|
-
<div class="start-body"><div class="start-title">${item.title}</div><div class="sub">${item.sub}</div></div>
|
|
791
|
+
<div class="start-body"><div class="start-head"><div class="start-title">${item.title}</div>${chip(item)}</div><div class="sub">${item.sub}</div></div>
|
|
646
792
|
</div>`).join('')}</div></section>`;
|
|
647
793
|
}
|
|
648
794
|
|
|
@@ -885,6 +1031,26 @@ const html = `<!doctype html>
|
|
|
885
1031
|
footer .brand { color:var(--accent); font-weight:700; text-decoration:none; }
|
|
886
1032
|
footer .brand:hover { text-decoration:underline; }
|
|
887
1033
|
footer .creds { display:flex; gap:16px; font:700 9.5px/1.6 var(--sans); letter-spacing:.16em; text-transform:uppercase; color:var(--dim2); }
|
|
1034
|
+
.spec { margin-top:18px; }
|
|
1035
|
+
.spec-rows { display:grid; gap:2px; margin-top:8px; }
|
|
1036
|
+
.spec-type { display:flex; align-items:baseline; gap:16px; padding:5px 0; border-bottom:1px solid var(--line-soft); }
|
|
1037
|
+
.spec-type:last-child { border-bottom:0; }
|
|
1038
|
+
.spec-val { font:600 11px/1.4 var(--mono); color:var(--dim2); min-width:104px; flex-shrink:0; }
|
|
1039
|
+
.spec-sample { color:var(--text); line-height:1.15; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
|
|
1040
|
+
font-family:var(--disp); letter-spacing:-.01em; }
|
|
1041
|
+
.spec-more { font:600 10.5px/1 var(--mono); color:var(--dim2); margin-top:10px; }
|
|
1042
|
+
.spec-grid { display:flex; flex-wrap:wrap; gap:16px; margin-top:12px; }
|
|
1043
|
+
.spec-cell { display:grid; gap:7px; justify-items:center; }
|
|
1044
|
+
.spec-box { width:46px; height:46px; background:var(--deep); box-shadow:inset 0 0 0 1px var(--cell-ring); }
|
|
1045
|
+
.spec-shadows { background:#f2f2f5; border-radius:14px; padding:20px 18px 14px; gap:22px; }
|
|
1046
|
+
.spec-card { width:84px; height:50px; border-radius:10px; background:#ffffff; }
|
|
1047
|
+
.spec-shadows .spec-cap { color:#71718a; max-width:104px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
1048
|
+
.spec-cap { font:500 10.5px/1 var(--mono); color:var(--dim2); }
|
|
1049
|
+
.start-head { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; }
|
|
1050
|
+
.delta { font:700 11px/1 var(--sans); letter-spacing:.04em; padding:5px 9px; border-radius:99px;
|
|
1051
|
+
background:var(--ok-soft); color:var(--ok); white-space:nowrap; }
|
|
1052
|
+
.delta-target { background:var(--amber-soft); color:var(--amber); font-weight:600; }
|
|
1053
|
+
.proj { color:var(--accent); font-weight:700; }
|
|
888
1054
|
.ndot { display:inline-block; width:9px; height:9px; border-radius:3px; margin-right:4px; vertical-align:-1px; box-shadow:inset 0 0 0 1px var(--cell-ring); }
|
|
889
1055
|
.nsim { color:var(--dim2); font-weight:400; padding:0 2px; }
|
|
890
1056
|
.gift-sec { margin-top:16px; }
|
|
@@ -975,8 +1141,8 @@ ${componentsSection()}
|
|
|
975
1141
|
|
|
976
1142
|
<footer>
|
|
977
1143
|
<div class="foot-left">
|
|
978
|
-
<div>Generated by <a class="brand" href="https://github.com/pencilrebel/roast-my-design-system">roast-my-design-system</a>,
|
|
979
|
-
<div class="foot-sub"
|
|
1144
|
+
<div>Generated by <a class="brand" href="https://github.com/pencilrebel/roast-my-design-system">roast-my-design-system</a> <span class="ver">ver. ${VERSION}</span>, free on npm and as a Claude Code + Codex skill</div>
|
|
1145
|
+
<div class="foot-sub">Designed and built by <a class="author" href="https://gregkozakiewicz.com"><span class="gk-mark"></span>Greg Kozakiewicz</a> · <span class="scan-id" title="scan id">${scanId}</span></div>
|
|
980
1146
|
</div>
|
|
981
1147
|
<span class="creds"><span>Non-destructive scan</span><span>Read-only</span><span>Paths are real</span></span>
|
|
982
1148
|
</footer>
|