@agent-scope/cli 1.20.0 → 1.20.2

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/dist/index.js CHANGED
@@ -1,11 +1,12 @@
1
- import { existsSync, writeFileSync, mkdirSync, readFileSync, statSync, appendFileSync, readdirSync, rmSync, createReadStream } from 'fs';
2
- import { resolve, join, extname, dirname } from 'path';
1
+ import { existsSync, writeFileSync, mkdirSync, readFileSync, statSync, appendFileSync, readdirSync, rmSync, createReadStream, constants, watch } from 'fs';
2
+ import { resolve, join, dirname, extname } from 'path';
3
3
  import { generateManifest } from '@agent-scope/manifest';
4
4
  import { SpriteSheetGenerator, safeRender, BrowserPool, ALL_CONTEXT_IDS, contextAxis, stressAxis, ALL_STRESS_IDS, RenderMatrix, SatoriRenderer } from '@agent-scope/render';
5
5
  import { TokenResolver, ComplianceEngine, parseTokenFileSync, ThemeResolver, exportTokens, validateTokenFile, TokenValidationError, TokenParseError, ImpactAnalyzer } from '@agent-scope/tokens';
6
6
  import { Command } from 'commander';
7
7
  import * as esbuild2 from 'esbuild';
8
8
  import { createRequire } from 'module';
9
+ import { mkdir, access, writeFile } from 'fs/promises';
9
10
  import * as readline from 'readline';
10
11
  import { loadTrace, generateTest, getBrowserEntryScript } from '@agent-scope/playwright';
11
12
  import { chromium } from 'playwright';
@@ -13,10 +14,15 @@ import { tmpdir } from 'os';
13
14
  import { createServer } from 'http';
14
15
  import { buildSite } from '@agent-scope/site';
15
16
 
16
- // src/ci/commands.ts
17
- async function buildComponentHarness(filePath, componentName, props, viewportWidth, projectCss, wrapperScript) {
17
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
18
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
19
+ }) : x)(function(x) {
20
+ if (typeof require !== "undefined") return require.apply(this, arguments);
21
+ throw Error('Dynamic require of "' + x + '" is not supported');
22
+ });
23
+ async function buildComponentHarness(filePath, componentName, props, viewportWidth, projectCss, wrapperScript, screenshotPadding = 0) {
18
24
  const bundledScript = await bundleComponentToIIFE(filePath, componentName, props);
19
- return wrapInHtml(bundledScript, viewportWidth, projectCss, wrapperScript);
25
+ return wrapInHtml(bundledScript, viewportWidth, projectCss, wrapperScript, screenshotPadding);
20
26
  }
