roast-my-design-system 5.6.2 → 5.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.
package/README.md CHANGED
@@ -8,11 +8,9 @@
8
8
 
9
9
  A free CLI tool (and Claude Code skill) that roasts your repo's design system with real data, then generates the rules that keep your AI agent on-system.
10
10
 
11
- > **New in 5.6: every fix comes with its prompt.** Each move in the report's Where to start now carries a copy button holding a ready-made fix prompt: the finding, the file paths, the expected score payoff, and the calm rules for fixing without steamrolling craft. Paste it into your agent, fix, re-run the scan, press the next button. View it first if you like: the prompt unfolds right in the report.
12
-
13
- > **New in 5.5: the adoption map.** The report now draws your real system to scale: a treemap where every adopted component's tile area is its import count, and the never-imported are grouped by the year git last saw them touched. The cupboard audit, with dates on the boxes.
11
+ > **New in 5.7: it reads web components, and it admits what it cannot read.** Stencil, Lit and plain custom elements are detected by their tag registrations (validated on telekom/scale, Shoelace, Ionic, Material Web, Adobe Spectrum, Siemens iX, Baloise and Lion); component libraries get composition maps and library language instead of adoption accusations; an earned token namespace like `--telekom-*` is named in the header. And when a repo's component pattern is beyond the scan, the tiles say "not measured" and the score takes no credit, because zeros the scanner never earned are not discipline.
14
12
 
