roast-my-design-system 9.6.0 → 9.7.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.
Files changed (23) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/skills/roast-my-design-system/scripts/diagnose/index.mjs +275 -37
  4. package/skills/roast-my-design-system/scripts/harvest/coloruse.mjs +856 -0
  5. package/skills/roast-my-design-system/scripts/harvest/index.mjs +57 -3
  6. package/skills/roast-my-design-system/scripts/harvest/tokens.mjs +16 -10
  7. package/skills/roast-my-design-system/scripts/harvest/walk.mjs +14 -4
  8. package/skills/roast-my-design-system/scripts/lib/fixprompt.mjs +13 -2
  9. package/skills/roast-my-design-system/scripts/lib/kitlist.mjs +60 -0
  10. package/skills/roast-my-design-system/scripts/lib/kitpaint.mjs +142 -8
  11. package/skills/roast-my-design-system/scripts/lib/palette.mjs +28 -11
  12. package/skills/roast-my-design-system/scripts/lib/version.mjs +1 -1
  13. package/skills/roast-my-design-system/scripts/mcp/engine.mjs +3 -1
  14. package/skills/roast-my-design-system/scripts/mcp/tools.mjs +57 -4
  15. package/skills/roast-my-design-system/scripts/profiles/antd.mjs +4 -0
  16. package/skills/roast-my-design-system/scripts/profiles/chakra.mjs +3 -0
  17. package/skills/roast-my-design-system/scripts/profiles/index.mjs +16 -3
  18. package/skills/roast-my-design-system/scripts/profiles/kit-common.mjs +118 -12
  19. package/skills/roast-my-design-system/scripts/profiles/mantine.mjs +3 -0
  20. package/skills/roast-my-design-system/scripts/profiles/mui.mjs +4 -0
  21. package/skills/roast-my-design-system/scripts/profiles/shadcn.mjs +25 -6
  22. package/skills/roast-my-design-system/scripts/profiles/tailwind.mjs +10 -5
  23. package/skills/roast-my-design-system/scripts/rules/build.mjs +50 -8
package/README.md CHANGED
@@ -142,11 +142,11 @@ Eleven reports, hosted exactly as the tool writes them. Every number determinist
142
142
 
143
143
  The full report for vercel/ai-chatbot. The verdict answers what the agent will learn here, the gap section comes first, then "What the repo teaches the agent", Claude's read of the scan:
144
144
 