21
27
  async function bundleComponentToIIFE(filePath, componentName, props) {
22
28
  const propsJson = JSON.stringify(props).replace(/<\/script>/gi, "<\\/script>");
@@ -122,7 +128,7 @@ ${msg}`);
122
128
  }
123
129
  return outputFile.text;
124
130
  }
125
- function wrapInHtml(bundledScript, viewportWidth, projectCss, wrapperScript) {
131
+ function wrapInHtml(bundledScript, viewportWidth, projectCss, wrapperScript, screenshotPadding = 0) {
126
132
  const projectStyleBlock = projectCss != null && projectCss.length > 0 ? `<style id="scope-project-css">
127
133
  ${projectCss.replace(/<\/style>/gi, "<\\/style>")}
128
134
  </style>` : "";
@@ -132,10 +138,17 @@ ${projectCss.replace(/<\/style>/gi, "<\\/style>")}
132
138
  <head>
133
139
  <meta charset="UTF-8" />
134
140
  <meta name="viewport" content="width=${viewportWidth}, initial-scale=1.0" />
141
+ <script>
142
+ // Reset globals that persist on window across page.setContent() calls
143
+ // (document.open/write/close clears the DOM but NOT the JS global scope)
144
+ window.__SCOPE_WRAPPER__ = null;
145
+ window.__SCOPE_RENDER_COMPLETE__ = false;
146
+ window.__SCOPE_RENDER_ERROR__ = null;
147
+ </script>
135
148
  <style>
136
149
  *, *::before, *::after { box-sizing: border-box; }
137
150
  html, body { margin: 0; padding: 0; background: #fff; font-family: system-ui, sans-serif; }
138
- #scope-root { display: inline-block; min-width: 1px; min-height: 1px; }
151
+ #scope-root { display: inline-block; min-width: 1px; min-height: 1px; margin: ${screenshotPadding}px; }
139
152
  </style>
140
153
  ${projectStyleBlock}
141
154
  </head>
@@ -427,6 +440,11 @@ var STYLE_ENTRY_CANDIDATES = [
427
440
  "index.css"
428
441
  ];
429
442
  var TAILWIND_IMPORT = /@import\s+["']tailwindcss["']\s*;?/;
443
+ function getElementClassNames(el) {
444
+ const className = el.className;
445
+ const raw = typeof className === "string" ? className : typeof className?.baseVal === "string" ? className.baseVal : el.getAttribute("class") ?? "";
446
+ return raw.split(/\s+/).filter(Boolean);
447
+ }
430
448
  var compilerCache = null;
431
449
  function getCachedBuild(cwd) {
432
450
  if (compilerCache !== null && resolve(compilerCache.cwd) === resolve(cwd)) {
@@ -517,22 +535,22 @@ async function getTailwindCompiler(cwd) {
517
535
  from: entryPath,
518
536
  loadStylesheet
519
537
  });
520
- const build3 = result.build.bind(result);
521
- compilerCache = { cwd, build: build3 };
522
- return build3;
538
+ const build4 = result.build.bind(result);
539
+ compilerCache = { cwd, build: build4 };
540
+ return build4;
523
541
  }
524
542
  async function getCompiledCssForClasses(cwd, classes) {
525
- const build3 = await getTailwindCompiler(cwd);
526
- if (build3 === null) return null;
543
+ const build4 = await getTailwindCompiler(cwd);
544
+ if (build4 === null) return null;
527
545
  const deduped = [...new Set(classes)].filter(Boolean);
528
546
  if (deduped.length === 0) return null;
529
- return build3(deduped);
547
+ return build4(deduped);
530
548
  }
531
549
  async function compileGlobalCssFile(cssFilePath, cwd) {
532
- const { existsSync: existsSync16, readFileSync: readFileSync14 } = await import('fs');
550
+ const { existsSync: existsSync18, readFileSync: readFileSync18 } = await import('fs');
533
551
  const { createRequire: createRequire3 } = await import('module');
534
- if (!existsSync16(cssFilePath)) return null;
535
- const raw = readFileSync14(cssFilePath, "utf-8");
552
+ if (!existsSync18(cssFilePath)) return null;
553
+ const raw = readFileSync18(cssFilePath, "utf-8");
536
554
  const needsCompile = /@tailwind|@import\s+['"]tailwindcss/.test(raw);
537
555
  if (!needsCompile) {
538
556
  return raw;
@@ -615,8 +633,17 @@ async function shutdownPool() {
615
633
  }
616
634
  }
617
635
  async function renderComponent(filePath, componentName, props, viewportWidth, viewportHeight) {
636
+ const PAD = 24;
618
637
  const pool = await getPool(viewportWidth, viewportHeight);
619
- const htmlHarness = await buildComponentHarness(filePath, componentName, props, viewportWidth);
638
+ const htmlHarness = await buildComponentHarness(
639
+ filePath,
640
+ componentName,
641
+ props,
642
+ viewportWidth,
643
+ void 0,
644
+ void 0,
645
+ PAD
646
+ );
620
647
  const slot = await pool.acquire();
621
648
  const { page } = slot;
622
649
  try {
@@ -638,8 +665,8 @@ async function renderComponent(filePath, componentName, props, viewportWidth, vi
638
665
  const classes = await page.evaluate(() => {
639
666
  const set = /* @__PURE__ */ new Set();
640
667
  document.querySelectorAll("[class]").forEach((el) => {
641
- for (const c of el.className.split(/\s+/)) {
642
- if (c) set.add(c);
668
+ for (const c of getElementClassNames(el)) {
669
+ set.add(c);
643
670
  }
644
671
  });
645
672
  return [...set];
@@ -656,7 +683,6 @@ async function renderComponent(filePath, componentName, props, viewportWidth, vi
656
683
  `Component "${componentName}" rendered with zero bounding box \u2014 it may be invisible or not mounted`
657
684
  );
658
685
  }
659
- const PAD = 24;
660
686
  const MIN_W = 320;
661
687
  const MIN_H = 200;
662
688
  const clipX = Math.max(0, boundingBox.x - PAD);
@@ -1001,6 +1027,124 @@ function createCiCommand() {
1001
1027
  }
1002
1028
  );
1003
1029
  }
1030
+ var PLAYWRIGHT_BROWSER_HINTS = [
1031
+ "executable doesn't exist",
1032
+ "browserType.launch",
1033
+ "looks like playwright was just installed or updated",
1034
+ "please run the following command to download new browsers",
1035
+ "could not find chromium"
1036
+ ];
1037
+ var MISSING_DEPENDENCY_HINTS = ["could not resolve", "cannot find module", "module not found"];
1038
+ var REQUIRED_HARNESS_DEPENDENCIES = ["react", "react-dom", "react/jsx-runtime"];
1039
+ function getEffectivePlaywrightBrowsersPath() {
1040
+ const value = process.env.PLAYWRIGHT_BROWSERS_PATH;
1041
+ return typeof value === "string" && value.length > 0 ? value : null;
1042
+ }
1043
+ function getPlaywrightBrowserRemediation(status) {
1044
+ const effectivePath = status?.effectiveBrowserPath ?? getEffectivePlaywrightBrowsersPath();
1045
+ if (effectivePath !== null) {
1046
+ const pathProblem = status?.browserPathExists === false ? "missing" : status?.browserPathWritable === false ? "unwritable" : "unavailable";
1047
+ return `PLAYWRIGHT_BROWSERS_PATH is set to ${effectivePath}, but that browser cache is ${pathProblem}. Unset PLAYWRIGHT_BROWSERS_PATH and run \`bunx playwright install chromium\`, or install and run with the same writable path: \`PLAYWRIGHT_BROWSERS_PATH=${effectivePath} bunx playwright install chromium\`.`;
1048
+ }
1049
+ return "Run `bunx playwright install chromium` in this sandbox, then retry the Scope command.";
1050
+ }
1051
+ function diagnoseScopeError(error, cwd = process.cwd()) {
1052
+ const message = error instanceof Error ? error.message : String(error);
1053
+ const normalized = message.toLowerCase();
1054
+ if (PLAYWRIGHT_BROWSER_HINTS.some((hint) => normalized.includes(hint))) {
1055
+ const browserPath = extractPlaywrightBrowserPath(message);
1056
+ const browserPathHint = browserPath === null ? "" : ` Scope tried to launch Chromium from ${browserPath}.`;
1057
+ return {
1058
+ code: "PLAYWRIGHT_BROWSERS_MISSING",
1059
+ message: "Playwright Chromium is unavailable for Scope browser rendering.",
1060
+ recovery: getPlaywrightBrowserRemediation() + browserPathHint + " Use `scope doctor --json` to verify the browser check passes before rerunning render/site/instrument."
1061
+ };
1062
+ }
1063
+ if (MISSING_DEPENDENCY_HINTS.some((hint) => normalized.includes(hint))) {
1064
+ const packageManager = detectPackageManager(cwd);
1065
+ return {
1066
+ code: "TARGET_PROJECT_DEPENDENCIES_MISSING",
1067
+ message: "The target project's dependencies appear to be missing or incomplete.",
1068
+ recovery: `Run \`${packageManager} install\` in ${cwd}, then rerun \`scope doctor\` and retry the Scope command.`
1069
+ };
1070
+ }
1071
+ return null;
1072
+ }
1073
+ function formatScopeDiagnostic(error, cwd = process.cwd()) {
1074
+ const message = error instanceof Error ? error.message : String(error);
1075
+ const diagnostic = diagnoseScopeError(error, cwd);
1076
+ if (diagnostic === null) return `Error: ${message}`;
1077
+ return `Error [${diagnostic.code}]: ${diagnostic.message}
1078
+ Recovery: ${diagnostic.recovery}
1079
+ Cause: ${message}`;
1080
+ }
1081
+ async function getPlaywrightBrowserStatus(cwd = process.cwd()) {
1082
+ const effectiveBrowserPath = getEffectivePlaywrightBrowsersPath();
1083
+ const executablePath = getPlaywrightChromiumExecutablePath(cwd);
1084
+ const available = executablePath !== null && existsSync(executablePath);
1085
+ const browserPathExists = effectiveBrowserPath === null ? null : existsSync(effectiveBrowserPath);
1086
+ const browserPathWritable = effectiveBrowserPath === null ? null : await isWritableBrowserPath(effectiveBrowserPath);
1087
+ return {
1088
+ effectiveBrowserPath,
1089
+ executablePath,
1090
+ available,
1091
+ browserPathExists,
1092
+ browserPathWritable,
1093
+ remediation: getPlaywrightBrowserRemediation({
1094
+ effectiveBrowserPath,
1095
+ browserPathExists,
1096
+ browserPathWritable
1097
+ })
1098
+ };
1099
+ }
1100
+ function getPlaywrightChromiumExecutablePath(cwd = process.cwd()) {
1101
+ try {
1102
+ const packageJsonPath = __require.resolve("playwright/package.json", { paths: [cwd] });
1103
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
1104
+ if (!packageJson.version) return null;
1105
+ const playwrightPath = __require.resolve("playwright", { paths: [cwd] });
1106
+ const { chromium: chromium5 } = __require(playwrightPath);
1107
+ const executablePath = chromium5?.executablePath?.();
1108
+ if (typeof executablePath !== "string" || executablePath.length === 0) return null;
1109
+ return executablePath;
1110
+ } catch {
1111
+ return null;
1112
+ }
1113
+ }
1114
+ async function isWritableBrowserPath(browserPath) {
1115
+ const candidate = existsSync(browserPath) ? browserPath : dirname(browserPath);
1116
+ try {
1117
+ await access(candidate, constants.W_OK);
1118
+ return true;
1119
+ } catch {
1120
+ return false;
1121
+ }
1122
+ }
1123
+ function detectPackageManager(cwd = process.cwd()) {
1124
+ if (existsSync(join(cwd, "bun.lock")) || existsSync(join(cwd, "bun.lockb"))) return "bun";
1125
+ if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
1126
+ if (existsSync(join(cwd, "yarn.lock"))) return "yarn";
1127
+ return "npm";
1128
+ }
1129
+ function hasLikelyInstalledDependencies(cwd = process.cwd()) {
1130
+ return existsSync(join(cwd, "node_modules"));
1131
+ }
1132
+ function getMissingHarnessDependencies(cwd = process.cwd()) {
1133
+ return REQUIRED_HARNESS_DEPENDENCIES.filter((dependencyName) => {
1134
+ try {
1135
+ __require.resolve(dependencyName, { paths: [cwd] });
1136
+ return false;
1137
+ } catch {
1138
+ return true;
1139
+ }
1140
+ });
1141
+ }
1142
+ function extractPlaywrightBrowserPath(message) {
1143
+ const match = message.match(/Executable doesn't exist at\s+([^\n]+)/i);
1144
+ return match?.[1]?.trim() ?? null;
1145
+ }
1146
+
1147
+ // src/doctor-commands.ts
1004
1148
  function collectSourceFiles(dir) {
1005
1149
  if (!existsSync(dir)) return [];
1006
1150
  const results = [];
@@ -1014,13 +1158,43 @@ function collectSourceFiles(dir) {
1014
1158
  }
1015
1159
  return results;
1016
1160
  }
1161
+ var TAILWIND_CONFIG_FILES = [
1162
+ "tailwind.config.js",
1163
+ "tailwind.config.cjs",
1164
+ "tailwind.config.mjs",
1165
+ "tailwind.config.ts",
1166
+ "postcss.config.js",
1167
+ "postcss.config.cjs",
1168
+ "postcss.config.mjs",
1169
+ "postcss.config.ts"
1170
+ ];
1171
+ function hasTailwindSetup(cwd) {
1172
+ if (TAILWIND_CONFIG_FILES.some((file) => existsSync(resolve(cwd, file)))) {
1173
+ return true;
1174
+ }
1175
+ const packageJsonPath = resolve(cwd, "package.json");
1176
+ if (!existsSync(packageJsonPath)) return false;
1177
+ try {
1178
+ const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
1179
+ return [pkg.dependencies, pkg.devDependencies].some(
1180
+ (deps) => deps && Object.keys(deps).some(
1181
+ (name) => name === "tailwindcss" || name.startsWith("@tailwindcss/")
1182
+ )
1183
+ );
1184
+ } catch {
1185
+ return false;
1186
+ }
1187
+ }
1188
+ function getPlaywrightInstallCommand(effectiveBrowserPath) {
1189
+ return effectiveBrowserPath === null ? "bunx playwright install chromium" : `PLAYWRIGHT_BROWSERS_PATH=${effectiveBrowserPath} bunx playwright install chromium`;
1190
+ }
1017
1191
  function checkConfig(cwd) {
1018
1192
  const configPath = resolve(cwd, "reactscope.config.json");
1019
1193
  if (!existsSync(configPath)) {
1020
1194
  return {
1021
1195
  name: "config",
1022
1196
  status: "error",
1023
- message: "reactscope.config.json not found \u2014 run `scope init`"
1197
+ message: "reactscope.config.json not found \u2014 run `scope init` in the target project root"
1024
1198
  };
1025
1199
  }
1026
1200
  try {
@@ -1068,6 +1242,13 @@ function checkGlobalCss(cwd) {
1068
1242
  }
1069
1243
  }
1070
1244
  if (globalCss.length === 0) {
1245
+ if (!hasTailwindSetup(cwd)) {
1246
+ return {
1247
+ name: "globalCSS",
1248
+ status: "ok",
1249
+ message: "No globalCSS configured \u2014 skipping CSS injection for this non-Tailwind project"
1250
+ };
1251
+ }
1071
1252
  return {
1072
1253
  name: "globalCSS",
1073
1254
  status: "warn",
@@ -1094,7 +1275,7 @@ function checkManifest(cwd) {
1094
1275
  return {
1095
1276
  name: "manifest",
1096
1277
  status: "warn",
1097
- message: "Manifest not found \u2014 run `scope manifest generate`"
1278
+ message: "Manifest not found \u2014 run `scope manifest generate` in the target project root"
1098
1279
  };
1099
1280
  }
1100
1281
  const manifestMtime = statSync(manifestPath).mtimeMs;
@@ -1111,6 +1292,54 @@ function checkManifest(cwd) {
1111
1292
  return { name: "manifest", status: "ok", message: "Manifest present and up to date" };
1112
1293
  }
1113
1294
  var ICONS = { ok: "\u2713", warn: "!", error: "\u2717" };
1295
+ function checkDependencies(cwd) {
1296
+ const packageManager = detectPackageManager(cwd);
1297
+ if (!hasLikelyInstalledDependencies(cwd)) {
1298
+ return {
1299
+ name: "dependencies",
1300
+ status: "error",
1301
+ remediationCode: "TARGET_PROJECT_DEPENDENCIES_MISSING",
1302
+ fixCommand: `${packageManager} install`,
1303
+ message: `node_modules not found \u2014 run \`${packageManager} install\` in ${cwd} before render/site/instrument`
1304
+ };
1305
+ }
1306
+ const missingHarnessDependencies = getMissingHarnessDependencies(cwd);
1307
+ if (missingHarnessDependencies.length > 0) {
1308
+ return {
1309
+ name: "dependencies",
1310
+ status: "error",
1311
+ remediationCode: "TARGET_PROJECT_HARNESS_DEPENDENCIES_MISSING",
1312
+ fixCommand: `${packageManager} install`,
1313
+ message: `Missing React harness dependencies: ${missingHarnessDependencies.join(", ")}. Run \`${packageManager} install\` in ${cwd}, then retry render/site/instrument.`
1314
+ };
1315
+ }
1316
+ return {
1317
+ name: "dependencies",
1318
+ status: "ok",
1319
+ message: "node_modules and React harness dependencies present"
1320
+ };
1321
+ }
1322
+ async function checkPlaywright(cwd) {
1323
+ const status = await getPlaywrightBrowserStatus(cwd);
1324
+ const pathDetails = status.effectiveBrowserPath === null ? "PLAYWRIGHT_BROWSERS_PATH is unset" : `PLAYWRIGHT_BROWSERS_PATH=${status.effectiveBrowserPath}; exists=${status.browserPathExists}; writable=${status.browserPathWritable}`;
1325
+ if (status.available) {
1326
+ return {
1327
+ name: "playwright",
1328
+ status: "ok",
1329
+ message: `Playwright package available (${pathDetails})`
1330
+ };
1331
+ }
1332
+ return {
1333
+ name: "playwright",
1334
+ status: "error",
1335
+ remediationCode: "PLAYWRIGHT_BROWSERS_MISSING",
1336
+ fixCommand: getPlaywrightInstallCommand(status.effectiveBrowserPath),
1337
+ message: `Playwright Chromium unavailable (${pathDetails}) \u2014 ${status.remediation}`
1338
+ };
1339
+ }
1340
+ function collectFixCommands(checks) {
1341
+ return checks.filter((check) => check.status === "error" && check.fixCommand !== void 0).map((check) => check.fixCommand).filter((command, index, commands) => commands.indexOf(command) === index);
1342
+ }
1114
1343
  function formatCheck(check) {
1115
1344
  return ` [${ICONS[check.status]}] ${check.name.padEnd(12)} ${check.message}`;
1116
1345
  }
@@ -1123,6 +1352,8 @@ CHECKS PERFORMED:
1123
1352
  tokens reactscope.tokens.json exists and passes validation
1124
1353
  css globalCSS files referenced in config actually exist
1125
1354
  manifest .reactscope/manifest.json exists and is not stale
1355
+ dependencies node_modules exists in the target project root
1356
+ playwright Playwright browser runtime is available
1126
1357
  (stale = source files modified after last generate)
1127
1358
 
1128
1359
  STATUS LEVELS: ok | warn | error
@@ -1132,20 +1363,34 @@ Run this first whenever renders fail or produce unexpected output.
1132
1363
  Examples:
1133
1364
  scope doctor
1134
1365
  scope doctor --json
1366
+ scope doctor --print-fix-commands
1135
1367
  scope doctor --json | jq '.checks[] | select(.status == "error")'`
1136
- ).option("--json", "Emit structured JSON output", false).action((opts) => {
1368
+ ).option("--json", "Emit structured JSON output", false).option(
1369
+ "--print-fix-commands",
1370
+ "Print deduplicated shell remediation commands for failing checks",
1371
+ false
1372
+ ).action(async (opts) => {
1137
1373
  const cwd = process.cwd();
1138
1374
  const checks = [
1139
1375
  checkConfig(cwd),
1140
1376
  checkTokens(cwd),
1141
1377
  checkGlobalCss(cwd),
1142
- checkManifest(cwd)
1378
+ checkManifest(cwd),
1379
+ checkDependencies(cwd),
1380
+ await checkPlaywright(cwd)
1143
1381
  ];
1144
1382
  const errors = checks.filter((c) => c.status === "error").length;
1145
1383
  const warnings = checks.filter((c) => c.status === "warn").length;
1384
+ const fixCommands = collectFixCommands(checks);
1385
+ if (opts.printFixCommands) {
1386
+ process.stdout.write(`${JSON.stringify({ cwd, fixCommands }, null, 2)}
1387
+ `);
1388
+ if (errors > 0) process.exit(1);
1389
+ return;
1390
+ }
1146
1391
  if (opts.json) {
1147
1392
  process.stdout.write(
1148
- `${JSON.stringify({ passed: checks.length - errors - warnings, warnings, errors, checks }, null, 2)}
1393
+ `${JSON.stringify({ passed: checks.length - errors - warnings, warnings, errors, fixCommands, checks }, null, 2)}
1149
1394
  `
1150
1395
  );
1151
1396
  if (errors > 0) process.exit(1);
@@ -1172,12 +1417,12 @@ Examples:
1172
1417
  }
1173
1418
 
1174
1419
  // src/skill-content.ts
1175
- var SKILL_CONTENT = '# Scope \u2014 Agent Skill\n\n## TLDR\nScope is a React codebase introspection toolkit. Use it to answer questions about component structure, props, context dependencies, side effects, and visual output \u2014 without running the app.\n\n**When to reach for it:** Any task requiring "which components use X", "what props does Y accept", "render Z for visual verification", "does this component depend on a provider", or "what design tokens are in use".\n\n**3-command workflow:**\n```\nscope init # scaffold config + auto-generate manifest\nscope manifest query --context ThemeContext # ask questions about the codebase\nscope render Button # produce a PNG of a component\n```\n\n---\n\n---\n\n## Mental Model\n\nUnderstanding how Scope\'s data flows is the key to using it effectively as an agent.\n\n```\nSource TypeScript files\n \u2193 (ts-morph AST parse)\n manifest.json \u2190 structural facts: props, hooks, contexts, complexity\n \u2193 (esbuild + Playwright)\n renders/*.json \u2190 visual facts: screenshot, computedStyles, dom, a11y\n \u2193 (token engine)\n compliance-styles.json \u2190 audit facts: which CSS values match tokens, which don\'t\n \u2193 (site generator)\n site/ \u2190 human-readable docs combining all of the above\n```\n\nEach layer depends on the previous. If you\'re getting unexpected results, check whether the earlier layers are stale (run `scope doctor` to diagnose).\n\n---\n\n## The Four Subsystems\n\n### 1. Manifest (`scope manifest *`)\nThe manifest is a static analysis snapshot of your TypeScript source. It tells you:\n- What components exist, where they live, and how they\'re exported\n- What props each component accepts (types, defaults, required/optional)\n- What React hooks they call (`detectedHooks`)\n- What contexts they consume (`requiredContexts`) \u2014 must be provided for a render to succeed\n- Whether they compose other components (`composes` / `composedBy`)\n- Their **complexity class** \u2014 `"simple"` or `"complex"` \u2014 which determines the render engine\n\nThe manifest never runs your code. It only reads TypeScript. This means it\'s fast and safe, but it can\'t know about runtime values.\n\n### 2. Render Engine (`scope render *`)\nThe render engine compiles components with esbuild and renders them in Chromium (Playwright). Two paths exist:\n\n| Path | When | Speed | Capability |\n|------|------|-------|------------|\n| **Satori** | `complexityClass: "simple"` | ~8ms | Flexbox only, no JS, no CSS-in-JS |\n| **BrowserPool** | `complexityClass: "complex"` | ~200\u2013800ms | Full DOM, CSS, Tailwind, animations |\n\nMost real-world components route through BrowserPool. Scope defaults to `"complex"` when uncertain (safe fallback).\n\nEach render produces:\n- `screenshot` \u2014 retina-quality PNG (2\xD7 `deviceScaleFactor`; display at CSS px dimensions)\n- `width` / `height` \u2014 CSS pixel dimensions of the component root\n- `computedStyles` \u2014 per-node computed CSS keyed by `#node-0`, `#node-1`, etc.\n- `dom` \u2014 full DOM tree with bounding boxes (BrowserPool only)\n- `accessibility` \u2014 role, aria-name, violation list (BrowserPool only)\n- `renderTimeMs` \u2014 wall-clock render duration\n\n### 3. Scope Files (`.scope.tsx`)\nScope files let you define **named rendering scenarios** for a component alongside it in the source tree. They are the primary way to ensure `render all` produces meaningful screenshots.\n\n```tsx\n// Button.scope.tsx\nimport type { ScopeFile } from \'@agent-scope/cli\';\nimport { Button } from \'./Button\';\n\nexport default {\n default: { variant: \'primary\', children: \'Click me\' },\n ghost: { variant: \'ghost\', children: \'Cancel\' },\n danger: { variant: \'danger\', children: \'Delete\' },\n disabled: { variant: \'primary\', children: \'Disabled\', disabled: true },\n} satisfies ScopeFile<typeof Button>;\n```\n\nKey rules:\n- The file must be named `<ComponentName>.scope.tsx` in the same directory\n- Export a default object where keys are scenario names and values are props\n- `render all` uses the `default` scenario (or first defined) as the primary screenshot\n- If 2+ scenarios exist, `render all` automatically runs a matrix and merges cells into the component JSON\n- Scenarios also feed the interactive Playground in the docs site\n\nWhen a component renders blank with `{}` props, **the fix is usually to create a `.scope.tsx` file** with real props.\n\n### 4. Token Compliance\nThe compliance pipeline:\n1. `scope render all` captures `computedStyles` for every element in every component\n2. These are written to `.reactscope/compliance-styles.json`\n3. The token engine compares each computed CSS value against your `reactscope.tokens.json`\n4. `scope tokens compliance` reports the aggregate on-system percentage\n5. `scope ci` fails if the percentage is below `complianceThreshold` (default 90%)\n\n**On-system** means the value exactly matches a resolved token value. Off-system means it\'s a hardcoded value with no token backing it.\n\n---\n\n## Complexity Classes \u2014 Practical Guide\n\nThe `complexityClass` field determines which render engine runs. Scope auto-detects it, but agents should understand it:\n\n**`"simple"` components:**\n- Pure presentational, flexbox layout only\n- No CSS grid, no absolute/fixed/sticky positioning\n- No CSS animations, transitions, or transforms\n- No `className` values Scope can\'t statically trace (e.g. dynamic Tailwind classes)\n- Renders in ~8ms via Satori (SVG-based, no browser needed)\n\n**`"complex"` components:**\n- Anything using Tailwind (CSS injection required)\n- CSS grid, positioned elements, overflow, z-index\n- Components that read from context at render time\n- Any component Scope isn\'t sure about (conservative default)\n- Renders in ~200\u2013800ms via Playwright BrowserPool\n\nWhen in doubt: complex is always safe. Simple is an optimization.\n\n---\n\n## Required Contexts \u2014 Why Renders Fail\n\nIf `requiredContexts` is non-empty, the component calls `useContext` on one or more contexts. Without a provider, it will either render broken or throw entirely.\n\nTwo ways to fix:\n1. **Provider presets in config** (recommended): add provider names to `reactscope.config.json \u2192 components.wrappers.providers`\n2. **Scope file with wrapper**: wrap the component in a provider in the scenario itself\n\nBuilt-in mocks (always provided): `ThemeContext \u2192 { theme: \'light\' }`, `LocaleContext \u2192 { locale: \'en-US\' }`.\n\n---\n\n## `scope doctor` \u2014 Always Run This First\n\nBefore debugging any render issue, run:\n```bash\nscope doctor\n```\n\nIt checks:\n- `reactscope.config.json` is valid JSON\n- Token file exists and has a `version` field\n- Every path in `globalCSS` resolves on disk\n- Manifest is present and up to date (not stale relative to source)\n\n**If `globalCSS` is empty or missing**: Tailwind styles won\'t apply to renders. Every component will look unstyled. This is the most common footgun. Fix: add your CSS entry file (the one with `@tailwind base; @tailwind components; @tailwind utilities;`) to `globalCSS` in config.\n\n---\n\n## Agent Decision Tree\n\n**"I want to know what props Component X accepts"**\n\u2192 `scope manifest get X --format json | jq \'.props\'`\n\n**"I want to know which components will break if I change a context"**\n\u2192 `scope manifest query --context MyContext --format json`\n\n**"I want to render a component to verify visual output"**\n\u2192 Create a `.scope.tsx` file with real props first, then `scope render X`\n\n**"I want to render all variants of a component"**\n\u2192 Define all variants in `.scope.tsx`, then `scope render all` (auto-matrix)\n\u2192 Or: `scope render matrix X --axes \'variant:primary,secondary,danger\'`\n\n**"I want to audit token compliance"**\n\u2192 `scope render all` first (populates computedStyles), then `scope tokens compliance`\n\n**"Renders look unstyled / blank"**\n\u2192 Run `scope doctor` \u2014 likely missing `globalCSS`\n\u2192 If props are the issue: create/update the `.scope.tsx` file\n\n**"I want to understand blast radius of a token change"**\n\u2192 `scope tokens impact color.primary.500 --new-value \'#0077dd\'`\n\u2192 `scope tokens preview color.primary.500 --new-value \'#0077dd\'` for visual diff\n\n**"I need to set up Scope in a new project"**\n\u2192 `scope init --yes` (auto-detects Tailwind + CSS, generates manifest automatically)\n\u2192 `scope doctor` to validate\n\u2192 Create `.scope.tsx` files for key components\n\u2192 `scope render all`\n\n**"I want to run Scope in CI"**\n\u2192 `scope ci --json --output ci-result.json`\n\u2192 Exit code 0 = pass, non-zero = specific failure type\n\n---\n\n\n## Installation\n\n```bash\nnpm install -g @agent-scope/cli # global\nnpm install --save-dev @agent-scope/cli # per-project\n```\n\nBinary: `scope`\n\n---\n\n## Core Workflow\n\n```\ninit \u2192 manifest generate \u2192 manifest query/get/list \u2192 render \u2192 (token audit) \u2192 ci\n```\n\n- **init**: Scaffold `reactscope.config.json` + token stub, auto-detect framework/globalCSS, **immediately runs `manifest generate`** so you see results right away.\n- **doctor**: Health-check command \u2014 validates config, token file, globalCSS presence, and manifest staleness.\n- **generate**: Parse TypeScript AST and emit `.reactscope/manifest.json`. Run once per codebase change (or automatically via `scope init`).\n- **query / get / list**: Ask structural questions. No network required. Works from manifest alone. Supports filtering by `--collection` and `--internal`.\n- **render**: Produce PNGs of components via esbuild + Playwright (BrowserPool). Requires manifest for file paths. Auto-injects required prop defaults and globalCSS.\n- **token audit**: Validate design tokens via `@scope/tokens` CLI commands (`tokens list`, `tokens compliance`, `tokens impact`, `tokens preview`, `tokens export`).\n- **ci**: Run compliance checks and exit with code 0/1 for CI pipelines. `report pr-comment` posts results to GitHub PRs.\n\n---\n\n## Full CLI Reference\n\n### `scope init`\nScaffold config, detect framework, extract Tailwind tokens, detect globalCSS files, and **automatically run `scope manifest generate`**.\n\n```bash\nscope init\nscope init --force # overwrite existing config\n```\n\nAfter init completes, the manifest is already written \u2014 no manual `scope manifest generate` step needed.\n\n**Tailwind token extraction**: reads `tailwind.config.js`, extracts colors (with nested scale support), spacing, fontFamily, borderRadius. Stored in `reactscope.tokens.json`.\n\n**globalCSS detection**: checks 9 common patterns (`src/styles.css`, `src/index.css`, `app/globals.css`, etc.). Stored in `components.wrappers.globalCSS` in config.\n\n---\n\n### `scope doctor`\nValidate the Scope setup. Exits non-zero on errors, zero on warnings-only.\n\n```bash\nscope doctor\nscope doctor --json\n```\n\nChecks:\n- `config` \u2014 `reactscope.config.json` is valid JSON with required fields\n- `tokens` \u2014 token file is present and has a valid `version` field\n- `globalCSS` \u2014 globalCSS files listed in config exist on disk\n- `manifest` \u2014 manifest exists and is not stale (compares source file mtimes)\n\n```\n$ scope doctor\nScope Doctor\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n [\u2713] config reactscope.config.json valid\n [\u2713] tokens Token file valid\n [\u2713] globalCSS 1 globalCSS file(s) present\n [!] manifest Manifest may be stale \u2014 5 source file(s) modified since last generate\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n 1 warning(s) \u2014 everything works but could be better\n```\n\n---\n\n### `scope capture <url>`\nCapture a live React component tree from a running app URL.\n\n```bash\nscope capture http://localhost:3000\nscope capture http://localhost:3000 --output report.json --pretty\nscope capture http://localhost:3000 --timeout 15000 --wait 2000\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `-o, --output <path>` | string | stdout | Write JSON to file |\n| `--pretty` | bool | false | Pretty-print JSON |\n| `--timeout <ms>` | number | 10000 | Max wait for React to mount |\n| `--wait <ms>` | number | 0 | Additional wait after page load |\n\nOutput (stdout): serialized PageReport JSON (or path when `--output` is set)\n\n---\n\n### `scope tree <url>`\nPrint the React component tree from a live URL.\n\n```bash\nscope tree http://localhost:3000\nscope tree http://localhost:3000 --depth 3 --show-props --show-hooks\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--depth <n>` | number | unlimited | Max depth to display |\n| `--show-props` | bool | false | Include prop names next to components |\n| `--show-hooks` | bool | false | Show hook counts per component |\n| `--timeout <ms>` | number | 10000 | Max wait for React to mount |\n| `--wait <ms>` | number | 0 | Additional wait after page load |\n\n---\n\n### `scope report <url>`\nCapture and print a human-readable summary of a React app.\n\n```bash\nscope report http://localhost:3000\nscope report http://localhost:3000 --json\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--json` | bool | false | Emit structured JSON instead of text |\n| `--timeout <ms>` | number | 10000 | Max wait for React to mount |\n| `--wait <ms>` | number | 0 | Additional wait after page load |\n\n---\n\n### `scope report baseline`\nSave a baseline snapshot for future diff comparisons.\n\n```bash\nscope report baseline\nscope report baseline --output baselines/my-baseline.json\n```\n\n---\n\n### `scope report diff`\nDiff the current app state against a saved baseline.\n\n```bash\nscope report diff\nscope report diff --baseline baselines/my-baseline.json\nscope report diff --json\n```\n\n---\n\n### `scope report pr-comment`\nPost a Scope CI report as a GitHub PR comment. Used in CI pipelines via the reusable `scope-ci` workflow.\n\n```bash\nscope report pr-comment --report-path scope-ci-report.json\n```\n\nRequires `GITHUB_TOKEN`, `GITHUB_REPOSITORY`, and `GITHUB_PR_NUMBER` in environment.\n\n---\n\n### `scope manifest generate`\nScan source files and write `.reactscope/manifest.json`.\n\n```bash\nscope manifest generate\nscope manifest generate --root ./packages/ui\nscope manifest generate --include "src/**/*.tsx" --exclude "**/*.test.tsx"\nscope manifest generate --output custom/manifest.json\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--root <path>` | string | cwd | Project root directory |\n| `--output <path>` | string | `.reactscope/manifest.json` | Output path |\n| `--include <globs>` | string | `src/**/*.tsx,src/**/*.ts` | Comma-separated include globs |\n| `--exclude <globs>` | string | `**/node_modules/**,...` | Comma-separated exclude globs |\n\n---\n\n### `scope manifest list`\nList all components in the manifest.\n\n```bash\nscope manifest list\nscope manifest list --filter "Button*"\nscope manifest list --format json\nscope manifest list --collection Forms # filter to named collection\nscope manifest list --internal # only internal components\nscope manifest list --no-internal # hide internal components\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--format <fmt>` | `json\\|table` | auto (TTY\u2192table, pipe\u2192json) | Output format |\n| `--filter <glob>` | string | \u2014 | Filter component names by glob |\n| `--collection <name>` | string | \u2014 | Filter to named collection |\n| `--internal` | bool | false | Show only internal components |\n| `--no-internal` | bool | false | Hide internal components |\n| `--manifest <path>` | string | `.reactscope/manifest.json` | Manifest path |\n\nTTY table output (includes COLLECTION and INTERNAL columns):\n```\nNAME FILE COMPLEXITY HOOKS CONTEXTS COLLECTION INTERNAL\n------------ --------------------------- ---------- ----- -------- ---------- --------\nButton src/components/Button.tsx simple 1 0 \u2014 no\nThemeToggle src/components/Toggle.tsx complex 3 1 Forms no\n```\n\n---\n\n### `scope manifest get <name>`\nGet full details of a single component.\n\n```bash\nscope manifest get Button\nscope manifest get Button --format json\n```\n\nJSON output includes `collection` and `internal` fields:\n```json\n{\n "name": "Button",\n "filePath": "src/components/Button.tsx",\n "collection": "Primitives",\n "internal": false,\n ...\n}\n```\n\n---\n\n### `scope manifest query`\nQuery components by attributes.\n\n```bash\nscope manifest query --context ThemeContext\nscope manifest query --hook useEffect\nscope manifest query --complexity complex\nscope manifest query --side-effects\nscope manifest query --has-fetch\nscope manifest query --has-prop <propName>\nscope manifest query --composed-by <ComponentName>\nscope manifest query --internal\nscope manifest query --collection Forms\nscope manifest query --context ThemeContext --format json\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--context <name>` | string | \u2014 | Find components consuming a context by name |\n| `--hook <name>` | string | \u2014 | Find components using a specific hook |\n| `--complexity <class>` | `simple\\|complex` | \u2014 | Filter by complexity class |\n| `--side-effects` | bool | false | Any side effects detected |\n| `--has-fetch` | bool | false | Components with fetch calls specifically |\n| `--has-prop <name>` | string | \u2014 | Components that accept a specific prop |\n| `--composed-by <name>` | string | \u2014 | Components rendered inside a specific parent |\n| `--internal` | bool | false | Only internal components |\n| `--collection <name>` | string | \u2014 | Filter to named collection |\n| `--format <fmt>` | `json\\|table` | auto | Output format |\n| `--manifest <path>` | string | `.reactscope/manifest.json` | Manifest path |\n\n---\n\n### `scope render <component>`\nRender a single component to PNG (TTY) or JSON (pipe).\n\n**Auto prop defaults**: if `--props` is omitted, Scope injects sensible defaults so required props don\'t produce blank renders: strings/nodes \u2192 component name, unions \u2192 first value, booleans \u2192 `false`, numbers \u2192 `0`.\n\n**globalCSS auto-injection**: reads `components.wrappers.globalCSS` from config and compiles/injects CSS (supports Tailwind v3 via PostCSS) into the render harness. A warning is printed to stderr if no globalCSS is configured (common cause of unstyled renders).\n\n```bash\nscope render Button\nscope render Button --props \'{"variant":"primary","children":"Click me"}\'\nscope render Button --viewport 375x812\nscope render Button --output button.png\nscope render Button --format json\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--props <json>` | string | `{}` | Inline props as JSON string |\n| `--viewport <WxH>` | string | `375x812` | Viewport size |\n| `--theme <name>` | string | \u2014 | Theme name from token system |\n| `-o, --output <path>` | string | \u2014 | Write PNG to specific path |\n| `--format <fmt>` | `png\\|json` | auto (TTY\u2192file, pipe\u2192json) | Output format |\n| `--manifest <path>` | string | `.reactscope/manifest.json` | Manifest path |\n\n---\n\n### `scope render matrix <component>`\nRender across a Cartesian product of prop axes. Accepts both `key:v1,v2` and `{"key":["v1","v2"]}` JSON format for `--axes`.\n\n```bash\nscope render matrix Button --axes \'variant:primary,secondary,danger\'\nscope render matrix Button --axes \'{"variant":["primary","secondary"]}\'\nscope render matrix Button --axes \'variant:primary,secondary size:sm,md,lg\'\nscope render matrix Button --sprite button-matrix.png --format json\n```\n\n---\n\n### `scope render all`\nRender every component in the manifest.\n\n```bash\nscope render all\nscope render all --concurrency 4 --output-dir renders/\n```\n\nHandles imports of CSS files in components (maps to empty loader so styles are injected at page level). SVG and font imports are handled via dataurl loaders.\n\n---\n\n### `scope instrument tree`\nCapture the live React component tree with instrumentation metadata.\n\n```bash\nscope instrument tree http://localhost:3000\nscope instrument tree http://localhost:3000 --depth 5 --show-props\n```\n\n**Implementation note**: uses a fresh `chromium.launch()` + `newContext()` + `newPage()` per call (not BrowserPool), with `addInitScript` called before `setContent` to ensure the Scope runtime is injected at document-start before React loads.\n\n---\n\n### `scope instrument hooks`\nProfile hook execution in live components.\n\n```bash\nscope instrument hooks http://localhost:3000\nscope instrument hooks http://localhost:3000 --component Button\n```\n\n**Implementation note**: requires `addInitScript({ content: getBrowserEntryScript() })` before `setContent` so `__REACT_DEVTOOLS_GLOBAL_HOOK__` is present when React loads its renderer.\n\n---\n\n### `scope instrument profile`\nProfile render performance of live components.\n\n```bash\nscope instrument profile http://localhost:3000\n```\n\n---\n\n### `scope instrument renders`\nRe-render causality analysis \u2014 what triggered each render.\n\n```bash\nscope instrument renders http://localhost:3000\n```\n\n---\n\n### `scope tokens get <name>`\nGet details of a single design token.\n\n### `scope tokens list`\nList all tokens. Token file must have a `version` field (written by `scope init`).\n\n```bash\nscope tokens list\nscope tokens list --type color\nscope tokens list --format json\n```\n\n### `scope tokens search <query>`\nFull-text search across token names/values.\n\n### `scope tokens resolve <value>`\nResolve a CSS value or alias back to its token name.\n\n### `scope tokens validate`\nValidate token file schema.\n\n### `scope tokens compliance`\nCheck rendered components for design token compliance.\n\n```bash\nscope tokens compliance\nscope tokens compliance --threshold 95\n```\n\n### `scope tokens impact <token>`\nAnalyze impact of changing a token \u2014 which components use it.\n\n```bash\nscope tokens impact --token color.primary.500\n```\n\n### `scope tokens preview <token>`\nPreview a token value change visually before committing.\n\n### `scope tokens export`\nExport tokens in multiple formats.\n\n```bash\nscope tokens export --format flat-json\nscope tokens export --format css\nscope tokens export --format scss\nscope tokens export --format ts\nscope tokens export --format tailwind\nscope tokens export --format style-dictionary\n```\n\n**Format aliases** (auto-corrected with "Did you mean?" hint):\n- `json` \u2192 `flat-json`\n- `js` \u2192 `ts`\n- `sass` \u2192 `scss`\n- `tw` \u2192 `tailwind`\n\n---\n\n### `scope ci`\nRun all CI checks (compliance, accessibility, console errors) and exit 0/1.\n\n```bash\nscope ci\nscope ci --json\nscope ci --threshold 90 # compliance threshold (default: 90)\n```\n\n```\n$ scope ci --json\n\u2192 CI passed in 3.2s\n\u2192 Compliance 100.0% >= threshold 90.0% \u2705\n\u2192 Accessibility audit not yet implemented \u2014 skipped \u2705\n\u2192 No console errors detected \u2705\n\u2192 Exit code 0\n```\n\nThe `scope-ci` **reusable GitHub Actions workflow** is available at `.github/workflows/scope-ci.yml` and can be included in any repo\'s CI to run `scope ci` and post results as a PR comment via `scope report pr-comment`.\n\n---\n\n### `scope site build`\nGenerate a static HTML component gallery site from the manifest.\n\n```bash\nscope site build\nscope site build --output ./dist/site\n```\n\n**Collections support**: components are grouped under named collection sections in the sidebar and index grid. Internal components are hidden from the sidebar and card grid but appear in composition detail sections with an `internal` badge.\n\n**Collection display rules**:\n- Sidebar: one section divider per collection + an "Ungrouped" section; internal components excluded\n- Index page: named sections with heading + optional description; internal components excluded\n- Component detail page: Composes/Composed By lists ALL components including internal ones (with subtle badge)\n- Falls back to flat list when no collections configured (backwards-compatible)\n\n### `scope site serve`\nServe the generated site locally.\n\n```bash\nscope site serve\nscope site serve --port 4000\n```\n\n---\n\n## Collections & Internal Components\n\nComponents can be organized into named **collections** and flagged as **internal** (library implementation details not shown in the public gallery).\n\n### Defining collections\n\n**1. TSDoc tag** (highest precedence):\n```tsx\n/**\n * @collection Forms\n */\nexport function Input() { ... }\n```\n\n**2. `.scope.ts` co-located file**:\n```ts\n// Input.scope.ts\nexport const collection = "Forms"\n```\n\n**3. Config-level glob patterns**:\n```json\n// reactscope.config.json\n{\n "collections": [\n { "name": "Forms", "description": "Form inputs and controls", "patterns": ["src/forms/**"] },\n { "name": "Primitives", "patterns": ["src/primitives/**"] }\n ]\n}\n```\n\nResolution precedence: TSDoc `@collection` > `.scope.ts` export > config pattern.\n\n### Flagging internal components\n\n**TSDoc tag**:\n```tsx\n/**\n * @internal\n */\nexport function InternalHelperButton() { ... }\n```\n\n**Config glob patterns**:\n```json\n{\n "internalPatterns": ["src/internal/**", "src/**/*Internal*"]\n}\n```\n\n---\n\n## Manifest Output Schema\n\nFile: `.reactscope/manifest.json`\n\n```typescript\n{\n version: "0.1",\n generatedAt: string, // ISO 8601\n collections: CollectionConfig[], // echoes config.collections, [] when not set\n components: Record<string, ComponentDescriptor>,\n tree: Record<string, { children: string[], parents: string[] }>\n}\n```\n\n### `ComponentDescriptor` fields\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `filePath` | `string` | Relative path from project root to source file |\n| `exportType` | `"named" \\| "default" \\| "none"` | How the component is exported |\n| `displayName` | `string` | `displayName` if set, else function/class name |\n| `collection` | `string?` | Resolved collection name (`undefined` = ungrouped) |\n| `internal` | `boolean` | `true` if flagged as internal (default: `false`) |\n| `props` | `Record<string, PropDescriptor>` | Extracted prop types keyed by prop name |\n| `composes` | `string[]` | Components this one renders in its JSX |\n| `composedBy` | `string[]` | Components that render this one in their JSX |\n| `complexityClass` | `"simple" \\| "complex"` | Render path: simple = Satori-safe, complex = requires BrowserPool |\n| `requiredContexts` | `string[]` | React context names consumed |\n| `detectedHooks` | `string[]` | All hooks called, sorted alphabetically |\n| `sideEffects` | `SideEffects` | Side effect categories detected |\n| `memoized` | `boolean` | Wrapped with `React.memo` |\n| `forwardedRef` | `boolean` | Wrapped with `React.forwardRef` |\n| `hocWrappers` | `string[]` | HOC wrapper names (excluding memo/forwardRef) |\n| `loc` | `{ start: number, end: number }` | Line numbers in source file |\n\n---\n\n## Common Agent Workflows\n\n### Structural queries\n\n```bash\n# Which components use ThemeContext?\nscope manifest query --context ThemeContext\n\n# What props does Button accept?\nscope manifest get Button --format json | jq \'.props\'\n\n# Which components are safe to render without a provider?\nscope manifest query --complexity simple # + check requiredContexts === []\n\n# Show all components with side effects\nscope manifest query --side-effects\n\n# Which components make fetch calls?\nscope manifest query --has-fetch\n\n# Which components use useEffect?\nscope manifest query --hook useEffect\n\n# Which components accept a disabled prop?\nscope manifest query --has-prop disabled\n\n# Which components are composed inside Modal?\nscope manifest query --composed-by Modal\n\n# All components in the Forms collection\nscope manifest list --collection Forms\n\n# All internal components (library implementation details)\nscope manifest list --internal\n\n# Public components only (hide internals)\nscope manifest list --no-internal\n```\n\n### Render workflows\n\n```bash\n# Render Button in all variants (auto-defaults props if not provided)\nscope render matrix Button --axes \'variant:primary,secondary,danger\'\n\n# Render with JSON axes format\nscope render matrix Button --axes \'{"variant":["primary","secondary"]}\'\n\n# Render with explicit props\nscope render Button --props \'{"variant":"primary","disabled":true}\'\n\n# Render all components (handles CSS/SVG/font imports automatically)\nscope render all --concurrency 8\n\n# Get render as JSON\nscope render Button --format json | jq \'.screenshot\' | base64 -d > button.png\n```\n\n### Token workflows\n\n```bash\n# List all tokens\nscope tokens list\n\n# Check compliance\nscope tokens compliance --threshold 95\n\n# See what a token change impacts\nscope tokens impact --token color.primary.500\n\n# Export for Tailwind\nscope tokens export --format tailwind\n```\n\n### CI workflow\n\n```bash\n# Full compliance check\nscope ci --json\n\n# In GitHub Actions \u2014 use the reusable workflow\n# .github/workflows/ci.yml:\n# uses: FlatFilers/Scope/.github/workflows/scope-ci.yml@main\n```\n\n---\n\n## Error Patterns\n\n| Error | Cause | Fix |\n|-------|-------|-----|\n| `"React root not found"` | App not running, wrong URL, or Vite HMR interfering | Use `scope capture --wait 2000` |\n| `"Component not in manifest"` | Manifest is stale | Run `scope manifest generate` first |\n| `"Manifest not found"` | Missing manifest | Run `scope init` or `scope manifest generate` |\n| `"requiredContexts missing"` | Component needs a provider | Add provider presets to `reactscope.config.json` |\n| Blank PNG / 16\xD76px renders | No globalCSS injected (common with Tailwind) | Set `components.wrappers.globalCSS` in config; run `scope doctor` to verify |\n| `"Invalid props JSON"` | Malformed JSON in `--props` | Use single outer quotes: `--props \'{"key":"val"}\'` |\n| `"SCOPE_CAPTURE_JSON not available"` | Scope runtime not injected before React loaded | Fixed in PR #83 \u2014 update CLI |\n| `"No React DevTools hook found"` | Hook instrumentation init order bug | Fixed in PR #83 \u2014 update CLI |\n| `"ERR_MODULE_NOT_FOUND"` after tokens commands | Old Node shebang in CLI binary | Fixed in PR #90 \u2014 CLI now uses `#!/usr/bin/env bun` |\n| `"version" field missing in tokens` | Token stub written by old `scope init` | Re-run `scope init --force` or add `"version": "1"` to token file |\n| `"unknown option --has-prop"` | Old CLI version | Fixed in PR #90 \u2014 update CLI |\n| Format alias error (`json`, `js`, `sass`, `tw`) | Wrong format name for `tokens export` | Use `flat-json`, `ts`, `scss`, `tailwind`; CLI shows "Did you mean?" hint |\n\n---\n\n## `reactscope.config.json`\n\n```json\n{\n "components": {\n "wrappers": {\n "globalCSS": ["src/styles.css"]\n }\n },\n "tokens": {\n "file": "reactscope.tokens.json"\n },\n "collections": [\n { "name": "Forms", "description": "Form inputs and controls", "patterns": ["src/forms/**"] },\n { "name": "Primitives", "patterns": ["src/primitives/**"] }\n ],\n "internalPatterns": ["src/internal/**"],\n "providers": {\n "theme": { "component": "ThemeProvider", "props": { "theme": "light" } },\n "router": { "component": "MemoryRouter", "props": { "initialEntries": ["/"] } }\n }\n}\n```\n\n**Built-in mock providers** (always available, no config needed):\n- `ThemeContext` \u2192 `{ theme: \'light\' }` (or `--theme <name>`)\n- `LocaleContext` \u2192 `{ locale: \'en-US\' }`\n\n---\n\n## What Scope Cannot Do\n\n- **Runtime state**: `useState` values after user interaction\n- **Network requests**: `fetch`, `XHR`, `WebSocket`\n- **User interactions**: click, type, hover, drag\n- **Auth/session-gated components**: components that redirect or throw without a session\n- **Server components (RSC)**: React Server Components\n- **Dynamic CSS**: CSS-in-JS styles computed at runtime from props Scope can\'t infer\n\n---\n\n## Version History\n\n| Version | Date | Summary |\n|---------|------|---------|\n| v1.0 | 2026-03-11 | Initial SKILL.md (PR #36) \u2014 manifest, render, capture, tree, report, tokens, ci commands |\n| v1.1 | 2026-03-11 | Updated through PR #82 \u2014 Phase 2 CLI commands complete |\n| v1.2 | 2026-03-13 | PRs #83\u2013#95: runtime injection fix, dogfooding fixes (12 bugs), `scope doctor`, `scope init` auto-manifest, globalCSS render warning, collections & internal components feature |\n';
1420
+ var SKILL_CONTENT = '# Scope \u2014 Agent Skill\n\n## TLDR\nScope is a React codebase introspection toolkit. Use it to answer questions about component structure, props, context dependencies, side effects, and visual output \u2014 without running the app.\n\n**When to reach for it:** Any task requiring "which components use X", "what props does Y accept", "render Z for visual verification", "does this component depend on a provider", or "what design tokens are in use".\n\n**Canonical agent workflow:**\n```\nscope get-skill > /tmp/scope-skill.md # bootstrap command semantics into agent context\nscope init --yes # scaffold config + auto-generate manifest\nscope doctor --json # validate config, tokens, globalCSS, manifest freshness, deps, Playwright\nscope manifest query --context ThemeContext --format json\nscope render all --format json --output-dir .reactscope/renders\nscope site build --output .reactscope/site\n```\n\n---\n\n---\n\n## Mental Model\n\nUnderstanding how Scope\'s data flows is the key to using it effectively as an agent.\n\n```\nSource TypeScript files\n \u2193 (ts-morph AST parse)\n manifest.json \u2190 structural facts: props, hooks, contexts, complexity\n \u2193 (esbuild + Playwright)\n renders/*.json \u2190 visual facts: screenshot, computedStyles, dom, a11y\n \u2193 (token engine)\n compliance-styles.json \u2190 audit facts: which CSS values match tokens, which don\'t\n \u2193 (site generator)\n site/ \u2190 human-readable docs combining all of the above\n```\n\nEach layer depends on the previous. If you\'re getting unexpected results, check whether the earlier layers are stale (run `scope doctor` to diagnose).\n\n---\n\n## The Four Subsystems\n\n### 1. Manifest (`scope manifest *`)\nThe manifest is a static analysis snapshot of your TypeScript source. It tells you:\n- What components exist, where they live, and how they\'re exported\n- What props each component accepts (types, defaults, required/optional)\n- What React hooks they call (`detectedHooks`)\n- What contexts they consume (`requiredContexts`) \u2014 must be provided for a render to succeed\n- Whether they compose other components (`composes` / `composedBy`)\n- Their **complexity class** \u2014 `"simple"` or `"complex"` \u2014 which determines the render engine\n\nThe manifest never runs your code. It only reads TypeScript. This means it\'s fast and safe, but it can\'t know about runtime values.\n\n### 2. Render Engine (`scope render *`)\nThe render engine compiles components with esbuild and renders them in Chromium (Playwright). Two paths exist:\n\n| Path | When | Speed | Capability |\n|------|------|-------|------------|\n| **Satori** | `complexityClass: "simple"` | ~8ms | Flexbox only, no JS, no CSS-in-JS |\n| **BrowserPool** | `complexityClass: "complex"` | ~200\u2013800ms | Full DOM, CSS, Tailwind, animations |\n\nMost real-world components route through BrowserPool. Scope defaults to `"complex"` when uncertain (safe fallback).\n\nEach render produces:\n- `screenshot` \u2014 retina-quality PNG (2\xD7 `deviceScaleFactor`; display at CSS px dimensions)\n- `width` / `height` \u2014 CSS pixel dimensions of the component root\n- `computedStyles` \u2014 per-node computed CSS keyed by `#node-0`, `#node-1`, etc.\n- `dom` \u2014 full DOM tree with bounding boxes (BrowserPool only)\n- `accessibility` \u2014 role, aria-name, violation list (BrowserPool only)\n- `renderTimeMs` \u2014 wall-clock render duration\n\n### 3. Scope Files (`.scope.tsx` / `.scope.ts`)\nScope files define **named rendering scenarios** and optional provider wrappers next to a component. They are the primary way to ensure `render all` produces meaningful screenshots.\n\nDiscovery is deterministic: for `Button.tsx`, Scope checks `Button.scope.tsx`, then `.scope.ts`, `.scope.jsx`, `.scope.js` in the same directory and uses the first file that exists.\n\n```tsx\n// Button.scope.tsx\nexport const scenarios = {\n default: { variant: \'primary\', children: \'Click me\' },\n ghost: { variant: \'ghost\', children: \'Cancel\' },\n danger: { variant: \'danger\', children: \'Delete\' },\n disabled: { variant: \'primary\', children: \'Disabled\', disabled: true },\n} satisfies Record<string, Record<string, unknown>>;\n```\n\nOptional wrapper:\n\n```tsx\nimport type { ReactNode } from \'react\';\nimport { ThemeProvider } from \'../providers/ThemeProvider\';\n\nexport function wrapper({ children }: { children: ReactNode }) {\n return <ThemeProvider theme="dark">{children}</ThemeProvider>;\n}\n```\n\nContract:\n- Export `scenarios` as a plain object of scenario-name \u2192 props-object, either as a named export or under `default.scenarios`\n- Export `wrapper` as a function, either as a named export or under `default.wrapper`\n- Non-object scenario values are skipped with a warning\n- If no scope file or no valid scenarios exist, Scope falls back to one bare render and inferred required-prop defaults\n- If 2+ scenarios exist, `render all` automatically runs a matrix and merges cells into each component JSON\n\nWhen a component renders blank with `{}` props, **the fix is usually to create a `.scope.tsx` file** with real props.\n\n### 4. Token Compliance\nThe compliance pipeline:\n1. `scope render all` captures `computedStyles` for every element in every component\n2. These are written to `.reactscope/compliance-styles.json`\n3. The token engine compares each computed CSS value against your `reactscope.tokens.json`\n4. `scope tokens compliance` reports the aggregate on-system percentage\n5. `scope ci` fails if the percentage is below `complianceThreshold` (default 90%)\n\n**On-system** means the value exactly matches a resolved token value. Off-system means it\'s a hardcoded value with no token backing it.\n\n---\n\n## Complexity Classes \u2014 Practical Guide\n\nThe `complexityClass` field determines which render engine runs. Scope auto-detects it, but agents should understand it:\n\n**`"simple"` components:**\n- Pure presentational, flexbox layout only\n- No CSS grid, no absolute/fixed/sticky positioning\n- No CSS animations, transitions, or transforms\n- No `className` values Scope can\'t statically trace (e.g. dynamic Tailwind classes)\n- Renders in ~8ms via Satori (SVG-based, no browser needed)\n\n**`"complex"` components:**\n- Anything using Tailwind (CSS injection required)\n- CSS grid, positioned elements, overflow, z-index\n- Components that read from context at render time\n- Any component Scope isn\'t sure about (conservative default)\n- Renders in ~200\u2013800ms via Playwright BrowserPool\n\nWhen in doubt: complex is always safe. Simple is an optimization.\n\n---\n\n## Required Contexts \u2014 Why Renders Fail\n\nIf `requiredContexts` is non-empty, the component calls `useContext` on one or more contexts. Without a provider, it will either render broken or throw entirely.\n\nTwo ways to fix:\n1. **Provider presets in config** (recommended): add provider names to `reactscope.config.json \u2192 components.wrappers.providers`\n2. **Scope file with wrapper**: wrap the component in a provider in the scenario itself\n\nBuilt-in mocks (always provided): `ThemeContext \u2192 { theme: \'light\' }`, `LocaleContext \u2192 { locale: \'en-US\' }`.\n\n---\n\n## `scope doctor` \u2014 Always Run This First\n\nBefore debugging any render issue, run:\n```bash\nscope doctor --json\n```\n\nIt checks:\n- `reactscope.config.json` is valid JSON\n- Token file exists and has a `version` field\n- Every path in `globalCSS` resolves on disk\n- Manifest is present and up to date (not stale relative to source)\n- Target-project dependencies are installed (`node_modules` exists)\n- Playwright Chromium is available for render/site/instrument commands\n\n**If `globalCSS` is empty or missing**: Tailwind styles won\'t apply to renders. Every component will look unstyled. This is the most common footgun. Fix: add your CSS entry file (the one with `@tailwind base; @tailwind components; @tailwind utilities;`) to `globalCSS` in config.\n\n---\n\n## Agent Decision Tree\n\n**"I want to know what props Component X accepts"**\n\u2192 `scope manifest get X --format json | jq \'.props\'`\n\n**"I want to know which components will break if I change a context"**\n\u2192 `scope manifest query --context MyContext --format json`\n\n**"I want to render a component to verify visual output"**\n\u2192 Create a `.scope.tsx` file with real props first, then `scope render component X --format json`\n\n**"I want to render all variants of a component"**\n\u2192 Define all variants in `.scope.tsx`, then `scope render all --format json --output-dir .reactscope/renders` (auto-matrix)\n\u2192 Or: `scope render matrix X --axes \'variant:primary,secondary,danger\' --format json`\n\n**"I want to audit token compliance"**\n\u2192 `scope render all` first (populates computedStyles), then `scope tokens compliance`\n\n**"Renders look unstyled / blank"**\n\u2192 Run `scope doctor` \u2014 likely missing `globalCSS`\n\u2192 If props are the issue: create/update the `.scope.tsx` file\n\n**"I want to understand blast radius of a token change"**\n\u2192 `scope tokens impact color.primary.500 --new-value \'#0077dd\'`\n\u2192 `scope tokens preview color.primary.500 --new-value \'#0077dd\'` for visual diff\n\n**"I need to set up Scope in a new project"**\n\u2192 `scope init --yes` (auto-detects Tailwind + CSS, generates manifest automatically)\n\u2192 `scope doctor` to validate\n\u2192 Create `.scope.tsx` files for key components\n\u2192 `scope render all`\n\n**"I want to profile a live SPA"**\n\u2192 Generate a manifest, then `scope instrument profile SearchPage --interaction \'[{"action":"click","target":"button"}]\'`\n\u2192 For auth/timing-heavy apps, follow `docs/profiling-production-spas.md`\n\n**"I want to run Scope in CI"**\n\u2192 `scope ci --json --output ci-result.json`\n\u2192 Exit code 0 = pass, non-zero = specific failure type\n\n---\n\n\n## Installation\n\nPublished package:\n\n```bash\nnpm install -g @agent-scope/cli # global\nnpm install --save-dev @agent-scope/cli # per-project\n```\n\nLocal-from-source quickstart for this repo:\n\n```bash\nbun install\nbun run build\nbunx playwright install chromium\ncd fixtures/tailwind-showcase\nbun install\n../../packages/cli/dist/cli.js init --yes\n../../packages/cli/dist/cli.js doctor --json\n../../packages/cli/dist/cli.js render all --format json --output-dir .reactscope/renders\n../../packages/cli/dist/cli.js site build --output .reactscope/site\n```\n\nBinary: `scope` (published install) or `packages/cli/dist/cli.js` (local build)\n\n---\n\n## Core Workflow\n\n```\nget-skill \u2192 init --yes \u2192 doctor --json \u2192 manifest query/get/list --format json \u2192 render all --format json \u2192 site build \u2192 instrument profile \u2192 ci\n```\n\n- **init**: Scaffold `reactscope.config.json` + token stub, auto-detect framework/globalCSS, **immediately runs `manifest generate`** so you see results right away.\n- **doctor**: Health-check command \u2014 validates config, token file, globalCSS presence, manifest staleness, target-project dependencies, and Playwright Chromium availability.\n- **generate**: Parse TypeScript AST and emit `.reactscope/manifest.json`. Run once per codebase change (or automatically via `scope init`).\n- **query / get / list**: Ask structural questions. No network required. Works from manifest alone. Supports filtering by `--collection` and `--internal`.\n- **render**: Produce PNGs of components via esbuild + Playwright (BrowserPool). Requires manifest for file paths. Auto-injects required prop defaults and globalCSS.\n- **token audit**: Validate design tokens via `@scope/tokens` CLI commands (`tokens list`, `tokens compliance`, `tokens impact`, `tokens preview`, `tokens export`).\n- **ci**: Run compliance checks and exit with code 0/1 for CI pipelines. `report pr-comment` posts results to GitHub PRs.\n\n---\n\n## Full CLI Reference\n\n### `scope init`\nScaffold config, detect framework, extract Tailwind tokens, detect globalCSS files, and **automatically run `scope manifest generate`**.\n\n```bash\nscope init\nscope init --force # overwrite existing config\n```\n\nAfter init completes, the manifest is already written \u2014 no manual `scope manifest generate` step needed.\n\n**Tailwind token extraction**: reads `tailwind.config.js`, extracts colors (with nested scale support), spacing, fontFamily, borderRadius. Stored in `reactscope.tokens.json`.\n\n**globalCSS detection**: checks 9 common patterns (`src/styles.css`, `src/index.css`, `app/globals.css`, etc.). Stored in `components.wrappers.globalCSS` in config.\n\n---\n\n### `scope doctor`\nValidate the Scope setup. Exits non-zero on errors, zero on warnings-only.\n\n```bash\nscope doctor --json\nscope doctor --json\n```\n\nChecks:\n- `config` \u2014 `reactscope.config.json` is valid JSON with required fields\n- `tokens` \u2014 token file is present and has a valid `version` field\n- `globalCSS` \u2014 globalCSS files listed in config exist on disk\n- `manifest` \u2014 manifest exists and is not stale (compares source file mtimes)\n- `dependencies` \u2014 `node_modules` exists in the target project root\n- `playwright` \u2014 Playwright Chromium is available before render/site/instrument\n\nIf `dependencies` fails, run `bun install` (or the package manager detected by your lockfile) in the target project root, then rerun `scope doctor --json`.\n\nIf `playwright` fails, run `bunx playwright install chromium`, then rerun `scope doctor --json` before retrying `scope render component`, `scope render all`, `scope site build`, or `scope instrument ...`.\n\n```\n$ scope doctor --json\nScope Doctor\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n [\u2713] config reactscope.config.json valid\n [\u2713] tokens Token file valid\n [\u2713] globalCSS 1 globalCSS file(s) present\n [!] manifest Manifest may be stale \u2014 5 source file(s) modified since last generate\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n 1 warning(s) \u2014 everything works but could be better\n```\n\n---\n\n### `scope capture <url>`\nCapture a live React component tree from a running app URL.\n\n```bash\nscope capture http://localhost:3000\nscope capture http://localhost:3000 --output report.json --pretty\nscope capture http://localhost:3000 --timeout 15000 --wait 2000\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `-o, --output <path>` | string | stdout | Write JSON to file |\n| `--pretty` | bool | false | Pretty-print JSON |\n| `--timeout <ms>` | number | 10000 | Max wait for React to mount |\n| `--wait <ms>` | number | 0 | Additional wait after page load |\n\nOutput (stdout): serialized PageReport JSON (or path when `--output` is set)\n\n---\n\n### `scope tree <url>`\nPrint the React component tree from a live URL.\n\n```bash\nscope tree http://localhost:3000\nscope tree http://localhost:3000 --depth 3 --show-props --show-hooks\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--depth <n>` | number | unlimited | Max depth to display |\n| `--show-props` | bool | false | Include prop names next to components |\n| `--show-hooks` | bool | false | Show hook counts per component |\n| `--timeout <ms>` | number | 10000 | Max wait for React to mount |\n| `--wait <ms>` | number | 0 | Additional wait after page load |\n\n---\n\n### `scope report <url>`\nCapture and print a human-readable summary of a React app.\n\n```bash\nscope report http://localhost:3000\nscope report http://localhost:3000 --json\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--json` | bool | false | Emit structured JSON instead of text |\n| `--timeout <ms>` | number | 10000 | Max wait for React to mount |\n| `--wait <ms>` | number | 0 | Additional wait after page load |\n\n---\n\n### `scope report baseline`\nSave a baseline snapshot for future diff comparisons.\n\n```bash\nscope report baseline\nscope report baseline --output baselines/my-baseline.json\n```\n\n---\n\n### `scope report diff`\nDiff the current app state against a saved baseline.\n\n```bash\nscope report diff\nscope report diff --baseline baselines/my-baseline.json\nscope report diff --json\n```\n\n---\n\n### `scope report pr-comment`\nPost a Scope CI report as a GitHub PR comment. Used in CI pipelines via the reusable `scope-ci` workflow.\n\n```bash\nscope report pr-comment --report-path scope-ci-report.json\n```\n\nRequires `GITHUB_TOKEN`, `GITHUB_REPOSITORY`, and `GITHUB_PR_NUMBER` in environment.\n\n---\n\n### `scope manifest generate`\nScan source files and write `.reactscope/manifest.json`.\n\n```bash\nscope manifest generate\nscope manifest generate --root ./packages/ui\nscope manifest generate --include "src/**/*.tsx" --exclude "**/*.test.tsx"\nscope manifest generate --output custom/manifest.json\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--root <path>` | string | cwd | Project root directory |\n| `--output <path>` | string | `.reactscope/manifest.json` | Output path |\n| `--include <globs>` | string | `src/**/*.tsx,src/**/*.ts` | Comma-separated include globs |\n| `--exclude <globs>` | string | `**/node_modules/**,...` | Comma-separated exclude globs |\n\n---\n\n### `scope manifest list`\nList all components in the manifest.\n\n```bash\nscope manifest list\nscope manifest list --filter "Button*"\nscope manifest list --format json\nscope manifest list --collection Forms # filter to named collection\nscope manifest list --internal # only internal components\nscope manifest list --no-internal # hide internal components\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--format <fmt>` | `json\\|table` | auto (TTY\u2192table, pipe\u2192json) | Output format |\n| `--filter <glob>` | string | \u2014 | Filter component names by glob |\n| `--collection <name>` | string | \u2014 | Filter to named collection |\n| `--internal` | bool | false | Show only internal components |\n| `--no-internal` | bool | false | Hide internal components |\n| `--manifest <path>` | string | `.reactscope/manifest.json` | Manifest path |\n\nTTY table output (includes COLLECTION and INTERNAL columns):\n```\nNAME FILE COMPLEXITY HOOKS CONTEXTS COLLECTION INTERNAL\n------------ --------------------------- ---------- ----- -------- ---------- --------\nButton src/components/Button.tsx simple 1 0 \u2014 no\nThemeToggle src/components/Toggle.tsx complex 3 1 Forms no\n```\n\n---\n\n### `scope manifest get <name>`\nGet full details of a single component.\n\n```bash\nscope manifest get Button\nscope manifest get Button --format json\n```\n\nJSON output includes `collection` and `internal` fields:\n```json\n{\n "name": "Button",\n "filePath": "src/components/Button.tsx",\n "collection": "Primitives",\n "internal": false,\n ...\n}\n```\n\n---\n\n### `scope manifest query`\nQuery components by attributes.\n\n```bash\nscope manifest query --context ThemeContext\nscope manifest query --hook useEffect\nscope manifest query --complexity complex\nscope manifest query --side-effects\nscope manifest query --has-fetch\nscope manifest query --has-prop <propName>\nscope manifest query --composed-by <ComponentName>\nscope manifest query --internal\nscope manifest query --collection Forms\nscope manifest query --context ThemeContext --format json\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--context <name>` | string | \u2014 | Find components consuming a context by name |\n| `--hook <name>` | string | \u2014 | Find components using a specific hook |\n| `--complexity <class>` | `simple\\|complex` | \u2014 | Filter by complexity class |\n| `--side-effects` | bool | false | Any side effects detected |\n| `--has-fetch` | bool | false | Components with fetch calls specifically |\n| `--has-prop <name>` | string | \u2014 | Components that accept a specific prop |\n| `--composed-by <name>` | string | \u2014 | Components rendered inside a specific parent |\n| `--internal` | bool | false | Only internal components |\n| `--collection <name>` | string | \u2014 | Filter to named collection |\n| `--format <fmt>` | `json\\|table` | auto | Output format |\n| `--manifest <path>` | string | `.reactscope/manifest.json` | Manifest path |\n\n---\n\n### `scope render <component>`\nRender a single component to PNG (TTY) or JSON (pipe).\n\n**Auto prop defaults**: if `--props` is omitted, Scope injects sensible defaults so required props don\'t produce blank renders: strings/nodes \u2192 component name, unions \u2192 first value, booleans \u2192 `false`, numbers \u2192 `0`.\n\n**globalCSS auto-injection**: reads `components.wrappers.globalCSS` from config and compiles/injects CSS (supports Tailwind v3 via PostCSS) into the render harness. A warning is printed to stderr if no globalCSS is configured (common cause of unstyled renders).\n\n```bash\nscope render component Button\nscope render component Button --props \'{"variant":"primary","children":"Click me"}\'\nscope render component Button --viewport 375x812\nscope render component Button --output button.png\nscope render component Button --format json\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--props <json>` | string | `{}` | Inline props as JSON string |\n| `--viewport <WxH>` | string | `375x812` | Viewport size |\n| `--theme <name>` | string | \u2014 | Theme name from token system |\n| `-o, --output <path>` | string | \u2014 | Write PNG to specific path |\n| `--format <fmt>` | `png\\|json` | auto (TTY\u2192file, pipe\u2192json) | Output format |\n| `--manifest <path>` | string | `.reactscope/manifest.json` | Manifest path |\n\n---\n\n### `scope render matrix <component>`\nRender across a Cartesian product of prop axes. Accepts both `key:v1,v2` and `{"key":["v1","v2"]}` JSON format for `--axes`.\n\n```bash\nscope render matrix Button --axes \'variant:primary,secondary,danger\'\nscope render matrix Button --axes \'{"variant":["primary","secondary"]}\'\nscope render matrix Button --axes \'variant:primary,secondary size:sm,md,lg\'\nscope render matrix Button --sprite button-matrix.png --format json\n```\n\n---\n\n### `scope render all`\nRender every component in the manifest.\n\n```bash\nscope render all\nscope render all --concurrency 4 --output-dir renders/\n```\n\nHandles imports of CSS files in components (maps to empty loader so styles are injected at page level). SVG and font imports are handled via dataurl loaders.\n\n---\n\n### `scope instrument tree`\nCapture the live React component tree with instrumentation metadata.\n\n```bash\nscope instrument tree http://localhost:3000\nscope instrument tree http://localhost:3000 --depth 5 --show-props\n```\n\n**Implementation note**: uses a fresh `chromium.launch()` + `newContext()` + `newPage()` per call (not BrowserPool), with `addInitScript` called before `setContent` to ensure the Scope runtime is injected at document-start before React loads.\n\n---\n\n### `scope instrument hooks`\nProfile hook execution in live components.\n\n```bash\nscope instrument hooks http://localhost:3000\nscope instrument hooks http://localhost:3000 --component Button\n```\n\n**Implementation note**: requires `addInitScript({ content: getBrowserEntryScript() })` before `setContent` so `__REACT_DEVTOOLS_GLOBAL_HOOK__` is present when React loads its renderer.\n\n---\n\n### `scope instrument profile`\nProfile render performance of live components.\n\n```bash\nscope instrument profile SearchPage --interaction \'[{"action":"click","target":"button"}]\'\n```\n\n---\n\n### `scope instrument renders`\nRe-render causality analysis \u2014 what triggered each render.\n\n```bash\nscope instrument renders http://localhost:3000\n```\n\n---\n\n### `scope tokens get <name>`\nGet details of a single design token.\n\n### `scope tokens list`\nList all tokens. Token file must have a `version` field (written by `scope init`).\n\n```bash\nscope tokens list\nscope tokens list --type color\nscope tokens list --format json\n```\n\n### `scope tokens search <query>`\nFull-text search across token names/values.\n\n### `scope tokens resolve <value>`\nResolve a CSS value or alias back to its token name.\n\n### `scope tokens validate`\nValidate token file schema.\n\n### `scope tokens compliance`\nCheck rendered components for design token compliance.\n\n```bash\nscope tokens compliance\nscope tokens compliance --threshold 95\n```\n\n### `scope tokens impact <token>`\nAnalyze impact of changing a token \u2014 which components use it.\n\n```bash\nscope tokens impact --token color.primary.500\n```\n\n### `scope tokens preview <token>`\nPreview a token value change visually before committing.\n\n### `scope tokens export`\nExport tokens in multiple formats.\n\n```bash\nscope tokens export --format flat-json\nscope tokens export --format css\nscope tokens export --format scss\nscope tokens export --format ts\nscope tokens export --format tailwind\nscope tokens export --format style-dictionary\n```\n\n**Format aliases** (auto-corrected with "Did you mean?" hint):\n- `json` \u2192 `flat-json`\n- `js` \u2192 `ts`\n- `sass` \u2192 `scss`\n- `tw` \u2192 `tailwind`\n\n---\n\n### `scope ci`\nRun all CI checks (compliance, accessibility, console errors) and exit 0/1.\n\n```bash\nscope ci\nscope ci --json\nscope ci --threshold 90 # compliance threshold (default: 90)\n```\n\n```\n$ scope ci --json\n\u2192 CI passed in 3.2s\n\u2192 Compliance 100.0% >= threshold 90.0% \u2705\n\u2192 Accessibility audit not yet implemented \u2014 skipped \u2705\n\u2192 No console errors detected \u2705\n\u2192 Exit code 0\n```\n\nThe `scope-ci` **reusable GitHub Actions workflow** is available at `.github/workflows/scope-ci.yml` and can be included in any repo\'s CI to run `scope ci` and post results as a PR comment via `scope report pr-comment`.\n\n---\n\n### `scope site build`\nGenerate a static HTML component gallery site from the manifest.\n\n```bash\nscope site build\nscope site build --output ./dist/site\n```\n\n**Collections support**: components are grouped under named collection sections in the sidebar and index grid. Internal components are hidden from the sidebar and card grid but appear in composition detail sections with an `internal` badge.\n\n**Collection display rules**:\n- Sidebar: one section divider per collection + an "Ungrouped" section; internal components excluded\n- Index page: named sections with heading + optional description; internal components excluded\n- Component detail page: Composes/Composed By lists ALL components including internal ones (with subtle badge)\n- Falls back to flat list when no collections configured (backwards-compatible)\n\n### `scope site serve`\nServe the generated site locally.\n\n```bash\nscope site serve\nscope site serve --port 4000\n```\n\n---\n\n## Collections & Internal Components\n\nComponents can be organized into named **collections** and flagged as **internal** (library implementation details not shown in the public gallery).\n\n### Defining collections\n\n**1. TSDoc tag** (highest precedence):\n```tsx\n/**\n * @collection Forms\n */\nexport function Input() { ... }\n```\n\n**2. `.scope.ts` co-located file**:\n```ts\n// Input.scope.ts\nexport const collection = "Forms"\n```\n\n**3. Config-level glob patterns**:\n```json\n// reactscope.config.json\n{\n "collections": [\n { "name": "Forms", "description": "Form inputs and controls", "patterns": ["src/forms/**"] },\n { "name": "Primitives", "patterns": ["src/primitives/**"] }\n ]\n}\n```\n\nResolution precedence: TSDoc `@collection` > `.scope.ts` export > config pattern.\n\n### Flagging internal components\n\n**TSDoc tag**:\n```tsx\n/**\n * @internal\n */\nexport function InternalHelperButton() { ... }\n```\n\n**Config glob patterns**:\n```json\n{\n "internalPatterns": ["src/internal/**", "src/**/*Internal*"]\n}\n```\n\n---\n\n## Manifest Output Schema\n\nFile: `.reactscope/manifest.json`\n\n```typescript\n{\n version: "0.1",\n generatedAt: string, // ISO 8601\n collections: CollectionConfig[], // echoes config.collections, [] when not set\n components: Record<string, ComponentDescriptor>,\n tree: Record<string, { children: string[], parents: string[] }>\n}\n```\n\n### `ComponentDescriptor` fields\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `filePath` | `string` | Relative path from project root to source file |\n| `exportType` | `"named" \\| "default" \\| "none"` | How the component is exported |\n| `displayName` | `string` | `displayName` if set, else function/class name |\n| `collection` | `string?` | Resolved collection name (`undefined` = ungrouped) |\n| `internal` | `boolean` | `true` if flagged as internal (default: `false`) |\n| `props` | `Record<string, PropDescriptor>` | Extracted prop types keyed by prop name |\n| `composes` | `string[]` | Components this one renders in its JSX |\n| `composedBy` | `string[]` | Components that render this one in their JSX |\n| `complexityClass` | `"simple" \\| "complex"` | Render path: simple = Satori-safe, complex = requires BrowserPool |\n| `requiredContexts` | `string[]` | React context names consumed |\n| `detectedHooks` | `string[]` | All hooks called, sorted alphabetically |\n| `sideEffects` | `SideEffects` | Side effect categories detected |\n| `memoized` | `boolean` | Wrapped with `React.memo` |\n| `forwardedRef` | `boolean` | Wrapped with `React.forwardRef` |\n| `hocWrappers` | `string[]` | HOC wrapper names (excluding memo/forwardRef) |\n| `loc` | `{ start: number, end: number }` | Line numbers in source file |\n\n---\n\n## Common Agent Workflows\n\n### Structural queries\n\n```bash\n# Which components use ThemeContext?\nscope manifest query --context ThemeContext\n\n# What props does Button accept?\nscope manifest get Button --format json | jq \'.props\'\n\n# Which components are safe to render without a provider?\nscope manifest query --complexity simple # + check requiredContexts === []\n\n# Show all components with side effects\nscope manifest query --side-effects\n\n# Which components make fetch calls?\nscope manifest query --has-fetch\n\n# Which components use useEffect?\nscope manifest query --hook useEffect\n\n# Which components accept a disabled prop?\nscope manifest query --has-prop disabled\n\n# Which components are composed inside Modal?\nscope manifest query --composed-by Modal\n\n# All components in the Forms collection\nscope manifest list --collection Forms\n\n# All internal components (library implementation details)\nscope manifest list --internal\n\n# Public components only (hide internals)\nscope manifest list --no-internal\n```\n\n### Render workflows\n\n```bash\n# Render Button in all variants (auto-defaults props if not provided)\nscope render matrix Button --axes \'variant:primary,secondary,danger\'\n\n# Render with JSON axes format\nscope render matrix Button --axes \'{"variant":["primary","secondary"]}\'\n\n# Render with explicit props\nscope render component Button --props \'{"variant":"primary","disabled":true}\'\n\n# Render all components (handles CSS/SVG/font imports automatically)\nscope render all --concurrency 8\n\n# Get render as JSON\nscope render component Button --format json | jq \'.screenshot\' | base64 -d > button.png\n```\n\n### Token workflows\n\n```bash\n# List all tokens\nscope tokens list\n\n# Check compliance\nscope tokens compliance --threshold 95\n\n# See what a token change impacts\nscope tokens impact --token color.primary.500\n\n# Export for Tailwind\nscope tokens export --format tailwind\n```\n\n### CI workflow\n\n```bash\n# Full compliance check\nscope ci --json\n\n# In GitHub Actions \u2014 use the reusable workflow\n# .github/workflows/ci.yml:\n# uses: FlatFilers/Scope/.github/workflows/scope-ci.yml@main\n```\n\n---\n\n## Error Patterns\n\n| Error | Cause | Fix |\n|-------|-------|-----|\n| `"React root not found"` | App not running, wrong URL, or Vite HMR interfering | Use `scope capture --wait 2000` |\n| `"Component not in manifest"` | Manifest is stale | Run `scope manifest generate` first |\n| `"Manifest not found"` | Missing manifest | Run `scope init` or `scope manifest generate` |\n| `"requiredContexts missing"` | Component needs a provider | Add provider presets to `reactscope.config.json` |\n| Blank PNG / 16\xD76px renders | No globalCSS injected (common with Tailwind) | Set `components.wrappers.globalCSS` in config; run `scope doctor` to verify |\n| `PLAYWRIGHT_BROWSERS_MISSING` / `browserType.launch: Executable doesn\'t exist` | Playwright Chromium is not installed in this sandbox | Run `bunx playwright install chromium`, then `scope doctor --json`, then retry render/site/instrument |\n| `TARGET_PROJECT_DEPENDENCIES_MISSING` / `Could not resolve "react"` | Target project dependencies are missing | Run `bun install` (or the detected package manager) in the target project root, then `scope doctor --json` |\n| `"Invalid props JSON"` | Malformed JSON in `--props` | Use single outer quotes: `--props \'{"key":"val"}\'` |\n| `"SCOPE_CAPTURE_JSON not available"` | Scope runtime not injected before React loaded | Fixed in PR #83 \u2014 update CLI |\n| `"No React DevTools hook found"` | Hook instrumentation init order bug | Fixed in PR #83 \u2014 update CLI |\n| `"ERR_MODULE_NOT_FOUND"` after tokens commands | Old Node shebang in CLI binary | Fixed in PR #90 \u2014 CLI now uses `#!/usr/bin/env bun` |\n| `"version" field missing in tokens` | Token stub written by old `scope init` | Re-run `scope init --force` or add `"version": "1"` to token file |\n| `"unknown option --has-prop"` | Old CLI version | Fixed in PR #90 \u2014 update CLI |\n| Format alias error (`json`, `js`, `sass`, `tw`) | Wrong format name for `tokens export` | Use `flat-json`, `ts`, `scss`, `tailwind`; CLI shows "Did you mean?" hint |\n\n---\n\n## `reactscope.config.json`\n\n```json\n{\n "components": {\n "wrappers": {\n "globalCSS": ["src/styles.css"]\n }\n },\n "tokens": {\n "file": "reactscope.tokens.json"\n },\n "collections": [\n { "name": "Forms", "description": "Form inputs and controls", "patterns": ["src/forms/**"] },\n { "name": "Primitives", "patterns": ["src/primitives/**"] }\n ],\n "internalPatterns": ["src/internal/**"],\n "providers": {\n "theme": { "component": "ThemeProvider", "props": { "theme": "light" } },\n "router": { "component": "MemoryRouter", "props": { "initialEntries": ["/"] } }\n }\n}\n```\n\n**Built-in mock providers** (always available, no config needed):\n- `ThemeContext` \u2192 `{ theme: \'light\' }` (or `--theme <name>`)\n- `LocaleContext` \u2192 `{ locale: \'en-US\' }`\n\n---\n\n## What Scope Cannot Do\n\n- **Runtime state**: `useState` values after user interaction\n- **Network requests**: `fetch`, `XHR`, `WebSocket`\n- **User interactions**: click, type, hover, drag\n- **Auth/session-gated components**: components that redirect or throw without a session\n- **Server components (RSC)**: React Server Components\n- **Dynamic CSS**: CSS-in-JS styles computed at runtime from props Scope can\'t infer\n\n---\n\n## Version History\n\n| Version | Date | Summary |\n|---------|------|---------|\n| v1.0 | 2026-03-11 | Initial SKILL.md (PR #36) \u2014 manifest, render, capture, tree, report, tokens, ci commands |\n| v1.1 | 2026-03-11 | Updated through PR #82 \u2014 Phase 2 CLI commands complete |\n| v1.2 | 2026-03-13 | PRs #83\u2013#95: runtime injection fix, dogfooding fixes (12 bugs), `scope doctor`, `scope init` auto-manifest, globalCSS render warning, collections & internal components feature |\n';
1176
1421
 
1177
1422
  // src/get-skill-command.ts
1178
1423
  function createGetSkillCommand() {
1179
1424
  return new Command("get-skill").description(
1180
- 'Print the embedded Scope SKILL.md to stdout.\n\nAgents: pipe this command into your context loader to bootstrap Scope knowledge.\nThe skill covers: when to use each command, config requirements, output format,\nrender engine selection, and common failure modes.\n\nEMBEDDED AT BUILD TIME \u2014 works in any install context (global npm, npx, local).\n\nExamples:\n scope get-skill # raw markdown to stdout\n scope get-skill --json # { "skill": "..." } for structured ingestion\n scope get-skill | head -50 # preview the skill\n scope get-skill > /tmp/SKILL.md # save locally'
1425
+ 'Print the embedded Scope SKILL.md to stdout.\n\nAgents: load this first, then follow the canonical workflow in the skill:\n init --yes \u2192 doctor --json \u2192 manifest --format json \u2192 render all --format json \u2192 site build\nThe skill documents .scope.tsx discovery/exports, config paths, profiler usage,\nand failure recovery.\n\nEMBEDDED AT BUILD TIME \u2014 works in any install context (global npm, npx, local).\n\nExamples:\n scope get-skill # raw markdown to stdout\n scope get-skill --json # { "skill": "..." } for structured ingestion\n scope get-skill | head -50 # preview the skill\n scope get-skill > /tmp/SKILL.md # save locally'
1181
1426
  ).option("--json", "Wrap output in JSON { skill: string } instead of raw markdown").action((opts) => {
1182
1427
  if (opts.json) {
1183
1428
  process.stdout.write(`${JSON.stringify({ skill: SKILL_CONTENT }, null, 2)}
@@ -1210,7 +1455,7 @@ function detectFramework(rootDir, packageDeps) {
1210
1455
  if ("react-scripts" in packageDeps) return "cra";
1211
1456
  return "unknown";
1212
1457
  }
1213
- function detectPackageManager(rootDir) {
1458
+ function detectPackageManager2(rootDir) {
1214
1459
  if (existsSync(join(rootDir, "bun.lock"))) return "bun";
1215
1460
  if (existsSync(join(rootDir, "yarn.lock"))) return "yarn";
1216
1461
  if (existsSync(join(rootDir, "pnpm-lock.yaml"))) return "pnpm";
@@ -1286,6 +1531,31 @@ var TAILWIND_STEMS = ["tailwind.config"];
1286
1531
  var CSS_EXTS = [".css", ".scss", ".sass", ".less"];
1287
1532
  var THEME_SUFFIXES = [".theme.ts", ".theme.js", ".theme.tsx"];
1288
1533
  var CSS_CUSTOM_PROPS_RE = /:root\s*\{[^}]*--[a-zA-Z]/;
1534
+ var TAILWIND_V4_THEME_RE = /@theme\s*(?:inline\s*)?\{[^}]*--[a-zA-Z]/;
1535
+ var MAX_SCAN_DEPTH = 4;
1536
+ var SKIP_CSS_NAMES = ["compiled", ".min."];
1537
+ function collectCSSFiles(dir, depth) {
1538
+ if (depth > MAX_SCAN_DEPTH) return [];
1539
+ const results = [];
1540
+ try {
1541
+ const entries = readdirSync(dir, { withFileTypes: true });
1542
+ for (const entry of entries) {
1543
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".next") {
1544
+ continue;
1545
+ }
1546
+ const full = join(dir, entry.name);
1547
+ if (entry.isFile() && CSS_EXTS.some((x) => entry.name.endsWith(x))) {
1548
+ if (!SKIP_CSS_NAMES.some((skip) => entry.name.includes(skip))) {
1549
+ results.push(full);
1550
+ }
1551
+ } else if (entry.isDirectory()) {
1552
+ results.push(...collectCSSFiles(full, depth + 1));
1553
+ }
1554
+ }
1555
+ } catch {
1556
+ }
1557
+ return results;
1558
+ }
1289
1559
  function detectTokenSources(rootDir) {
1290
1560
  const sources = [];
1291
1561
  for (const stem of TAILWIND_STEMS) {
@@ -1301,32 +1571,53 @@ function detectTokenSources(rootDir) {
1301
1571
  }
1302
1572
  }
1303
1573
  const srcDir = join(rootDir, "src");
1304
- const dirsToScan = existsSync(srcDir) ? [srcDir] : [];
1305
- for (const scanDir of dirsToScan) {
1306
- try {
1307
- const entries = readdirSync(scanDir, { withFileTypes: true });
1308
- for (const entry of entries) {
1309
- if (entry.isFile() && CSS_EXTS.some((x) => entry.name.endsWith(x))) {
1310
- const filePath = join(scanDir, entry.name);
1311
- const content = readSafe(filePath);
1312
- if (content !== null && CSS_CUSTOM_PROPS_RE.test(content)) {
1313
- sources.push({ kind: "css-custom-properties", path: filePath });
1314
- }
1315
- }
1574
+ if (existsSync(srcDir)) {
1575
+ const cssFiles = collectCSSFiles(srcDir, 0);
1576
+ const seen = /* @__PURE__ */ new Set();
1577
+ for (const filePath of cssFiles) {
1578
+ const content = readSafe(filePath);
1579
+ if (content === null) continue;
1580
+ if (TAILWIND_V4_THEME_RE.test(content) && !seen.has(filePath)) {
1581
+ sources.push({ kind: "tailwind-v4-theme", path: filePath });
1582
+ seen.add(filePath);
1583
+ }
1584
+ if (CSS_CUSTOM_PROPS_RE.test(content) && !seen.has(filePath)) {
1585
+ sources.push({ kind: "css-custom-properties", path: filePath });
1586
+ seen.add(filePath);
1587
+ }
1588
+ }
1589
+ }
1590
+ for (const tokenDir of ["tokens", "styles", "theme"]) {
1591
+ const dir = join(rootDir, tokenDir);
1592
+ if (!existsSync(dir)) continue;
1593
+ const cssFiles = collectCSSFiles(dir, 0);
1594
+ for (const filePath of cssFiles) {
1595
+ const content = readSafe(filePath);
1596
+ if (content === null) continue;
1597
+ if (TAILWIND_V4_THEME_RE.test(content)) {
1598
+ sources.push({ kind: "tailwind-v4-theme", path: filePath });
1599
+ } else if (CSS_CUSTOM_PROPS_RE.test(content)) {
1600
+ sources.push({ kind: "css-custom-properties", path: filePath });
1316
1601
  }
1317
- } catch {
1318
1602
  }
1319
1603
  }
1320
1604
  if (existsSync(srcDir)) {
1321
- try {
1322
- const entries = readdirSync(srcDir);
1323
- for (const entry of entries) {
1324
- if (THEME_SUFFIXES.some((s) => entry.endsWith(s))) {
1325
- sources.push({ kind: "theme-file", path: join(srcDir, entry) });
1605
+ const scanThemeFiles = (dir, depth) => {
1606
+ if (depth > MAX_SCAN_DEPTH) return;
1607
+ try {
1608
+ const entries = readdirSync(dir, { withFileTypes: true });
1609
+ for (const entry of entries) {
1610
+ if (entry.name === "node_modules" || entry.name === "dist") continue;
1611
+ if (entry.isFile() && THEME_SUFFIXES.some((s) => entry.name.endsWith(s))) {
1612
+ sources.push({ kind: "theme-file", path: join(dir, entry.name) });
1613
+ } else if (entry.isDirectory()) {
1614
+ scanThemeFiles(join(dir, entry.name), depth + 1);
1615
+ }
1326
1616
  }
1617
+ } catch {
1327
1618
  }
1328
- } catch {
1329
- }
1619
+ };
1620
+ scanThemeFiles(srcDir, 0);
1330
1621
  }
1331
1622
  return sources;
1332
1623
  }
@@ -1346,7 +1637,7 @@ function detectProject(rootDir) {
1346
1637
  }
1347
1638
  const framework = detectFramework(rootDir, packageDeps);
1348
1639
  const { typescript, tsconfigPath } = detectTypeScript(rootDir);
1349
- const packageManager = detectPackageManager(rootDir);
1640
+ const packageManager = detectPackageManager2(rootDir);
1350
1641
  const componentPatterns = detectComponentPatterns(rootDir, typescript);
1351
1642
  const tokenSources = detectTokenSources(rootDir);
1352
1643
  const globalCSSFiles = detectGlobalCSSFiles(rootDir);
@@ -1397,9 +1688,9 @@ function createRL() {
1397
1688
  });
1398
1689
  }
1399
1690
  async function ask(rl, question) {
1400
- return new Promise((resolve19) => {
1691
+ return new Promise((resolve21) => {
1401
1692
  rl.question(question, (answer) => {
1402
- resolve19(answer.trim());
1693
+ resolve21(answer.trim());
1403
1694
  });
1404
1695
  });
1405
1696
  }
@@ -1861,21 +2152,64 @@ function registerQuery(manifestCmd) {
1861
2152
  }
1862
2153
  );
1863
2154
  }
2155
+ function loadReactScopeConfig(rootDir) {
2156
+ const configPath = resolve(rootDir, "reactscope.config.json");
2157
+ if (!existsSync(configPath)) return null;
2158
+ try {
2159
+ const raw = readFileSync(configPath, "utf-8");
2160
+ const cfg = JSON.parse(raw);
2161
+ const result = {};
2162
+ const components = cfg.components;
2163
+ if (components !== void 0 && typeof components === "object" && components !== null) {
2164
+ if (Array.isArray(components.include)) {
2165
+ result.include = components.include;
2166
+ }
2167
+ if (Array.isArray(components.exclude)) {
2168
+ result.exclude = components.exclude;
2169
+ }
2170
+ }
2171
+ if (Array.isArray(cfg.internalPatterns)) {
2172
+ result.internalPatterns = cfg.internalPatterns;
2173
+ }
2174
+ if (Array.isArray(cfg.collections)) {
2175
+ result.collections = cfg.collections;
2176
+ }
2177
+ const icons = cfg.icons;
2178
+ if (icons !== void 0 && typeof icons === "object" && icons !== null) {
2179
+ if (Array.isArray(icons.patterns)) {
2180
+ result.iconPatterns = icons.patterns;
2181
+ }
2182
+ }
2183
+ return result;
2184
+ } catch {
2185
+ return null;
2186
+ }
2187
+ }
1864
2188
  function registerGenerate(manifestCmd) {
1865
2189
  manifestCmd.command("generate").description(
1866
- 'Scan source files and generate .reactscope/manifest.json.\n\nUses Babel static analysis \u2014 no runtime or bundler required.\nRe-run whenever components are added, removed, or significantly changed.\n\nWHAT IT CAPTURES per component:\n - File path and export name\n - All props with types and default values\n - Hook usage (useState, useEffect, useContext, custom hooks)\n - Side effects (fetch, timers, subscriptions)\n - Complexity class: simple | complex\n - Context dependencies and composed child components\n\nExamples:\n scope manifest generate\n scope manifest generate --root ./packages/ui\n scope manifest generate --include "src/components/**/*.tsx" --exclude "**/*.stories.tsx"\n scope manifest generate --output ./custom-manifest.json'
2190
+ 'Scan source files and generate .reactscope/manifest.json.\n\nUses Babel static analysis \u2014 no runtime or bundler required.\nRe-run whenever components are added, removed, or significantly changed.\n\nReads reactscope.config.json for:\n components.include glob patterns for source files\n components.exclude glob patterns to skip\n internalPatterns globs to flag components as internal\n collections named groups of components\n\nCLI flags (--include, --exclude) override config-file values.\n\nWHAT IT CAPTURES per component:\n - File path and export name\n - All props with types and default values\n - Hook usage (useState, useEffect, useContext, custom hooks)\n - Side effects (fetch, timers, subscriptions)\n - Complexity class: simple | complex\n - Context dependencies and composed child components\n\nExamples:\n scope manifest generate\n scope manifest generate --root ./packages/ui\n scope manifest generate --include "src/components/**/*.tsx" --exclude "**/*.stories.tsx"\n scope manifest generate --output ./custom-manifest.json'
1867
2191
  ).option("--root <path>", "Project root directory (default: cwd)").option("--output <path>", "Output path for manifest.json", MANIFEST_PATH).option("--include <globs>", "Comma-separated glob patterns to include").option("--exclude <globs>", "Comma-separated glob patterns to exclude").action(async (opts) => {
1868
2192
  try {
1869
2193
  const rootDir = resolve(process.cwd(), opts.root ?? ".");
1870
2194
  const outputPath = resolve(process.cwd(), opts.output);
1871
- const include = opts.include?.split(",").map((s) => s.trim());
1872
- const exclude = opts.exclude?.split(",").map((s) => s.trim());
2195
+ const configValues = loadReactScopeConfig(rootDir);
2196
+ const include = opts.include?.split(",").map((s) => s.trim()) ?? configValues?.include;
2197
+ const exclude = opts.exclude?.split(",").map((s) => s.trim()) ?? configValues?.exclude;
1873
2198
  process.stderr.write(`Scanning ${rootDir} for React components...
1874
2199
  `);
1875
2200
  const manifest = await generateManifest({
1876
2201
  rootDir,
1877
2202
  ...include !== void 0 && { include },
1878
- ...exclude !== void 0 && { exclude }
2203
+ ...exclude !== void 0 && { exclude },
2204
+ ...configValues?.internalPatterns !== void 0 && {
2205
+ internalPatterns: configValues.internalPatterns
2206
+ },
2207
+ ...configValues?.collections !== void 0 && {
2208
+ collections: configValues.collections
2209
+ },
2210
+ ...configValues?.iconPatterns !== void 0 && {
2211
+ iconPatterns: configValues.iconPatterns
2212
+ }
1879
2213
  });
1880
2214
  const componentCount = Object.keys(manifest.components).length;
1881
2215
  process.stderr.write(`Found ${componentCount} components.
@@ -2264,7 +2598,7 @@ Available: ${available}`
2264
2598
  process.stdout.write(`${JSON.stringify(result, null, 2)}
2265
2599
  `);
2266
2600
  } catch (err) {
2267
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
2601
+ process.stderr.write(`${formatScopeDiagnostic(err)}
2268
2602
  `);
2269
2603
  process.exit(1);
2270
2604
  }
@@ -2367,13 +2701,11 @@ function buildProfilingCollectScript() {
2367
2701
  // mount commit), it *may* have been wasted if it didn't actually need to re-render.
2368
2702
  // For the initial snapshot we approximate: wastedRenders = max(0, totalCommits - 1) * 0.3
2369
2703
  // This is a heuristic \u2014 real wasted render detection needs shouldComponentUpdate/React.memo tracing.
2370
- var wastedRenders = Math.max(0, Math.round((totalCommits - 1) * uniqueNames.length * 0.3));
2371
-
2372
2704
  return {
2373
2705
  commitCount: totalCommits,
2374
2706
  uniqueComponents: uniqueNames.length,
2375
2707
  componentNames: uniqueNames,
2376
- wastedRenders: wastedRenders,
2708
+ wastedRenders: null,
2377
2709
  layoutTime: window.__scopeLayoutTime || 0,
2378
2710
  paintTime: window.__scopePaintTime || 0,
2379
2711
  layoutShifts: window.__scopeLayoutShifts || { count: 0, score: 0 }
@@ -2421,7 +2753,7 @@ async function replayInteraction(page, steps) {
2421
2753
  }
2422
2754
  function analyzeProfileFlags(totalRenders, wastedRenders, timing, layoutShifts) {
2423
2755
  const flags = /* @__PURE__ */ new Set();
2424
- if (wastedRenders > 0 && wastedRenders / Math.max(1, totalRenders) > 0.3) {
2756
+ if (wastedRenders !== null && wastedRenders > 0 && wastedRenders / Math.max(1, totalRenders) > 0.3) {
2425
2757
  flags.add("WASTED_RENDER");
2426
2758
  }
2427
2759
  if (totalRenders > 10) {
@@ -2492,13 +2824,18 @@ async function runInteractionProfile(componentName, filePath, props, interaction
2492
2824
  };
2493
2825
  const totalRenders = profileData.commitCount ?? 0;
2494
2826
  const uniqueComponents = profileData.uniqueComponents ?? 0;
2495
- const wastedRenders = profileData.wastedRenders ?? 0;
2827
+ const wastedRenders = profileData.wastedRenders ?? null;
2496
2828
  const flags = analyzeProfileFlags(totalRenders, wastedRenders, timing, layoutShifts);
2497
2829
  return {
2498
2830
  component: componentName,
2499
2831
  totalRenders,
2500
2832
  uniqueComponents,
2501
2833
  wastedRenders,
2834
+ wastedRendersHeuristic: {
2835
+ measured: false,
2836
+ value: null,
2837
+ note: "profile.wastedRenders is retained for compatibility but set to null because Scope does not directly measure wasted renders yet."
2838
+ },
2502
2839
  timing,
2503
2840
  layoutShifts,
2504
2841
  flags,
@@ -2573,7 +2910,7 @@ Available: ${available}`
2573
2910
  process.stdout.write(`${JSON.stringify(result, null, 2)}
2574
2911
  `);
2575
2912
  } catch (err) {
2576
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
2913
+ process.stderr.write(`${formatScopeDiagnostic(err)}
2577
2914
  `);
2578
2915
  process.exit(1);
2579
2916
  }
@@ -2918,7 +3255,7 @@ Available: ${available}`
2918
3255
  `);
2919
3256
  }
2920
3257
  } catch (err) {
2921
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
3258
+ process.stderr.write(`${formatScopeDiagnostic(err)}
2922
3259
  `);
2923
3260
  process.exit(1);
2924
3261
  }
@@ -3410,7 +3747,7 @@ Examples:
3410
3747
  }
3411
3748
  } catch (err) {
3412
3749
  await shutdownPool2();
3413
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
3750
+ process.stderr.write(`${formatScopeDiagnostic(err)}
3414
3751
  `);
3415
3752
  process.exit(1);
3416
3753
  }
@@ -3488,6 +3825,54 @@ function writeReportToFile(report, outputPath, pretty) {
3488
3825
  const json = pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report);
3489
3826
  writeFileSync(outputPath, json, "utf-8");
3490
3827
  }
