roast-my-design-system 7.7.0 → 7.9.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
@@ -1,4 +1,4 @@
1
- <img src="https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/roaster_logo_300px.png?v=7.7.0" width="72" alt="roast-my-design-system">
1
+ <img src="https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/roaster_logo_300px.png?v=7.9.0" width="72" alt="roast-my-design-system">
2
2
 
3
3
  # roast-my-design-system
4
4
 
@@ -10,6 +10,8 @@
10
10
 
11
11
  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.
12
12
 
13
+ > **New in 7.9: a shadcn/lint config is read as the team's declared policy.** shadcn shipped `@shadcn/lint` on 14 September, six rules with errors that name the variant or theme variable to use. When a repo runs it, the report shows which rules are on and what each allows, read from `.oxlintrc.json` in full or from `eslint.config.*` by pattern, and marks the palette colours the config allows. The score does not move: what a linter allows is a team decision, and the agent still reads the class. Checked against all 44 example configs in shadcn/lint's docs at 0.1.0. `summary.json` gains `shadcn.lint`.
14
+
13
15
  > **New in 7.7: four repo profiles, and the fourth is `registry`.** The scanner decides once what kind of repo it is reading, `product`, `library`, `shadcn` or `registry`, and every count, ideal and benchmark line reads that decision. `registry` is new: a repo that publishes a shadcn registry (a `registry.json` with typed items, or a route that builds one from a packages folder) is counted on what it publishes and nothing else. The docs site, demos and examples are kept out and named in the header with file counts; the same components kept in several variants count once; a name repeated only inside published blocks is the range, not a duplicate; published components are the project's own work, with no installed-code exemption. Published themes get their own tile: every shadcn colour variable present for light and for dark, ideal 0. For a registry that publishes themes and no code, that check is the score. Covers shadcn's own source (84), magicui (69), kibo-ui (78) and tweakcn (100, 36 themes, all complete). No other kind of repo moves. `summary.json`: `kind: "registry"` with a `registry` block (`publishes`, `counted`, `showcase`, `variantsDropped`, `themes`); tiles gain `themesIncomplete` on registries.
14
16
 
15
17
  > **New in 7.0.** The scoring code is now a separate file that other tools can import, so a CI check gets the same score as the report. Every `summary.json` now records its schema version and which benchmark it was scored against. Five counting bugs are fixed: a bracket spacing class like `p-[13px]` was counted twice, a hex colour inside a CSS comment was counted, and three smaller ones. Most repos lose a few counts. shadcn/ui goes from 325 colours to 323 and keeps its score of 65. Large monorepos scan about ten times faster. If you use the score as a CI threshold, run a fresh scan before comparing. If you read the JSON, a tile's `value` is now a number and the printed text is in `display`.
@@ -75,11 +77,11 @@ Not to be confused with each other: **"Why this matters"** is generic, ships wit
75
77
 
76
78
  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:
77
79
 
78
- ![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 the summary, 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, 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](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-full-dark.png?v=7.7.0)
80
+ ![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 the summary, 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, 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](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-full-dark.png?v=7.9.0)
79
81
 
80
82
  The same report in light mode (one file, built-in toggle):
81
83
 