145
- ![The full diagnosis report for vercel/ai-chatbot in dark mode: a fixed side panel with the health score and what it measures, the stack, how the repo was read as a shadcn install, an index of every section and what is not the team's and not counted; then where the agent will have to guess, the fixes you can make right now, each with what it is worth and its copy-the-fix-prompt button, the wrapped present with the agent rules, an agent trap callout, 3-yardstick tiles including the 2 shadcn tiles, the adoption map treemap, palette forensics, the shadcn theme variable by variable, spacing receipts, typography specimens, offenders, duplicates, and the component usage ledger](assets/report-full-dark.png?v=9.6.0)
145
+ ![The full diagnosis report for vercel/ai-chatbot in dark mode: a fixed side panel with the health score and what it measures, the stack, how the repo was read as a shadcn install, an index of every section and what is not the team's and not counted; then where the agent will have to guess, the fixes you can make right now, each with what it is worth and its copy-the-fix-prompt button, the wrapped present with the agent rules, an agent trap callout, 3-yardstick tiles including the 2 shadcn tiles, the adoption map treemap, palette forensics, the shadcn theme variable by variable, spacing receipts, typography specimens, offenders, duplicates, and the component usage ledger](assets/report-full-dark.png?v=9.7.0)
146
146
 
147
147
  The same report in light mode (one file, built-in toggle):
148
148
 
149
- ![The diagnosis report in light mode](assets/report-light-hero.png?v=9.6.0)
149
+ ![The diagnosis report in light mode](assets/report-light-hero.png?v=9.7.0)
150
150
 
151
151
  ## Why the numbers hold
152
152
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "9.6.0",
3
+ "version": "9.7.0",
4
4
  "mcpName": "io.github.gregkozakiewicz/roast-my-design-system",
5
5
  "description": "Your AI can write the UI. This makes sure it writes your UI. A deterministic scanner scores your design system 0-100 against 112 public repos, reads React and web components (Stencil, Lit), hands you a copy-paste fix prompt for each top finding, writes rules for Claude, Cursor, Copilot and Windsurf with --apply, and runs as a local MCP server with --mcp.",
6
6
  "keywords": [
@@ -36,6 +36,8 @@ import { ownSpacing, profileOf, installedOf, splitArbitrary, installedByOwner }
36
36
  import { KITS } from '../profiles/kit-common.mjs';
37
37
  import { rulesOn as lintRulesOn, describeRule as lintDescribe, entryMatcher as lintMatcher, RULE_TILE as LINT_TILE } from '../profiles/shadcn-lint.mjs';
38
38
  import { publishesLine } from '../profiles/registry.mjs';
39
+ import { CONTRACT_MIN } from '../lib/palette.mjs';
40
+ import { GREY_HUE_RE } from '../harvest/paint.mjs';
39
41
 
40
42
  // The benchmark and every judgement made against it live in score.mjs; this
41
43
  // file only draws. The same numbers reach summary.json through scoreHarvest.
@@ -55,6 +57,13 @@ const h = JSON.parse(readFileSync(inPath, 'utf8'));
55
57
  // the shadcn repos in the fleet).
56
58
  const B = benchHelpers(bench, profileOf(h).kind);
57
59
  const { percentile, cleanerPct, ideal, median, displayAvg, refMedian } = B;
60
+ // The group the medians come from, by name: a registry is compared with the
61
+ // shadcn repos, an MUI product with the MUI ones. The verdict said "the
62
+ // median of 34 scanned repos" when the medians came from a kit's own group,
63
+ // on 51 of 204 fleet scans, and the tiles said "Avg registry repo" for a
64
+ // group that does not exist (2026-10-01).
65
+ const GROUP_NAMES = { shadcn: 'shadcn', registry: 'shadcn', tailwind: 'Tailwind', mui: 'MUI', mantine: 'Mantine', chakra: 'Chakra', antd: 'Ant Design' };
66
+ const benchGroup = B.sliceInfo ? { name: GROUP_NAMES[B.sliceInfo.kind] ?? B.sliceInfo.kind, repoCount: B.sliceInfo.repoCount } : null;
58
67
  const healthOf = makeHealthOf(B);
59
68
  const M = coreMetrics(h);
60
69
 
@@ -260,6 +269,14 @@ const fresh = P.isShadcn && P.shadcn?.fresh?.fresh === true;
260
269
  if (P.isShadcn) arbitrary = splitArbitrary(arbitrary, installedOf(P)).own;
261
270
  const breakdown = scoreBreakdown(h, bench);
262
271
  const neverImported = neverImportedComponents(h.components, P.uiDir);
272
+ // In a shadcn catalogue folder the unused list holds two things: shadcn's
273
+ // stock, installed and waiting to be used, and components the team wrote
274
+ // itself that nothing imports. Both were called stock until 9.7.0: 178 of
275
+ // 2,182 such components in 16 shadcn repos are the team's own (cal.com 38,
276
+ // 2026-10-01). Neither is scored; the words now tell them apart.
277
+ const isInstalled = installedOf(P);
278
+ const neverStock = P.vendoredUi ? neverImported.filter((c) => isInstalled(c.file)) : [];
279
+ const neverOwn = P.vendoredUi ? neverImported.filter((c) => !isInstalled(c.file)) : neverImported;
263
280
 
264
281
  // Measurability and kind, decided once in profiles/ (telekom/scale, 2026-09-01):
265
282
  // when the component detector could not read this repo's stack, the component
@@ -372,7 +389,8 @@ if (bench && findings.length && !fresh) {
372
389
  const core = [['colors', tokenLed ? colorStrays : colors.length], ['greys', tokenLed ? greyStrays : greys.length],
373
390
  ['spacing', spacingTotal], ['typefaces', typefaces.length], ['exactDuplicates', hardDupes.length], ['inlineStyles', inline.count]];
374
391
  const worse = core.filter(([m, v]) => median(m) !== null && v > median(m)).length;
375
- if (worse >= 3) verdict += ` Messier than the median of ${bench.repoCount} scanned repos on ${worse} of ${core.length} core metrics. And the median repo is already a mess.`;
392
+ const pool = benchGroup ? `${benchGroup.repoCount} scanned ${benchGroup.name} repos` : `${bench.repoCount} scanned repos`;
393
+ if (worse >= 3) verdict += ` Messier than the median of ${pool} on ${worse} of ${core.length} core metrics. And the median repo is already a mess.`;
376
394
  }
377
395
 
378
396
  // ---------- health scoring ----------
@@ -429,13 +447,15 @@ function tile(t, pLabel, pMetric, pFallback) {
429
447
  const avgNote = pct !== null && pct >= 60 && health !== 'good' ? ` · messier than ${pct}%`
430
448
  : clean !== null && clean >= 60 ? ` · cleaner than ${clean}%` : '';
431
449
  const av = displayAvg(metric);
432
- if (av !== null) rows.push(row(B.sliceInfo ? `Avg ${B.sliceInfo.kind} repo` : 'Avg Design System', `${n(av)}${avgNote}`, av, value));
450
+ if (av !== null) rows.push(row(benchGroup ? `Avg ${benchGroup.name} repo` : 'Avg Design System', `${n(av)}${avgNote}`, av, value));
433
451
  if (rm !== null && (rm > 0 || ZERO_IDEAL.has(metric) || metric === 'arbitrary')) rows.push(row('Reputable systems', n(rm), rm, value));
434
452
  // A vendored catalogue is never judged for what it left on the shelf, and a
435
453
  // library's internal use says nothing about adoption: shown, not scored.
436
454
  if (health === 'info' && metric === 'neverImported') {
437
455
  rows.push({ label: isLibrary
438
456
  ? 'library: internal use only, downstream consumers invisible'
457
+ : neverOwn.length
458
+ ? `${neverStock.length ? `${n(neverStock.length)} installed by the shadcn CLI and ` : ''}${n(neverOwn.length)} of the team's own, not used yet; not scored`
439
459
  : 'catalogue stock: installed by the shadcn CLI, not used yet', val: '', dir: '' });
440
460
  }
441
461
  if (health === 'info' && metric === 'paintTin') {
@@ -520,16 +540,7 @@ function paletteSection() {
520
540
  const strays = colors.length - tokens;
521
541
  const tokenPct = Math.round((tokens / colors.length) * 100);
522
542
 
523
- // Usage-weighted colour bar: every colour, width proportional to use.
524
- // Beyond 60 segments the bar turns to noise; fold the tail into one cell.
525
- const sorted = [...colors].sort((a, b) => b.count - a.count);
526
- const shown = sorted.slice(0, 60);
527
- const rest = sorted.slice(60);
528
- const cells = shown.map((c) => {
529
- const src = c.files?.[0] ? ` in ${c.files[0].file}` : '';
530
- return `<div class="uc${c.isToken ? '' : ' stray'}" style="background:${swatch(c.value)};flex-grow:${Math.max(c.count, 1)}" title="${esc(c.value)} ×${c.count}${esc(src)}">${c.isToken ? '' : '<i></i>'}</div>`;
531
- }).join('');
532
- const restCell = rest.length ? `<div class="uc uc-rest" style="flex-grow:${Math.max(rest.reduce((s, c) => s + c.count, 0), 1)}" title="${rest.length} more colours">+${rest.length}</div>` : '';
543
+ const usage = colourUsage();
533
544
 
534
545
  const rampFor = (list) => `
535
546
  <div class="grey-strip">${list.map((c) => `<div class="grey-cell" style="background:${swatch(c.value)}" title="${esc(c.value)} ×${c.count}"></div>`).join('')}</div>
@@ -555,10 +566,11 @@ function paletteSection() {
555
566
  </div>
556
567
  <div class="glass pal-usage">
557
568
  <div class="pal-usage-head">
558
- <div>${eyebrow('Colour usage')}<div class="h3d">Sized by how often each is used</div></div>
559
- <div class="legend"><span><i class="lg lg-tok"></i> token</span><span><i class="lg lg-stray"></i> stray</span></div>
569
+ <div>${eyebrow('Colour usage')}<div class="h3d">${usage.subtitle}</div></div>
570
+ <div class="legend">${usage.legend}</div>
560
571
  </div>
561
- <div class="usage-bar">${cells}${restCell}</div>
572
+ <div class="usage-bar">${usage.cells}</div>
573
+ ${usage.line}
562
574
  ${ramp}
563
575
  ${nearPairs.length ? `
564
576
  <div class="receipts">${eyebrow(`${nearPairs.length} nearly identical pair${nearPairs.length === 1 ? '' : 's'} · each pair is 1 colour recorded twice`)}
@@ -568,6 +580,138 @@ function paletteSection() {
568
580
  </section>`;
569
581
  }
570
582
 
583
+ // The Colour usage bar. Since 9.7.0 it is sized by USE (harvest/coloruse.mjs):
584
+ // a theme class, a var() read or a read of the kit's theme joins its colour's
585
+ // segment, a Tailwind palette class is a palette cell or a stray as the one
586
+ // palette rule says, and a definition is not a use. Until then it was sized by
587
+ // literal writes, the theme's own definitions included, so bg-primary on every
588
+ // button drew primary as one of the thinnest segments. Where the repo reads
589
+ // its theme mostly through code the pass cannot follow, and for a harvest
590
+ // written before 9.7.0, the bar stays sized by written values and says so.
591
+ // Beyond 60 segments the bar turns to noise; the tail folds into one cell
592
+ // per state, so the visible proportions stay true.
593
+ function colourUsage() {
594
+ const use = h.colourUse ?? null;
595
+ const written = () => {
596
+ const sorted = [...colors].sort((a, b) => b.count - a.count);
597
+ const shown = sorted.slice(0, 60);
598
+ const rest = sorted.slice(60);
599
+ const cells = shown.map((c) => {
600
+ const src = c.files?.[0] ? ` in ${c.files[0].file}` : '';
601
+ return `<div class="uc${c.isToken ? '' : ' stray'}" style="background:${swatch(c.value)};flex-grow:${Math.max(c.count, 1)}" title="${esc(c.value)} ×${c.count}${esc(src)}">${c.isToken ? '' : '<i></i>'}</div>`;
602
+ }).join('');
603
+ const restCell = rest.length ? `<div class="uc uc-rest" style="flex-grow:${Math.max(rest.reduce((s, c) => s + c.count, 0), 1)}" title="${rest.length} more colours">+${rest.length}</div>` : '';
604
+ return { cells: cells + restCell, subtitle: 'Sized by how often each value is written',
605
+ legend: '<span><i class="lg lg-tok"></i> token</span><span><i class="lg lg-stray"></i> stray</span>' };
606
+ };
607
+ if (!use || !use.totals?.total) return { ...written(), line: '' };
608
+ if (use.fallback) {
609
+ const pct = Math.round(use.fallback.share * 100);
610
+ const words = { sass: 'Sass variables', less: 'Less variables', 'js-theme': 'JavaScript theme objects' };
611
+ const what = listWords(use.fallback.why.map((w) => words[w]).filter(Boolean));
612
+ return { ...written(), line: `<p class="sub use-line">${pct} in every 100 colour uses here go through ${what}. This bar cannot follow those yet, so it counts written values instead.</p>` };
613
+ }
614
+
615
+ const kitName = P.kit?.name ?? 'kit';
616
+ // a dark: override on a theme whose configured sheet sets dark values
617
+ const darkRows = (P.shadcn?.sheet?.darkRows ?? 0) > 0;
618
+ const how = (c) => listWords([c.classes ? `${n(c.classes)} as classes` : '', c.vars ? `${n(c.vars)} through var()` : '', c.kit ? `${n(c.kit)} through the ${kitName} theme` : ''].filter(Boolean));
619
+ const label = (c) => {
620
+ if (c.named === 'kit') return `${c.names[0]} ×${n(c.weight)}, read by name from the ${kitName} theme`;
621
+ if (c.named === 'outside') return `${c.names[0]} ×${n(c.weight)}, read by name; its value is not a colour this scan can read`;
622
+ if (c.state === 'token') return `${c.value}${c.names.length ? ` (${c.names.join(', ')})` : ''} ×${n(c.weight)}: ${how(c)}`;
623
+ if (c.state === 'palette') return `${c.names[0]} ×${n(c.weight)} as Tailwind palette classes`;
624
+ if (c.written) return `${c.value} ×${n(c.weight)} written by hand${c.names.length ? `; the theme names it ${c.names[0]}` : c.token ? '; a token already holds this value' : ''}`;
625
+ if (c.dark) return `${c.names[0]} ×${n(c.weight)} as dark: overrides written by hand${darkRows ? ', where the theme already sets its own dark-mode colours' : ''}`;
626
+ return `${c.names[0]} ×${n(c.weight)} as palette classes where the theme names its own colours`;
627
+ };
628
+ const MARK = { token: '', palette: ' pal', stray: ' stray' };
629
+ const cells = use.segments.map((c) => {
630
+ const src = c.file ? ` · most in ${c.file}` : '';
631
+ const bg = c.named ? '' : `background:${swatch(c.value)};`;
632
+ return `<div class="uc${MARK[c.state]}${c.named ? ' named' : ''}" style="${bg}flex-grow:${Math.max(c.weight, 1)}" title="${esc(label(c))}${esc(src)}">${c.state === 'token' ? '' : '<i></i>'}</div>`;
633
+ }).join('');
634
+ const REST = { token: 'reached by name', palette: 'as Tailwind palette classes', stray: 'as strays' };
635
+ const rest = ['token', 'palette', 'stray'].filter((k) => use.rest[k].count)
636
+ .map((k) => `<div class="uc uc-rest${MARK[k]}" style="flex-grow:${Math.max(use.rest[k].weight, 1)}" title="${n(use.rest[k].count)} more colours ${REST[k]}, ${n(use.rest[k].weight)} uses">+${n(use.rest[k].count)}${k === 'token' ? '' : '<i></i>'}</div>`).join('');
637
+
638
+ // A legend entry for each kind of cell the bar holds. "token" is a colour
639
+ // drawn with its swatch, here or folded into the "+N" tail; a bar whose
640
+ // token cells and tail are all names with no colour shown has none
641
+ // (Unleash: every cell an MUI read). The dotted "named" entry stands for
642
+ // the dotted cells drawn.
643
+ const has = (state) => use.segments.some((c) => c.state === state) || use.rest[state].count > 0;
644
+ const restSwatched = use.rest.token.count - (use.rest.token.named?.count ?? 0);
645
+ const swatched = use.segments.some((c) => c.state === 'token' && !c.named) || restSwatched > 0;
646
+ const named = use.segments.some((c) => c.state === 'token' && c.named);
647
+ const legend = [
648
+ swatched ? '<span><i class="lg lg-tok"></i> token</span>' : '',
649
+ named ? '<span><i class="lg lg-named"></i> named, colour not shown</span>' : '',
650
+ has('palette') ? '<span><i class="lg lg-pal"></i> Tailwind palette</span>' : '',
651
+ has('stray') ? '<span><i class="lg lg-stray"></i> stray</span>' : '',
652
+ ].join('');
653
+
654
+ // Of every 100 uses: three shares that add up to 100 (largest remainder)
655
+ const raw = [use.share.token, use.share.palette, use.share.stray].map((x) => x * 100);
656
+ const [t, p, st] = (() => {
657
+ const out = raw.map(Math.floor);
658
+ const order = raw.map((x, i) => [x - Math.floor(x), i]).sort((a, b) => b[0] - a[0] || a[1] - b[1]);
659
+ const short = 100 - out.reduce((a, b) => a + b, 0);
660
+ for (let k = 0; k < short; k++) out[order[k][1]] += 1;
661
+ return out;
662
+ })();
663
+ const T = use.totals;
664
+ // what a stray is here, said once after the shares
665
+ const strayWhat = T.written && T.paletteStray ? 'a value written by hand, or a palette class where the theme has its own colour'
666
+ : T.written ? 'a value written by hand' : 'a palette class where the theme has its own colour';
667
+ const parts = [
668
+ t ? `${t} use${t === 1 ? 's' : ''} a theme colour by name` : '',
669
+ p ? `${p} use${p === 1 ? 's' : ''} Tailwind's palette` : '',
670
+ st ? `${st} ${st === 1 ? 'is a stray' : 'are strays'}` : '',
671
+ ].filter(Boolean);
672
+ const sentences = [`Of every 100 colour uses, ${listWords(parts)}.${st ? ` A stray is ${strayWhat}.` : ''}`];
673
+ // why the palette cells are not strays, in the one rule's terms
674
+ if (p) {
675
+ const w = use.paletteWhy ?? {};
676
+ if (!P.palette && (w.off ?? 0) > 0) {
677
+ sentences.push(ds.kind === 'shadcn' && ds.cssVariables === false
678
+ ? "shadcn is set up without CSS variables here, so Tailwind's palette is the theme."
679
+ // a shadcn repo with no theme the rule can read still has its
680
+ // palette classes counted by the off-theme colours tile (workout-cool)
681
+ : P.isShadcn && (P.shadcn?.paint?.tin?.uses ?? 0) > 0
682
+ ? "No theme was found to check these palette classes against, so the bar shows them as Tailwind's palette. The off-theme colours tile still counts palette classes."
683
+ : 'The report does not check palette classes against a theme here, so none of them count as strays.');
684
+ }
685
+ const fam = P.palette?.families ?? null;
686
+ // white and black apart, and which dark: overrides are strays on the bar
687
+ const wb = [(w.white ?? 0) > 0 ? 'white' : '', (w.black ?? 0) > 0 ? 'black' : ''].filter(Boolean);
688
+ const left = [
689
+ // the rule flags dark: backgrounds, text and borders; every other
690
+ // dark: white or black (dark:via-white, dark:ring-white) stays plain
691
+ wb.length ? `plain ${listWords(wb)}${T.paletteStrayDark ? ', except dark: backgrounds, text and borders' : ''}` : '',
692
+ w.family && fam ? (!fam.grey ? 'greys (the theme names no grey)' : 'colours other than grey (the theme names only greys)') : '',
693
+ w.apply ? '@apply lines in stylesheets' : '',
694
+ w.sides ? `side borders and ring offsets${w.sidesTop ? `, such as ${w.sidesTop}` : ''}` : '',
695
+ w.installed ? "shadcn's own components" : '',
696
+ ].filter(Boolean);
697
+ // semicolons: "plain white and black" carries its own "and"
698
+ if (P.palette && left.length) sentences.push(`Palette classes not counted as strays: ${left.join('; ')}.`);
699
+ }
700
+ if (use.scope === 'installed') sentences.push("Your own code uses colour fewer than 20 times so far, so shadcn's installed components are counted too.");
701
+ else if (P.isShadcn && use.doorFiles) sentences.push("shadcn's own components are left out, as they are in the score.");
702
+ return {
703
+ cells: cells + rest, legend,
704
+ subtitle: 'Sized by how often the code uses each colour',
705
+ // the shares in one line; how they were counted folded under it, the
706
+ // same fold as "why this matters" (Greg, 1 October)
707
+ line: `<p class="sub use-line">${esc(sentences[0])}</p>${sentences.length > 1 ? `<div class="whywrap"><button type="button" class="whybtn" data-why="colouruse">${WHY_ICON}<span class="why-lab">how this is counted</span></button><div class="whytext" hidden id="why-colouruse">${sentences.slice(1).map((x) => `<p>${esc(x)}</p>`).join('')}</div></div>` : ''}`,
708
+ };
709
+ }
710
+ // "a", "a and b", "a, b and c"
711
+ function listWords(xs) {
712
+ return xs.length <= 1 ? (xs[0] ?? '') : `${xs.slice(0, -1).join(', ')} and ${xs.at(-1)}`;
713
+ }
714
+
571
715
  // Agent traps: findings that do not just sit there but multiply, because an
572
716
  // agent reads the repo as instruction. Copy only, no new measurement, and a
573
717
  // trap renders only when its mechanism is real for this repo, so the marker
@@ -914,12 +1058,12 @@ function adoptionMap(tiled) {
914
1058
  // the obituary column. "Untouched since 2023" needs no decoding, and a heavy
915
1059
  // year reads as what it is, the fossil of one abandoned effort. Orphans
916
1060
  // without a date (no git) fall back to one plain row, exactly as before.
917
- function orphanRows() {
918
- const shown = neverImported.slice(0, 16);
1061
+ function orphanRows(list = neverImported) {
1062
+ const shown = list.slice(0, 16);
919
1063
  const chip = (c) => `<span class="vchip" title="${esc(c.file)}${c.lastTouched ? ` · untouched since ${esc(c.lastTouched)}` : ''}">&lt;${esc(c.name)}&gt;</span>`;
920
- const more = neverImported.length > 16 ? `<span class="vchip dim">+${neverImported.length - 16} more</span>` : '';
1064
+ const more = list.length > 16 ? `<span class="vchip dim">+${list.length - 16} more</span>` : '';
921
1065
  const dated = shown.filter((c) => c.lastTouched);
922
- if (dated.length < 2) return `<div class="chips-row">${shown.slice(0, 8).map(chip).join('')}${neverImported.length > 8 ? `<span class="vchip dim">+${neverImported.length - 8} more</span>` : ''}</div>`;
1066
+ if (dated.length < 2) return `<div class="chips-row">${shown.slice(0, 8).map(chip).join('')}${list.length > 8 ? `<span class="vchip dim">+${list.length - 8} more</span>` : ''}</div>`;
923
1067
  const byYear = new Map();
924
1068
  for (const c of shown) {
925
1069
  const k = c.lastTouched ? c.lastTouched.slice(0, 4) : 'no git date';
@@ -959,11 +1103,18 @@ function componentsSection() {
959
1103
  ${fresh ? '<p class="sub">No code of your own uses the kit yet. The counts below are the components using each other.</p>' : ''}
960
1104
  ${onceUsed.length && !fresh ? `<p class="sub">${n(onceUsed.length)} component${onceUsed.length === 1 ? ' is' : 's are'} imported exactly once: ${onceUsed.slice(0, 6).map((c) => `&lt;${esc(c.name)}&gt;`).join(', ')}${onceUsed.length > 6 ? ` and ${onceUsed.length - 6} more` : ''}. Quiet corners, not yet a system.</p>` : ''}
961
1105
  ${top.length ? `<div class="tbl-wrap"><table><thead><tr><th>component</th><th>used</th><th>defined in</th><th>props</th></tr></thead><tbody>${rows}</tbody></table></div>` : ''}
962
- ${neverImported.length >= 2 ? `
963
- <div class="receipts">${eyebrow(isLibrary ? `${n(neverImported.length)} components unused internally · showroom stock to review, not dead weight: consumers in other repos are invisible from here` : vendoredUi ? `${n(neverImported.length)} catalogue components not used yet · installed by the shadcn CLI and waiting to be used, not written by this team` : `${n(neverImported.length)} components defined but never imported · they sit in the system as wrong answers waiting to be picked`)}
1106
+ ${neverImported.length >= 2 && vendoredUi ? `${neverStock.length ? `
1107
+ <div class="receipts">${eyebrow(`${n(neverStock.length)} shadcn component${neverStock.length === 1 ? '' : 's'} installed but not used yet · added by the shadcn CLI, not written by this team`)}
1108
+ ${orphanRows(neverStock)}
1109
+ <p class="sub" style="margin-top:8px">Use one before you build anything new. This list is counted and shown, and takes nothing off the score.</p></div>` : ''}${neverOwn.length ? `
1110
+ <div class="receipts">${eyebrow(`${n(neverOwn.length)} component${neverOwn.length === 1 ? '' : 's'} the team wrote in ${esc(P.uiDir)} ${neverOwn.length === 1 ? 'is' : 'are'} never imported · an agent may use ${neverOwn.length === 1 ? 'it' : 'one of these'} by mistake`)}
1111
+ ${orphanRows(neverOwn)}
1112
+ <p class="sub" style="margin-top:8px">Routers, dynamic imports and barrel files can hide real usage, so treat this as a shortlist to check, not a demolition order. Counted and shown, not scored.</p>
1113
+ ${whyToggle('neverImported')}</div>` : ''}` : neverImported.length >= 2 ? `
1114
+ <div class="receipts">${eyebrow(isLibrary ? `${n(neverImported.length)} components unused internally · showroom stock to review, not dead weight: consumers in other repos are invisible from here` : `${n(neverImported.length)} components defined but never imported · an agent may use one of these by mistake`)}
964
1115
  ${orphanRows()}
965
- <p class="sub" style="margin-top:8px">${vendoredUi ? 'Reach for one before building anything new. This list is counted and shown, and takes nothing off the score.' : 'Routers, dynamic imports and barrel files can hide real usage, so treat this as a shortlist to check, not a demolition order.'}</p>
966
- ${isLibrary || vendoredUi ? '' : whyToggle('neverImported')}` : ''}</section>`;
1116
+ <p class="sub" style="margin-top:8px">Routers, dynamic imports and barrel files can hide real usage, so treat this as a shortlist to check, not a demolition order.</p>
1117
+ ${isLibrary ? '' : whyToggle('neverImported')}` : ''}</section>`;
967
1118
  }
968
1119
 
969
1120
  // "Where to start" — at most three moves, every one derived from this repo's
@@ -1164,12 +1315,41 @@ function whereToStartSection() {
1164
1315
  const pt = P.shadcn.paint;
1165
1316
  if (pt.tin.uses >= 10) {
1166
1317
  const s0 = pt.tin.samples[0], f0 = productFirst(pt.tin.top)[0];
1167
- // The swap only works when the theme file holds the variables. A
1168
- // product whose sheet defines none of them (formbricks: brand rows
1169
- // only, slate as the palette) needs a decision first, not a prompt:
1170
- // swapping classes there leaves text with no colour.
1171
- const sheetHasRows = (P.shadcn?.sheet?.shadcnPresent ?? 0) >= 5 || P.shadcn?.kit?.cssVariables === false;
1172
- c.push(sheetHasRows
1318
+ // The swap only works when the theme holds the variables. Which first
1319
+ // move fits depends on where the theme lives (9.7.0). Until then the
1320
+ // gate read the configured sheet alone, and sent documenso and
1321
+ // taxonomy (shadcn's rows in a stylesheet no config names) and
1322
+ // formbricks (a v4 theme of its own, written straight into @theme) to
1323
+ // "Decide what the product paints from", telling the first two their
1324
+ // theme defined none of shadcn's colour variables.
1325
+ // - a configured sheet holding shadcn's rows, or utility-class mode: repaint
1326
+ // - a configured sheet that is a theme of the repo's own (3 names or
1327
+ // more) lacking shadcn's greys: map the palette onto it first
1328
+ // - no configured sheet, but shadcn's rows in a stylesheet: repaint
1329
+ // - otherwise the palette is the system today: decide first
1330
+ const sc = P.shadcn ?? {};
1331
+ const tr = sc.themeRead ?? null;
1332
+ const missing = tr ? ['muted-foreground', 'border', 'background', 'foreground'].filter((r) => !tr.names.includes(r)) : [];
1333
+ const gate = sc.kit?.cssVariables === false ? 'repaint'
1334
+ : sc.sheet?.found
1335
+ ? ((sc.sheet.shadcnPresent ?? 0) >= CONTRACT_MIN ? 'repaint'
1336
+ : tr && tr.file === sc.sheet.file && tr.count >= 3 ? (missing.length ? 'map' : 'repaint') : 'decide')
1337
+ : (sc.contract?.rows ?? 0) >= CONTRACT_MIN ? 'repaint' : 'decide';
1338
+ // a palette grey among the samples (white and black are not greys here)
1339
+ const isGreyClass = (v) => GREY_HUE_RE.test(v) && !/-(?:white|black)(?:\/|$)/.test(v);
1340
+ const grey = (pt.tin.samples ?? []).map((x) => x.value).find(isGreyClass) ?? null;
1341
+ const textGrey = (pt.tin.samples ?? []).map((x) => x.value.replace(/^(?:[\w-]+:)*/, '').replace(/\/\d+$/, '')).find((v) => /^text-/.test(v) && isGreyClass(v)) ?? 'text-slate-500';
1342
+ // a theme's names, one per family (brand, not brand-light and brand-dark),
1343
+ // in the theme's own order
1344
+ const families = tr ? [...tr.names.reduce((m, nm) => { const k = nm.split('-')[0]; if (!m.has(k) || nm.length < m.get(k).length) m.set(k, nm); return m; }, new Map()).values()] : [];
1345
+ const themeNames = !tr ? ''
1346
+ : tr.count <= 4 ? `${n(tr.count)} colours of its own (${listWords(tr.names)})`
1347
+ : families.length >= 4 ? `${n(tr.count)} colours of its own, from ${listWords(families.slice(0, 3))} to ${families.at(-1)}`
1348
+ : `${n(tr.count)} colours of its own, among them ${listWords(families)}`;
1349
+ if (gate === 'map') c.push({ score: 20 + pt.tin.per100 / 4, metric: 'paintTin', after: 0,
1350
+ title: `Map the palette onto the theme you already have`,
1351
+ sub: `${esc(s0.value)} appears ${s0.count} times.${f0 ? ` ${esc(basename(f0.file))} alone has ${f0.count} palette classes.` : ''} ${esc(tr.file)} already names ${esc(themeNames)}. ${grey ? 'It does not name the greys these classes replace' : "It does not name shadcn's colours for text, borders and surfaces"}: ${listWords(missing.slice(0, 3))}. Add ${grey ? 'those greys' : 'them'} to the theme first, then change the classes one file at a time.${missing.includes('muted-foreground') ? ` If you change <code>${esc(textGrey)}</code> to <code>text-muted-foreground</code> before that, the text will have no colour.` : ''}` });
1352
+ else c.push(gate === 'repaint'
1173
1353
  ? { score: 20 + pt.tin.per100 / 4, metric: 'paintTin', after: 0,
1174
1354
  title: `Repaint the ${n(pt.tin.uses)} colours from outside the theme`,
1175
1355
  sub: `${esc(s0.value)} appears ${s0.count} times${f0 ? `, ${esc(basename(f0.file))} alone carries ${f0.count}` : ''}. The theme file already has a variable for each (a grey is <code>text-muted-foreground</code>, a status colour is a Badge variant or a variable you add). Swap the class, not the value.` }
@@ -1191,13 +1371,13 @@ function whereToStartSection() {
1191
1371
  const s0 = k.colour.samples[0], f0 = productFirst(k.colour.top)[0];
1192
1372
  c.push({ score: 20 + k.colour.per100 / 4, metric: 'kitColour', after: 0,
1193
1373
  title: `Move the ${n(k.colour.uses)} colours written on components into the ${esc(k.name)} theme`,
1194
- sub: `The most written is ${esc(s0.value)} (${s0.count} times)${f0 ? `; ${esc(basename(f0.file))} carries the most, ${f0.count}, mostly ${esc(f0.sample)}` : ''}.${s0.inTheme ? ` ${esc(s0.value)} is already in your theme: read it from there, from the entry that holds it in every mode.` : ''} ${k.themeFiles.length ? `The theme in ${esc(k.themeFiles[0])} is where a colour is decided` : `There is no theme yet: start one with ${KITS[k.name]?.advice.themeCall ?? "the kit's theme call"} and put the palette there`}. ${KITS[k.name]?.advice.colourHow ?? ''}` });
1374
+ sub: `The most written is ${esc(s0.value)} (${s0.count} times)${f0 ? `; ${esc(basename(f0.file))} carries the most, ${f0.count}, mostly ${esc(f0.sample)}` : ''}.${s0.inTheme ? ` ${esc(s0.value)} is already in your theme: read it from there, from the entry that holds it in every mode.` : ''} ${k.themeFiles.length ? `The theme in ${esc(k.themeFiles[0])} is where a colour is decided` : `There is no theme yet: start one with ${KITS[k.name]?.advice.themeCall ?? "the kit's theme call"} and put the palette there`}. ${KITS[k.name]?.advice.colourHow ?? ''}${secondKitMoveNote(k, 'colours')}` });
1195
1375
  }
1196
1376
  if (k.px.uses >= 10) {
1197
1377
  const s0 = k.px.samples[0], f0 = productFirst(k.px.top)[0];
1198
1378
  c.push({ score: 15 + k.px.per100 / 4, metric: 'kitPx', after: 0,
1199
1379
  title: `Put the ${n(k.px.uses)} pixel spacings on the theme's spacing steps`,
1200
- sub: `The most written is ${esc(s0.value)} (${s0.count} times)${f0 ? `; ${esc(basename(f0.file))} carries the most, ${f0.count}, mostly ${esc(f0.sample)}` : ''}. ${KITS[k.name]?.advice.spacingHow(k) ?? ''}` });
1380
+ sub: `The most written is ${esc(s0.value)} (${s0.count} times)${f0 ? `; ${esc(basename(f0.file))} carries the most, ${f0.count}, mostly ${esc(f0.sample)}` : ''}. ${KITS[k.name]?.advice.spacingHow(k) ?? ''}${secondKitMoveNote(k, 'px')}` });
1201
1381
  }
1202
1382
  }
1203
1383
 
@@ -1271,6 +1451,7 @@ function whereToStartSection() {
1271
1451
  const promptFor = (item) => fixPrompt({
1272
1452
  metric: item.metric,
1273
1453
  kit: P.isKit ? P.kit?.name : null,
1454
+ second: P.isKit && P.kit?.second ? { name: P.kit.second.name, pkg: P.kit.second.pkg } : null,
1274
1455
  title: unesc(item.title),
1275
1456
  sub: unesc(item.sub),
1276
1457
  // the points arrive when the whole finding is done; one file moves the count
@@ -1469,9 +1650,24 @@ function shadcnReceipt() {
1469
1650
  const counted = (r.counted?.code ?? 0) === 0 && r.themes?.total
1470
1651
  ? `<b>It publishes no code, so the themes check is the score</b>: the ${n(kept)} files of the app that makes them are not counted.`
1471
1652
  : `<b>Only what it publishes is counted</b>: ${n(r.counted?.code ?? 0)} code file${(r.counted?.code ?? 0) === 1 ? '' : 's'}${kept ? `, with ${n(kept)} more kept out as its site, demos and examples` : ''}.`;
1472
- return `<div class="excl fresh">Read as a shadcn registry: ${what}${r.variants.length ? `, the same components kept in ${r.variants.length} variants` : ''}. ${counted}${th}</div><div class="excl">Evidence: ${receipt}</div>`;
1653
+ return `<div class="excl fresh">Read as a shadcn registry: ${what}${r.variants.length ? `, the same components kept in ${r.variants.length} variants` : ''}. ${counted}${th}</div><div class="excl">Evidence: ${receipt}</div>${themeUsesLine()}`;
1473
1654
  }
1474
- return `<div class="excl">Read as a shadcn install (${esc(P.confidence ?? 'medium')} confidence): ${receipt}</div>`;
1655
+ return `<div class="excl">Read as a shadcn install (${esc(P.confidence ?? 'medium')} confidence): ${receipt}</div>${themeUsesLine()}`;
1656
+ }
1657
+ // The theme's names, and how often class names matching them appear across
1658
+ // the repo, by the Tailwind receipt's counter with comments blanked (shadcn's
1659
+ // own components included, as there) (9.7.0). A count under half the bar's
1660
+ // own class uses means the receipt read the wrong names: onlook's @theme
1661
+ // holds only its 8 sidebar names (49 uses, the bar counts 1,926), and
1662
+ // supabase's reader took a comment saying "@theme inline block" for the
1663
+ // theme (551 against 5,869). Said nothing rather than something the bar
1664
+ // beside it contradicts.
1665
+ function themeUsesLine() {
1666
+ const u = P.shadcn?.themeUses ?? null;
1667
+ if (!u?.uses) return '';
1668
+ if (h.colourUse?.totals && u.uses * 2 < h.colourUse.totals.classes) return '';
1669
+ const N = u.names.length;
1670
+ return `<div class="excl">Its theme names ${n(N)} colour${N === 1 ? '' : 's'} in ${esc(u.file)}; class names matching them appear ${n(u.uses)} time${u.uses === 1 ? '' : 's'} across ${n(u.usedIn)} file${u.usedIn === 1 ? '' : 's'}.</div>`;
1475
1671
  }
1476
1672
 
1477
1673
  // A Tailwind repo with its own theme: what the vocabulary is, how much of
@@ -1488,7 +1684,39 @@ function publishesRegistryLine() {
1488
1684
  function kitReceipt() {
1489
1685
  if (!P.isKit || !P.kit) return '';
1490
1686
  const k = P.kit;
1491
- return `<div class="excl fresh">Read as a product built on ${esc(k.name)}: imported in ${n(k.kitFiles)} files, ${k.themeFiles.length ? `theme defined in ${esc(k.themeFiles[0])}` : `no theme of its own, so the ${esc(k.name)} defaults are the theme`}. The kit's own code is installed from npm and not counted; what is counted is what the repo writes onto its components.</div><div class="excl">Evidence: ${esc(P.evidence.join(' · '))}</div>`;
1687
+ const sk = k.second;
1688
+ // a second kit: named, and why its own files are not in the counts
1689
+ // the files without the first kit stay out of the kit counts; they still
1690
+ // count in the repo-wide tiles like any other file
1691
+ const without = !sk?.withoutFirst ? '' : sk.withoutFirst === sk.files ? `, none of them with ${esc(k.name)}` : `, ${n(sk.withoutFirst)} of them without ${esc(k.name)}`;
1692
+ const second = !sk ? '' : ` ${esc(sk.name)} is imported in ${n(sk.files)} files too${sk.withoutFirst
1693
+ ? `${without}. ${sk.withoutFirst === 1 ? 'That file is' : `Those ${n(sk.withoutFirst)} files are`} not in the kit counts: ${esc(sk.name)} components do not read the ${esc(k.name)} theme.`
1694
+ : `, each of them beside ${esc(k.name)}.`}`;
1695
+ return `<div class="excl fresh">Read as a product built on ${esc(k.name)}: imported in ${n(k.kitFiles)} files, ${k.themeFiles.length ? `theme defined in ${esc(k.themeFiles[0])}` : `no theme of its own, so the ${esc(k.name)} defaults are the theme`}. The kit's own code is installed from npm and not counted; what is counted is what the repo writes onto its components.${second}</div><div class="excl">Evidence: ${esc(P.evidence.join(' · '))}</div>`;
1696
+ }
1697
+
1698
+ // A second kit, on the two kit moves: change only the first kit's
1699
+ // components, said only where some of that card's values sit in files that
1700
+ // use both kits
1701
+ function secondKitMoveNote(k, part) {
1702
+ const sk = k.second;
1703
+ return sk?.mixed && sk.mixedPaint?.[part] ? ` In the ${n(sk.mixed)} file${sk.mixed === 1 ? '' : 's'} that also use${sk.mixed === 1 ? 's' : ''} ${esc(sk.name)}, change only what sits on ${esc(k.name)} components.` : '';
1704
+ }
1705
+
1706
+ const aKitName = (name) => `${/^(?:[AEIOU]|MUI)/.test(name) ? 'an' : 'a'} ${esc(name)}`;
1707
+ // A second kit, in the kit section: which files are counted and which are not
1708
+ function secondKitBlock(k) {
1709
+ const sk = k.second;
1710
+ if (!sk) return '';
1711
+ const mp = sk.mixedPaint ?? { colours: 0, px: 0 };
1712
+ const inThem = [mp.colours ? `${n(mp.colours)} of the colours` : '', mp.px ? `${n(mp.px)} of the pixel sizes` : ''].filter(Boolean).join(' and ');
1713
+ const one = mp.colours + mp.px === 1;
1714
+ const parts = [
1715
+ sk.withoutFirst ? `${n(sk.withoutFirst)} file${sk.withoutFirst === 1 ? ' uses' : 's use'} ${esc(sk.name)} without ${esc(k.name)}. ${sk.withoutFirst === 1 ? 'It is' : 'They are'} not in the counts above.` : '',
1716
+ sk.mixed ? `${n(sk.mixed)} file${sk.mixed === 1 ? ' uses' : 's use'} both kits and ${sk.mixed === 1 ? 'is' : 'are'} counted.${inThem ? ` ${inThem[0].toUpperCase()}${inThem.slice(1)} above ${one ? 'is' : 'are'} in ${sk.mixed === 1 ? 'that file' : 'those files'}, so ${one ? `it may be on ${aKitName(sk.name)} component` : `some may be on ${esc(sk.name)} components`}.` : ''}` : '',
1717
+ "Follow the kit the file already uses. Never put one kit's styling on the other's components.",
1718
+ ].filter(Boolean);
1719
+ return `<div class="receipts">${eyebrow(`Two kits · ${esc(sk.name)} in ${n(sk.files)} files`)}<p class="sub">${parts.join(' ')}</p></div>`;
1492
1720
  }
1493
1721
 
1494
1722
  // A kit product: the theme, how often components read it, and where a colour
@@ -1501,6 +1729,7 @@ function kitSection() {
1501
1729
  const parts = [`<div class="receipts">${eyebrow(k.themeFiles.length ? `Theme in ${esc(k.themeFiles[0])}${k.themeFiles.length > 1 ? ` and ${n(k.themeFiles.length - 1)} more` : ''} · read ${n(k.refs)} times from components` : `No theme of its own · the ${esc(k.name)} defaults`)}<p class="sub">${k.themeFiles.length ? `Components reach the theme ${n(k.refsPer100)} times per 100 kit files, through ${KITS[k.name]?.advice.refExamples ?? 'theme references'}. That is the system working.` : `Without a theme of its own, every colour a component needs is either ${esc(k.name)}'s default or written by hand.`}</p></div>`];
1502
1730
  parts.push(`<div class="receipts">${eyebrow(`${n(k.colour.uses)} colours written on components · ${n(k.colour.per100)} per 100 kit files`)}${k.colour.uses ? `<div class="chips-row">${chips(k.colour.samples, 'bad')}</div><div class="chips-row">${files(k.colour.top)}</div>` : '<p class="sub">Every colour on a component comes from the theme.</p>'}${whyToggle('kitColour')}</div>`);
1503
1731
  parts.push(`<div class="receipts">${eyebrow(`${n(k.px.uses)} pixel sizes written on components · ${n(k.px.per100)} per 100 kit files`)}${k.px.uses ? `<div class="chips-row">${chips(k.px.samples, 'warn')}</div><div class="chips-row">${files(k.px.top)}</div>` : '<p class="sub">Spacing, type size and radius on components come from the theme.</p>'}${whyToggle('kitPx')}</div>`);
1732
+ if (k.second) parts.push(secondKitBlock(k));
1504
1733
  if (k.exempt?.length) parts.push(`<p class="sub">Not counted: ${n(k.exempt.length)} file${k.exempt.length === 1 ? '' : 's'} of colour data or artwork (${esc(k.exempt.slice(0, 3).map((e) => basename(e.file)).join(', '))}).</p>`);
1505
1734
  return `<section class="glass pad">${sectionHead(`Your ${esc(k.name)} theme, and what is written around it`, 'the theme, and the places a colour or a pixel size was written onto a component instead')}${parts.join('')}</section>`;
1506
1735
  }
@@ -1659,7 +1888,8 @@ function exceptionsBlock() {
1659
1888
  const vd = r.variantsDropped ?? [];
1660
1889
  if (vd.length) lines.push(`${vd.length} variant${vd.length === 1 ? '' : 's'} of the same components (${vd.map((e) => esc(basename(dirname(e.dir)) === 'bases' ? basename(dirname(e.dir)) + '/' + basename(e.dir) : e.dir)).join(', ')}, ${n(vd.reduce((a, e) => a + e.files, 0))} files) counted once, through the variant the registry file names.`);
1661
1890
  }
1662
- if (vendoredUi && neverImported.length) lines.push(`${n(neverImported.length)} catalogue component${neverImported.length === 1 ? '' : 's'} not used yet: stock on the shelf, not scored.`);
1891
+ if (vendoredUi && neverStock.length) lines.push(`${n(neverStock.length)} shadcn component${neverStock.length === 1 ? ' is' : 's are'} installed but not used yet. ${neverStock.length === 1 ? 'It is' : 'They are'} not scored.`);
1892
+ if (vendoredUi && neverOwn.length) lines.push(`${n(neverOwn.length)} component${neverOwn.length === 1 ? '' : 's'} the team wrote in the same folder ${neverOwn.length === 1 ? 'is' : 'are'} not imported either. ${neverOwn.length === 1 ? 'It is' : 'They are'} listed under the adoption map and ${neverOwn.length === 1 ? 'is' : 'are'} not scored.`);
1663
1893
  const rp = P.shadcn?.registryPaint;
1664
1894
  if (rp) lines.push(`Installed registr${rp.dirs.length === 1 ? 'y' : 'ies'} ${esc(rp.dirs.map((d) => basename(d)).join(', '))}: ${n(rp.files)} file${rp.files === 1 ? '' : 's'}${rp.tinUses ? `, ${n(rp.tinUses)} palette colour${rp.tinUses === 1 ? '' : 's'}` : ''}. Kept in the score, left out of the fixes. Your agent reads them like everything else.`);
1665
1895
  } else if (isLibrary) {
@@ -1905,6 +2135,7 @@ ${ogTags()}
1905
2135
 
1906
2136
  /* palette */
1907
2137
  .palette-grid { display:grid; grid-template-columns:1fr 1.6fr; gap:16px; }
2138
+ .palette-grid > * { min-width:0; }
1908
2139
  @media (max-width:860px) { .palette-grid { grid-template-columns:1fr; } }
1909
2140
  .pal-hero { position:relative; overflow:hidden; padding:26px 28px; }
1910
2141
  .pal-hero .b1 { right:-40px; top:-40px; width:160px; height:160px; background:var(--blob); filter:blur(32px); }
@@ -1924,8 +2155,13 @@ ${ogTags()}
1924
2155
  .lg-tok { background:var(--text); } .lg-stray { background:var(--coral); }
1925
2156
  .usage-bar { display:flex; height:96px; gap:2px; margin-top:18px; border-radius:16px; overflow:hidden; }
1926
2157
  .uc { position:relative; min-width:3px; box-shadow:0 0 0 1px var(--cell-ring) inset; }
2158
+ .usage-bar .uc { min-width:2px; }
1927
2159
  .uc:first-child { border-radius:16px 0 0 16px; } .uc:last-child { border-radius:0 16px 16px 0; }
1928
- .uc.stray i { position:absolute; top:4px; right:4px; width:6px; height:6px; border-radius:99px; background:var(--coral); box-shadow:0 0 0 2px var(--card-solid); }
2160
+ .uc.stray i, .uc.pal i { position:absolute; top:4px; right:4px; width:6px; height:6px; border-radius:99px; background:var(--coral); box-shadow:0 0 0 2px var(--card-solid); }
2161
+ .uc.pal i { background:var(--dim); }
2162
+ .uc.named { background:radial-gradient(var(--dim2) 1.2px, transparent 1.8px) 0 0/7px 7px, var(--deep); }
2163
+ .lg-pal { background:var(--dim); } .lg-named { background:radial-gradient(var(--dim) 1px, transparent 1.4px) 0 0/4px 4px, var(--deep); }
2164
+ .use-line { margin-top:12px; }
1929
2165
  .uc-rest { background:repeating-linear-gradient(45deg,var(--deep),var(--deep) 4px,var(--card-solid) 4px,var(--card-solid) 8px);
1930
2166
  display:flex; align-items:center; justify-content:center; font:600 10px var(--mono); color:var(--dim); }
1931
2167
  .ramp-head { margin-top:26px; }
@@ -2320,7 +2556,9 @@ if (summaryPath) {
2320
2556
  role: P.role,
2321
2557
  kind: P.kind,
2322
2558
  ...(P.publishesRegistry ? { publishesRegistry: { source: P.publishesRegistry.source, publishes: P.publishesRegistry.publishes } } : {}),
2323
- ...(P.isKit && P.kit ? { kit: { name: P.kit.name, kitFiles: P.kit.kitFiles, themeFiles: P.kit.themeFiles, spacingUnit: P.kit.spacingUnit ?? null, refs: P.kit.refs, colours: P.kit.colour.uses, pixelSizes: P.kit.px.uses, evidence: P.evidence } } : {}),
2559
+ ...(P.isKit && P.kit ? { kit: { name: P.kit.name, kitFiles: P.kit.kitFiles, themeFiles: P.kit.themeFiles, spacingUnit: P.kit.spacingUnit ?? null, refs: P.kit.refs, colours: P.kit.colour.uses, pixelSizes: P.kit.px.uses, evidence: P.evidence,
2560
+ // a second kit, named and not scored (9.7.0); absent when there is none
2561
+ ...(P.kit.second ? { second: { name: P.kit.second.name, pkg: P.kit.second.pkg, files: P.kit.second.files, withoutFirst: P.kit.second.withoutFirst, mixed: P.kit.second.mixed } } : {}) } } : {}),
2324
2562
  ...(P.isTailwind && P.tailwind ? { tailwind: { file: P.tailwind.file, names: P.tailwind.names.length, restated: P.tailwind.restated, retuned: P.tailwind.retuned?.length ?? 0, uses: P.tailwind.uses, usedIn: P.tailwind.usedIn, adopted: P.tailwind.adopted, evidence: P.evidence } } : {}),
2325
2563
  ...(P.isRegistry && P.registry ? { registry: { source: P.registry.source, builtFrom: P.registry.builtFrom, items: P.registry.items, publishes: P.registry.publishes, variants: P.registry.variants, counted: P.registry.counted ?? null, showcase: P.registry.showcase ?? [], variantsDropped: P.registry.variantsDropped ?? [], themes: P.registry.themes ? { total: P.registry.themes.total, incomplete: P.registry.themes.incomplete } : null } } : {}),
2326
2564
  ...(P.isShadcn && P.shadcn ? { shadcn: { confidence: P.confidence, evidence: P.evidence, style: P.shadcn.kit?.style ?? null, baseColor: P.shadcn.kit?.baseColor ?? null, tailwind: P.shadcn.kit?.tailwind ?? null, catalogues: P.uiDirs, registries: P.shadcn.registryDirs ?? [], ...(P.shadcn.registryPaint ? { registryFiles: P.shadcn.registryPaint.files, registryPaletteColours: P.shadcn.registryPaint.tinUses } : {}), ownFiles: P.shadcn.paint?.ownFiles ?? null, fresh: P.shadcn.fresh?.fresh === true, ...(P.shadcn.lint ? { lint: { file: P.shadcn.lint.file, kind: P.shadcn.lint.kind, rulesOn: lintRulesOn(P.shadcn.lint), readFully: P.shadcn.lint.readFully } } : {}) } } : {}),