3828
+ var RUN_SUMMARY_PATH = ".reactscope/run-summary.json";
3829
+ function buildNextActions(summary) {
3830
+ const actions = /* @__PURE__ */ new Set();
3831
+ for (const failure of summary.failures) {
3832
+ if (failure.stage === "render" || failure.stage === "matrix") {
3833
+ actions.add(
3834
+ `Inspect ${failure.outputPath ?? ".reactscope/renders"} and add/fix ${failure.component}.scope.tsx scenarios or wrappers.`
3835
+ );
3836
+ } else if (failure.stage === "playground") {
3837
+ actions.add(
3838
+ `Open the generated component page and inspect the playground bundling error for ${failure.component}.`
3839
+ );
3840
+ } else if (failure.stage === "compliance") {
3841
+ actions.add(
3842
+ "Run `scope render all` first, then inspect .reactscope/compliance-styles.json and reactscope.tokens.json."
3843
+ );
3844
+ } else if (failure.stage === "site") {
3845
+ actions.add(
3846
+ "Inspect .reactscope/site output and rerun `scope site build` after fixing render/playground failures."
3847
+ );
3848
+ }
3849
+ }
3850
+ if (summary.compliance && summary.compliance.auditedProperties === 0) {
3851
+ actions.add(
3852
+ "No CSS properties were audited. Verify renders produced computed styles and your token file contains matching token categories."
3853
+ );
3854
+ } else if (summary.compliance && summary.compliance.threshold !== void 0 && summary.compliance.score < summary.compliance.threshold) {
3855
+ actions.add(
3856
+ "Inspect .reactscope/compliance-report.json for off-system values and update tokens or component styles."
3857
+ );
3858
+ }
3859
+ if (actions.size === 0) {
3860
+ actions.add("No follow-up needed. Outputs are ready for inspection.");
3861
+ }
3862
+ return [...actions];
3863
+ }
3864
+ function writeRunSummary(summary, summaryPath = RUN_SUMMARY_PATH) {
3865
+ const outputPath = resolve(process.cwd(), summaryPath);
3866
+ mkdirSync(dirname(outputPath), { recursive: true });
3867
+ const payload = {
3868
+ ...summary,
3869
+ generatedAt: summary.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
3870
+ nextActions: summary.nextActions ?? buildNextActions(summary)
3871
+ };
3872
+ writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}
3873
+ `, "utf-8");
3874
+ return outputPath;
3875
+ }
3491
3876
  var SCOPE_EXTENSIONS = [".scope.tsx", ".scope.ts", ".scope.jsx", ".scope.js"];
3492
3877
  function findScopeFile(componentFilePath) {
3493
3878
  const dir = dirname(componentFilePath);
@@ -3615,6 +4000,63 @@ function loadGlobalCssFilesFromConfig(cwd) {
3615
4000
  return [];
3616
4001
  }
3617
4002
  }
4003
+ var TAILWIND_CONFIG_FILES2 = [
4004
+ "tailwind.config.js",
4005
+ "tailwind.config.cjs",
4006
+ "tailwind.config.mjs",
4007
+ "tailwind.config.ts",
4008
+ "postcss.config.js",
4009
+ "postcss.config.cjs",
4010
+ "postcss.config.mjs",
4011
+ "postcss.config.ts"
4012
+ ];
4013
+ function shouldWarnForMissingGlobalCss(cwd) {
4014
+ if (TAILWIND_CONFIG_FILES2.some((file) => existsSync(resolve(cwd, file)))) {
4015
+ return true;
4016
+ }
4017
+ const packageJsonPath = resolve(cwd, "package.json");
4018
+ if (!existsSync(packageJsonPath)) return false;
4019
+ try {
4020
+ const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
4021
+ return [pkg.dependencies, pkg.devDependencies].some(
4022
+ (deps) => deps && Object.keys(deps).some(
4023
+ (name) => name === "tailwindcss" || name.startsWith("@tailwindcss/")
4024
+ )
4025
+ );
4026
+ } catch {
4027
+ return false;
4028
+ }
4029
+ }
4030
+ function loadIconPatternsFromConfig(cwd) {
4031
+ const configPath = resolve(cwd, "reactscope.config.json");
4032
+ if (!existsSync(configPath)) return [];
4033
+ try {
4034
+ const raw = readFileSync(configPath, "utf-8");
4035
+ const cfg = JSON.parse(raw);
4036
+ return cfg.icons?.patterns ?? [];
4037
+ } catch {
4038
+ return [];
4039
+ }
4040
+ }
4041
+ function matchGlob2(pattern, value) {
4042
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
4043
+ const regexStr = escaped.replace(/\*\*/g, "\xA7GLOBSTAR\xA7").replace(/\*/g, "[^/]*").replace(/§GLOBSTAR§/g, ".*");
4044
+ return new RegExp(`^${regexStr}$`, "i").test(value);
4045
+ }
4046
+ function isIconComponent(filePath, displayName, patterns) {
4047
+ return patterns.length > 0 && patterns.some((p) => matchGlob2(p, filePath) || matchGlob2(p, displayName));
4048
+ }
4049
+ function formatAggregateRenderFailureJson(componentName, failures, scenarioCount, runSummaryPath) {
4050
+ return {
4051
+ command: `scope render component ${componentName}`,
4052
+ status: "failed",
4053
+ component: componentName,
4054
+ scenarioCount,
4055
+ failureCount: failures.length,
4056
+ failures,
4057
+ runSummaryPath
4058
+ };
4059
+ }
3618
4060
  var MANIFEST_PATH6 = ".reactscope/manifest.json";
3619
4061
  var DEFAULT_OUTPUT_DIR = ".reactscope/renders";
3620
4062
  var _pool3 = null;
@@ -3635,7 +4077,7 @@ async function shutdownPool3() {
3635
4077
  _pool3 = null;
3636
4078
  }
3637
4079
  }
3638
- function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, globalCssFiles = [], projectCwd = process.cwd(), wrapperScript) {
4080
+ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, globalCssFiles = [], projectCwd = process.cwd(), wrapperScript, iconMode = false) {
3639
4081
  const satori = new SatoriRenderer({
3640
4082
  defaultViewport: { width: viewportWidth, height: viewportHeight }
3641
4083
  });
@@ -3645,13 +4087,15 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
3645
4087
  const startMs = performance.now();
3646
4088
  const pool = await getPool3(viewportWidth, viewportHeight);
3647
4089
  const projectCss = await loadGlobalCss(globalCssFiles, projectCwd);
4090
+ const PAD = 8;
3648
4091
  const htmlHarness = await buildComponentHarness(
3649
4092
  filePath,
3650
4093
  componentName,
3651
4094
  props,
3652
4095
  viewportWidth,
3653
4096
  projectCss ?? void 0,
3654
- wrapperScript
4097
+ wrapperScript,
4098
+ PAD
3655
4099
  );
3656
4100
  const slot = await pool.acquire();
3657
4101
  const { page } = slot;
@@ -3674,8 +4118,8 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
3674
4118
  const classes = await page.evaluate(() => {
3675
4119
  const set = /* @__PURE__ */ new Set();
3676
4120
  document.querySelectorAll("[class]").forEach((el) => {
3677
- for (const c of el.className.split(/\s+/)) {
3678
- if (c) set.add(c);
4121
+ for (const c of getElementClassNames(el)) {
4122
+ set.add(c);
3679
4123
  }
3680
4124
  });
3681
4125
  return [...set];
@@ -3692,17 +4136,28 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
3692
4136
  `Component "${componentName}" rendered with zero bounding box \u2014 it may be invisible or not mounted`
3693
4137
  );
3694
4138
  }
3695
- const PAD = 8;
3696
4139
  const clipX = Math.max(0, boundingBox.x - PAD);
3697
4140
  const clipY = Math.max(0, boundingBox.y - PAD);
3698
4141
  const rawW = boundingBox.width + PAD * 2;
3699
4142
  const rawH = boundingBox.height + PAD * 2;
3700
4143
  const safeW = Math.min(rawW, viewportWidth - clipX);
3701
4144
  const safeH = Math.min(rawH, viewportHeight - clipY);
3702
- const screenshot = await page.screenshot({
3703
- clip: { x: clipX, y: clipY, width: safeW, height: safeH },
3704
- type: "png"
3705
- });
4145
+ let svgContent;
4146
+ let screenshot;
4147
+ if (iconMode) {
4148
+ svgContent = await page.evaluate((sel) => {
4149
+ const root = document.querySelector(sel);
4150
+ const el = root?.firstElementChild;
4151
+ if (!el) return void 0;
4152
+ return el.outerHTML;
4153
+ }, "[data-reactscope-root]") ?? void 0;
4154
+ screenshot = Buffer.alloc(0);
4155
+ } else {
4156
+ screenshot = await page.screenshot({
4157
+ clip: { x: clipX, y: clipY, width: safeW, height: safeH },
4158
+ type: "png"
4159
+ });
4160
+ }
3706
4161
  const STYLE_PROPS = [
3707
4162
  "display",
3708
4163
  "width",
@@ -3825,7 +4280,7 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
3825
4280
  name: a11yInfo.name,
3826
4281
  violations: imgViolations
3827
4282
  };
3828
- return {
4283
+ const renderResult = {
3829
4284
  screenshot,
3830
4285
  width: Math.round(safeW),
3831
4286
  height: Math.round(safeH),
@@ -3834,6 +4289,10 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
3834
4289
  dom,
3835
4290
  accessibility
3836
4291
  };
4292
+ if (iconMode && svgContent) {
4293
+ renderResult.svgContent = svgContent;
4294
+ }
4295
+ return renderResult;
3837
4296
  } finally {
3838
4297
  pool.release(slot);
3839
4298
  }
@@ -3933,7 +4392,7 @@ Available: ${available}`
3933
4392
  const wrapperScript = scopeData?.hasWrapper === true ? await buildWrapperScript(scopeData.filePath) : void 0;