82
- ![The diagnosis report in light mode](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-light-hero.png?v=7.7.0)
84
+ ![The diagnosis report in light mode](https://raw.githubusercontent.com/gregkozakiewicz/roast-my-design-system/main/assets/report-light-hero.png?v=7.9.0)
83
85
 
84
86
  ## What makes the numbers trustworthy
85
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roast-my-design-system",
3
- "version": "7.7.0",
3
+ "version": "7.9.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 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": [
@@ -33,6 +33,7 @@ import { WHY } from './why.mjs';
33
33
  import { parseColor, luminance, isGrey } from '../lib/color.mjs';
34
34
  import { loadBenchmark, benchHelpers, makeHealthOf, coreMetrics, tileHealths, scoreOfTiles, scoreBreakdown, scorePackage as scorePackageOf, ZERO_IDEAL, WARN_TOLERANCE, SCORE_OF, SCHEMA_VERSION } from './score.mjs';
35
35
  import { ownSpacing, profileOf, installedDirs, splitArbitrary } from '../profiles/index.mjs';
36
+ import { rulesOn as lintRulesOn, describeRule as lintDescribe, entryMatcher as lintMatcher, RULE_TILE as LINT_TILE } from '../profiles/shadcn-lint.mjs';
36
37
  import { publishesLine } from '../profiles/registry.mjs';
37
38
 
38
39
  // The benchmark and every judgement made against it live in score.mjs; this
@@ -619,13 +620,20 @@ function sheetSection() {
619
620
  } else if (sheet) {
620
621
  parts.push(`<div class="receipts">${eyebrow('the theme')}<p class="sub">components.json names ${esc(sheet.file)} as the theme file, and it was not found.</p></div>`);
621
622
  }
623
+ if (P.shadcn?.lint) {
624
+ const L = P.shadcn.lint;
625
+ const on = lintRulesOn(L);
626
+ const rows = on.map((r) => `<div class="ledger-row"><span class="mono strong">shadcn/${esc(r)}</span><span class="dim">${esc(lintDescribe(r, L.rules[r]).replace(/^[\w-]+\s?/, '').replace(/^\((.*)\)$/, '$1') || 'on')}${LINT_TILE[r] ? '' : ' · no tile on this page reads it'}</span></div>`).join('');
627
+ parts.push(`<div class="receipts">${eyebrow(`shadcn/lint is set up · ${n(on.length)} of 6 rules on · ${esc(L.file)}`)}<p class="sub">${L.readFully ? 'Read from the config file.' : 'The config is code; the rule names were read, some options were not.'} What the linter allows is the team's decision and is marked below, and the score does not move: your agent still reads the class. ${on.length ? 'An agent that runs the lint gets an error with the variant or variable to use instead.' : 'No shadcn rule is switched on yet.'}</p>${rows ? `<div class="ledger">${rows}</div>` : ''}${L.overrides ? `<p class="sub">${n(L.overrides)} override${L.overrides === 1 ? '' : 's'} scope the rules to folders.</p>` : ''}</div>`);
628
+ }
622
629
  if (P.isRegistry && P.registry?.themes?.total) {
623
630
  const t = P.registry.themes;
624
631
  const rows = t.worst.map((w) => `<div class="ledger-row"><span class="mono strong">${esc(w.name)}</span><span class="dim">${w.missingLight.length ? `light missing ${w.missingLight.slice(0, 4).map((r) => `--${esc(r)}`).join(', ')}${w.missingLight.length > 4 ? ` and ${w.missingLight.length - 4} more` : ''}` : ''}${w.missingLight.length && w.missingDark.length ? ' · ' : ''}${w.missingDark.length ? `dark missing ${w.missingDark.slice(0, 4).map((r) => `--${esc(r)}`).join(', ')}${w.missingDark.length > 4 ? ` and ${w.missingDark.length - 4} more` : ''}` : ''}</span></div>`).join('');
625
632
  parts.push(`<div class="receipts">${eyebrow(`${n(t.total)} published theme${t.total === 1 ? '' : 's'} · ${n(t.incomplete)} incomplete`)}<p class="sub">${t.incomplete ? `A theme missing a variable ships that gap into every repo that installs it. ${t.incomplete === 1 ? 'The one' : `The ${t.incomplete}`} below ${t.incomplete === 1 ? 'is' : 'are'} short of the full set in at least one mode.` : 'Every published theme carries every shadcn colour variable, for light and for dark.'}</p>${rows ? `<div class="ledger">${rows}</div>` : ''}${whyToggle('themesIncomplete')}</div>`);
626
633
  }
627
634
  if (paint) {
628
- const tinChips = (paint.tin.samples ?? []).slice(0, 8).map((s) => `<span class="vchip bad">${esc(s.value)} ×${s.count}</span>`).join('');
635
+ const rawAllow = P.shadcn?.lint?.rules?.['no-raw-colors'] ? lintMatcher(P.shadcn.lint.rules['no-raw-colors'].allow) : null;
636
+ const tinChips = (paint.tin.samples ?? []).slice(0, 8).map((s) => `<span class="vchip bad">${esc(s.value)} ×${s.count}${rawAllow?.test(s.value) ? ' · allowed by your lint config' : ''}</span>`).join('');
629
637
  const tinFiles = (paint.tin.top ?? []).slice(0, 5).map((f) => `<span class="vchip" title="${esc(f.file)}">${esc(basename(f.file))} ×${f.count}</span>`).join('');
630
638
  const rp = sc.registryPaint ?? null;
631
639
  parts.push(`<div class="receipts">${eyebrow(`${n(paint.tin.uses)} colours from outside the theme in ${n(paint.tin.files)} of ${n(paint.ownFiles)} own files · ${n(paint.tin.per100)} per 100 files`)}${paint.tin.uses ? `<div class="chips-row">${tinChips}</div><div class="chips-row">${tinFiles}</div>` : '<p class="sub">Your own code takes every colour from the theme file. This is what shadcn is designed for.</p>'}${rp?.tinUses ? `<p class="sub">Not counted above: ${n(rp.tinUses)} palette colour${rp.tinUses === 1 ? '' : 's'} inside ${esc(rp.dirs.map((d) => basename(d)).join(', '))}, installed by a registry rather than written here. An agent reading those files will still copy them.</p>` : ''}</div>`);
@@ -1240,16 +1248,18 @@ function agentSection() {
1240
1248
  const managedBy = managedOnly ? agentFiles[0].managedBy : null;
1241
1249
  const skill = (h.context ?? []).find((c) => c.kind === 'agent-skill');
1242
1250
  const skillLine = P.isShadcn ? (skill ? ` The shadcn skill is installed (<span class="mono">${esc(skill.file)}</span>), so the agent can read the kit's configuration.` : ' shadcn offers a skill for that and it is not installed here.') : '';
1251
+ const lintOn = P.shadcn?.lint ? lintRulesOn(P.shadcn.lint) : [];
1252
+ const lintLine = P.shadcn?.lint ? ` shadcn/lint is set up${lintOn.length ? ` with ${lintOn.length} rule${lintOn.length === 1 ? '' : 's'} on` : ', no rule on yet'}: an agent that runs it gets an error with the fix when it goes off-theme.` : '';
1243
1253
  // the files name the kit (or the skill is installed): the gap is the
1244
1254
  // repo's own habits, which no hand-written file carries
1245
1255
  const namesKit = P.isShadcn && (skill || agentFiles.some((c) => c.mentionsDesign === true && c.managedOnly !== true));
1246
1256
  const msg = managedOnly
1247
- ? `<p class="sub">Your agent reads ${readsList}. ${esc(managedBy)} wrote it, and it is only about ${esc(managedBy)}: nothing in it mentions the theme file or the component folder, so the agent does not know the ${P.isShadcn ? 'kit' : 'design system'} is there.${skillLine} Add the rules file below and the agent knows the theme, the components and the styling rules.</p>`
1257
+ ? `<p class="sub">Your agent reads ${readsList}. ${esc(managedBy)} wrote it, and it is only about ${esc(managedBy)}: nothing in it mentions the theme file or the component folder, so the agent does not know the ${P.isShadcn ? 'kit' : 'design system'} is there.${skillLine}${lintLine} Add the rules file below and the agent knows the theme, the components and the styling rules.</p>`
1248
1258
  : namesKit
1249
- ? `<p class="sub">Your agent reads ${readsList}${nestedCount && rootAgent.length ? `, plus ${nestedCount} rules file${nestedCount === 1 ? '' : 's'} nested in subfolders` : ''}${skill ? `, and the shadcn skill is installed (<span class="mono">${esc(skill.file)}</span>)` : ''}. ${skill && !agentFiles.some((c) => c.mentionsDesign) ? 'The skill tells it' : 'They tell it'} the kit is there. What ${skill && !agentFiles.some((c) => c.mentionsDesign) ? 'it does' : 'they do'} not carry is how this repo actually uses it: the numbers below, which the rules file turns into rules with receipts.</p>`
1259
+ ? `<p class="sub">Your agent reads ${readsList}${nestedCount && rootAgent.length ? `, plus ${nestedCount} rules file${nestedCount === 1 ? '' : 's'} nested in subfolders` : ''}${skill ? `, and the shadcn skill is installed (<span class="mono">${esc(skill.file)}</span>)` : ''}. ${skill && !agentFiles.some((c) => c.mentionsDesign) ? 'The skill tells it' : 'They tell it'} the kit is there. What ${skill && !agentFiles.some((c) => c.mentionsDesign) ? 'it does' : 'they do'} not carry is how this repo actually uses it: the numbers below, which the rules file turns into rules with receipts.${lintLine}</p>`
1250
1260
  : agentFiles.length
1251
1261
  ? `<p class="sub">Your agent reads ${readsList}${nestedCount && rootAgent.length ? `, plus ${nestedCount} rules file${nestedCount === 1 ? '' : 's'} nested in subfolders` : ''}. But none of it points at a single source of truth for components and tokens, because there isn't one yet. The numbers below are what your agent actually works from.</p>`
1252
- : `<p class="sub">No <span class="mono">CLAUDE.md</span>, no <span class="mono">AGENTS.md</span>, no <span class="mono">.cursorrules</span>. Every time your AI builds UI here, it guesses, from everything below. This is why its output looks almost-but-not-quite right.</p>`;
1262
+ : `<p class="sub">No <span class="mono">CLAUDE.md</span>, no <span class="mono">AGENTS.md</span>, no <span class="mono">.cursorrules</span>. Every time your AI builds UI here, it guesses, from everything below. This is why its output looks almost-but-not-quite right.${lintLine}</p>`;
1253
1263
  // Door coverage, stated as fact: which tools can read what exists. Claude
1254
1264
  // Code reads CLAUDE.md only; Codex reads AGENTS.md; Cursor reads AGENTS.md
1255
1265
  // and .cursor/rules (its legacy .cursorrules is labelled, not judged).
@@ -1358,6 +1368,29 @@ function exclusionsLine() {
1358
1368
  }
1359
1369
 
1360
1370
 
1371
+ // Link previews (LinkedIn, Slack, iMessage) read Open Graph tags; without
1372
+ // them a shared report shows whichever heading the crawler found first.
1373
+ // Title and description come from the scan; an image and canonical URL are
1374
+ // passed in when the report is hosted (--og-image, --og-url), never guessed.
1375
+ function ogTags() {
1376
+ const title = `${repoName}: ${healthScore !== null ? `${healthScore}/100` : 'design-system diagnosis'}${healthScore !== null ? ' design-system health' : ''}`;
1377
+ const desc = verdict.replace(/\s+/g, ' ').trim().slice(0, 200);
1378
+ const image = arg('og-image', null), url = arg('og-url', null);
1379
+ return [
1380
+ `<meta property="og:type" content="website">`,
1381
+ `<meta property="og:title" content="${esc(title)}">`,
1382
+ `<meta property="og:description" content="${esc(desc)}">`,
1383
+ `<meta property="og:site_name" content="roast-my-design-system">`,
1384
+ url ? `<meta property="og:url" content="${esc(url)}">` : '',
1385
+ image ? `<meta property="og:image" content="${esc(image)}">` : '',
1386
+ image ? `<meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">` : '',
1387
+ `<meta name="twitter:card" content="${image ? 'summary_large_image' : 'summary'}">`,
1388
+ `<meta name="twitter:title" content="${esc(title)}">`,
1389
+ `<meta name="twitter:description" content="${esc(desc)}">`,
1390
+ image ? `<meta name="twitter:image" content="${esc(image)}">` : '',
1391
+ ].filter(Boolean).join('\n');
1392
+ }
1393
+
1361
1394
  // ---------- the side panel (fixed left column, 2026-09 template) ----------
1362
1395
  // Score, the definition it measures, the stack, how the repo was read, the
1363
1396
  // index of what rendered, and what is not the team's and not counted.
@@ -1371,7 +1404,7 @@ function sidePanel() {
1371
1404
  ? `<div class="bd">Of which <b>${breakdown.installedPoints} point${breakdown.installedPoints === 1 ? '' : 's'}</b> come from installed code you did not write: ${esc(rp.dirs.map((d) => basename(d)).join(', '))} (${n(rp.files)} file${rp.files === 1 ? '' : 's'}, ${n(rp.tinUses)} palette colour${rp.tinUses === 1 ? '' : 's'}). Your own code alone would score <b>${breakdown.ownScore}</b>. Kept in the score because your agent reads those files like everything else; left out of the fixes because they are not yours to edit.</div>` : '';
1372
1405
  const scoreBlock = healthScore !== null
1373
1406
  ? `<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</div>' : ''}<div class="def">${def}${lift}</div>${bd}</div>` : '';
1374
- const chips = `<div class="chips">${stack.map((c) => `<span class="chip">${esc(c)}</span>`).join('')}${(P.shadcn?.sheet?.tweakcnPresent ?? 0) >= 10 ? '<span class="chip">tweakcn theme</span>' : ''}${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>`;
1407
+ const chips = `<div class="chips">${stack.map((c) => `<span class="chip">${esc(c)}</span>`).join('')}${(P.shadcn?.sheet?.tweakcnPresent ?? 0) >= 10 ? '<span class="chip">tweakcn theme</span>' : ''}${P.shadcn?.lint ? '<span class="chip">shadcn/lint</span>' : ''}${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>`;
1375
1408
  const facts = [
1376
1409
  shadcnReceipt(),
1377
1410
  exclusionsLine(),
@@ -1473,6 +1506,7 @@ const html = `<!doctype html>
1473
1506
  <html lang="en" data-theme="${themeName}"><head><meta charset="utf-8">
1474
1507
  <meta name="viewport" content="width=device-width, initial-scale=1">
1475
1508
  <title>Design-system diagnosis:${nsMark} ${esc(repoName)}</title>
1509
+ ${ogTags()}
1476
1510
  <meta name="author" content="Greg Kozakiewicz · gregkozakiewicz.com">
1477
1511
  <link rel="icon" type="image/png" sizes="32x32" href="${GK_MARK}">
1478
1512
  <link rel="icon" type="image/png" sizes="192x192" href="data:image/png;base64,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">
@@ -2056,7 +2090,7 @@ if (summaryPath) {
2056
2090
  role: P.role,
2057
2091
  kind: P.kind,
2058
2092
  ...(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 } } : {}),
2059
- ...(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 } } : {}),
2093
+ ...(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 } } : {}) } } : {}),
2060
2094
  componentsMeasured,
2061
2095
  metrics: (({ colors, colorTokens, colorStrays, greys, greyStrays, spacing, exactDuplicates, inlineStyles, nearPairs, important, neverImported, arbitrary, tokenLed }) =>
2062
2096
  ({ colors, colorTokens, colorStrays, greys, greyStrays, spacing, exactDuplicates, inlineStyles, nearPairs, important, neverImported, arbitrary, tokenLed }))(M),
@@ -30,7 +30,8 @@ import { PALETTE } from '../profiles/shadcn-data.mjs';
30
30
  // and any opacity suffix. Word boundary at the front stops `text-primary` and
31
31
  // `bg-sidebar` from matching; the shade stops `border-gray` (no shade) which
32
32
  // is not a Tailwind class.
33
- const TIN_RE = new RegExp(`(?<![\\w-])(?:[\\w-]+:)*(?:bg|text|border|ring|outline|from|to|via|fill|stroke|divide|decoration|placeholder|caret|accent|shadow)-(?:${PALETTE})-(?:50|[1-9]00|950)(?:/\\d+)?(?![\\w-])`, 'g');
33
+ // exported for the guard: the same pattern judges an added line
34
+ export const PALETTE_CLASS_RE = new RegExp(`(?<![\\w-])(?:[\\w-]+:)*(?:bg|text|border|ring|outline|from|to|via|fill|stroke|divide|decoration|placeholder|caret|accent|shadow)-(?:${PALETTE})-(?:50|[1-9]00|950)(?:/\\d+)?(?![\\w-])`, 'g');
34
35
  // Evening overrides painted by hand with white or black (the palette shades
35
36
  // are already caught above with their dark: prefix).
36
37
  const DARK_WB_RE = /(?<![\w-])dark:(?:bg|text|border)-(?:white|black)(?:\/\d+)?(?![\w-])/g;
@@ -70,7 +71,7 @@ export function countPaint(root, codeFiles, { uiDirs = [], kitNames = new Set()
70
71
  if (src === null || exemptReason(f, src)) continue;
71
72
  ownFiles += 1;
72
73
 
73
- const tinHits = [...src.matchAll(TIN_RE)].map((m) => m[0]);
74
+ const tinHits = [...src.matchAll(PALETTE_CLASS_RE)].map((m) => m[0]);
74
75
  const wbHits = [...src.matchAll(DARK_WB_RE)].map((m) => m[0]);
75
76
  const all = [...tinHits, ...wbHits];
76
77
  if (all.length) {
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { existsSync } from 'node:fs';
10
10
  import {
11
- EMAIL_PRINT_RE, ARTWORK_NAME_RE, RENDER_TO_IMAGE_RE, OG_ROUTE_RE, RENDERER_PATH_RE, CRASH_PAGE_RE, svgHeavy, exemptReason, isLibraryClass,
11
+ EMAIL_PRINT_RE, ARTWORK_NAME_RE, RENDER_TO_IMAGE_RE, OG_ROUTE_RE, RENDERER_PATH_RE, CRASH_PAGE_RE, svgHeavy, exemptReason, isLibraryClass, WIDGET_CSS_RE, WIDGET_CONFIG_RE,
12
12
  } from '../lib/exempt.mjs';
13
13
  import { join } from 'node:path';
14
14
  import { canonical, parseColor } from '../lib/color.mjs';
@@ -297,8 +297,6 @@ export function harvestTokens(root, styleFiles, codeFiles) {
297
297
  // page and must beat the host's CSS: Tailwind imported with the important
298
298
  // flag, or a config scoping every utility under an id. There, !important is
299
299
  // the medium, like inline styles in email. Skipped, named with the reason.
300
- const WIDGET_CSS_RE = /@import\s+["']tailwindcss(?:\/utilities\.css)?["'][^;]*\bimportant\b/;
301
- const WIDGET_CONFIG_RE = /\bimportant\s*:\s*["']#/;
302
300
  const widgetDirs = codeFiles.filter((f) => /(^|\/)tailwind\.config\.[mc]?[jt]s$/.test(f))
303
301
  .filter((f) => WIDGET_CONFIG_RE.test(readSource(join(root, f)) ?? ''))
304
302
  .map((f) => f.slice(0, f.lastIndexOf('/') + 1));
@@ -64,3 +64,11 @@ export function exemptReason(file, text = '') {
64
64
  */
65
65
  export const LIBRARY_CLASS_RE = /^(cm-|ͼ|ProseMirror|monaco-|sp-|react-datepicker|react-tel-input|DateInput_|DayPicker|react-grid-|react-resizable|react-flow|react-select|react-toastify|Toastify|select2|EmojiPickerReact|epr-|notion-|fc-|fc$|tippy-|hljs|language-|ps__|ace_|ng-|docsearch|DocSearch|grecaptcha|rr-|sbdocs|docs-story|mapboxgl-|leaflet-|swiper-|slick-|rc-|ant-|Mui|ck-|ql-|tox-|mce-|DraftEditor|public-Draft|w-md-editor|rdp-|recharts-|apexcharts-|intercom-|crisp-|hubspot|shiki|katex|mermaid|prism-)/;
66
66
  export const isLibraryClass = (c) => LIBRARY_CLASS_RE.test(c);
67
+
68
+ /**
69
+ * An embedded widget (a survey, a chat bubble) lives inside a stranger's page
70
+ * and must beat the host's CSS: Tailwind imported with the important flag, or
71
+ * a config scoping every utility under an id. There, !important is the medium.
72
+ */
73
+ export const WIDGET_CSS_RE = /@import\s+["']tailwindcss(?:\/utilities\.css)?["'][^;]*\bimportant\b/;
74
+ export const WIDGET_CONFIG_RE = /\bimportant\s*:\s*["']#/;
@@ -10,10 +10,13 @@
10
10
  * text for styling, and say which on-system value a stray most resembles.
11
11
  */
12
12
  import { extname, join } from 'node:path';
13
- import { walkRepo, readSource } from '../harvest/walk.mjs';
13
+ import { walkRepo, readSource, profileRepo } from '../harvest/walk.mjs';
14
+ import { decideProfile, profileOf, installedDirs } from '../profiles/index.mjs';
15
+ import { PALETTE_CLASS_RE } from '../harvest/paint.mjs';
14
16
  import { harvestTokens, extractStyling, normalizeHex, isGrey } from '../harvest/tokens.mjs';
15
17
  import { harvestComponents, definedComponents } from '../harvest/components.mjs';
16
18
  import { loadExclusions } from './exclusions.mjs';
19
+ import { WIDGET_CONFIG_RE } from './exempt.mjs';
17
20
  import { hexRgb } from './nearpairs.mjs';
18
21
  import { typefaceOf, GENERIC_FONTS } from './typefaces.mjs';
19
22
 
@@ -26,6 +29,13 @@ export { definedComponents };
26
29
  // The files no checker should judge. Exported so a guard reads the same list
27
30
  // as the engine rather than keeping a copy that drifts.
28
31
  export { EMAIL_PRINT_RE, ARTWORK_NAME_RE, SVG_MARKUP_RE, exemptReason } from './exempt.mjs';
32
+ // !important as the medium (7.5): a widget stylesheet that must beat its host
33
+ // page, and a selector aimed at a library's own class names. The guard reads
34
+ // the same patterns, so the two checkers agree about the same declaration.
35
+ export { WIDGET_CSS_RE, WIDGET_CONFIG_RE, LIBRARY_CLASS_RE, isLibraryClass } from './exempt.mjs';
36
+ // A palette class (bg-blue-500, text-gray-600) where a theme variable exists:
37
+ // the shadcn paint check (7.2). Only meaningful when system.profile.paletteReady.
38
+ export { PALETTE_CLASS_RE };
29
39
 
30
40
  // Radius, font size, shadow and typeface: the patterns, so both checkers agree
31
41
  // on what a declaration is and what counts as a disciplined value.
@@ -63,6 +73,14 @@ export function learnSystem(repoRoot, { exclude = [] } = {}) {
63
73
  const exclusions = loadExclusions(repoRoot, exclude);
64
74
  const files = walkRepo(repoRoot, 14, exclusions);
65
75
  const t = harvestTokens(repoRoot, files.styles, files.code);
76
+ const components = harvestComponents(repoRoot, files.code).components
77
+ .map(({ name, file, usageCount, isPage }) => ({ name, file, usageCount, isPage }));
78
+ const profile = profileRepo(repoRoot, files);
79
+ decideProfile(profile, components, files, repoRoot);
80
+ const P = profileOf(profile);
81
+ const widgetDirs = files.code.filter((f) => /(^|\/)tailwind\.config\.[mc]?[jt]s$/.test(f))
82
+ .filter((f) => WIDGET_CONFIG_RE.test(readSource(join(repoRoot, f)) ?? ''))
83
+ .map((f) => f.slice(0, f.lastIndexOf('/') + 1));
66
84
 
67
85
  // Every --var definition, as normalizedValue → name, so a guard can say
68
86
  // "use var(--blue-500)" instead of leaving the reader to hunt the hex.
@@ -97,9 +115,34 @@ export function learnSystem(repoRoot, { exclude = [] } = {}) {
97
115
  // Trimmed to the four fields a guard can act on: what it is called, where
98
116
  // it lives, how much the repo leans on it, and whether it is a page (pages
99
117
  // are routes, not reusable parts, so two of a name is not a duplicate).
100
- components: harvestComponents(repoRoot, files.code).components
101
- .map(({ name, file, usageCount, isPage }) => ({ name, file, usageCount, isPage })),
118
+ components,
102
119
  files: { styles: files.styles.length, code: files.code.length },
120
+ // How the repo was read (7.1 to 7.7), decided once by the same profiles
121
+ // the report uses, so a guard treats installed code, a registry's
122
+ // published folders and a widget's stylesheet the way the score does.
123
+ profile: {
124
+ kind: P.kind,
125
+ role: P.role,
126
+ // folders the team did not write (a shadcn catalogue, installed
127
+ // registries, kit blocks): their brackets and copies are not the PR's
128
+ // sin. Empty on a registry: what it publishes is its own work.
129
+ // only a shadcn kit has installed code; a hand-written components/ui on a
130
+ // plain product is the team's own (the report splits nothing there either)
131
+ installedDirs: P.isShadcn && !P.isRegistry ? installedDirs(P) : [],
132
+ // a shadcn kit whose theme file holds the variables, in CSS-variable
133
+ // mode: a palette class in own code is paint from a tin
134
+ paletteReady: P.isShadcn && (P.shadcn?.sheet?.shadcnPresent ?? 0) >= 5 && P.designSystem?.cssVariables !== false,
135
+ sheetFile: P.shadcn?.sheet?.found ? P.shadcn.sheet.file : null,
136
+ // packages whose Tailwind config scopes utilities under an id: widgets
137
+ widgetDirs,
138
+ // a registry: only the published folders are counted; sibling variants
139
+ // and blocks hold the same names by design
140
+ registry: P.isRegistry && P.registry ? {
141
+ countedDirs: P.registry.publishedDirs ?? [],
142
+ variants: P.registry.variants ?? [],
143
+ blockDirs: P.registry.blockDirs ?? [],
144
+ } : null,
145
+ },
103
146
  };
104
147
  }
105
148
 
@@ -1,7 +1,7 @@
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 = '7.7.0';
4
+ export const VERSION = '7.9.0';
5
5
  // The shape of harvest.json and summary.json. Bumped only when a field is
6
6
  // renamed, removed or changes meaning; a new field is not a new schema. A
7
7
  // tool comparing two scans compares like with like by this number, not by VERSION.
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { join, basename } from 'node:path';
2
+ import { join, basename, dirname } from 'node:path';
3
3
  /**
4
4
  * Profiles — what kind of repo is this, decided once and read everywhere.
5
5
  *
@@ -23,6 +23,7 @@ import { join, basename } from 'node:path';
23
23
  */
24
24
  import shadcn from './shadcn.mjs';
25
25
  import { readRegistry, publishesLine, variantsFromDirs, scopeFiles } from './registry.mjs';
26
+ import { readShadcnLint } from './shadcn-lint.mjs';
26
27
  export { scopeFiles };
27
28
  import library from './library.mjs';
28
29
  import product from './product.mjs';
@@ -65,6 +66,12 @@ export function decideProfile(profile, components, files, root = null) {
65
66
  // A shadcn repo that PUBLISHES a registry is a registry: the fourth kind.
66
67
  // Every count still reads the shadcn facts (release (a): zero score change);
67
68
  // the kind, the receipt and the header line say what it is.
69
+ // shadcn/lint, when the team runs it: their declared policy, read and
70
+ // reported, never scored (see profiles/shadcn-lint.mjs)
71
+ if (root && profile.shadcn) {
72
+ const wsDirs = (profile.shadcn.installs ?? []).map((i) => i.config).filter(Boolean).map((f) => dirname(f)).filter((d) => d && d !== '.');
73
+ profile.shadcn.lint = readShadcnLint(root, wsDirs);
74
+ }
68
75
  if (root) {
69
76
  const reg = readRegistry(root, files);
70
77
  if (reg) {
@@ -0,0 +1,137 @@
1
+ /**
2
+ * shadcn/lint (github.com/shadcn-ui/lint), read as the team's declared
3
+ * policy. Six rules, each `"error" | "warn" | "off"` or `[level, options]`
4
+ * with `allow`, `deny`, `contracts` and `message`. Read from their docs and
5
+ * source (docs/rules.md, packages/lint/src/rules/contracts.ts) at
6
+ * @shadcn/lint 0.1.0, 2026-09-15. Re-check when they release.
7
+ *
8
+ * Two doors: `.oxlintrc.json` is data and is read in full; `eslint.config.*`
9
+ * is code and is read conservatively (the rule names and their allow/deny
10
+ * arrays, by pattern). Anything the reader is not sure of is reported as
11
+ * present and not read. Nothing here ever moves the score: what a linter
12
+ * allows is a team decision, and the agent still reads the class.
13
+ */
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { join, relative } from 'node:path';
16
+
17
+ export const SHADCN_LINT_VERSION_READ = '0.1.0';
18
+ export const RULES = ['no-restyle', 'no-raw-colors', 'no-arbitrary-values', 'no-inline-styles', 'no-unknown-classes', 'require-static-classes'];
19
+ /** Their rule → the roaster's tile it speaks to. Two rules have no tile. */
20
+ export const RULE_TILE = { 'no-raw-colors': 'paintTin', 'no-restyle': 'doorOverrides', 'no-arbitrary-values': 'arbitrary', 'no-inline-styles': 'inlineStyles' };
21
+ /** Category words an entry may name (docs/rules.md, Categories). Named in the report, never applied per class. */
22
+ export const CATEGORY_WORDS = new Set(['layout', 'color', 'typography', 'spacing', 'shape', 'effects', 'motion']);
23
+
24
+ /** Their glob: `*` matches any run of non-space characters (contracts.ts globToRegExp). */
25
+ export function globToRegExp(glob) {
26
+ const escaped = glob.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^\\s]+');
27
+ return new RegExp(`^${escaped}$`);
28
+ }
29
+
30
+ /** A matcher for an allow or deny list: categories named, classes tested exact or by glob. */
31
+ export function entryMatcher(entries) {
32
+ const list = Array.isArray(entries) ? entries.filter((e) => typeof e === 'string') : [];
33
+ const categories = list.filter((e) => CATEGORY_WORDS.has(e));
34
+ const patterns = list.filter((e) => !CATEGORY_WORDS.has(e)).map((e) => (e.includes('*') ? globToRegExp(e) : null) ?? e);
35
+ return {
36
+ categories,
37
+ classes: list.filter((e) => !CATEGORY_WORDS.has(e)),
38
+ test: (cls) => patterns.some((p) => (p instanceof RegExp ? p.test(cls) : p === cls)),
39
+ };
40
+ }
41
+
42
+ function ruleFromValue(v) {
43
+ if (typeof v === 'string') return { level: v, allow: null, deny: null, contracts: [] };
44
+ if (Array.isArray(v) && typeof v[0] === 'string') {
45
+ const o = v[1] && typeof v[1] === 'object' ? v[1] : {};
46
+ return {
47
+ level: v[0],
48
+ allow: Array.isArray(o.allow) ? o.allow.filter((e) => typeof e === 'string') : null,
49
+ deny: Array.isArray(o.deny) ? o.deny.filter((e) => typeof e === 'string') : null,
50
+ contracts: Array.isArray(o.contracts) ? o.contracts.filter((c) => c && typeof c.pattern === 'string').map((c) => ({ pattern: c.pattern, allow: Array.isArray(c.allow) ? c.allow : null, deny: Array.isArray(c.deny) ? c.deny : null })) : [],
51
+ };
52
+ }
53
+ return null;
54
+ }
55
+
56
+ function readOxlint(path) {
57
+ let j;
58
+ try { j = JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
59
+ const plugins = Array.isArray(j?.jsPlugins) ? j.jsPlugins : [];
60
+ const ruleKeys = Object.keys(j?.rules ?? {}).filter((k) => k.startsWith('shadcn/'));
61
+ if (!plugins.includes('@shadcn/lint') && !ruleKeys.length) return null;
62
+ const rules = {};
63
+ let readFully = true;
64
+ for (const k of ruleKeys) {
65
+ const r = ruleFromValue(j.rules[k]);
66
+ if (r) rules[k.slice('shadcn/'.length)] = r; else readFully = false;
67
+ }
68
+ const overrides = (Array.isArray(j.overrides) ? j.overrides : []).filter((o) => Object.keys(o?.rules ?? {}).some((k) => k.startsWith('shadcn/'))).length;
69
+ return { kind: 'oxlint', rules, overrides, settings: j.settings?.shadcn ?? null, readFully };
70
+ }
71
+
72
+ // A flat ESLint config is code. The rule names and the array literals next
73
+ // to them are readable by pattern; a computed value is not, and says so.
74
+ function readEslint(path) {
75
+ let text;
76
+ try { text = readFileSync(path, 'utf8'); } catch { return null; }
77
+ if (!/@shadcn\/lint/.test(text)) return null;
78
+ const rules = {};
79
+ let readFully = true;
80
+ const strings = (blob) => [...blob.matchAll(/["'`]([^"'`]+)["'`]/g)].map((m) => m[1]);
81
+ for (const m of text.matchAll(/["']shadcn\/([\w-]+)["']\s*:\s*/g)) {
82
+ const name = m[1];
83
+ let i = m.index + m[0].length;
84
+ const rest = text.slice(i);
85
+ const lvl = rest.match(/^["'](error|warn|off)["']/);
86
+ if (lvl) { rules[name] = { level: lvl[1], allow: null, deny: null, contracts: [] }; continue; }
87
+ const arr = rest.match(/^\[\s*["'](error|warn|off)["']\s*(,|\])/);
88
+ if (!arr) { rules[name] = { level: null, allow: null, deny: null, contracts: [], unread: true }; readFully = false; continue; }
89
+ // the options object: balanced braces after the level
90
+ let j = arr[0].length, depth = 0, start = -1, end = -1;
91
+ for (; j < rest.length; j++) {
92
+ const ch = rest[j];
93
+ if (ch === '{') { if (depth === 0) start = j; depth++; }
94
+ else if (ch === '}') { depth--; if (depth === 0) { end = j; break; } }
95
+ else if (depth === 0 && ch === ']') break;
96
+ }
97
+ const blob = start >= 0 && end > start ? rest.slice(start, end + 1) : '';
98
+ const list = (key) => { const mm = blob.match(new RegExp(`\\b${key}\\s*:\\s*\\[([^\\]]*)\\]`)); return mm ? strings(mm[1]) : null; };
99
+ const contracts = [...blob.matchAll(/pattern\s*:\s*["']([^"']+)["']/g)].map((c) => ({ pattern: c[1], allow: null, deny: null }));
100
+ rules[name] = { level: arr[1], allow: list('allow'), deny: list('deny'), contracts };
101
+ if (blob && /\b(allow|deny)\s*:\s*(?![\[\s])/.test(blob)) readFully = false; // a variable, not a literal list
102
+ }
103
+ return { kind: 'eslint', rules, overrides: 0, settings: null, readFully };
104
+ }
105
+
106
+ /**
107
+ * The first shadcn/lint config found, root first, then the folders given
108
+ * (workspace roots that carry a components.json). Null when there is none.
109
+ */
110
+ export function readShadcnLint(root, dirs = []) {
111
+ const seen = new Set();
112
+ for (const d of [root, ...dirs.map((x) => join(root, x))]) {
113
+ if (seen.has(d)) continue;
114
+ seen.add(d);
115
+ const ox = join(d, '.oxlintrc.json');
116
+ if (existsSync(ox)) { const r = readOxlint(ox); if (r) return { file: relative(root, ox) || '.oxlintrc.json', ...r }; }
117
+ for (const n of ['eslint.config.mjs', 'eslint.config.js', 'eslint.config.cjs', 'eslint.config.ts', 'eslint.config.mts']) {
118
+ const p = join(d, n);
119
+ if (existsSync(p)) { const r = readEslint(p); if (r) return { file: relative(root, p), ...r }; }
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+
125
+ /** Rules switched on (level error or warn), in their documented order. */
126
+ export const rulesOn = (lint) => RULES.filter((r) => lint?.rules?.[r] && lint.rules[r].level !== 'off');
127
+
128
+ /** One line per rule, the way the report and the rules file say it. */
129
+ export function describeRule(name, r) {
130
+ const bits = [];
131
+ if (r.level === 'warn') bits.push('as a warning');
132
+ if (r.allow?.length) bits.push(`allows ${r.allow.map((e) => (CATEGORY_WORDS.has(e) ? e : `\`${e}\``)).join(', ')}`);
133
+ if (r.deny?.length) bits.push(`denies ${r.deny.map((e) => `\`${e}\``).join(', ')}`);
134
+ if (r.contracts?.length) bits.push(`${r.contracts.length} component contract${r.contracts.length === 1 ? '' : 's'}`);
135
+ if (r.unread) bits.push('options not read');
136
+ return `${name}${bits.length ? ` (${bits.join('; ')})` : ''}`;
137
+ }
@@ -9,6 +9,7 @@ import { nearColorPairs } from './../lib/nearpairs.mjs';
9
9
  import { neverImportedComponents } from '../lib/neverimported.mjs';
10
10
  import { VERSION } from '../lib/version.mjs';
11
11
  import { profileOf, installedDirs, splitArbitrary, ownSpacing } from '../profiles/index.mjs';
12
+ import { rulesOn as lintRulesOn, describeRule as lintDescribe } from '../profiles/shadcn-lint.mjs';
12
13
 
13
14
  export function rulesMarkdown(h, opts = {}) {
14
15
  // compact: the size-aware variant for agent files with tight practical
@@ -193,6 +194,10 @@ if (neverImported.length >= 3) {
193
194
  rule(`Edit the component you own in \`${P.uiDir ?? 'components/ui'}\`. Never build a second one beside it under another name. A wrapper that composes shadcn components is fine; a second implementation is not.`);
194
195
  rule('Merge classes with `cn()`. Never concatenate strings and never write a ternary inside a className string.');
195
196
  rule('Add a component with `npx shadcn@latest add <name>`, then edit it. To see what changed upstream, run `npx shadcn@latest add <name> --diff`.');
197
+ if (sc.lint) {
198
+ const on = lintRulesOn(sc.lint);
199
+ rule(`This repo runs shadcn/lint (\`${sc.lint.file}\`${on.length ? `: ${on.map((r) => lintDescribe(r, sc.lint.rules[r])).join('; ')}` : ': no rule on yet'}). Run the project's lint after every change; its errors name the variant or theme variable to use.`);
200
+ }
196
201
  const ai = sc.arbitraryInstalled ?? null;
197
202
  if (ai?.uses) rule(`Bracket values in \`${P.uiDir ?? 'components/ui'}\` are shadcn's, not a pattern to copy. The installed components use a few values Tailwind's scale does not have (${ai.values.slice(0, 3).map((v) => `\`${v.value}\``).join(', ')}), written by shadcn's authors for those components only. In your own code, do not write a bracket value: use a step from the scale, or a variable from the theme file. If a value you need is missing, add it to the theme file once and use it by name.`);
198
203
  else rule('In your own code, do not write a bracket value: use a step from the scale, or a variable from the theme file. If a value you need is missing, add it to the theme file once and use it by name.');