15
- > **New in 5.4: the MCP server is verified in Cursor and Windsurf (now Devin Desktop), alongside Claude Code.** Each tested end to end in the editor itself: server connected, five tools listed, real answers in the chat. Setup for each lives in [Live answers over MCP](#live-answers-over-mcp).
13
+ > **New in 5.6: every fix comes with its prompt.** Each move in the report's Where to start now carries a copy button holding a ready-made fix prompt: the finding, the file paths, the expected score payoff, and the calm rules for fixing without steamrolling craft. Paste it into your agent, fix, re-run the scan, press the next button. View it first if you like: the prompt unfolds right in the report.
16
14
 
17
15
  > **New in 5.0: it runs as a local MCP server.** One command, and your agent asks the design system before writing UI, then gets the work checked after: which Button is canonical, which token holds that colour, review my changes. Local, deterministic, nothing leaves your machine. See [Live answers over MCP](#live-answers-over-mcp).
18
16
 
@@ -20,6 +18,7 @@ Run it on your codebase and get, in about a second:
20
18
 
21
19
  - **A health score you can defend in a meeting.** 0-100, deterministic, benchmarked against Ideal Design System norms, 34 scanned public repos and 10 reputable design systems (Primer, Polaris, Carbon, shadcn/ui…).
22
20
  - **Per-package scores for monorepos.** One blended number hides which package is the problem: `packages/ui` scores 80 while `apps/web` scores 40, and now you can see it.
21
+ - **Reads React and web components alike.** Stencil, Lit and custom elements detected by tag registration, counted by kebab tag; libraries get composition maps, not adoption accusations; earned token namespaces named in the header; and anything the scan cannot read is declared "not measured" instead of scored.
23
22
  - **The receipts behind it.** Every colour and its near-identical twin, every spacing value, typeface, duplicated or never-imported component, inline style and !important, with real file paths, in one self-contained HTML report you can open, Slack or email.
24
23
  - **The first fixes ranked by payoff, each with its prompt.** A "Where to start" list derived from your own numbers, and every move carries a copy button with a ready-made fix prompt for your agent: the finding, the files, the expected payoff, and rules that respect deliberate craft. Fix, re-run the scan, press the next button.
25
24
  - **Rules that stop the mess coming back.** A generated `design-system-rules.md` with canonical components, your token file, and known duplicates to avoid, so your AI agent follows your system instead of guessing at it. `--apply` injects them into every agent file you have: Claude, Cursor, GitHub Copilot, and Windsurf. Every scan also checks the rules you already have for stale references: paths that no longer exist, components named canonical that nothing imports anymore.
@@ -61,7 +60,7 @@ One scan powers all of it; the flags decide what lands on disk. Combine freely.
61
60
 
62
61
  The full report for vercel/ai-chatbot, top to bottom — including "What the numbers mean", Claude's read of the scan, embedded right under the verdict:
63
62
 
64
- ![The full diagnosis report for vercel/ai-chatbot in dark mode: health score, the What the numbers mean analysis written by Claude, priced Where to start moves each with its copy-the-fix-prompt button, the wrapped present with the agent rules, an agent trap callout, three-yardstick tiles, the adoption map treemap, palette forensics, spacing receipts, typography specimens, offenders, duplicates, and the component usage ledger](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-full-dark.png?v=5.6.1)
63
+ ![The full diagnosis report for vercel/ai-chatbot in dark mode: health score, the What the numbers mean analysis written by Claude, priced Where to start moves each with its copy-the-fix-prompt button, the wrapped present with the agent rules, an agent trap callout, three-yardstick tiles, the adoption map treemap, palette forensics, spacing receipts, typography specimens, offenders, duplicates, and the component usage ledger](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-full-dark.png?v=5.7.0)
65
64
 
66
65
  The same report in light mode (one file, built-in toggle):
67
66
 
@@ -73,6 +72,7 @@ The same report in light mode (one file, built-in toggle):
73
72
  - **Read-only.** Nothing in your repo is modified. The only outputs are a temp JSON and the HTML report.
74
73
  - **No network, no telemetry.** Everything runs locally. Nothing about your code leaves your machine.
75
74
  - **Zero dependencies, enforced by the test suite.** The package installs nothing but itself, and that is a tested promise rather than a habit: the suite fails if package.json ever declares a dependency, and the exact file list npm ships is a photographed contract, so nothing can stow away in a release.
75
+ - **Honest gaps.** When a repo's components register in a pattern the scan cannot read, the component tiles say "not measured" and drop out of the score. A zero the scanner never earned is presented as blindness, not discipline.
76
76
  - **Honest exclusions.** Test files, Storybook stories, docs sites, example apps, SVG artwork, and email templates (which *must* inline styles) are excluded, so you can't discredit the numbers on a technicality. Your own exclusions (`.roastignore`, `--exclude`) are printed in the report header with file counts, so a scoped scan can never pass itself off as the whole repo.
77
77
  - **Intent-aware counting (v3).** Runtime-computed inline styles, compound-component APIs and wrapper components are not crimes and are not counted as ones. Token-led repos are judged on their hardcoded strays, not their token architecture. Repeated arbitrary values are read as decisions without names, not drift.
78
78
  - **A real benchmark.** The "Avg Design System" yardstick comes from scanning 34 public React repos (cal.com, excalidraw, supabase, grafana, twenty, dub, langfuse…). Median: 130 colours, 17 greys, 20 duplicated components, 49 inline style blocks, 70 arbitrary Tailwind values.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "5.6.2",
3
+ "version": "5.7.0",
4
4
  "mcpName": "io.github.gregkozakiewicz/roast-my-design-system",
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 34 public repos, 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.",
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 34 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": [
7
7
  "design-system",
8
8
  "mcp",
@@ -19,7 +19,11 @@
19
19
  "linter",
20
20
  "code-quality",
21
21
  "cursor",
22
- "windsurf"
22
+ "windsurf",
23
+ "web-components",
24
+ "stencil",
25
+ "lit",
26
+ "devin"
23
27
  ],
24
28
  "homepage": "https://gregkozakiewicz.github.io/roast-my-design-system/",
25
29
  "bugs": "https://github.com/gregkozakiewicz/roast-my-design-system/issues",
@@ -228,6 +228,15 @@ const nearPairs = nearColorPairs(colors);
228
228
 
229
229
  const neverImported = neverImportedComponents(h.components, h.profile?.uiDir);
230
230
 
231
+ // Measurability and role, decided by the harvest (telekom/scale, 2026-09-01):
232
+ // when the component detector could not read this repo's stack, the component
233
+ // metrics say so and take no score credit; when the repo is a published
234
+ // library, usage means composition (how the system uses itself), never
235
+ // adoption, and every accusation about orphans is softened to match.
236
+ const componentsMeasured = h.profile?.componentDetection?.measured !== false;
237
+ const notMeasuredReason = h.profile?.componentDetection?.reason ?? 'an unrecognised component pattern';
238
+ const isLibrary = h.profile?.role === 'library';
239
+
231
240
  // Benchmark helpers: for a metric value, where does this repo sit among the
232
241
  // scanned fleet? ("more colours than 90% of scanned repos")
233
242
  function percentile(metric, value) {
@@ -283,8 +292,8 @@ if (effGreys > 13 * 1.25) candidates.push({ ratio: effGreys / 13, text: tokenLed
283
292
  if (spacingTotal > 15) candidates.push({ ratio: spacingTotal / 12, text: `${spacingTotal} off-scale spacing values where a dozen would do` });
284
293
  if (nearPairs.length >= 3) candidates.push({ ratio: 1 + nearPairs.length / 6, text: `${nearPairs.length} colour pairs are nearly identical (${nearPairs[0].a.value} next to ${nearPairs[0].b.value}), copy-paste, not decisions` });
285
294
  if (important.count >= 10) candidates.push({ ratio: 1 + important.count / 30, text: `${n(important.count)} !important declarations, the cascade admitting defeat` });
286
- if (iconCollisions.length >= 5) candidates.push({ ratio: 1 + iconCollisions.length / 8, text: `two icon sets collide on ${iconCollisions.length} names` });
287
- if (hardDupes.length > 0) candidates.push({ ratio: 1 + hardDupes.length / 5, text: `${hardDupes.length} component${hardDupes.length > 1 ? 's' : ''} implemented more than once, so an agent asked for a Button has several random options` });
295
+ if (componentsMeasured && iconCollisions.length >= 5) candidates.push({ ratio: 1 + iconCollisions.length / 8, text: `two icon sets collide on ${iconCollisions.length} names` });
296
+ if (componentsMeasured && hardDupes.length > 0) candidates.push({ ratio: 1 + hardDupes.length / 5, text: `${hardDupes.length} component${hardDupes.length > 1 ? 's' : ''} implemented more than once, so an agent asked for a Button has several random options` });
288
297
  if (inline.count > 20) candidates.push({ ratio: inline.count / 20, text: `${n(inline.count)} inline style blocks bypassing every system, and teaching your agent to do the same` });
289
298
  if (arbitraryCount >= 20) candidates.push({ ratio: arbitraryCount / 30, text: `${n(arbitraryCount)} arbitrary values like ${arbitrary[0].value} punched through the Tailwind scale, each one a precedent your agent will follow` });
290
299
  if (agentFiles.length === 0) candidates.push({ ratio: 1.3, text: `no agent rules, so your AI is guessing` });
@@ -339,6 +348,7 @@ const ICONS = {
339
348
  warn: '<svg class="ic ic-warn" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12" y2="16.01"/></svg>',
340
349
  bad: '<svg class="ic ic-bad" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
341
350
  info: '',
351
+ na: '<svg class="ic ic-na" viewBox="0 0 24 24"><line x1="7" y1="12" x2="17" y2="12"/></svg>',
342
352
  };
343
353
 
344
354
  // Feather "activity", the pulse line, points reversed so it traces left to
@@ -380,6 +390,21 @@ const bigStats = [
380
390
  tile(arbitraryCount, 'arbitrary bracket values', 'arbitrary', 'a handful of deliberate exceptions'),
381
391
  ];
382
392
 
393
+ // Component metrics the detector could not measure render as such and drop
394
+ // out of the score ('na' is not a scored health); a library's never-imported
395
+ // count stays visible but unscored, with the reason on the tile.
396
+ if (!componentsMeasured) {
397
+ for (const m of ['exactDuplicates', 'neverImported']) {
398
+ const i = bigStats.findIndex((st) => st.metric === m);
399
+ bigStats[i] = { num: '—', label: bigStats[i].label, health: 'na', metric: m, healthValue: null,
400
+ rows: [{ label: `not measured: ${notMeasuredReason}`, val: '', dir: '' }] };
401
+ }
402
+ } else if (isLibrary) {
403
+ const t = bigStats.find((st) => st.metric === 'neverImported');
404
+ t.health = 'info';
405
+ t.rows.push({ label: 'library: internal use only, downstream consumers invisible', val: '', dir: '' });
406
+ }
407
+
383
408
  // Health score for the hero: the scored tiles averaged (good 100 / warn 55 / bad 10).
384
409
  const SCORE_OF = { good: 100, warn: 55, bad: 10 };
385
410
  const scoredTiles = bigStats.filter((s) => s.health in SCORE_OF);
@@ -500,6 +525,7 @@ function trapBox(text) {
500
525
  return `<div class="trap">${ICONS.warn}<span><b>Agent trap.</b> ${text}</span></div>`;
501
526
  }
502
527
  function dupesTrap() {
528
+ if (!componentsMeasured) return '';
503
529
  const hard = exactDupes.filter((d) => !d.wrapped);
504
530
  if (hard.length < 2) return '';
505
531
  const top = hard[0];
@@ -530,6 +556,7 @@ function importantTrap() {
530
556
  return trapBox(`${n(important.count)} !important declarations, and each one is a shouting match a previous developer decided to win by force. An agent whose style will not apply does what the repo taught it and shouts louder. The volume in this codebase only goes up.`);
531
557
  }
532
558
  function orphansTrap() {
559
+ if (!componentsMeasured || isLibrary) return '';
533
560
  if (neverImported.length < 10) return '';
534
561
  return trapBox(`${neverImported.length} components are never imported anywhere. An agent searching for a Button finds the abandoned ones alongside the canonical one with nothing to tell them apart, so yesterday's dead end becomes today's example.`);
535
562
  }
@@ -780,6 +807,14 @@ function orphanRows() {
780
807
  }
781
808
 
782
809
  function componentsSection() {
810
+ // The wing the inspector could not enter gets named, never skipped: a
811
+ // missing section claims "nothing worth mapping", which is a different
812
+ // claim from "I could not see them".
813
+ if (!componentsMeasured) {
814
+ return `<section class="glass pad">
815
+ ${sectionHead('The component ledger', 'not measured in this repo')}
816
+ <p class="sub">Components here are built as ${esc(notMeasuredReason)}, a pattern this scan cannot read yet. Nothing on this page makes claims about component count, duplication, usage or adoption, and the score takes no credit for those tiles. Everything measured from stylesheets stands unaffected: colours, spacing, typography, !important.</p></section>`;
817
+ }
783
818
  const adopted = reusable.filter((c) => c.usageCount > 0);
784
819
  const top = adopted.slice(0, 10);
785
820
  if (!top.length && neverImported.length < 2) return '';
@@ -793,13 +828,13 @@ function componentsSection() {
793
828
  <td class="path">${esc(c.file)}</td>
794
829
  <td>${c.propsHint?.named?.length ? c.propsHint.named.slice(0, 5).map((p) => `<span class="chip chip-xs">${esc(p)}</span>`).join(' ') : '<span class="dim">none</span>'}</td></tr>`).join('');
795
830
  return `<section class="glass pad">
796
- ${sectionHead('The adoption map', `${n(reusable.length)} components defined · tile area is import count · the real system, drawn to scale`)}
831
+ ${sectionHead(isLibrary ? 'The composition map' : 'The adoption map', `${n(reusable.length)} components defined · tile area is ${isLibrary ? 'internal use: how the system builds from itself, downstream consumers invisible from here' : 'import count'} · the real system, drawn to scale`)}
797
832
  ${mapped}
798
833
  ${overflow > 0 ? `<p class="sub">${overflow} more adopted component${overflow === 1 ? '' : 's'} below the top 24, not drawn.</p>` : ''}
799
834
  ${onceUsed.length ? `<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>` : ''}
800
835
  ${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>` : ''}
801
836
  ${neverImported.length >= 2 ? `
802
- <div class="receipts">${eyebrow(`${n(neverImported.length)} components defined but never imported · they sit in the system as wrong answers waiting to be picked`)}
837
+ <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 · they sit in the system as wrong answers waiting to be picked`)}
803
838
  ${orphanRows()}
804
839
  <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>` : ''}</section>`;
805
840
  }
@@ -900,7 +935,7 @@ function whereToStartSection() {
900
935
  const c = [];
901
936
  if (agentFiles.length === 0) c.push({ score: 60, metric: null, title: 'Write the agent rules file',
902
937
  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.` });
903
- if (hardDupes.length > 0) {
938
+ if (componentsMeasured && hardDupes.length > 0) {
904
939
  // Pick the pair with the strongest copy-paste evidence: sibling files in
905
940
  // app code (same basename, neither in a shared package) are true
906
941
  // duplicates; a library-vs-app pair may be an intentional override, and
@@ -918,7 +953,7 @@ function whereToStartSection() {
918
953
  title: `Crown the canonical &lt;${esc(d.name)}&gt;`,
919
954
  sub: `${hardDupes.length} component name${hardDupes.length > 1 ? 's have' : ' has'} competing implementations. Start with &lt;${esc(d.name)}&gt;: ${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.` });
920
955
  }
921
- if (iconCollisions.length >= 5) c.push({ score: 20 + iconCollisions.length * 3, metric: null, title: 'Merge the two icon sets',
956
+ if (componentsMeasured && iconCollisions.length >= 5) c.push({ score: 20 + iconCollisions.length * 3, metric: null, title: 'Merge the two icon sets',
922
957
  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])}.` });
923
958
  const strayOffender = offenders.find((o) => o.strayColors > 0);
924
959
  if (colorStrays > 12) {
@@ -978,7 +1013,7 @@ function whereToStartSection() {
978
1013
  title: `Unwind the ${n(important.count)} !important declarations`,
979
1014
  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.` });
980
1015
  }
981
- if (neverImported.length >= 3) {
1016
+ if (componentsMeasured && !isLibrary && neverImported.length >= 3) {
982
1017
  c.push({ score: 15 + neverImported.length, metric: 'neverImported', after: 0,
983
1018
  title: `Decide about the ${n(neverImported.length)} components nobody imports`,
984
1019
  sub: `${neverImported.slice(0, 3).map((x) => `&lt;${esc(x.name)}&gt;`).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.` });
@@ -1129,13 +1164,33 @@ function agentSection() {
1129
1164
  }
1130
1165
 
1131
1166
  // ---------- page ----------
1167
+ // One chip owns the design-system slot, and bigger titles are earned, not
1168
+ // assumed (Greg's rule, 2026-09-01): a recognised library by name; a token
1169
+ // NAMESPACE upgrades to "custom design system (--brand-*)" because
1170
+ // components-plus-tokens is the system definition this tool preaches; a
1171
+ // library with tokens but no namespace gets the plain title; a tokenless
1172
+ // library stays "component library"; a product repo we cannot read gets the
1173
+ // quiet unrecognised chip, because not knowing IS a finding here.
1174
+ const ns = h.tokens.namespaces ?? null;
1175
+ const dsChip =
1176
+ ds.kind === 'shadcn' ? 'shadcn/ui'
1177
+ : ds.kind === 'library' ? ds.name
1178
+ : ns ? `custom design system (--${ns.primary}-*${ns.partner ? ` + --${ns.partner}-*` : ''})`
1179
+ // A tokenFile alone is a technicality (Lion's is one drawer style file);
1180
+ // the plain title needs a token LAYER: ten defined colour tokens.
1181
+ : isLibrary && h.tokens.tokenFile && (h.tokens.colors ?? []).filter((c) => c.isToken).length >= 10 ? 'custom design system'
1182
+ : isLibrary ? 'component library'
1183
+ : ds.name === 'CSS tokens' ? 'CSS tokens'
1184
+ : null;
1132
1185
  const stack = [
1133
1186
  h.profile.framework !== 'unknown' ? h.profile.framework : null,
1134
1187
  h.profile.typescript ? 'TypeScript' : null,
1135
1188
  ...(h.profile.stylingDeps ?? []),
1136
- ds.kind === 'shadcn' ? 'shadcn/ui' : ds.kind === 'library' ? ds.name : null,
1189
+ dsChip,
1137
1190
  h.profile.monorepo ? 'monorepo' : null,
1138
1191
  ].filter(Boolean);
1192
+ const dsUnrecognised = !dsChip && !noSystemLikely;
1193
+ const legacyChip = ns?.others?.length ? `also present: ${ns.others.map((l) => `--${l}-*`).join(', ')}` : null;
1139
1194
 
1140
1195
  // User exclusions are printed in the header, never hidden: a scoped scan must
1141
1196
  // say it is scoped, or the score could be quietly gamed. Grouped by source
@@ -1364,6 +1419,9 @@ const html = `<!doctype html>
1364
1419
  border-radius:99px; padding:2px 10px; }
1365
1420
  .vchip.bad { color:var(--coral); border-color:var(--coral-soft); background:var(--coral-soft); }
1366
1421
  .vchip.dim { color:var(--dim2); }
1422
+ .chip-dim { opacity:.6; font-style:italic; }
1423
+ .st-na .num { color:var(--dim2); }
1424
+ .ic-na { stroke:var(--dim2); }
1367
1425
  .amap { width:100%; height:auto; display:block; margin:10px 0 4px; }
1368
1426
  .atile { fill:var(--ok-soft); stroke:var(--bg, #0f172a); stroke-width:2; }
1369
1427
  .orow { display:flex; align-items:baseline; gap:12px; margin-top:8px; }
@@ -1511,7 +1569,7 @@ const html = `<!doctype html>
1511
1569
  </div>
1512
1570
  ${healthScore !== null ? `<div class="score${noSystemLikely ? ' muted' : ''}">${eyebrow('Health score')}<div class="val">${healthScore}<span class="slash">/</span><span class="of">100</span></div>${noSystemLikely ? '<div class="note">little here to score · see the note below</div>' : ''}</div>` : ''}
1513
1571
  </div>
1514
- <div class="chips">${stack.map((s) => `<span class="chip">${esc(s)}</span>`).join('')}${agentFiles.map((c) => `<span class="chip chip-agent">${esc(c.file)}</span>`).join('')}</div>
1572
+ <div class="chips">${stack.map((s) => `<span class="chip">${esc(s)}</span>`).join('')}${dsUnrecognised ? '<span class="chip chip-dim">design system: unrecognised</span>' : ''}${legacyChip ? `<span class="chip chip-dim">${esc(legacyChip)}</span>` : ''}${agentFiles.map((c) => `<span class="chip chip-agent">${esc(c.file)}</span>`).join('')}</div>
1515
1573
  ${exclusionsLine()}
1516
1574
  ${noSystemLikely ? `<div class="nods">${ICONS.warn}<span>There is most likely <b>no design system in this repo</b>: almost no colour or spacing values were found. Styling may live outside this codebase (CDN stylesheets, a parent repo, or generated output).</span></div>` : ''}
1517
1575
  <div class="glass verdict-card">
@@ -1645,6 +1703,8 @@ if (summaryPath) {
1645
1703
  score: healthScore,
1646
1704
  noSystemLikely,
1647
1705
  verdict,
1706
+ role: h.profile?.role ?? 'product',
1707
+ componentsMeasured,
1648
1708
  tiles: bigStats.map((s) => ({ label: s.label, value: s.num, health: s.health })),
1649
1709
  ...(startMoves.length ? { moves: startMoves } : {}),
1650
1710
  packages: (h.packages ?? []).filter((p) => p.scored && p.metrics)
@@ -122,6 +122,31 @@ export function definedComponents(src) {
122
122
 
123
123
  const looksLikeJSXFile = (src) => /<[A-Za-z][\w.]*[\s/>]/.test(src);
124
124
 
125
+ // ---------- web components: the tag-registered world ----------
126
+ // Stencil, Lit and raw customElements.define register components by TAG, and
127
+ // pages consume them as <kebab-tags> — invisible to the JSX pass, which is
128
+ // how 93 Telekom components once scanned as one (telekom/scale, 2026-09-01).
129
+ // Generated framework wrappers (Stencil's react/vue/angular output targets)
130
+ // are skipped for definitions AND usage, or every component would gain
131
+ // phantom adoption from its own machine-made bindings.
132
+ const GENERATED_RE = /(@stencil\/(?:react|vue|angular)-output-target|output-target|auto-?generated|generated file|do not edit)/i;
133
+ const TAG_RE = String.raw`([a-z][\w]*(?:-[\w]+)+)`;
134
+ export function webComponentDefs(src) {
135
+ const defs = [];
136
+ const classAfter = (idx) => src.slice(idx).match(/(?:export\s+)?class\s+([A-Za-z]\w*)/)?.[1];
137
+ for (const m of src.matchAll(new RegExp(String.raw`@Component\(\s*\{[^{}]*?tag\s*:\s*['"]${TAG_RE}['"]`, 'g')))
138
+ defs.push({ name: classAfter(m.index) ?? m[1], tag: m[1] });
139
+ for (const m of src.matchAll(new RegExp(String.raw`@customElement\(\s*['"]${TAG_RE}['"]\s*\)\s*(?:export\s+)?class\s+([A-Za-z]\w*)`, 'g')))
140
+ defs.push({ name: m[2], tag: m[1] });
141
+ for (const m of src.matchAll(new RegExp(String.raw`customElements\.define\(\s*['"]${TAG_RE}['"]\s*,\s*([A-Za-z]\w*)`, 'g')))
142
+ defs.push({ name: m[2], tag: m[1] });
143
+ // Shoelace-style static registration: SlButton.define('sl-button')
144
+ for (const m of src.matchAll(new RegExp(String.raw`\b([A-Z]\w*)\.define\(\s*['"]${TAG_RE}['"]`, 'g')))
145
+ defs.push({ name: m[1], tag: m[2] });
146
+ const seen = new Set();
147
+ return defs.filter((d) => !seen.has(d.tag) && seen.add(d.tag));
148
+ }
149
+
125
150
  /** Count real `<Name` tag usages of `name` in `src` (tag boundary checked). */
126
151
  function countUsages(src, name) {
127
152
  let count = 0, from = 0, open;
@@ -186,6 +211,34 @@ export function harvestComponents(root, codeFiles) {
186
211
  }
187
212
  }
188
213
 
214
+ // Web-component pass: definitions by tag registration (any code file, JSX
215
+ // or not — Lit lives in plain .ts), usage counted as the kebab tag across
216
+ // every non-generated source.
217
+ const wcSources = new Map();
218
+ for (const f of codeFiles.filter((x) => /\.(tsx|jsx|ts|js|mjs)$/.test(x))) {
219
+ let src = sources.get(f);
220
+ if (src === undefined) { try { src = readFileSync(join(root, f), 'utf8'); } catch { continue; } }
221
+ if (GENERATED_RE.test(src.slice(0, 2000))) continue;
222
+ wcSources.set(f, src);
223
+ }
224
+ const wcComponents = [];
225
+ for (const [file, src] of wcSources) {
226
+ for (const def of webComponentDefs(src)) {
227
+ wcComponents.push({ name: def.name, tag: def.tag, file, isPage: false,
228
+ variants: extractVariants(src), propsHint: null, usageCount: 0, usedIn: [] });
229
+ }
230
+ }
231
+ for (const [file, src] of wcSources) {
232
+ for (const def of wcComponents) {
233
+ if (def.file === file) continue;
234
+ const u = countUsages(src, def.tag);
235
+ if (!u) continue;
236
+ def.usageCount += u;
237
+ if (def.usedIn.length < 8) def.usedIn.push(file);
238
+ }
239
+ }
240
+ components.push(...wcComponents);
241
+
189
242
  components.sort((a, b) => b.usageCount - a.usageCount || a.name.localeCompare(b.name));
190
243
 
191
244
  // Pass 3: golden examples — for the most-used components, quote the repo's
@@ -62,6 +62,38 @@ const staleRules = ruleStaleness(target, components,
62
62
  new Set(neverImportedComponents(components, profile.uiDir).map((c) => c.name)),
63
63
  [...files.code, ...files.styles, ...files.other]);
64
64
 
65
+ // Repo role and measurability, decided from what the scan actually found.
66
+ // A published components package with no app pages is a LIBRARY: its real
67
+ // consumers live in other repos, so usage counts mean composition, not
68
+ // adoption, and the report's language follows. And when a component stack
69
+ // the detector cannot read is present while it found almost nothing, the
70
+ // component metrics are declared NOT MEASURED instead of scoring zeros as
71
+ // discipline (the telekom/scale lesson, 2026-09-01).
72
+ {
73
+ const reusableCount = components.filter((c) => !c.isPage).length;
74
+ // A web-components repo with many tag-registered components and no pages
75
+ // is a library by construction, whatever its packages are named (Ionic's
76
+ // @ionic/core and Material Web both dodge the name rule).
77
+ // A handful of pages next to hundreds of components is a demo or test app
78
+ // riding in the library's monorepo (Siemens iX ships two), not a product.
79
+ const pageCount = components.length - reusableCount;
80
+ const fewPages = pageCount <= reusableCount * 0.05;
81
+ profile.role = (profile.libraryPkg && fewPages && reusableCount >= 5)
82
+ || (String(profile.framework).startsWith('web components') && fewPages && reusableCount >= 10)
83
+ ? 'library' : 'product';
84
+ // The gate fires on the situation, never a list of known frameworks: a
85
+ // repo with real code volume where the detector found almost nothing is a
86
+ // repo we could not read, whatever it is written in (the Shoelace lesson:
87
+ // its .define() idiom was invisible and the named-framework gate stayed
88
+ // silent, which would have shipped blind zeros as discipline again).
89
+ // Either trigger suffices: a named unreadable stack (Vue/Angular/Svelte
90
+ // markers, however small the repo), or sheer volume the detector saw
91
+ // nothing in (the Shoelace near-miss: unknown idiom, no marker).
92
+ profile.componentDetection = reusableCount < 3 && (profile.unreadableComponentStack || files.code.length >= 40)
93
+ ? { measured: false, reason: profile.unreadableComponentStack ?? 'a component pattern this scan cannot read' }
94
+ : { measured: true };
95
+ }
96
+
65
97
  // Orphans carry a receipt: the last time git saw anyone touch the file.
66
98
  // Only never-imported components are dated (the adoption map's evidence);
67
99
  // outside a git repo the field is simply absent and no claim is made.
@@ -32,7 +32,12 @@ class Tally {
32
32
 
33
33
  // ---------- colour parsing ----------
34
34
  const HEX_RE = /#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g;
35
- const FUNC_COLOR_RE = /\b(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\(\s*[^)]{1,80}\)/g;
35
+ const FUNC_COLOR_RE = /\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(\s*[^)]{1,80}\)|\bcolor\(\s*(?:from|srgb|srgb-linear|display-p3|a98-rgb|prophoto-rgb|rec2020|xyz(?:-d(?:50|65))?)\b[^)]{0,80}\)/g;
36
+
37
+ /** Fully transparent values are the CSS word for "nothing here" — counting
38
+ * rgba(0,0,0,0) as a palette colour 581 times (Siemens iX) inflates every
39
+ * colour metric with non-decisions. */
40
+ export const isTransparent = (v) => /^(?:rgba|hsla)\(\s*[\d.,%\s]+,\s*0(?:\.0+)?\s*\)$/.test(v) || /\/\s*0(?:\.0+)?\s*\)$/.test(v);
36
41
 
37
42
  /** normalize: lowercase; expand #abc → #aabbcc so duplicates merge. */
38
43
  export function normalizeHex(hex) {
@@ -197,17 +202,28 @@ export function harvestTokens(root, styleFiles, codeFiles) {
197
202
  // hinges on this split.
198
203
  const tokenDefined = new Set();
199
204
  const tokenDefsPerFile = new Map();
205
+ const nsDefs = new Map(); // --telekom-x: value → 'telekom' (definitions)
206
+ const nsRefs = new Map(); // var(--telekom-x) → 'telekom' (references)
200
207
 
201
208
  const scanCssText = (text, file) => {
202
209
  const imp = (text.match(/!\s*important/gi) ?? []).length;
203
210
  if (imp) importantFiles.set(file, (importantFiles.get(file) ?? 0) + imp);
204
- for (const m of text.matchAll(/--[\w-]+\s*:\s*([^;{}]+)[;}]/g)) {
211
+ for (const m of text.matchAll(/--([\w-]+)\s*:\s*([^;{}]+)[;}]/g)) {
205
212
  tokenDefsPerFile.set(file, (tokenDefsPerFile.get(file) ?? 0) + 1);
206
- for (const c of m[1].matchAll(HEX_RE)) tokenDefined.add(normalizeHex(c[0]));
207
- for (const c of m[1].matchAll(FUNC_COLOR_RE)) tokenDefined.add(c[0].replace(/\s+/g, ' ').toLowerCase());
213
+ const stem = m[1].split('-')[0].toLowerCase();
214
+ if (stem) nsDefs.set(stem, (nsDefs.get(stem) ?? 0) + 1);
215
+ for (const c of m[2].matchAll(HEX_RE)) tokenDefined.add(normalizeHex(c[0]));
216
+ for (const c of m[2].matchAll(FUNC_COLOR_RE)) tokenDefined.add(c[0].replace(/\s+/g, ' ').toLowerCase());
217
+ }
218
+ // References count too: a system whose tokens are defined in a package
219
+ // dependency still answers to its namespace in every var(--telekom-...)
220
+ // (telekom/scale defines few tokens in-repo but references hundreds).
221
+ for (const m of text.matchAll(/var\(\s*--([\w-]+)/g)) {
222
+ const stem = m[1].split('-')[0].toLowerCase();
223
+ if (stem) nsRefs.set(stem, (nsRefs.get(stem) ?? 0) + 1);
208
224
  }
209
225
  for (const m of text.matchAll(HEX_RE)) colors.add(normalizeHex(m[0]), file);
210
- for (const m of text.matchAll(FUNC_COLOR_RE)) colors.add(m[0].replace(/\s+/g, ' ').toLowerCase(), file);
226
+ for (const m of text.matchAll(FUNC_COLOR_RE)) { const v = m[0].replace(/\s+/g, ' ').toLowerCase(); if (!isTransparent(v)) colors.add(v, file); }
211
227
  for (const m of text.matchAll(SPACING_PROPS)) {
212
228
  for (const len of (m[2].match(LENGTH_RE) ?? [])) spacing.add(len, file);
213
229
  }
@@ -215,8 +231,12 @@ export function harvestTokens(root, styleFiles, codeFiles) {
215
231
  for (const m of text.matchAll(FONTSIZE_PROPS)) fontSizes.add(m[1].trim(), file);
216
232
  for (const m of text.matchAll(FONTFAMILY_PROPS)) {
217
233
  const v = m[1].trim().replace(/\s+/g, ' ');
218
- // var(--x) and inherit are disciplined token usage, not declarations
234
+ // var(--x) and inherit are disciplined token usage, not declarations —
235
+ // and so are Sass token references ($label-text-font, map.get($tokens,
236
+ // ...)), Material Web's whole idiom: 59 fake "typefaces" once counted
237
+ // where one Roboto lived behind tokens (caught 2026-09-02).
219
238
  if (/^(var\(--[\w-]+\)|inherit)$/i.test(v)) continue;
239
+ if (v.startsWith('$') || /\bmap[.-]get\b/.test(v)) continue;
220
240
  fontFamilies.add(v, file);
221
241
  }
222
242
  for (const m of text.matchAll(SHADOW_PROPS)) shadows.add(m[1].trim().replace(/\s+/g, ' '), file);
@@ -248,6 +268,24 @@ export function harvestTokens(root, styleFiles, codeFiles) {
248
268
  if (EXEMPT_RE.test(f)) continue;
249
269
  let src; try { src = readFileSync(join(root, f), 'utf8'); } catch { continue; }
250
270
 
271
+ // CSS-in-tagged-templates (Lit css``, styled-components css``) IS the
272
+ // stylesheet in those worlds: Shoelace keeps its entire component styling
273
+ // in .styles.ts files, and skipping them scored a mostly-unread repo 94
274
+ // (caught by Greg, 2026-09-02). Each block runs through the same CSS
275
+ // scanner as a stylesheet; interpolations are blanked first (dynamic
276
+ // values are not static styling); the block text is then removed from
277
+ // the raw-hex pass below so nothing is counted twice.
278
+ {
279
+ let cssBlocks = 0;
280
+ for (const m of src.matchAll(/(?:^|[\s=(,:])css\s*`([^`]*)`/gs)) {
281
+ const text = m[1].replace(/\$\{[^}]*\}/g, ' ');
282
+ if (text.length < 20) continue;
283
+ scanCssText(text, f);
284
+ cssBlocks++;
285
+ }
286
+ if (cssBlocks) src = src.replace(/(?:^|[\s=(,:])css\s*`([^`]*)`/gs, ' ');
287
+ }
288
+
251
289
  // A Tailwind config's colours ARE the token layer (the tailwind-native
252
290
  // equivalent of --var definitions). Its fontFamily block is the typeface
253
291
  // declaration for the whole app.
@@ -350,8 +388,53 @@ export function harvestTokens(root, styleFiles, codeFiles) {
350
388
 
351
389
  const tokenFile = [...tokenDefsPerFile.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
352
390
 
391
+ // Token namespace: the brand stem the repo's custom properties answer to
392
+ // (--telekom-*), named only when earned — enough definitions, a dominant
393
+ // share, not a generic category word. A second qualifying stem is the
394
+ // fossil of a migration, named as legacy. Below the bar: no claim.
395
+ const NS_GENERIC = new Set(['color', 'colors', 'colour', 'font', 'fonts', 'text', 'spacing', 'space',
396
+ 'size', 'sizes', 'bg', 'background', 'border', 'shadow', 'shadows', 'radius', 'radii', 'grey', 'gray',
397
+ 'theme', 'base', 'global', 'primary', 'secondary', 'brand', 'var', 'css', 'root', 'light', 'dark',
398
+ 'transition', 'duration', 'animation', 'ease', 'focus', 'hover', 'active', 'disabled', 'gap', 'grid', 'index',
399
+ 'container', 'content', 'item', 'label', 'icon', 'surface', 'outline', 'track', 'thumb', 'state', 'input',
400
+ 'width', 'height', 'margin', 'padding', 'opacity', 'line', 'letter']);
401
+ let namespaces = null;
402
+ {
403
+ // The contest is namespace vs namespace, never namespace vs category
404
+ // words: --telekom-* competes with --scl-*, not with --color-*. Primary
405
+ // must carry a real share of all custom-property traffic AND clearly beat
406
+ // its nearest rival; every other qualifying rival is a legacy namespace,
407
+ // the fossil of a migration, named as such.
408
+ const nsAll = new Map();
409
+ for (const [st, c] of nsDefs) nsAll.set(st, c);
410
+ for (const [st, c] of nsRefs) nsAll.set(st, (nsAll.get(st) ?? 0) + c);
411
+ const total = [...nsAll.values()].reduce((a, b) => a + b, 0);
412
+ const candidates = [...nsAll.entries()]
413
+ .filter(([st, c]) => st.length >= 2 && !NS_GENERIC.has(st) && c >= 10 && c / total >= 0.05)
414
+ .sort((a, b) => b[1] - a[1]);
415
+ const [top, second] = candidates;
416
+ if (top && top[1] / total >= 0.25) {
417
+ // The machine names namespaces; it never judges them. "Legacy" failed
418
+ // the admission test across seven real systems (2026-09-02): scale's
419
+ // dead --scl-* and Baloise's live --mod-* tier share the exact same
420
+ // defs/refs shape, so no ratio can tell abandonment from architecture.
421
+ // Facts only: a co-equal partner joins the title; every other
422
+ // qualifying namespace is reported as present, verdict-free. The roast
423
+ // notes, which can read deprecation notices and docs, make the legacy
424
+ // call per repo — judgment stays where context lives.
425
+ if (!second || top[1] >= second[1] * 2) {
426
+ const others = candidates.slice(1).map(([st]) => st);
427
+ namespaces = { primary: top[0], count: top[1], totalRefs: total, ...(others.length ? { others } : {}) };
428
+ } else if (second[1] / total >= 0.15) {
429
+ const others = candidates.slice(2).map(([st]) => st);
430
+ namespaces = { primary: top[0], partner: second[0], count: top[1], totalRefs: total, ...(others.length ? { others } : {}) };
431
+ }
432
+ }
433
+ }
434
+
353
435
  return {
354
436
  tokenFile,
437
+ namespaces,
355
438
  colors: colorList,
356
439
  offenders: offenderList,
357
440
  greyCount: colorList.filter((c) => c.value.startsWith('#') && isGrey(c.value)).length,
@@ -145,14 +145,42 @@ export function profileRepo(root, files) {
145
145
  // Hand-built sites are a real category, not a detection failure: "unknown"
146
146
  // next to a good score reads like a shrug, so name what is actually there.
147
147
  const htmlFiles = files.other.filter((f) => /\.html?$/.test(f)).length;
148
+ // Web-component builders come FIRST: a Stencil monorepo ships generated
149
+ // React/Vue/Angular wrappers whose deps would otherwise win, and calling
150
+ // Telekom's Stencil system "react" is how a report loses its reader in one
151
+ // glance (learned on telekom/scale, 2026-09-01).
148
152
  const framework =
149
- deps.next ? 'next'
153
+ deps['@stencil/core'] ? 'web components (Stencil)'
154
+ : deps.lit || deps['lit-element'] ? 'web components (Lit)'
155
+ : deps.next ? 'next'
150
156
  : deps['@remix-run/react'] || deps['@react-router/dev'] ? 'remix'
151
157
  : deps.vite && deps.react ? 'vite-react'
152
158
  : deps.react ? 'react'
159
+ : deps.vue ? 'vue'
160
+ : deps['@angular/core'] ? 'angular'
161
+ : deps.svelte ? 'svelte'
153
162
  : htmlFiles > 0 ? 'static HTML/CSS'
154
163
  : 'unknown';
155
164
 
165
+ // A published components package marks a LIBRARY: a repo whose consumers
166
+ // live in other repos entirely. The distinction changes what usage counts
167
+ // mean (composition, not adoption) and how orphans may be judged.
168
+ // Two honest signals: the ROOT package being publishable (private !== true
169
+ // with a main/module/exports) marks the whole repo as the product being
170
+ // shipped, whatever it is named (Shoelace); a publishable SUB-package needs
171
+ // a components-ish name, or every monorepo publishing anything would flip.
172
+ let libraryPkg = null;
173
+ if (pkg.private !== true && (pkg.main || pkg.module || pkg.exports)) libraryPkg = pkg.name ?? 'root package';
174
+ if (!libraryPkg) {
175
+ for (const f of pkgFiles) {
176
+ if (f === 'package.json') continue;
177
+ const p = readJSON(join(root, f));
178
+ if (!p || p.private === true) continue;
179
+ if (!(p.main || p.module || p.exports)) continue;
180
+ if (/components|design.?system|design.?tokens|ui-kit|\bui\b/i.test(p.name ?? '')) { libraryPkg = p.name; break; }
181
+ }
182
+ }
183
+
156
184
  const componentsJson = readJSON(join(root, 'components.json'));
157
185
  const uiDir = ['src/components/ui', 'components/ui', 'app/components/ui'].find((d) => existsSync(join(root, d)));
158
186
  const isShadcn = Boolean(componentsJson) || Boolean(uiDir);
@@ -208,5 +236,13 @@ export function profileRepo(root, files) {
208
236
  stylingDeps: styling,
209
237
  importAlias: componentsJson?.aliases?.components?.split('/')[0] || alias || null,
210
238
  uiDir: uiDir || null,
239
+ libraryPkg,
240
+ // markers the component detector cannot read (yet): used by the harvest
241
+ // to declare component metrics "not measured" instead of scoring zeros
242
+ unreadableComponentStack:
243
+ deps.vue || files.other.some((f) => f.endsWith('.vue')) ? 'Vue single-file components'
244
+ : deps['@angular/core'] ? 'Angular components'
245
+ : deps.svelte || files.other.some((f) => f.endsWith('.svelte')) ? 'Svelte components'
246
+ : null,
211
247
  };
212
248
  }
@@ -1,4 +1,4 @@
1
1
  // Single version constant for the engine — imported by diagnose (report
2
2
  // footer) and rules (generated-by line). This is the bump spot that used to
3
3
  // live as a const inside diagnose/index.mjs.
4
- export const VERSION = '5.6.2';
4
+ export const VERSION = '5.7.0';
@@ -51,6 +51,11 @@ const repoName = h.profile?.name ?? 'this repo';
51
51
  const tokenCount = (t.colors ?? []).filter((c) => c.isToken).length;
52
52
  if (t.tokenFile || (t.colors ?? []).length) {
53
53
  section('Colours and tokens');
54
+ if (t.namespaces?.primary) {
55
+ const nsLabel = t.namespaces.partner ? `\`--${t.namespaces.primary}-*\` and \`--${t.namespaces.partner}-*\` namespaces` : `\`--${t.namespaces.primary}-*\` namespace`;
56
+ rule(`This system's tokens answer to the ${nsLabel}. Reach for an existing \`var(--...)\` from them before inventing any value.`);
57
+ if (t.namespaces.others?.length) rule(`${t.namespaces.others.map((l) => `\`--${l}-*\``).join(', ')} ${t.namespaces.others.length === 1 ? 'is also' : 'are also'} present in the code. Before writing new references to ${t.namespaces.others.length === 1 ? 'it' : 'them'}, check whether this repo treats ${t.namespaces.others.length === 1 ? 'it' : 'them'} as current or as a migration source; when unsure, prefer \`--${t.namespaces.primary}-*\`.`);
58
+ }
54
59
  if (t.tokenFile) {
55
60
  rule(`Design tokens live in \`${t.tokenFile}\`. Reach for an existing token before inventing any value.`);
56
61
  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.`);