3934
4393
  const scenarios = buildScenarioMap(opts, scopeData);
3935
4394
  const globalCssFiles = loadGlobalCssFilesFromConfig(rootDir);
3936
- if (globalCssFiles.length === 0) {
4395
+ if (globalCssFiles.length === 0 && shouldWarnForMissingGlobalCss(rootDir)) {
3937
4396
  process.stderr.write(
3938
4397
  "warning: No globalCSS files configured. Tailwind/CSS styles will not be applied to renders.\n Add `components.wrappers.globalCSS` to reactscope.config.json\n"
3939
4398
  );
@@ -3952,7 +4411,8 @@ Available: ${available}`
3952
4411
  `
3953
4412
  );
3954
4413
  const fmt2 = resolveSingleFormat(opts.format);
3955
- let anyFailed = false;
4414
+ const failures = [];
4415
+ const outputPaths = [];
3956
4416
  for (const [scenarioName, props2] of Object.entries(scenarios)) {
3957
4417
  const isNamed = scenarioName !== "__default__";
3958
4418
  const label = isNamed ? `${componentName}:${scenarioName}` : componentName;
@@ -3975,7 +4435,14 @@ Available: ${available}`
3975
4435
  process.stderr.write(` Hints: ${hintList}
3976
4436
  `);
3977
4437
  }
3978
- anyFailed = true;
4438
+ failures.push({
4439
+ component: componentName,
4440
+ scenario: isNamed ? scenarioName : void 0,
4441
+ stage: "render",
4442
+ message: outcome.error.message,
4443
+ outputPath: `${DEFAULT_OUTPUT_DIR}/${isNamed ? `${componentName}-${scenarioName}.error.json` : `${componentName}.error.json`}`,
4444
+ hints: outcome.error.heuristicFlags
4445
+ });
3979
4446
  continue;
3980
4447
  }
3981
4448
  const result = outcome.result;
@@ -3983,6 +4450,7 @@ Available: ${available}`
3983
4450
  if (opts.output !== void 0 && !isNamed) {
3984
4451
  const outPath = resolve(process.cwd(), opts.output);
3985
4452
  writeFileSync(outPath, result.screenshot);
4453
+ outputPaths.push(outPath);
3986
4454
  process.stdout.write(
3987
4455
  `\u2713 ${label} \u2192 ${opts.output} (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
3988
4456
  `
@@ -3997,17 +4465,36 @@ Available: ${available}`
3997
4465
  const outPath = resolve(dir, outFileName);
3998
4466
  writeFileSync(outPath, result.screenshot);
3999
4467
  const relPath = `${DEFAULT_OUTPUT_DIR}/${outFileName}`;
4468
+ outputPaths.push(relPath);
4000
4469
  process.stdout.write(
4001
4470
  `\u2713 ${label} \u2192 ${relPath} (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
4002
4471
  `
4003
4472
  );
4004
4473
  }
4005
4474
  }
4475
+ const summaryPath = writeRunSummary({
4476
+ command: `scope render ${componentName}`,
4477
+ status: failures.length > 0 ? "failed" : "success",
4478
+ outputPaths,
4479
+ failures
4480
+ });
4481
+ process.stderr.write(`[scope/render] Run summary written to ${summaryPath}
4482
+ `);
4483
+ if (fmt2 === "json" && failures.length > 0) {
4484
+ const aggregateFailure = formatAggregateRenderFailureJson(
4485
+ componentName,
4486
+ failures,
4487
+ Object.keys(scenarios).length,
4488
+ summaryPath
4489
+ );
4490
+ process.stderr.write(`${JSON.stringify(aggregateFailure, null, 2)}
4491
+ `);
4492
+ }
4006
4493
  await shutdownPool3();
4007
- if (anyFailed) process.exit(1);
4494
+ if (failures.length > 0) process.exit(1);
4008
4495
  } catch (err) {
4009
4496
  await shutdownPool3();
4010
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
4497
+ process.stderr.write(`${formatScopeDiagnostic(err)}
4011
4498
  `);
4012
4499
  process.exit(1);
4013
4500
  }
@@ -4168,7 +4655,7 @@ Available: ${available}`
4168
4655
  }
4169
4656
  } catch (err) {
4170
4657
  await shutdownPool3();
4171
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
4658
+ process.stderr.write(`${formatScopeDiagnostic(err)}
4172
4659
  `);
4173
4660
  process.exit(1);
4174
4661
  }
@@ -4186,7 +4673,21 @@ function registerRenderAll(renderCmd) {
4186
4673
  const total = componentNames.length;
4187
4674
  if (total === 0) {
4188
4675
  process.stderr.write("No components found in manifest.\n");
4189
- return;
4676
+ const summaryPath2 = writeRunSummary({
4677
+ command: "scope render all",
4678
+ status: "failed",
4679
+ outputPaths: [],
4680
+ failures: [
4681
+ {
4682
+ component: "*",
4683
+ stage: "render",
4684
+ message: "No components found in manifest; refusing to report a false-green batch render."
4685
+ }
4686
+ ]
4687
+ });
4688
+ process.stderr.write(`[scope/render] Run summary written to ${summaryPath2}
4689
+ `);
4690
+ process.exit(1);
4190
4691
  }
4191
4692
  const concurrency = Math.max(1, parseInt(opts.concurrency, 10) || 4);
4192
4693
  const outputDir = resolve(process.cwd(), opts.outputDir);
@@ -4195,13 +4696,17 @@ function registerRenderAll(renderCmd) {
4195
4696
  process.stderr.write(`Rendering ${total} components (concurrency: ${concurrency})\u2026
4196
4697
  `);
4197
4698
  const results = [];
4699
+ const failures = [];
4700
+ const outputPaths = [];
4198
4701
  const complianceStylesMap = {};
4199
4702
  let completed = 0;
4703
+ const iconPatterns = loadIconPatternsFromConfig(process.cwd());
4200
4704
  const renderOne = async (name) => {
4201
4705
  const descriptor = manifest.components[name];
4202
4706
  if (descriptor === void 0) return;
4203
4707
  const filePath = resolve(rootDir, descriptor.filePath);
4204
4708
  const allCssFiles = loadGlobalCssFilesFromConfig(process.cwd());
4709
+ const isIcon = isIconComponent(descriptor.filePath, name, iconPatterns);
4205
4710
  const scopeData = await loadScopeFileForComponent(filePath);
4206
4711
  const scenarioEntries = scopeData !== null ? Object.entries(scopeData.scenarios) : [];
4207
4712
  const defaultEntry = scenarioEntries.find(([k]) => k === "default") ?? scenarioEntries[0];
@@ -4214,7 +4719,8 @@ function registerRenderAll(renderCmd) {
4214
4719
  812,
4215
4720
  allCssFiles,
4216
4721
  process.cwd(),
4217
- wrapperScript
4722
+ wrapperScript,
4723
+ isIcon
4218
4724
  );
4219
4725
  const outcome = await safeRender(
4220
4726
  () => renderer.renderCell(renderProps, descriptor.complexityClass),
@@ -4251,14 +4757,32 @@ function registerRenderAll(renderCmd) {
4251
4757
  2
4252
4758
  )
4253
4759
  );
4760
+ failures.push({
4761
+ component: name,
4762
+ stage: "render",
4763
+ message: outcome.error.message,
4764
+ outputPath: errPath,
4765
+ hints: outcome.error.heuristicFlags
4766
+ });
4767
+ outputPaths.push(errPath);
4254
4768
  return;
4255
4769
  }
4256
4770
  const result = outcome.result;
4257
4771
  results.push({ name, renderTimeMs: result.renderTimeMs, success: true });
4258
- const pngPath = resolve(outputDir, `${name}.png`);
4259
- writeFileSync(pngPath, result.screenshot);
4772
+ if (!isIcon) {
4773
+ const pngPath = resolve(outputDir, `${name}.png`);
4774
+ writeFileSync(pngPath, result.screenshot);
4775
+ outputPaths.push(pngPath);
4776
+ }
4260
4777
  const jsonPath = resolve(outputDir, `${name}.json`);
4261
- writeFileSync(jsonPath, JSON.stringify(formatRenderJson(name, {}, result), null, 2));
4778
+ const renderJson = formatRenderJson(name, {}, result);
4779
+ const extResult = result;
4780
+ if (isIcon && extResult.svgContent) {
4781
+ renderJson.svgContent = extResult.svgContent;
4782
+ delete renderJson.screenshot;
4783
+ }
4784
+ writeFileSync(jsonPath, JSON.stringify(renderJson, null, 2));
4785
+ outputPaths.push(jsonPath);
4262
4786
  const rawStyles = result.computedStyles["[data-reactscope-root] > *"] ?? {};
4263
4787
  const compStyles = {
4264
4788
  colors: {},
@@ -4324,15 +4848,21 @@ function registerRenderAll(renderCmd) {
4324
4848
  existingJson.axisLabels = [scenarioAxis.values];
4325
4849
  writeFileSync(jsonPath, JSON.stringify(existingJson, null, 2));
4326
4850
  } catch (matrixErr) {
4327
- process.stderr.write(
4328
- ` [warn] Matrix render for ${name} failed: ${matrixErr instanceof Error ? matrixErr.message : String(matrixErr)}
4329
- `
4330
- );
4851
+ const message = matrixErr instanceof Error ? matrixErr.message : String(matrixErr);
4852
+ process.stderr.write(` [warn] Matrix render for ${name} failed: ${message}
4853
+ `);
4854
+ failures.push({
4855
+ component: name,
4856
+ stage: "matrix",
4857
+ message,
4858
+ outputPath: jsonPath
4859
+ });
4331
4860
  }
4332
4861
  }
4333
4862
  if (isTTY()) {
4863
+ const suffix = isIcon ? " [icon/svg]" : "";
4334
4864
  process.stdout.write(
4335
- `\u2713 ${name} \u2192 ${opts.outputDir}/${name}.png (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
4865
+ `\u2713 ${name} \u2192 ${opts.outputDir}/${name}${isIcon ? ".json" : ".png"} (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)${suffix}
4336
4866
  `
4337
4867
  );
4338
4868
  }
@@ -4359,15 +4889,25 @@ function registerRenderAll(renderCmd) {
4359
4889
  "compliance-styles.json"
4360
4890
  );
4361
4891
  writeFileSync(compStylesPath, JSON.stringify(complianceStylesMap, null, 2));
4892
+ outputPaths.push(compStylesPath);
4362
4893
  process.stderr.write(`[scope/render] \u2713 Wrote compliance-styles.json
4363
4894
  `);
4364
4895
  process.stderr.write("\n");
4365
4896
  const summary = formatSummaryText(results, outputDir);
4366
4897
  process.stderr.write(`${summary}
4367
4898
  `);
4899
+ const summaryPath = writeRunSummary({
4900
+ command: "scope render all",
4901
+ status: failures.length > 0 ? "failed" : "success",
4902
+ outputPaths,
4903
+ failures
4904
+ });
4905
+ process.stderr.write(`[scope/render] Run summary written to ${summaryPath}
4906
+ `);
4907
+ if (failures.length > 0) process.exit(1);
4368
4908
  } catch (err) {
4369
4909
  await shutdownPool3();
4370
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
4910
+ process.stderr.write(`${formatScopeDiagnostic(err)}
4371
4911
  `);
4372
4912
  process.exit(1);
4373
4913
  }
@@ -4427,8 +4967,17 @@ async function shutdownPool4() {
4427
4967
  }
4428
4968
  }
4429
4969
  async function renderComponent2(filePath, componentName, props, viewportWidth, viewportHeight) {
4970
+ const PAD = 24;
4430
4971
  const pool = await getPool4(viewportWidth, viewportHeight);
4431
- const htmlHarness = await buildComponentHarness(filePath, componentName, props, viewportWidth);
4972
+ const htmlHarness = await buildComponentHarness(
4973
+ filePath,
4974
+ componentName,
4975
+ props,
4976
+ viewportWidth,
4977
+ void 0,
4978
+ void 0,
4979
+ PAD
4980
+ );
4432
4981
  const slot = await pool.acquire();
4433
4982
  const { page } = slot;
4434
4983
  try {
@@ -4450,8 +4999,8 @@ async function renderComponent2(filePath, componentName, props, viewportWidth, v
4450
4999
  const classes = await page.evaluate(() => {
4451
5000
  const set = /* @__PURE__ */ new Set();
4452
5001
  document.querySelectorAll("[class]").forEach((el) => {
4453
- for (const c of el.className.split(/\s+/)) {
4454
- if (c) set.add(c);
5002
+ for (const c of getElementClassNames(el)) {
5003
+ set.add(c);
4455
5004
  }
4456
5005
  });
4457
5006
  return [...set];
@@ -4468,7 +5017,6 @@ async function renderComponent2(filePath, componentName, props, viewportWidth, v
4468
5017
  `Component "${componentName}" rendered with zero bounding box \u2014 it may be invisible or not mounted`
4469
5018
  );
4470
5019
  }
4471
- const PAD = 24;
4472
5020
  const MIN_W = 320;
4473
5021
  const MIN_H = 200;
4474
5022
  const clipX = Math.max(0, boundingBox.x - PAD);
@@ -4560,12 +5108,12 @@ async function runBaseline(options = {}) {
4560
5108
  mkdirSync(rendersDir, { recursive: true });
4561
5109
  let manifest;
4562
5110
  if (manifestPath !== void 0) {
4563
- const { readFileSync: readFileSync14 } = await import('fs');
5111
+ const { readFileSync: readFileSync18 } = await import('fs');
4564
5112
  const absPath = resolve(rootDir, manifestPath);
4565
5113
  if (!existsSync(absPath)) {
4566
5114
  throw new Error(`Manifest not found at ${absPath}.`);
4567
5115
  }
4568
- manifest = JSON.parse(readFileSync14(absPath, "utf-8"));
5116
+ manifest = JSON.parse(readFileSync18(absPath, "utf-8"));
4569
5117
  process.stderr.write(`Loaded manifest from ${manifestPath}
4570
5118
  `);
4571
5119
  } else {
@@ -4757,8 +5305,17 @@ async function shutdownPool5() {
4757
5305
  }
4758
5306
  }
4759
5307
  async function renderComponent3(filePath, componentName, props, viewportWidth, viewportHeight) {
5308
+ const PAD = 24;
4760
5309
  const pool = await getPool5(viewportWidth, viewportHeight);
4761
- const htmlHarness = await buildComponentHarness(filePath, componentName, props, viewportWidth);
5310
+ const htmlHarness = await buildComponentHarness(
5311
+ filePath,
5312
+ componentName,
5313
+ props,
5314
+ viewportWidth,
5315
+ void 0,
5316
+ void 0,
5317
+ PAD
5318
+ );
4762
5319
  const slot = await pool.acquire();
4763
5320
  const { page } = slot;
4764
5321
  try {
@@ -4780,8 +5337,8 @@ async function renderComponent3(filePath, componentName, props, viewportWidth, v
4780
5337
  const classes = await page.evaluate(() => {
4781
5338
  const set = /* @__PURE__ */ new Set();
4782
5339
  document.querySelectorAll("[class]").forEach((el) => {
4783
- for (const c of el.className.split(/\s+/)) {
4784
- if (c) set.add(c);
5340
+ for (const c of getElementClassNames(el)) {
5341
+ set.add(c);
4785
5342
  }
4786
5343
  });
4787
5344
  return [...set];
@@ -4798,7 +5355,6 @@ async function renderComponent3(filePath, componentName, props, viewportWidth, v
4798
5355
  `Component "${componentName}" rendered with zero bounding box \u2014 it may be invisible or not mounted`
4799
5356
  );
4800
5357
  }
4801
- const PAD = 24;
4802
5358
  const MIN_W = 320;
4803
5359
  const MIN_H = 200;
4804
5360
  const clipX = Math.max(0, boundingBox.x - PAD);
@@ -4895,6 +5451,7 @@ function classifyComponent(entry, regressionThreshold) {
4895
5451
  async function runDiff(options = {}) {
4896
5452
  const {
4897
5453
  baselineDir: baselineDirRaw = DEFAULT_BASELINE_DIR2,
5454
+ complianceTokens = [],
4898
5455
  componentsGlob,
4899
5456
  manifestPath,
4900
5457
  viewportWidth = 375,
@@ -5010,7 +5567,7 @@ async function runDiff(options = {}) {
5010
5567
  if (isTTY() && total > 0) {
5011
5568
  process.stderr.write("\n");
5012
5569
  }
5013
- const resolver = new TokenResolver([]);
5570
+ const resolver = new TokenResolver(complianceTokens);
5014
5571
  const engine = new ComplianceEngine(resolver);
5015
5572
  const currentBatchReport = engine.auditBatch(computedStylesMap);
5016
5573
  const entries = [];
@@ -5606,129 +6163,803 @@ function buildStructuredReport(report) {
5606
6163
  route: report.route?.pattern ?? null
5607
6164
  };
5608
6165
  }
5609
- var MIME_TYPES = {
5610
- ".html": "text/html; charset=utf-8",
5611
- ".css": "text/css; charset=utf-8",
5612
- ".js": "application/javascript; charset=utf-8",
5613
- ".json": "application/json; charset=utf-8",
5614
- ".png": "image/png",
5615
- ".jpg": "image/jpeg",
5616
- ".jpeg": "image/jpeg",
5617
- ".svg": "image/svg+xml",
5618
- ".ico": "image/x-icon"
5619
- };
5620
- function registerBuild(siteCmd) {
5621
- siteCmd.command("build").description(
5622
- 'Build the static HTML site from manifest + render outputs.\n\nINPUT DIRECTORY (.reactscope/ by default) must contain:\n manifest.json component registry\n renders/ screenshots and render.json files from `scope render all`\n\nOPTIONAL:\n --compliance <path> include token compliance scores on detail pages\n --base-path <path> set if deploying to a subdirectory (e.g. /ui-docs)\n\nExamples:\n scope site build\n scope site build --title "Design System" -o .reactscope/site\n scope site build --compliance .reactscope/compliance-styles.json\n scope site build --base-path /ui'
5623
- ).option("-i, --input <path>", "Path to .reactscope input directory", ".reactscope").option("-o, --output <path>", "Output directory for generated site", ".reactscope/site").option("--base-path <path>", "Base URL path prefix for subdirectory deployment", "/").option("--compliance <path>", "Path to compliance batch report JSON").option("--title <text>", "Site title", "Scope \u2014 Component Gallery").action(
5624
- async (opts) => {
5625
- try {
5626
- const inputDir = resolve(process.cwd(), opts.input);
5627
- const outputDir = resolve(process.cwd(), opts.output);
5628
- if (!existsSync(inputDir)) {
5629
- throw new Error(
5630
- `Input directory not found: ${inputDir}
5631
- Run \`scope manifest generate\` and \`scope render\` first.`
5632
- );
5633
- }
5634
- const manifestPath = join(inputDir, "manifest.json");
5635
- if (!existsSync(manifestPath)) {
5636
- throw new Error(
5637
- `Manifest not found at ${manifestPath}
5638
- Run \`scope manifest generate\` first.`
5639
- );
5640
- }
5641
- process.stderr.write(`Building site from ${inputDir}\u2026
5642
- `);
5643
- await buildSite({
5644
- inputDir,
5645
- outputDir,
5646
- basePath: opts.basePath,
5647
- ...opts.compliance !== void 0 && {
5648
- compliancePath: resolve(process.cwd(), opts.compliance)
5649
- },
5650
- title: opts.title
5651
- });
5652
- process.stderr.write(`Site written to ${outputDir}
5653
- `);
5654
- process.stdout.write(`${outputDir}
5655
- `);
5656
- } catch (err) {
5657
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
5658
- `);
5659
- process.exit(1);
6166
+ async function buildPlaygroundHarness(filePath, componentName, projectCss, wrapperScript) {
6167
+ const bundledScript = await bundlePlaygroundIIFE(filePath, componentName);
6168
+ return wrapPlaygroundHtml(bundledScript, projectCss);
6169
+ }
6170
+ async function bundlePlaygroundIIFE(filePath, componentName) {
6171
+ const wrapperCode = (
6172
+ /* ts */
6173
+ `
6174
+ import * as __scopeMod from ${JSON.stringify(filePath)};
6175
+ import { createRoot } from "react-dom/client";
6176
+ import { createElement, Component as ReactComponent } from "react";
6177
+
6178
+ (function scopePlaygroundHarness() {
6179
+ var Target =
6180
+ __scopeMod["default"] ||
6181
+ __scopeMod[${JSON.stringify(componentName)}] ||
6182
+ (Object.values(__scopeMod).find(
6183
+ function(v) { return typeof v === "function" && /^[A-Z]/.test(v.name || ""); }
6184
+ ));
6185
+
6186
+ if (!Target) {
6187
+ document.getElementById("scope-root").innerHTML =
6188
+ '<p style="color:#dc2626;font-family:system-ui;font-size:13px">No renderable component found.</p>';
6189
+ return;
6190
+ }
6191
+
6192
+ // Error boundary to catch async render errors (React unmounts the whole
6193
+ // root when an error is uncaught \u2014 this keeps the error visible instead).
6194
+ var errorStyle = "color:#dc2626;font-family:system-ui;font-size:13px;padding:12px";
6195
+ class ScopeBoundary extends ReactComponent {
6196
+ constructor(p) { super(p); this.state = { error: null }; }
6197
+ static getDerivedStateFromError(err) { return { error: err }; }
6198
+ render() {
6199
+ if (this.state.error) {
6200
+ return createElement("pre", { style: errorStyle },
6201
+ "Render error: " + (this.state.error.message || String(this.state.error)));
5660
6202
  }
6203
+ return this.props.children;
6204
+ }
6205
+ }
6206
+
6207
+ var rootEl = document.getElementById("scope-root");
6208
+ var root = createRoot(rootEl);
6209
+ var Wrapper = window.__SCOPE_WRAPPER__;
6210
+
6211
+ function render(props) {
6212
+ var inner = createElement(Target, props);
6213
+ if (Wrapper) inner = createElement(Wrapper, null, inner);
6214
+ root.render(createElement(ScopeBoundary, null, inner));
6215
+ }
6216
+
6217
+ // Render immediately with empty props
6218
+ render({});
6219
+
6220
+ // Listen for messages from the parent frame
6221
+ window.addEventListener("message", function(e) {
6222
+ if (!e.data) return;
6223
+ if (e.data.type === "scope-playground-props") {
6224
+ render(e.data.props || {});
6225
+ } else if (e.data.type === "scope-playground-theme") {
6226
+ document.documentElement.classList.toggle("dark", e.data.theme === "dark");
6227
+ }
6228
+ });
6229
+
6230
+ // Report content height changes to the parent frame
6231
+ var ro = new ResizeObserver(function() {
6232
+ var h = rootEl.scrollHeight;
6233
+ if (parent !== window) {
6234
+ parent.postMessage({ type: "scope-playground-height", height: h }, "*");
5661
6235
  }
6236
+ });
6237
+ ro.observe(rootEl);
6238
+ })();
6239
+ `
5662
6240
  );
6241
+ const result = await esbuild2.build({
6242
+ stdin: {
6243
+ contents: wrapperCode,
6244
+ resolveDir: dirname(filePath),
6245
+ loader: "tsx",
6246
+ sourcefile: "__scope_playground__.tsx"
6247
+ },
6248
+ bundle: true,
6249
+ format: "iife",
6250
+ write: false,
6251
+ platform: "browser",
6252
+ jsx: "automatic",
6253
+ jsxImportSource: "react",
6254
+ target: "es2020",
6255
+ external: [],
6256
+ define: {
6257
+ "process.env.NODE_ENV": '"production"',
6258
+ global: "globalThis"
6259
+ },
6260
+ logLevel: "silent",
6261
+ banner: {
6262
+ js: "/* @agent-scope/cli playground harness */"
6263
+ },
6264
+ loader: {
6265
+ ".css": "empty",
6266
+ ".svg": "dataurl",
6267
+ ".png": "dataurl",
6268
+ ".jpg": "dataurl",
6269
+ ".jpeg": "dataurl",
6270
+ ".gif": "dataurl",
6271
+ ".webp": "dataurl",
6272
+ ".ttf": "dataurl",
6273
+ ".woff": "dataurl",
6274
+ ".woff2": "dataurl"
6275
+ }
6276
+ });
6277
+ if (result.errors.length > 0) {
6278
+ const msg = result.errors.map((e) => `${e.text}${e.location ? ` (${e.location.file}:${e.location.line})` : ""}`).join("\n");
6279
+ throw new Error(`esbuild failed to bundle playground component:
6280
+ ${msg}`);
6281
+ }
6282
+ const outputFile = result.outputFiles?.[0];
6283
+ if (outputFile === void 0 || outputFile.text.length === 0) {
6284
+ throw new Error("esbuild produced no playground output");
6285
+ }
6286
+ return outputFile.text;
5663
6287
  }
5664
- function registerServe(siteCmd) {
5665
- siteCmd.command("serve").description(
5666
- "Start a local HTTP server for the built site directory.\n\nRun `scope site build` first.\nCtrl+C to stop.\n\nExamples:\n scope site serve\n scope site serve --port 8080\n scope site serve --dir ./my-site-output"
5667
- ).option("-p, --port <number>", "Port to listen on", "3000").option("-d, --dir <path>", "Directory to serve", ".reactscope/site").action((opts) => {
6288
+ function wrapPlaygroundHtml(bundledScript, projectCss, wrapperScript) {
6289
+ const projectStyleBlock = projectCss != null && projectCss.length > 0 ? `<style id="scope-project-css">
6290
+ ${projectCss.replace(/<\/style>/gi, "<\\/style>")}
6291
+ </style>` : "";
6292
+ const wrapperScriptBlock = "";
6293
+ return `<!DOCTYPE html>
6294
+ <html lang="en">
6295
+ <head>
6296
+ <meta charset="UTF-8" />
6297
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6298
+ <script>
6299
+ window.__SCOPE_WRAPPER__ = null;
6300
+ // Prevent React DevTools from interfering with the embedded playground.
6301
+ // The hook causes render instability in same-origin iframes.
6302
+ delete window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
6303
+ </script>
6304
+ <style>
6305
+ *, *::before, *::after { box-sizing: border-box; }
6306
+ html, body { margin: 0; padding: 0; font-family: system-ui, sans-serif; }
6307
+ #scope-root { padding: 16px; min-width: 1px; min-height: 1px; }
6308
+ </style>
6309
+ ${projectStyleBlock}
6310
+ <style>html, body { background: transparent !important; }</style>
6311
+ </head>
6312
+ <body>
6313
+ <div id="scope-root" data-reactscope-root></div>
6314
+ ${wrapperScriptBlock}
6315
+ <script>${bundledScript}</script>
6316
+ </body>
6317
+ </html>`;
6318
+ }
6319
+
6320
+ // src/site-commands.ts
6321
+ var MIME_TYPES = {
6322
+ ".html": "text/html; charset=utf-8",
6323
+ ".css": "text/css; charset=utf-8",
6324
+ ".js": "application/javascript; charset=utf-8",
6325
+ ".json": "application/json; charset=utf-8",
6326
+ ".png": "image/png",
6327
+ ".jpg": "image/jpeg",
6328
+ ".jpeg": "image/jpeg",
6329
+ ".svg": "image/svg+xml",
6330
+ ".ico": "image/x-icon"
6331
+ };
6332
+ function slugify(name) {
6333
+ return name.replace(/([A-Z])/g, (m) => `-${m.toLowerCase()}`).replace(/^-/, "").replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
6334
+ }
6335
+ function loadGlobalCssFilesFromConfig2(cwd) {
6336
+ const configPath = resolve(cwd, "reactscope.config.json");
6337
+ if (!existsSync(configPath)) return [];
6338
+ try {
6339
+ const raw = readFileSync(configPath, "utf-8");
6340
+ const cfg = JSON.parse(raw);
6341
+ return cfg.components?.wrappers?.globalCSS ?? [];
6342
+ } catch {
6343
+ return [];
6344
+ }
6345
+ }
6346
+ function loadIconPatternsFromConfig2(cwd) {
6347
+ const configPath = resolve(cwd, "reactscope.config.json");
6348
+ if (!existsSync(configPath)) return [];
6349
+ try {
6350
+ const raw = readFileSync(configPath, "utf-8");
6351
+ const cfg = JSON.parse(raw);
6352
+ return cfg.icons?.patterns ?? [];
6353
+ } catch {
6354
+ return [];
6355
+ }
6356
+ }
6357
+ var LIVERELOAD_SCRIPT = `<script>(function(){var s=new EventSource("/__livereload");s.onmessage=function(e){if(e.data==="reload")location.reload()};s.onerror=function(){setTimeout(function(){location.reload()},2000)}})()</script>`;
6358
+ function injectLiveReloadScript(html) {
6359
+ const idx = html.lastIndexOf("</body>");
6360
+ if (idx >= 0) return html.slice(0, idx) + LIVERELOAD_SCRIPT + html.slice(idx);
6361
+ return html + LIVERELOAD_SCRIPT;
6362
+ }
6363
+ function loadWatchConfig(rootDir) {
6364
+ const configPath = resolve(rootDir, "reactscope.config.json");
6365
+ if (!existsSync(configPath)) return null;
6366
+ try {
6367
+ const raw = readFileSync(configPath, "utf-8");
6368
+ const cfg = JSON.parse(raw);
6369
+ const result = {};
6370
+ const components = cfg.components;
6371
+ if (components && typeof components === "object") {
6372
+ if (Array.isArray(components.include)) result.include = components.include;
6373
+ if (Array.isArray(components.exclude)) result.exclude = components.exclude;
6374
+ }
6375
+ if (Array.isArray(cfg.internalPatterns))
6376
+ result.internalPatterns = cfg.internalPatterns;
6377
+ if (Array.isArray(cfg.collections)) result.collections = cfg.collections;
6378
+ const icons = cfg.icons;
6379
+ if (icons && typeof icons === "object" && Array.isArray(icons.patterns)) {
6380
+ result.iconPatterns = icons.patterns;
6381
+ }
6382
+ return result;
6383
+ } catch {
6384
+ return null;
6385
+ }
6386
+ }
6387
+ function watchGlob(pattern, filePath) {
6388
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
6389
+ const regexStr = escaped.replace(/\*\*/g, "\xA7GLOBSTAR\xA7").replace(/\*/g, "[^/]*").replace(/\u00a7GLOBSTAR\u00a7/g, ".*");
6390
+ return new RegExp(`^${regexStr}$`, "i").test(filePath);
6391
+ }
6392
+ function matchesWatchPatterns(filePath, include, exclude) {
6393
+ for (const pattern of exclude) {
6394
+ if (watchGlob(pattern, filePath)) return false;
6395
+ }
6396
+ for (const pattern of include) {
6397
+ if (watchGlob(pattern, filePath)) return true;
6398
+ }
6399
+ return false;
6400
+ }
6401
+ function findAffectedComponents(manifest, changedFiles, previousManifest) {
6402
+ const affected = /* @__PURE__ */ new Set();
6403
+ const normalised = changedFiles.map((f) => f.replace(/\\/g, "/"));
6404
+ for (const [name, descriptor] of Object.entries(manifest.components)) {
6405
+ const componentFile = descriptor.filePath.replace(/\\/g, "/");
6406
+ for (const changed of normalised) {
6407
+ if (componentFile === changed) {
6408
+ affected.add(name);
6409
+ break;
6410
+ }
6411
+ const scopeBase = changed.replace(/\.scope\.(ts|tsx|js|jsx)$/, "");
6412
+ const compBase = componentFile.replace(/\.(tsx|ts|jsx|js)$/, "");
6413
+ if (scopeBase !== changed && compBase === scopeBase) {
6414
+ affected.add(name);
6415
+ break;
6416
+ }
6417
+ }
6418
+ }
6419
+ if (previousManifest) {
6420
+ const oldNames = new Set(Object.keys(previousManifest.components));
6421
+ for (const name of Object.keys(manifest.components)) {
6422
+ if (!oldNames.has(name)) affected.add(name);
6423
+ }
6424
+ }
6425
+ return [...affected];
6426
+ }
6427
+ async function renderComponentsForWatch(manifest, componentNames, rootDir, inputDir) {
6428
+ if (componentNames.length === 0) return;
6429
+ const rendersDir = join(inputDir, "renders");
6430
+ await mkdir(rendersDir, { recursive: true });
6431
+ const cssFiles = loadGlobalCssFilesFromConfig2(rootDir);
6432
+ const iconPatterns = loadIconPatternsFromConfig2(rootDir);
6433
+ const complianceStylesPath = join(inputDir, "compliance-styles.json");
6434
+ let complianceStyles = {};
6435
+ if (existsSync(complianceStylesPath)) {
6436
+ try {
6437
+ complianceStyles = JSON.parse(readFileSync(complianceStylesPath, "utf-8"));
6438
+ } catch {
6439
+ }
6440
+ }
6441
+ for (const name of componentNames) {
6442
+ const descriptor = manifest.components[name];
6443
+ if (!descriptor) continue;
6444
+ const filePath = resolve(rootDir, descriptor.filePath);
6445
+ const isIcon = isIconComponent(descriptor.filePath, name, iconPatterns);
6446
+ let scopeData = null;
5668
6447
  try {
5669
- const port = Number.parseInt(opts.port, 10);
5670
- if (Number.isNaN(port) || port < 1 || port > 65535) {
5671
- throw new Error(`Invalid port: ${opts.port}`);
6448
+ scopeData = await loadScopeFileForComponent(filePath);
6449
+ } catch {
6450
+ }
6451
+ const scenarioEntries = scopeData ? Object.entries(scopeData.scenarios) : [];
6452
+ const defaultEntry = scenarioEntries.find(([k]) => k === "default") ?? scenarioEntries[0];
6453
+ const renderProps = defaultEntry?.[1] ?? {};
6454
+ let wrapperScript;
6455
+ try {
6456
+ wrapperScript = scopeData?.hasWrapper ? await buildWrapperScript(scopeData.filePath) : void 0;
6457
+ } catch {
6458
+ }
6459
+ const renderer = buildRenderer(
6460
+ filePath,
6461
+ name,
6462
+ 375,
6463
+ 812,
6464
+ cssFiles,
6465
+ rootDir,
6466
+ wrapperScript,
6467
+ isIcon
6468
+ );
6469
+ const outcome = await safeRender(
6470
+ () => renderer.renderCell(renderProps, descriptor.complexityClass),
6471
+ {
6472
+ props: renderProps,
6473
+ sourceLocation: { file: descriptor.filePath, line: descriptor.loc.start, column: 0 }
5672
6474
  }
5673
- const serveDir = resolve(process.cwd(), opts.dir);
5674
- if (!existsSync(serveDir)) {
5675
- throw new Error(
5676
- `Serve directory not found: ${serveDir}
5677
- Run \`scope site build\` first.`
5678
- );
6475
+ );
6476
+ if (outcome.crashed) {
6477
+ process.stderr.write(` \u2717 ${name}: ${outcome.error.message}
6478
+ `);
6479
+ continue;
6480
+ }
6481
+ const result = outcome.result;
6482
+ if (!isIcon) {
6483
+ writeFileSync(join(rendersDir, `${name}.png`), result.screenshot);
6484
+ }
6485
+ const renderJson = formatRenderJson(name, renderProps, result);
6486
+ const extResult = result;
6487
+ if (isIcon && extResult.svgContent) {
6488
+ renderJson.svgContent = extResult.svgContent;
6489
+ delete renderJson.screenshot;
6490
+ }
6491
+ writeFileSync(join(rendersDir, `${name}.json`), JSON.stringify(renderJson, null, 2));
6492
+ const rawStyles = result.computedStyles["[data-reactscope-root] > *"] ?? {};
6493
+ const compStyles = {
6494
+ colors: {},
6495
+ spacing: {},
6496
+ typography: {},
6497
+ borders: {},
6498
+ shadows: {}
6499
+ };
6500
+ for (const [prop, val] of Object.entries(rawStyles)) {
6501
+ if (!val || val === "none" || val === "") continue;
6502
+ const lower = prop.toLowerCase();
6503
+ if (lower.includes("color") || lower.includes("background")) {
6504
+ compStyles.colors[prop] = val;
6505
+ } else if (lower.includes("padding") || lower.includes("margin") || lower.includes("gap") || lower.includes("width") || lower.includes("height")) {
6506
+ compStyles.spacing[prop] = val;
6507
+ } else if (lower.includes("font") || lower.includes("lineheight") || lower.includes("letterspacing") || lower.includes("texttransform")) {
6508
+ compStyles.typography[prop] = val;
6509
+ } else if (lower.includes("border") || lower.includes("radius") || lower.includes("outline")) {
6510
+ compStyles.borders[prop] = val;
6511
+ } else if (lower.includes("shadow")) {
6512
+ compStyles.shadows[prop] = val;
5679
6513
  }
5680
- const server = createServer((req, res) => {
5681
- const rawUrl = req.url ?? "/";
5682
- const urlPath = decodeURIComponent(rawUrl.split("?")[0] ?? "/");
5683
- const filePath = join(serveDir, urlPath.endsWith("/") ? `${urlPath}index.html` : urlPath);
5684
- if (!filePath.startsWith(serveDir)) {
5685
- res.writeHead(403, { "Content-Type": "text/plain" });
5686
- res.end("Forbidden");
5687
- return;
6514
+ }
6515
+ complianceStyles[name] = compStyles;
6516
+ process.stderr.write(` \u2713 ${name} (${result.renderTimeMs.toFixed(0)}ms)
6517
+ `);
6518
+ }
6519
+ await shutdownPool3();
6520
+ writeFileSync(complianceStylesPath, JSON.stringify(complianceStyles, null, 2), "utf-8");
6521
+ }
6522
+ async function watchRebuildSite(inputDir, outputDir, title, basePath) {
6523
+ const rootDir = process.cwd();
6524
+ await generatePlaygrounds(inputDir, outputDir);
6525
+ const iconPatterns = loadIconPatternsFromConfig2(rootDir);
6526
+ let tokenFilePath;
6527
+ const autoPath = resolve(rootDir, "reactscope.tokens.json");
6528
+ if (existsSync(autoPath)) tokenFilePath = autoPath;
6529
+ let compliancePath;
6530
+ const crPath = join(inputDir, "compliance-report.json");
6531
+ if (existsSync(crPath)) compliancePath = crPath;
6532
+ await buildSite({
6533
+ inputDir,
6534
+ outputDir,
6535
+ basePath,
6536
+ ...compliancePath && { compliancePath },
6537
+ ...tokenFilePath && { tokenFilePath },
6538
+ title,
6539
+ iconPatterns
6540
+ });
6541
+ }
6542
+ function findStaleComponents(manifest, previousManifest, rendersDir) {
6543
+ const stale = [];
6544
+ for (const [name, descriptor] of Object.entries(manifest.components)) {
6545
+ const jsonPath = join(rendersDir, `${name}.json`);
6546
+ if (!existsSync(jsonPath)) {
6547
+ stale.push(name);
6548
+ continue;
6549
+ }
6550
+ if (!previousManifest) continue;
6551
+ const prev = previousManifest.components[name];
6552
+ if (!prev) {
6553
+ stale.push(name);
6554
+ continue;
6555
+ }
6556
+ if (JSON.stringify(prev) !== JSON.stringify(descriptor)) {
6557
+ stale.push(name);
6558
+ }
6559
+ }
6560
+ return stale;
6561
+ }
6562
+ async function runFullBuild(rootDir, inputDir, outputDir, title, basePath) {
6563
+ process.stderr.write("[watch] Starting\u2026\n");
6564
+ const config = loadWatchConfig(rootDir);
6565
+ const manifestPath = join(inputDir, "manifest.json");
6566
+ let previousManifest = null;
6567
+ if (existsSync(manifestPath)) {
6568
+ try {
6569
+ previousManifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
6570
+ } catch {
6571
+ }
6572
+ }
6573
+ process.stderr.write("[watch] Generating manifest\u2026\n");
6574
+ const manifest = await generateManifest({
6575
+ rootDir,
6576
+ ...config?.include && { include: config.include },
6577
+ ...config?.exclude && { exclude: config.exclude },
6578
+ ...config?.internalPatterns && { internalPatterns: config.internalPatterns },
6579
+ ...config?.collections && { collections: config.collections },
6580
+ ...config?.iconPatterns && { iconPatterns: config.iconPatterns }
6581
+ });
6582
+ await mkdir(inputDir, { recursive: true });
6583
+ writeFileSync(join(inputDir, "manifest.json"), JSON.stringify(manifest, null, 2), "utf-8");
6584
+ const count = Object.keys(manifest.components).length;
6585
+ process.stderr.write(`[watch] Found ${count} components
6586
+ `);
6587
+ const rendersDir = join(inputDir, "renders");
6588
+ const stale = findStaleComponents(manifest, previousManifest, rendersDir);
6589
+ if (stale.length > 0) {
6590
+ process.stderr.write(
6591
+ `[watch] Rendering ${stale.length} component(s) (${count - stale.length} already up-to-date)
6592
+ `
6593
+ );
6594
+ await renderComponentsForWatch(manifest, stale, rootDir, inputDir);
6595
+ } else {
6596
+ process.stderr.write("[watch] All renders up-to-date, skipping render step\n");
6597
+ }
6598
+ process.stderr.write("[watch] Building site\u2026\n");
6599
+ await watchRebuildSite(inputDir, outputDir, title, basePath);
6600
+ process.stderr.write("[watch] Ready\n");
6601
+ return manifest;
6602
+ }
6603
+ function startFileWatcher(opts) {
6604
+ const { rootDir, inputDir, outputDir, title, basePath, notifyReload } = opts;
6605
+ let previousManifest = opts.previousManifest;
6606
+ const config = loadWatchConfig(rootDir);
6607
+ const includePatterns = config?.include ?? ["src/**/*.tsx", "src/**/*.ts"];
6608
+ const excludePatterns = config?.exclude ?? [
6609
+ "**/node_modules/**",
6610
+ "**/*.test.*",
6611
+ "**/*.spec.*",
6612
+ "**/dist/**",
6613
+ "**/*.d.ts"
6614
+ ];
6615
+ let debounceTimer = null;
6616
+ const pendingFiles = /* @__PURE__ */ new Set();
6617
+ let isRunning = false;
6618
+ const IGNORE_PREFIXES = ["node_modules/", ".reactscope/", "dist/", ".git/", ".next/", ".turbo/"];
6619
+ const handleChange = async () => {
6620
+ if (isRunning) return;
6621
+ isRunning = true;
6622
+ const changedFiles = [...pendingFiles];
6623
+ pendingFiles.clear();
6624
+ try {
6625
+ process.stderr.write(`
6626
+ [watch] ${changedFiles.length} file(s) changed
6627
+ `);
6628
+ process.stderr.write("[watch] Regenerating manifest\u2026\n");
6629
+ const newManifest = await generateManifest({
6630
+ rootDir,
6631
+ ...config?.include && { include: config.include },
6632
+ ...config?.exclude && { exclude: config.exclude },
6633
+ ...config?.internalPatterns && { internalPatterns: config.internalPatterns },
6634
+ ...config?.collections && { collections: config.collections },
6635
+ ...config?.iconPatterns && { iconPatterns: config.iconPatterns }
6636
+ });
6637
+ writeFileSync(join(inputDir, "manifest.json"), JSON.stringify(newManifest, null, 2), "utf-8");
6638
+ const affected = findAffectedComponents(newManifest, changedFiles, previousManifest);
6639
+ if (affected.length > 0) {
6640
+ process.stderr.write(`[watch] Re-rendering: ${affected.join(", ")}
6641
+ `);
6642
+ await renderComponentsForWatch(newManifest, affected, rootDir, inputDir);
6643
+ } else {
6644
+ process.stderr.write("[watch] No components directly affected\n");
6645
+ }
6646
+ process.stderr.write("[watch] Rebuilding site\u2026\n");
6647
+ await watchRebuildSite(inputDir, outputDir, title, basePath);
6648
+ previousManifest = newManifest;
6649
+ process.stderr.write("[watch] Done\n");
6650
+ notifyReload();
6651
+ } catch (err) {
6652
+ process.stderr.write(`[watch] Error: ${err instanceof Error ? err.message : String(err)}
6653
+ `);
6654
+ } finally {
6655
+ isRunning = false;
6656
+ if (pendingFiles.size > 0) {
6657
+ handleChange();
6658
+ }
6659
+ }
6660
+ };
6661
+ const onFileChange = (_eventType, filename) => {
6662
+ if (!filename) return;
6663
+ const normalised = filename.replace(/\\/g, "/");
6664
+ for (const prefix of IGNORE_PREFIXES) {
6665
+ if (normalised.startsWith(prefix)) return;
6666
+ }
6667
+ if (!matchesWatchPatterns(normalised, includePatterns, excludePatterns)) return;
6668
+ pendingFiles.add(normalised);
6669
+ if (debounceTimer) clearTimeout(debounceTimer);
6670
+ debounceTimer = setTimeout(() => {
6671
+ debounceTimer = null;
6672
+ handleChange();
6673
+ }, 500);
6674
+ };
6675
+ try {
6676
+ watch(rootDir, { recursive: true }, onFileChange);
6677
+ process.stderr.write(`[watch] Watching for changes (${includePatterns.join(", ")})
6678
+ `);
6679
+ } catch (err) {
6680
+ process.stderr.write(
6681
+ `[watch] Warning: Could not start watcher: ${err instanceof Error ? err.message : String(err)}
6682
+ `
6683
+ );
6684
+ }
6685
+ }
6686
+ async function generatePlaygrounds(inputDir, outputDir) {
6687
+ const manifestPath = join(inputDir, "manifest.json");
6688
+ const raw = readFileSync(manifestPath, "utf-8");
6689
+ const manifest = JSON.parse(raw);
6690
+ const rootDir = process.cwd();
6691
+ const componentNames = Object.keys(manifest.components);
6692
+ if (componentNames.length === 0) return [];
6693
+ const playgroundDir = join(outputDir, "playground");
6694
+ await mkdir(playgroundDir, { recursive: true });
6695
+ const cssFiles = loadGlobalCssFilesFromConfig2(rootDir);
6696
+ const projectCss = await loadGlobalCss(cssFiles, rootDir) ?? void 0;
6697
+ let succeeded = 0;
6698
+ const failures = [];
6699
+ const allDefaults = {};
6700
+ for (const name of componentNames) {
6701
+ const descriptor = manifest.components[name];
6702
+ if (!descriptor) continue;
6703
+ const filePath = resolve(rootDir, descriptor.filePath);
6704
+ const slug = slugify(name);
6705
+ try {
6706
+ const scopeData = await loadScopeFileForComponent(filePath);
6707
+ if (scopeData) {
6708
+ const defaultScenario = scopeData.scenarios.default ?? Object.values(scopeData.scenarios)[0];
6709
+ if (defaultScenario) allDefaults[name] = defaultScenario;
6710
+ }
6711
+ } catch {
6712
+ }
6713
+ try {
6714
+ const html = await buildPlaygroundHarness(filePath, name, projectCss);
6715
+ await writeFile(join(playgroundDir, `${slug}.html`), html, "utf-8");
6716
+ succeeded++;
6717
+ } catch (err) {
6718
+ process.stderr.write(
6719
+ `[scope/site] \u26A0 playground skip: ${name} \u2014 ${err instanceof Error ? err.message : String(err)}
6720
+ `
6721
+ );
6722
+ failures.push({
6723
+ component: name,
6724
+ stage: "playground",
6725
+ message: err instanceof Error ? err.message : String(err),
6726
+ outputPath: join(playgroundDir, `${slug}.html`)
6727
+ });
6728
+ }
6729
+ }
6730
+ await writeFile(
6731
+ join(inputDir, "playground-defaults.json"),
6732
+ JSON.stringify(allDefaults, null, 2),
6733
+ "utf-8"
6734
+ );
6735
+ process.stderr.write(
6736
+ `[scope/site] Playgrounds: ${succeeded} built${failures.length > 0 ? `, ${failures.length} failed` : ""}
6737
+ `
6738
+ );
6739
+ return failures;
6740
+ }
6741
+ function registerBuild(siteCmd) {
6742
+ siteCmd.command("build").description(
6743
+ 'Build the static HTML site from manifest + render outputs.\n\nINPUT DIRECTORY (.reactscope/ by default) must contain:\n manifest.json component registry\n renders/ screenshots and render.json files from `scope render all`\n\nOPTIONAL:\n --compliance <path> include token compliance scores on detail pages\n --base-path <path> set if deploying to a subdirectory (e.g. /ui-docs)\n\nExamples:\n scope site build\n scope site build --title "Design System" -o .reactscope/site\n scope site build --compliance .reactscope/compliance-report.json\n scope site build --tokens reactscope.tokens.json\n scope site build --base-path /ui'
6744
+ ).option("-i, --input <path>", "Path to .reactscope input directory", ".reactscope").option("-o, --output <path>", "Output directory for generated site", ".reactscope/site").option("--base-path <path>", "Base URL path prefix for subdirectory deployment", "/").option("--compliance <path>", "Path to compliance batch report JSON").option("--tokens <path>", "Path to reactscope.tokens.json (enables token browser page)").option("--title <text>", "Site title", "Scope \u2014 Component Gallery").action(
6745
+ async (opts) => {
6746
+ try {
6747
+ const inputDir = resolve(process.cwd(), opts.input);
6748
+ const outputDir = resolve(process.cwd(), opts.output);
6749
+ if (!existsSync(inputDir)) {
6750
+ throw new Error(
6751
+ `Input directory not found: ${inputDir}
6752
+ Run \`scope manifest generate\` and \`scope render\` first.`
6753
+ );
5688
6754
  }
5689
- if (existsSync(filePath) && statSync(filePath).isFile()) {
5690
- const ext = extname(filePath).toLowerCase();
5691
- const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
5692
- res.writeHead(200, { "Content-Type": contentType });
5693
- createReadStream(filePath).pipe(res);
5694
- return;
6755
+ const manifestPath = join(inputDir, "manifest.json");
6756
+ if (!existsSync(manifestPath)) {
6757
+ throw new Error(
6758
+ `Manifest not found at ${manifestPath}
6759
+ Run \`scope manifest generate\` first.`
6760
+ );
5695
6761
  }
5696
- const htmlPath = `${filePath}.html`;
5697
- if (existsSync(htmlPath) && statSync(htmlPath).isFile()) {
5698
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
5699
- createReadStream(htmlPath).pipe(res);
5700
- return;
6762
+ process.stderr.write(`Building site from ${inputDir}\u2026
6763
+ `);
6764
+ process.stderr.write("Bundling playgrounds\u2026\n");
6765
+ const failures = await generatePlaygrounds(inputDir, outputDir);
6766
+ const iconPatterns = loadIconPatternsFromConfig2(process.cwd());
6767
+ let tokenFilePath = opts.tokens ? resolve(process.cwd(), opts.tokens) : void 0;
6768
+ if (tokenFilePath === void 0) {
6769
+ const autoPath = resolve(process.cwd(), "reactscope.tokens.json");
6770
+ if (existsSync(autoPath)) {
6771
+ tokenFilePath = autoPath;
6772
+ }
5701
6773
  }
5702
- res.writeHead(404, { "Content-Type": "text/plain" });
5703
- res.end(`Not found: ${urlPath}`);
5704
- });
5705
- server.listen(port, () => {
5706
- process.stderr.write(`Scope site running at http://localhost:${port}
6774
+ await buildSite({
6775
+ inputDir,
6776
+ outputDir,
6777
+ basePath: opts.basePath,
6778
+ ...opts.compliance !== void 0 && {
6779
+ compliancePath: resolve(process.cwd(), opts.compliance)
6780
+ },
6781
+ ...tokenFilePath !== void 0 && { tokenFilePath },
6782
+ title: opts.title,
6783
+ iconPatterns
6784
+ });
6785
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
6786
+ const componentCount = Object.keys(manifest.components).length;
6787
+ const generatedPlaygroundCount = componentCount === 0 ? 0 : statSync(join(outputDir, "playground")).isDirectory() ? componentCount - failures.length : 0;
6788
+ const siteFailures = [...failures];
6789
+ if (componentCount === 0) {
6790
+ siteFailures.push({
6791
+ component: "*",
6792
+ stage: "site",
6793
+ message: "Manifest contains zero components; generated site is structurally degraded.",
6794
+ outputPath: manifestPath
6795
+ });
6796
+ } else if (generatedPlaygroundCount === 0) {
6797
+ siteFailures.push({
6798
+ component: "*",
6799
+ stage: "site",
6800
+ message: "No playground pages were generated successfully; site build is degraded and should not be treated as green.",
6801
+ outputPath: join(outputDir, "playground")
6802
+ });
6803
+ }
6804
+ const summaryPath = writeRunSummary({
6805
+ command: "scope site build",
6806
+ status: siteFailures.length > 0 ? "failed" : "success",
6807
+ outputPaths: [outputDir, join(outputDir, "index.html")],
6808
+ failures: siteFailures
6809
+ });
6810
+ process.stderr.write(`Site written to ${outputDir}
5707
6811
  `);
5708
- process.stderr.write(`Serving ${serveDir}
6812
+ process.stderr.write(`[scope/site] Run summary written to ${summaryPath}
5709
6813
  `);
5710
- process.stderr.write("Press Ctrl+C to stop.\n");
5711
- });
5712
- server.on("error", (err) => {
5713
- if (err.code === "EADDRINUSE") {
5714
- process.stderr.write(`Error: Port ${port} is already in use.
6814
+ process.stdout.write(`${outputDir}
5715
6815
  `);
5716
- } else {
5717
- process.stderr.write(`Server error: ${err.message}
6816
+ if (siteFailures.length > 0) process.exit(1);
6817
+ } catch (err) {
6818
+ process.stderr.write(`${formatScopeDiagnostic(err)}
5718
6819
  `);
5719
- }
5720
6820
  process.exit(1);
5721
- });
5722
- } catch (err) {
5723
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
6821
+ }
6822
+ }
6823
+ );
6824
+ }
6825
+ function registerServe(siteCmd) {
6826
+ siteCmd.command("serve").description(
6827
+ "Start a local HTTP server for the built site directory.\n\nRun `scope site build` first, or use --watch to auto-rebuild on changes.\nCtrl+C to stop.\n\nExamples:\n scope site serve\n scope site serve --port 8080\n scope site serve --dir ./my-site-output\n scope site serve --watch"
6828
+ ).option("-p, --port <number>", "Port to listen on", "3000").option("-d, --dir <path>", "Directory to serve", ".reactscope/site").option("-w, --watch", "Watch source files and rebuild on changes").option(
6829
+ "-i, --input <path>",
6830
+ "Input directory for .reactscope data (watch mode)",
6831
+ ".reactscope"
6832
+ ).option("--title <text>", "Site title (watch mode)", "Scope \u2014 Component Gallery").option("--base-path <path>", "Base URL path prefix (watch mode)", "/").action(
6833
+ async (opts) => {
6834
+ try {
6835
+ let notifyReload2 = function() {
6836
+ for (const client of sseClients) {
6837
+ client.write("data: reload\n\n");
6838
+ }
6839
+ };
6840
+ var notifyReload = notifyReload2;
6841
+ const port = Number.parseInt(opts.port, 10);
6842
+ if (Number.isNaN(port) || port < 1 || port > 65535) {
6843
+ throw new Error(`Invalid port: ${opts.port}`);
6844
+ }
6845
+ const serveDir = resolve(process.cwd(), opts.dir);
6846
+ const watchMode = opts.watch === true;
6847
+ const sseClients = /* @__PURE__ */ new Set();
6848
+ if (watchMode) {
6849
+ await mkdir(serveDir, { recursive: true });
6850
+ }
6851
+ if (!watchMode && !existsSync(serveDir)) {
6852
+ throw new Error(
6853
+ `Serve directory not found: ${serveDir}
6854
+ Run \`scope site build\` first.`
6855
+ );
6856
+ }
6857
+ const server = createServer((req, res) => {
6858
+ const rawUrl = req.url ?? "/";
6859
+ const urlPath = decodeURIComponent(rawUrl.split("?")[0] ?? "/");
6860
+ if (watchMode && urlPath === "/__livereload") {
6861
+ res.writeHead(200, {
6862
+ "Content-Type": "text/event-stream",
6863
+ "Cache-Control": "no-cache",
6864
+ Connection: "keep-alive",
6865
+ "Access-Control-Allow-Origin": "*"
6866
+ });
6867
+ res.write("data: connected\n\n");
6868
+ sseClients.add(res);
6869
+ req.on("close", () => sseClients.delete(res));
6870
+ return;
6871
+ }
6872
+ const filePath = join(
6873
+ serveDir,
6874
+ urlPath.endsWith("/") ? `${urlPath}index.html` : urlPath
6875
+ );
6876
+ if (!filePath.startsWith(serveDir)) {
6877
+ res.writeHead(403, { "Content-Type": "text/plain" });
6878
+ res.end("Forbidden");
6879
+ return;
6880
+ }
6881
+ if (existsSync(filePath) && statSync(filePath).isFile()) {
6882
+ const ext = extname(filePath).toLowerCase();
6883
+ const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
6884
+ if (watchMode && ext === ".html") {
6885
+ const html = injectLiveReloadScript(readFileSync(filePath, "utf-8"));
6886
+ res.writeHead(200, { "Content-Type": contentType });
6887
+ res.end(html);
6888
+ return;
6889
+ }
6890
+ res.writeHead(200, { "Content-Type": contentType });
6891
+ createReadStream(filePath).pipe(res);
6892
+ return;
6893
+ }
6894
+ const htmlPath = `${filePath}.html`;
6895
+ if (existsSync(htmlPath) && statSync(htmlPath).isFile()) {
6896
+ if (watchMode) {
6897
+ const html = injectLiveReloadScript(readFileSync(htmlPath, "utf-8"));
6898
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
6899
+ res.end(html);
6900
+ return;
6901
+ }
6902
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
6903
+ createReadStream(htmlPath).pipe(res);
6904
+ return;
6905
+ }
6906
+ res.writeHead(404, { "Content-Type": "text/plain" });
6907
+ res.end(`Not found: ${urlPath}`);
6908
+ });
6909
+ server.listen(port, () => {
6910
+ process.stderr.write(`Scope site running at http://localhost:${port}
5724
6911
  `);
5725
- process.exit(1);
6912
+ process.stderr.write(`Serving ${serveDir}
6913
+ `);
6914
+ if (watchMode) {
6915
+ process.stderr.write(
6916
+ "Watch mode enabled \u2014 source changes trigger rebuild + browser reload\n"
6917
+ );
6918
+ }
6919
+ process.stderr.write("Press Ctrl+C to stop.\n");
6920
+ });
6921
+ server.on("error", (err) => {
6922
+ if (err.code === "EADDRINUSE") {
6923
+ process.stderr.write(`Error: Port ${port} is already in use.
6924
+ `);
6925
+ } else {
6926
+ process.stderr.write(`Server error: ${err.message}
6927
+ `);
6928
+ }
6929
+ process.exit(1);
6930
+ });
6931
+ if (watchMode) {
6932
+ const rootDir = process.cwd();
6933
+ const inputDir = resolve(rootDir, opts.input);
6934
+ const initialManifest = await runFullBuild(
6935
+ rootDir,
6936
+ inputDir,
6937
+ serveDir,
6938
+ opts.title,
6939
+ opts.basePath
6940
+ );
6941
+ notifyReload2();
6942
+ startFileWatcher({
6943
+ rootDir,
6944
+ inputDir,
6945
+ outputDir: serveDir,
6946
+ title: opts.title,
6947
+ basePath: opts.basePath,
6948
+ previousManifest: initialManifest,
6949
+ notifyReload: notifyReload2
6950
+ });
6951
+ }
6952
+ } catch (err) {
6953
+ process.stderr.write(`${formatScopeDiagnostic(err)}
6954
+ `);
6955
+ process.exit(1);
6956
+ }
5726
6957
  }
5727
- });
6958
+ );
5728
6959
  }
5729
6960
  function createSiteCommand() {
5730
6961
  const siteCmd = new Command("site").description(
5731
- 'Build and serve the static HTML component gallery site.\n\nPREREQUISITES:\n scope manifest generate (manifest.json)\n scope render all (renders/ + compliance-styles.json)\n\nSITE CONTENTS:\n /index.html component gallery with screenshots + metadata\n /<component>/index.html detail page: props, renders, matrix, X-Ray, compliance\n\nExamples:\n scope site build && scope site serve\n scope site build --title "Acme UI" --compliance .reactscope/compliance-styles.json\n scope site serve --port 8080'
6962
+ 'Build and serve the static HTML component gallery site.\n\nPREREQUISITES:\n scope manifest generate (manifest.json)\n scope render all (renders/ + compliance-styles.json)\n\nSITE CONTENTS:\n /index.html component gallery with screenshots + metadata\n /<component>/index.html detail page: props, renders, matrix, X-Ray, compliance\n\nExamples:\n scope site build && scope site serve\n scope site build --title "Acme UI" --compliance .reactscope/compliance-report.json\n scope site serve --port 8080'
5732
6963
  );
5733
6964
  registerBuild(siteCmd);
5734
6965
  registerServe(siteCmd);
@@ -5772,11 +7003,11 @@ function categoryForProperty(property) {
5772
7003
  }
5773
7004
  function buildCategorySummary(batch) {
5774
7005
  const cats = {
5775
- color: { total: 0, onSystem: 0, offSystem: 0, compliance: 1 },
5776
- spacing: { total: 0, onSystem: 0, offSystem: 0, compliance: 1 },
5777
- typography: { total: 0, onSystem: 0, offSystem: 0, compliance: 1 },
5778
- border: { total: 0, onSystem: 0, offSystem: 0, compliance: 1 },
5779
- shadow: { total: 0, onSystem: 0, offSystem: 0, compliance: 1 }
7006
+ color: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 },
7007
+ spacing: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 },
7008
+ typography: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 },
7009
+ border: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 },
7010
+ shadow: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 }
5780
7011
  };
5781
7012
  for (const report of Object.values(batch.components)) {
5782
7013
  for (const [property, result] of Object.entries(report.properties)) {
@@ -5792,7 +7023,7 @@ function buildCategorySummary(batch) {
5792
7023
  }
5793
7024
  }
5794
7025
  for (const summary of Object.values(cats)) {
5795
- summary.compliance = summary.total === 0 ? 1 : summary.onSystem / summary.total;
7026
+ summary.compliance = summary.total === 0 ? 0 : summary.onSystem / summary.total;
5796
7027
  }
5797
7028
  return cats;
5798
7029
  }
@@ -5833,6 +7064,11 @@ function formatComplianceReport(batch, threshold) {
5833
7064
  const lines = [];
5834
7065
  const thresholdLabel = threshold !== void 0 ? pct >= threshold ? " \u2713 (pass)" : ` \u2717 (below threshold ${threshold}%)` : "";
5835
7066
  lines.push(`Overall compliance score: ${pct}%${thresholdLabel}`);
7067
+ if (batch.totalProperties === 0) {
7068
+ lines.push(
7069
+ "No CSS properties were audited; run `scope render all` and inspect .reactscope/compliance-styles.json before treating compliance as green."
7070
+ );
7071
+ }
5836
7072
  lines.push("");
5837
7073
  const cats = buildCategorySummary(batch);
5838
7074
  const catEntries = Object.entries(cats).filter(([, s]) => s.total > 0);
@@ -5867,42 +7103,84 @@ function formatComplianceReport(batch, threshold) {
5867
7103
  }
5868
7104
  function registerCompliance(tokensCmd) {
5869
7105
  tokensCmd.command("compliance").description(
5870
- "Compute a token compliance score across all rendered components.\n\nCompares computed CSS values from .reactscope/compliance-styles.json\nagainst the token file \u2014 reports what % of style values are on-token.\n\nPREREQUISITES:\n scope render all must have run first (produces compliance-styles.json)\n\nSCORING:\n compliant value exactly matches a token\n near-match value is within tolerance of a token (e.g. close color)\n off-token value not found in token file\n\nEXIT CODES:\n 0 compliance >= threshold (or no --threshold set)\n 1 compliance < threshold\n\nExamples:\n scope tokens compliance\n scope tokens compliance --threshold 90\n scope tokens compliance --format json | jq '.summary'\n scope tokens compliance --styles ./custom/compliance-styles.json"
5871
- ).option("--file <path>", "Path to token file (overrides config)").option("--styles <path>", `Path to compliance styles JSON (default: ${DEFAULT_STYLES_PATH})`).option("--threshold <n>", "Exit code 1 if compliance score is below this percentage (0-100)").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action((opts) => {
5872
- try {
5873
- const tokenFilePath = resolveTokenFilePath(opts.file);
5874
- const { tokens } = loadTokens(tokenFilePath);
5875
- const resolver = new TokenResolver(tokens);
5876
- const engine = new ComplianceEngine(resolver);
5877
- const stylesPath = opts.styles ?? DEFAULT_STYLES_PATH;
5878
- const stylesFile = loadStylesFile(stylesPath);
5879
- const componentMap = /* @__PURE__ */ new Map();
5880
- for (const [name, styles] of Object.entries(stylesFile)) {
5881
- componentMap.set(name, styles);
5882
- }
5883
- if (componentMap.size === 0) {
5884
- process.stderr.write(`Warning: No components found in styles file at ${stylesPath}
7106
+ "Compute a token compliance score across all rendered components.\n\nCompares computed CSS values from .reactscope/compliance-styles.json\nagainst the token file \u2014 reports what % of style values are on-token.\n\nPREREQUISITES:\n scope render all must have run first (produces compliance-styles.json)\n\nSCORING:\n compliant value exactly matches a token\n near-match value is within tolerance of a token (e.g. close color)\n off-token value not found in token file\n\nEXIT CODES:\n 0 compliance >= threshold (or no --threshold set)\n 1 compliance < threshold\n\nExamples:\n scope tokens compliance\n scope tokens compliance --threshold 90\n scope tokens compliance --format json | jq '.summary'\n scope tokens compliance --out .reactscope/compliance-report.json\n scope tokens compliance --styles ./custom/compliance-styles.json"
7107
+ ).option("--file <path>", "Path to token file (overrides config)").option("--styles <path>", `Path to compliance styles JSON (default: ${DEFAULT_STYLES_PATH})`).option(
7108
+ "--out <path>",
7109
+ "Write JSON report to file (for use with scope site build --compliance)"
7110
+ ).option("--threshold <n>", "Exit code 1 if compliance score is below this percentage (0-100)").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action(
7111
+ (opts) => {
7112
+ try {
7113
+ const tokenFilePath = resolveTokenFilePath(opts.file);
7114
+ const { tokens } = loadTokens(tokenFilePath);
7115
+ const resolver = new TokenResolver(tokens);
7116
+ const engine = new ComplianceEngine(resolver);
7117
+ const stylesPath = opts.styles ?? DEFAULT_STYLES_PATH;
7118
+ const stylesFile = loadStylesFile(stylesPath);
7119
+ const componentMap = /* @__PURE__ */ new Map();
7120
+ for (const [name, styles] of Object.entries(stylesFile)) {
7121
+ componentMap.set(name, styles);
7122
+ }
7123
+ if (componentMap.size === 0) {
7124
+ process.stderr.write(`Warning: No components found in styles file at ${stylesPath}
5885
7125
  `);
5886
- }
5887
- const batch = engine.auditBatch(componentMap);
5888
- const useJson = opts.format === "json" || opts.format !== "text" && !isTTY();
5889
- const threshold = opts.threshold !== void 0 ? Number.parseInt(opts.threshold, 10) : void 0;
5890
- if (useJson) {
5891
- process.stdout.write(`${JSON.stringify(batch, null, 2)}
7126
+ }
7127
+ const batch = engine.auditBatch(componentMap);
7128
+ const threshold = opts.threshold !== void 0 ? Number.parseInt(opts.threshold, 10) : void 0;
7129
+ const failures = [];
7130
+ if (batch.totalProperties === 0) {
7131
+ failures.push({
7132
+ component: "*",
7133
+ stage: "compliance",
7134
+ message: `No CSS properties were audited from ${stylesPath}; refusing to report silent success.`,
7135
+ outputPath: stylesPath
7136
+ });
7137
+ } else if (threshold !== void 0 && Math.round(batch.aggregateCompliance * 100) < threshold) {
7138
+ failures.push({
7139
+ component: "*",
7140
+ stage: "compliance",
7141
+ message: `Compliance ${Math.round(batch.aggregateCompliance * 100)}% is below threshold ${threshold}%.`,
7142
+ outputPath: opts.out ?? ".reactscope/compliance-report.json"
7143
+ });
7144
+ }
7145
+ if (opts.out !== void 0) {
7146
+ const outPath = resolve(process.cwd(), opts.out);
7147
+ writeFileSync(outPath, JSON.stringify(batch, null, 2), "utf-8");
7148
+ process.stderr.write(`Compliance report written to ${outPath}
5892
7149
  `);
5893
- } else {
5894
- process.stdout.write(`${formatComplianceReport(batch, threshold)}
7150
+ }
7151
+ const useJson = opts.format === "json" || opts.format !== "text" && !isTTY();
7152
+ if (useJson) {
7153
+ process.stdout.write(`${JSON.stringify(batch, null, 2)}
7154
+ `);
7155
+ } else {
7156
+ process.stdout.write(`${formatComplianceReport(batch, threshold)}
7157
+ `);
7158
+ }
7159
+ const summaryPath = writeRunSummary({
7160
+ command: "scope tokens compliance",
7161
+ status: failures.length > 0 ? "failed" : "success",
7162
+ outputPaths: [opts.out ?? ".reactscope/compliance-report.json", stylesPath],
7163
+ compliance: {
7164
+ auditedProperties: batch.totalProperties,
7165
+ onSystemProperties: batch.totalOnSystem,
7166
+ offSystemProperties: batch.totalOffSystem,
7167
+ score: Math.round(batch.aggregateCompliance * 100),
7168
+ threshold
7169
+ },
7170
+ failures
7171
+ });
7172
+ process.stderr.write(`[scope/tokens] Run summary written to ${summaryPath}
7173
+ `);
7174
+ if (failures.length > 0) {
7175
+ process.exit(1);
7176
+ }
7177
+ } catch (err) {
7178
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
5895
7179
  `);
5896
- }
5897
- if (threshold !== void 0 && Math.round(batch.aggregateCompliance * 100) < threshold) {
5898
7180
  process.exit(1);
5899
7181
  }
5900
- } catch (err) {
5901
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
5902
- `);
5903
- process.exit(1);
5904
7182
  }
5905
- });
7183
+ );
5906
7184
  }
5907
7185
  var DEFAULT_TOKEN_FILE = "reactscope.tokens.json";
5908
7186
  var CONFIG_FILE = "reactscope.config.json";
@@ -6107,18 +7385,242 @@ ${formatImpactSummary(report)}
6107
7385
  }
6108
7386
  );
6109
7387
  }
7388
+ var DEFAULT_TOKEN_FILE2 = "reactscope.tokens.json";
7389
+ var CONFIG_FILE2 = "reactscope.config.json";
7390
+ function resolveOutputPath(fileFlag) {
7391
+ if (fileFlag !== void 0) {
7392
+ return resolve(process.cwd(), fileFlag);
7393
+ }
7394
+ const configPath = resolve(process.cwd(), CONFIG_FILE2);
7395
+ if (existsSync(configPath)) {
7396
+ try {
7397
+ const raw = readFileSync(configPath, "utf-8");
7398
+ const config = JSON.parse(raw);
7399
+ if (typeof config === "object" && config !== null && "tokens" in config && typeof config.tokens === "object" && config.tokens !== null && typeof config.tokens?.file === "string") {
7400
+ const file = config.tokens.file;
7401
+ return resolve(process.cwd(), file);
7402
+ }
7403
+ } catch {
7404
+ }
7405
+ }
7406
+ return resolve(process.cwd(), DEFAULT_TOKEN_FILE2);
7407
+ }
7408
+ var CSS_VAR_RE = /--([\w-]+)\s*:\s*([^;]+)/g;
7409
+ var HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
7410
+ var COLOR_FN_RE = /^(?:rgba?|hsla?|oklch|oklab|lch|lab|color|hwb)\(/;
7411
+ var DIMENSION_RE = /^-?\d+(?:\.\d+)?(?:px|rem|em|%|vw|vh|ch|ex|cap|lh|dvh|svh|lvh)$/;
7412
+ var DURATION_RE = /^-?\d+(?:\.\d+)?(?:ms|s)$/;
7413
+ var FONT_FAMILY_RE = /^["']|,\s*(?:sans-serif|serif|monospace|cursive|fantasy|system-ui)/;
7414
+ var NUMBER_RE = /^-?\d+(?:\.\d+)?$/;
7415
+ var CUBIC_BEZIER_RE = /^cubic-bezier\(/;
7416
+ var SHADOW_RE = /^\d.*(?:px|rem|em)\s+(?:#|rgba?|hsla?|oklch|oklab)/i;
7417
+ function inferTokenType(value) {
7418
+ const v = value.trim();
7419
+ if (HEX_COLOR_RE.test(v) || COLOR_FN_RE.test(v)) return "color";
7420
+ if (DURATION_RE.test(v)) return "duration";
7421
+ if (DIMENSION_RE.test(v)) return "dimension";
7422
+ if (FONT_FAMILY_RE.test(v)) return "fontFamily";
7423
+ if (CUBIC_BEZIER_RE.test(v)) return "cubicBezier";
7424
+ if (SHADOW_RE.test(v)) return "shadow";
7425
+ if (NUMBER_RE.test(v)) return "number";
7426
+ return "color";
7427
+ }
7428
+ function setNestedToken(root, segments, value, type) {
7429
+ let node = root;
7430
+ for (let i = 0; i < segments.length - 1; i++) {
7431
+ const seg = segments[i];
7432
+ if (seg === void 0) continue;
7433
+ if (!(seg in node) || typeof node[seg] !== "object" || node[seg] === null) {
7434
+ node[seg] = {};
7435
+ }
7436
+ node = node[seg];
7437
+ }
7438
+ const leaf = segments[segments.length - 1];
7439
+ if (leaf === void 0) return;
7440
+ node[leaf] = { value, type };
7441
+ }
7442
+ function extractBlockBody(css, openBrace) {
7443
+ let depth = 0;
7444
+ let end = -1;
7445
+ for (let i = openBrace; i < css.length; i++) {
7446
+ if (css[i] === "{") depth++;
7447
+ else if (css[i] === "}") {
7448
+ depth--;
7449
+ if (depth === 0) {
7450
+ end = i;
7451
+ break;
7452
+ }
7453
+ }
7454
+ }
7455
+ if (end === -1) return "";
7456
+ return css.slice(openBrace + 1, end);
7457
+ }
7458
+ function parseScopedBlocks(css) {
7459
+ const blocks = [];
7460
+ const blockRe = /(?::root|@theme(?:\s+inline)?|\.dark\.high-contrast|\.dark)\s*\{/g;
7461
+ let match = blockRe.exec(css);
7462
+ while (match !== null) {
7463
+ const selector = match[0];
7464
+ const braceIdx = css.indexOf("{", match.index);
7465
+ if (braceIdx === -1) {
7466
+ match = blockRe.exec(css);
7467
+ continue;
7468
+ }
7469
+ const body = extractBlockBody(css, braceIdx);
7470
+ let scope;
7471
+ if (selector.includes(".dark.high-contrast")) scope = "dark-high-contrast";
7472
+ else if (selector.includes(".dark")) scope = "dark";
7473
+ else if (selector.includes("@theme")) scope = "theme";
7474
+ else scope = "root";
7475
+ blocks.push({ scope, body });
7476
+ match = blockRe.exec(css);
7477
+ }
7478
+ return blocks;
7479
+ }
7480
+ function extractVarsFromBody(body) {
7481
+ const results = [];
7482
+ for (const m of body.matchAll(CSS_VAR_RE)) {
7483
+ const name = m[1];
7484
+ const value = m[2]?.trim();
7485
+ if (name === void 0 || value === void 0 || value.length === 0) continue;
7486
+ if (value.startsWith("var(") || value.startsWith("calc(")) continue;
7487
+ results.push({ name, value });
7488
+ }
7489
+ return results;
7490
+ }
7491
+ function extractCSSCustomProperties(tokenSources) {
7492
+ const cssSources = tokenSources.filter(
7493
+ (s) => s.kind === "css-custom-properties" || s.kind === "tailwind-v4-theme"
7494
+ );
7495
+ if (cssSources.length === 0) return null;
7496
+ const tokens = {};
7497
+ const themes = {};
7498
+ let found = false;
7499
+ for (const source of cssSources) {
7500
+ try {
7501
+ if (source.path.includes("compiled") || source.path.includes(".min.")) continue;
7502
+ const raw = readFileSync(source.path, "utf-8");
7503
+ const blocks = parseScopedBlocks(raw);
7504
+ for (const block of blocks) {
7505
+ const vars = extractVarsFromBody(block.body);
7506
+ for (const { name, value } of vars) {
7507
+ const segments = name.split("-").filter(Boolean);
7508
+ if (segments.length === 0) continue;
7509
+ if (block.scope === "root" || block.scope === "theme") {
7510
+ const type = inferTokenType(value);
7511
+ setNestedToken(tokens, segments, value, type);
7512
+ found = true;
7513
+ } else {
7514
+ const themeName = block.scope;
7515
+ if (!themes[themeName]) themes[themeName] = {};
7516
+ const path = segments.join(".");
7517
+ themes[themeName][path] = value;
7518
+ found = true;
7519
+ }
7520
+ }
7521
+ }
7522
+ } catch {
7523
+ }
7524
+ }
7525
+ return found ? { tokens, themes } : null;
7526
+ }
7527
+ function registerTokensInit(tokensCmd) {
7528
+ tokensCmd.command("init").description(
7529
+ "Detect design-token sources in the project and generate a token file.\n\nDETECTED SOURCES:\n - Tailwind config (colors, spacing, fontFamily, borderRadius)\n - CSS custom properties (:root { --color-primary: #0070f3; })\n\nWill not overwrite an existing token file unless --force is passed.\n\nOUTPUT PATH RESOLUTION (in priority order):\n 1. --file <path> explicit override\n 2. tokens.file in reactscope.config.json\n 3. reactscope.tokens.json default (project root)\n\nExamples:\n scope tokens init\n scope tokens init --force\n scope tokens init --file tokens/brand.json\n scope tokens init --force --file custom-tokens.json"
7530
+ ).option("--file <path>", "Output path for the token file (overrides config)").option("--force", "Overwrite existing token file", false).action((opts) => {
7531
+ try {
7532
+ const outPath = resolveOutputPath(opts.file);
7533
+ if (existsSync(outPath) && !opts.force) {
7534
+ process.stderr.write(
7535
+ `Token file already exists at ${outPath}.
7536
+ Run with --force to overwrite.
7537
+ `
7538
+ );
7539
+ process.exit(1);
7540
+ }
7541
+ const rootDir = process.cwd();
7542
+ const detected = detectProject(rootDir);
7543
+ const tailwindTokens = extractTailwindTokens(detected.tokenSources);
7544
+ const cssResult = extractCSSCustomProperties(detected.tokenSources);
7545
+ const mergedTokens = {};
7546
+ const mergedThemes = {};
7547
+ if (tailwindTokens !== null) {
7548
+ Object.assign(mergedTokens, tailwindTokens);
7549
+ }
7550
+ if (cssResult !== null) {
7551
+ for (const [key, value] of Object.entries(cssResult.tokens)) {
7552
+ if (!(key in mergedTokens)) {
7553
+ mergedTokens[key] = value;
7554
+ }
7555
+ }
7556
+ for (const [themeName, overrides] of Object.entries(cssResult.themes)) {
7557
+ if (!mergedThemes[themeName]) mergedThemes[themeName] = {};
7558
+ Object.assign(mergedThemes[themeName], overrides);
7559
+ }
7560
+ }
7561
+ const tokenFile = {
7562
+ $schema: "https://raw.githubusercontent.com/FlatFilers/Scope/main/packages/tokens/schema.json",
7563
+ version: "1.0.0",
7564
+ meta: {
7565
+ name: "Design Tokens",
7566
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
7567
+ },
7568
+ tokens: mergedTokens
7569
+ };
7570
+ if (Object.keys(mergedThemes).length > 0) {
7571
+ tokenFile.themes = mergedThemes;
7572
+ }
7573
+ writeFileSync(outPath, `${JSON.stringify(tokenFile, null, 2)}
7574
+ `);
7575
+ const tokenGroupCount = Object.keys(mergedTokens).length;
7576
+ const themeNames = Object.keys(mergedThemes);
7577
+ if (detected.tokenSources.length > 0) {
7578
+ process.stdout.write("Detected token sources:\n");
7579
+ for (const source of detected.tokenSources) {
7580
+ process.stdout.write(` ${source.kind}: ${source.path}
7581
+ `);
7582
+ }
7583
+ process.stdout.write("\n");
7584
+ }
7585
+ if (tokenGroupCount > 0) {
7586
+ process.stdout.write(`Extracted ${tokenGroupCount} token group(s) \u2192 ${outPath}
7587
+ `);
7588
+ if (themeNames.length > 0) {
7589
+ for (const name of themeNames) {
7590
+ const count = Object.keys(mergedThemes[name] ?? {}).length;
7591
+ process.stdout.write(` theme "${name}": ${count} override(s)
7592
+ `);
7593
+ }
7594
+ }
7595
+ } else {
7596
+ process.stdout.write(
7597
+ `No token sources detected. Created empty token file \u2192 ${outPath}
7598
+ Add tokens manually or re-run after configuring a design system.
7599
+ `
7600
+ );
7601
+ }
7602
+ } catch (err) {
7603
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
7604
+ `);
7605
+ process.exit(1);
7606
+ }
7607
+ });
7608
+ }
6110
7609
  var DEFAULT_STYLES_PATH3 = ".reactscope/compliance-styles.json";
6111
7610
  var DEFAULT_MANIFEST_PATH = ".reactscope/manifest.json";
6112
7611
  var DEFAULT_OUTPUT_DIR2 = ".reactscope/previews";
6113
7612
  async function renderComponentWithCssOverride(filePath, componentName, cssOverride, vpWidth, vpHeight, timeoutMs) {
7613
+ const PAD = 16;
6114
7614
  const htmlHarness = await buildComponentHarness(
6115
7615
  filePath,
6116
7616
  componentName,
6117
7617
  {},
6118
7618
  // no props
6119
7619
  vpWidth,
6120
- cssOverride
7620
+ cssOverride,
6121
7621
  // injected as <style>
7622
+ void 0,
7623
+ PAD
6122
7624
  );
6123
7625
  const pool = new BrowserPool({
6124
7626
  size: { browsers: 1, pagesPerBrowser: 1 },
@@ -6139,7 +7641,6 @@ async function renderComponentWithCssOverride(filePath, componentName, cssOverri
6139
7641
  );
6140
7642
  const rootLocator = page.locator("[data-reactscope-root]");
6141
7643
  const bb = await rootLocator.boundingBox();
6142
- const PAD = 16;
6143
7644
  const MIN_W = 320;
6144
7645
  const MIN_H = 120;
6145
7646
  const clipX = Math.max(0, (bb?.x ?? 0) - PAD);
@@ -6328,8 +7829,8 @@ function registerPreview(tokensCmd) {
6328
7829
  }
6329
7830
 
6330
7831
  // src/tokens/commands.ts
6331
- var DEFAULT_TOKEN_FILE2 = "reactscope.tokens.json";
6332
- var CONFIG_FILE2 = "reactscope.config.json";
7832
+ var DEFAULT_TOKEN_FILE3 = "reactscope.tokens.json";
7833
+ var CONFIG_FILE3 = "reactscope.config.json";
6333
7834
  function isTTY2() {
6334
7835
  return process.stdout.isTTY === true;
6335
7836
  }
@@ -6351,7 +7852,7 @@ function resolveTokenFilePath(fileFlag) {
6351
7852
  if (fileFlag !== void 0) {
6352
7853
  return resolve(process.cwd(), fileFlag);
6353
7854
  }
6354
- const configPath = resolve(process.cwd(), CONFIG_FILE2);
7855
+ const configPath = resolve(process.cwd(), CONFIG_FILE3);
6355
7856
  if (existsSync(configPath)) {
6356
7857
  try {
6357
7858
  const raw = readFileSync(configPath, "utf-8");
@@ -6363,7 +7864,7 @@ function resolveTokenFilePath(fileFlag) {
6363
7864
  } catch {
6364
7865
  }
6365
7866
  }
6366
- return resolve(process.cwd(), DEFAULT_TOKEN_FILE2);
7867
+ return resolve(process.cwd(), DEFAULT_TOKEN_FILE3);
6367
7868
  }
6368
7869
  function loadTokens(absPath) {
6369
7870
  if (!existsSync(absPath)) {
@@ -6692,6 +8193,7 @@ function createTokensCommand() {
6692
8193
  const tokensCmd = new Command("tokens").description(
6693
8194
  'Query, validate, and export design tokens from reactscope.tokens.json.\n\nTOKEN FILE RESOLUTION (in priority order):\n 1. --file <path> explicit override\n 2. tokens.file in reactscope.config.json\n 3. reactscope.tokens.json default (project root)\n\nTOKEN FILE FORMAT (reactscope.tokens.json):\n Nested JSON. Each leaf is a token with { value, type } or just a raw value.\n Paths use dot notation: color.primary.500, spacing.4, font.size.base\n References use {path.to.other.token} syntax.\n Themes: top-level "themes" key with named override maps.\n\nTOKEN TYPES: color | spacing | typography | shadow | radius | opacity | other\n\nExamples:\n scope tokens validate\n scope tokens list color\n scope tokens get color.primary.500\n scope tokens compliance\n scope tokens export --format css --out tokens.css'
6694
8195
  );
8196
+ registerTokensInit(tokensCmd);
6695
8197
  registerGet2(tokensCmd);
6696
8198
  registerList2(tokensCmd);
6697
8199
  registerSearch(tokensCmd);
@@ -6707,7 +8209,7 @@ function createTokensCommand() {
6707
8209
  // src/program.ts
6708
8210
  function createProgram(options = {}) {
6709
8211
  const program = new Command("scope").version(options.version ?? "0.1.0").description(
6710
- 'Scope \u2014 static analysis + visual rendering toolkit for React component libraries.\n\nScope answers questions about React codebases \u2014 structure, props, visual output,\ndesign token compliance \u2014 without running the full application.\n\nQUICKSTART (new project):\n scope init # detect config, scaffold reactscope.config.json\n scope doctor # verify setup before doing anything else\n scope manifest generate # scan source and build component manifest\n scope render all # screenshot every component\n scope site build # build HTML gallery\n scope site serve # open at http://localhost:3000\n\nQUICKSTART (existing project / CI):\n scope ci # manifest \u2192 render \u2192 compliance \u2192 regression in one step\n\nAGENT BOOTSTRAP:\n scope get-skill # print SKILL.md to stdout \u2014 pipe into agent context\n\nCONFIG FILE: reactscope.config.json (created by `scope init`)\n components.include glob patterns for component files (e.g. "src/**/*.tsx")\n components.wrappers providers and globalCSS to wrap every render\n render.viewport default viewport width\xD7height in px\n tokens.file path to reactscope.tokens.json (default)\n output.dir output root (default: .reactscope/)\n ci.complianceThreshold fail threshold for `scope ci` (default: 0.90)\n\nOUTPUT DIRECTORY: .reactscope/\n manifest.json component registry \u2014 updated by `scope manifest generate`\n renders/<Name>/ PNGs + render.json per component\n compliance-styles.json computed-style map for token matching\n site/ static HTML gallery (built by `scope site build`)\n\nRun `scope <command> --help` for detailed flags and examples.'
8212
+ 'Scope \u2014 static analysis + visual rendering toolkit for React component libraries.\n\nScope answers questions about React codebases \u2014 structure, props, visual output,\ndesign token compliance \u2014 without running the full application.\n\nAGENT QUICKSTART:\n scope get-skill > /tmp/scope-skill.md\n scope init --yes\n scope doctor --json\n scope manifest list --format json\n scope render all --format json --output-dir .reactscope/renders\n scope site build --output .reactscope/site\n scope instrument profile http://localhost:5173\n\nCI QUICKSTART:\n scope ci --json --output .reactscope/ci-result.json\n\nCONFIG FILE: reactscope.config.json (created by `scope init`)\n components.include glob patterns for component files (e.g. "src/**/*.tsx")\n components.wrappers providers and globalCSS to wrap every render\n render.viewport default viewport width\xD7height in px\n tokens.file path to reactscope.tokens.json (default)\n output.dir output root (default: .reactscope/)\n ci.complianceThreshold fail threshold for `scope ci` (default: 0.90)\n\nOUTPUT DIRECTORY: .reactscope/\n manifest.json component registry \u2014 updated by `scope manifest generate`\n renders/<Name>/ PNGs + render.json per component\n compliance-styles.json computed-style map for token matching\n site/ static HTML gallery (built by `scope site build`)\n\nRun `scope <command> --help` for detailed flags and examples.'
6711
8213
  );
6712
8214
  program.command("capture <url>").description(
6713
8215
  "Capture the live React component tree from a running app and emit it as JSON.\nRequires a running dev server at the given URL (e.g. http://localhost:5173).\n\nExamples:\n scope capture http://localhost:5173\n scope capture http://localhost:5173 -o report.json --pretty\n scope capture http://localhost:5173 --timeout 15000 --wait 500"