@agent-scope/cli 1.19.0 → 1.20.1

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.cjs CHANGED
@@ -8,6 +8,7 @@ var tokens = require('@agent-scope/tokens');
8
8
  var commander = require('commander');
9
9
  var esbuild2 = require('esbuild');
10
10
  var module$1 = require('module');
11
+ var promises = require('fs/promises');
11
12
  var readline = require('readline');
12
13
  var playwright = require('@agent-scope/playwright');
13
14
  var playwright$1 = require('playwright');
@@ -37,10 +38,15 @@ function _interopNamespace(e) {
37
38
  var esbuild2__namespace = /*#__PURE__*/_interopNamespace(esbuild2);
38
39
  var readline__namespace = /*#__PURE__*/_interopNamespace(readline);
39
40
 
40
- // src/ci/commands.ts
41
- async function buildComponentHarness(filePath, componentName, props, viewportWidth, projectCss, wrapperScript) {
41
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
42
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
43
+ }) : x)(function(x) {
44
+ if (typeof require !== "undefined") return require.apply(this, arguments);
45
+ throw Error('Dynamic require of "' + x + '" is not supported');
46
+ });
47
+ async function buildComponentHarness(filePath, componentName, props, viewportWidth, projectCss, wrapperScript, screenshotPadding = 0) {
42
48
  const bundledScript = await bundleComponentToIIFE(filePath, componentName, props);
43
- return wrapInHtml(bundledScript, viewportWidth, projectCss, wrapperScript);
49
+ return wrapInHtml(bundledScript, viewportWidth, projectCss, wrapperScript, screenshotPadding);
44
50
  }
45
51
  async function bundleComponentToIIFE(filePath, componentName, props) {
46
52
  const propsJson = JSON.stringify(props).replace(/<\/script>/gi, "<\\/script>");
@@ -146,7 +152,7 @@ ${msg}`);
146
152
  }
147
153
  return outputFile.text;
148
154
  }
149
- function wrapInHtml(bundledScript, viewportWidth, projectCss, wrapperScript) {
155
+ function wrapInHtml(bundledScript, viewportWidth, projectCss, wrapperScript, screenshotPadding = 0) {
150
156
  const projectStyleBlock = projectCss != null && projectCss.length > 0 ? `<style id="scope-project-css">
151
157
  ${projectCss.replace(/<\/style>/gi, "<\\/style>")}
152
158
  </style>` : "";
@@ -156,10 +162,17 @@ ${projectCss.replace(/<\/style>/gi, "<\\/style>")}
156
162
  <head>
157
163
  <meta charset="UTF-8" />
158
164
  <meta name="viewport" content="width=${viewportWidth}, initial-scale=1.0" />
165
+ <script>
166
+ // Reset globals that persist on window across page.setContent() calls
167
+ // (document.open/write/close clears the DOM but NOT the JS global scope)
168
+ window.__SCOPE_WRAPPER__ = null;
169
+ window.__SCOPE_RENDER_COMPLETE__ = false;
170
+ window.__SCOPE_RENDER_ERROR__ = null;
171
+ </script>
159
172
  <style>
160
173
  *, *::before, *::after { box-sizing: border-box; }
161
174
  html, body { margin: 0; padding: 0; background: #fff; font-family: system-ui, sans-serif; }
162
- #scope-root { display: inline-block; min-width: 1px; min-height: 1px; }
175
+ #scope-root { display: inline-block; min-width: 1px; min-height: 1px; margin: ${screenshotPadding}px; }
163
176
  </style>
164
177
  ${projectStyleBlock}
165
178
  </head>
@@ -191,13 +204,15 @@ function buildTable(headers, rows) {
191
204
  }
192
205
  function formatListTable(rows) {
193
206
  if (rows.length === 0) return "No components found.";
194
- const headers = ["NAME", "FILE", "COMPLEXITY", "HOOKS", "CONTEXTS"];
207
+ const headers = ["NAME", "FILE", "COMPLEXITY", "HOOKS", "CONTEXTS", "COLLECTION", "INTERNAL"];
195
208
  const tableRows = rows.map((r) => [
196
209
  r.name,
197
210
  r.file,
198
211
  r.complexityClass,
199
212
  String(r.hookCount),
200
- String(r.contextCount)
213
+ String(r.contextCount),
214
+ r.collection ?? "\u2014",
215
+ r.internal ? "yes" : "no"
201
216
  ]);
202
217
  return buildTable(headers, tableRows);
203
218
  }
@@ -228,6 +243,8 @@ function formatGetTable(name, descriptor) {
228
243
  ` Composes: ${descriptor.composes.join(", ") || "none"}`,
229
244
  ` Composed By: ${descriptor.composedBy.join(", ") || "none"}`,
230
245
  ` Side Effects: ${formatSideEffects(descriptor.sideEffects)}`,
246
+ ` Collection: ${descriptor.collection ?? "\u2014"}`,
247
+ ` Internal: ${descriptor.internal}`,
231
248
  "",
232
249
  ` Props (${propNames.length}):`
233
250
  ];
@@ -250,8 +267,16 @@ function formatGetJson(name, descriptor) {
250
267
  }
251
268
  function formatQueryTable(rows, queryDesc) {
252
269
  if (rows.length === 0) return `No components match: ${queryDesc}`;
253
- const headers = ["NAME", "FILE", "COMPLEXITY", "HOOKS", "CONTEXTS"];
254
- const tableRows = rows.map((r) => [r.name, r.file, r.complexityClass, r.hooks, r.contexts]);
270
+ const headers = ["NAME", "FILE", "COMPLEXITY", "HOOKS", "CONTEXTS", "COLLECTION", "INTERNAL"];
271
+ const tableRows = rows.map((r) => [
272
+ r.name,
273
+ r.file,
274
+ r.complexityClass,
275
+ r.hooks,
276
+ r.contexts,
277
+ r.collection ?? "\u2014",
278
+ r.internal ? "yes" : "no"
279
+ ]);
255
280
  return `Query: ${queryDesc}
256
281
 
257
282
  ${buildTable(headers, tableRows)}`;
@@ -529,22 +554,22 @@ async function getTailwindCompiler(cwd) {
529
554
  from: entryPath,
530
555
  loadStylesheet
531
556
  });
532
- const build3 = result.build.bind(result);
533
- compilerCache = { cwd, build: build3 };
534
- return build3;
557
+ const build4 = result.build.bind(result);
558
+ compilerCache = { cwd, build: build4 };
559
+ return build4;
535
560
  }
536
561
  async function getCompiledCssForClasses(cwd, classes) {
537
- const build3 = await getTailwindCompiler(cwd);
538
- if (build3 === null) return null;
562
+ const build4 = await getTailwindCompiler(cwd);
563
+ if (build4 === null) return null;
539
564
  const deduped = [...new Set(classes)].filter(Boolean);
540
565
  if (deduped.length === 0) return null;
541
- return build3(deduped);
566
+ return build4(deduped);
542
567
  }
543
568
  async function compileGlobalCssFile(cssFilePath, cwd) {
544
- const { existsSync: existsSync16, readFileSync: readFileSync14 } = await import('fs');
569
+ const { existsSync: existsSync18, readFileSync: readFileSync18 } = await import('fs');
545
570
  const { createRequire: createRequire3 } = await import('module');
546
- if (!existsSync16(cssFilePath)) return null;
547
- const raw = readFileSync14(cssFilePath, "utf-8");
571
+ if (!existsSync18(cssFilePath)) return null;
572
+ const raw = readFileSync18(cssFilePath, "utf-8");
548
573
  const needsCompile = /@tailwind|@import\s+['"]tailwindcss/.test(raw);
549
574
  if (!needsCompile) {
550
575
  return raw;
@@ -627,8 +652,17 @@ async function shutdownPool() {
627
652
  }
628
653
  }
629
654
  async function renderComponent(filePath, componentName, props, viewportWidth, viewportHeight) {
655
+ const PAD = 24;
630
656
  const pool = await getPool(viewportWidth, viewportHeight);
631
- const htmlHarness = await buildComponentHarness(filePath, componentName, props, viewportWidth);
657
+ const htmlHarness = await buildComponentHarness(
658
+ filePath,
659
+ componentName,
660
+ props,
661
+ viewportWidth,
662
+ void 0,
663
+ void 0,
664
+ PAD
665
+ );
632
666
  const slot = await pool.acquire();
633
667
  const { page } = slot;
634
668
  try {
@@ -668,7 +702,6 @@ async function renderComponent(filePath, componentName, props, viewportWidth, vi
668
702
  `Component "${componentName}" rendered with zero bounding box \u2014 it may be invisible or not mounted`
669
703
  );
670
704
  }
671
- const PAD = 24;
672
705
  const MIN_W = 320;
673
706
  const MIN_H = 200;
674
707
  const clipX = Math.max(0, boundingBox.x - PAD);
@@ -970,7 +1003,7 @@ function parseChecks(raw) {
970
1003
  }
971
1004
  function createCiCommand() {
972
1005
  return new commander.Command("ci").description(
973
- "Run a non-interactive CI pipeline (manifest -> render -> compliance -> regression) with exit codes"
1006
+ "Run the full Scope pipeline non-interactively and exit with a structured code.\n\nPIPELINE STEPS (in order):\n 1. manifest generate scan source, build .reactscope/manifest.json\n 2. render all screenshot every component\n 3. tokens compliance score on-token CSS coverage\n 4. visual regression pixel diff against --baseline (if provided)\n\nCHECKS (--checks flag, comma-separated):\n compliance token coverage below --threshold \u2192 exit 1\n a11y accessibility violations \u2192 exit 2\n console-errors console.error during render \u2192 exit 3\n visual-regression pixel diff against baseline \u2192 exit 4\n (render failures always \u2192 exit 5 regardless of --checks)\n\nEXIT CODES:\n 0 all checks passed\n 1 compliance below threshold\n 2 accessibility violations\n 3 console errors during render\n 4 visual regression detected\n 5 component render failures\n\nExamples:\n scope ci\n scope ci --baseline .reactscope/baseline --threshold 0.95\n scope ci --checks compliance,a11y --json -o ci-result.json\n scope ci --viewport 1280x720"
974
1007
  ).option(
975
1008
  "-b, --baseline <dir>",
976
1009
  "Baseline directory for visual regression comparison (omit to skip)"
@@ -1013,6 +1046,124 @@ function createCiCommand() {
1013
1046
  }
1014
1047
  );
1015
1048
  }
1049
+ var PLAYWRIGHT_BROWSER_HINTS = [
1050
+ "executable doesn't exist",
1051
+ "browserType.launch",
1052
+ "looks like playwright was just installed or updated",
1053
+ "please run the following command to download new browsers",
1054
+ "could not find chromium"
1055
+ ];
1056
+ var MISSING_DEPENDENCY_HINTS = ["could not resolve", "cannot find module", "module not found"];
1057
+ var REQUIRED_HARNESS_DEPENDENCIES = ["react", "react-dom", "react/jsx-runtime"];
1058
+ function getEffectivePlaywrightBrowsersPath() {
1059
+ const value = process.env.PLAYWRIGHT_BROWSERS_PATH;
1060
+ return typeof value === "string" && value.length > 0 ? value : null;
1061
+ }
1062
+ function getPlaywrightBrowserRemediation(status) {
1063
+ const effectivePath = status?.effectiveBrowserPath ?? getEffectivePlaywrightBrowsersPath();
1064
+ if (effectivePath !== null) {
1065
+ const pathProblem = status?.browserPathExists === false ? "missing" : status?.browserPathWritable === false ? "unwritable" : "unavailable";
1066
+ 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\`.`;
1067
+ }
1068
+ return "Run `bunx playwright install chromium` in this sandbox, then retry the Scope command.";
1069
+ }
1070
+ function diagnoseScopeError(error, cwd = process.cwd()) {
1071
+ const message = error instanceof Error ? error.message : String(error);
1072
+ const normalized = message.toLowerCase();
1073
+ if (PLAYWRIGHT_BROWSER_HINTS.some((hint) => normalized.includes(hint))) {
1074
+ const browserPath = extractPlaywrightBrowserPath(message);
1075
+ const browserPathHint = browserPath === null ? "" : ` Scope tried to launch Chromium from ${browserPath}.`;
1076
+ return {
1077
+ code: "PLAYWRIGHT_BROWSERS_MISSING",
1078
+ message: "Playwright Chromium is unavailable for Scope browser rendering.",
1079
+ recovery: getPlaywrightBrowserRemediation() + browserPathHint + " Use `scope doctor --json` to verify the browser check passes before rerunning render/site/instrument."
1080
+ };
1081
+ }
1082
+ if (MISSING_DEPENDENCY_HINTS.some((hint) => normalized.includes(hint))) {
1083
+ const packageManager = detectPackageManager(cwd);
1084
+ return {
1085
+ code: "TARGET_PROJECT_DEPENDENCIES_MISSING",
1086
+ message: "The target project's dependencies appear to be missing or incomplete.",
1087
+ recovery: `Run \`${packageManager} install\` in ${cwd}, then rerun \`scope doctor\` and retry the Scope command.`
1088
+ };
1089
+ }
1090
+ return null;
1091
+ }
1092
+ function formatScopeDiagnostic(error, cwd = process.cwd()) {
1093
+ const message = error instanceof Error ? error.message : String(error);
1094
+ const diagnostic = diagnoseScopeError(error, cwd);
1095
+ if (diagnostic === null) return `Error: ${message}`;
1096
+ return `Error [${diagnostic.code}]: ${diagnostic.message}
1097
+ Recovery: ${diagnostic.recovery}
1098
+ Cause: ${message}`;
1099
+ }
1100
+ async function getPlaywrightBrowserStatus(cwd = process.cwd()) {
1101
+ const effectiveBrowserPath = getEffectivePlaywrightBrowsersPath();
1102
+ const executablePath = getPlaywrightChromiumExecutablePath(cwd);
1103
+ const available = executablePath !== null && fs.existsSync(executablePath);
1104
+ const browserPathExists = effectiveBrowserPath === null ? null : fs.existsSync(effectiveBrowserPath);
1105
+ const browserPathWritable = effectiveBrowserPath === null ? null : await isWritableBrowserPath(effectiveBrowserPath);
1106
+ return {
1107
+ effectiveBrowserPath,
1108
+ executablePath,
1109
+ available,
1110
+ browserPathExists,
1111
+ browserPathWritable,
1112
+ remediation: getPlaywrightBrowserRemediation({
1113
+ effectiveBrowserPath,
1114
+ browserPathExists,
1115
+ browserPathWritable
1116
+ })
1117
+ };
1118
+ }
1119
+ function getPlaywrightChromiumExecutablePath(cwd = process.cwd()) {
1120
+ try {
1121
+ const packageJsonPath = __require.resolve("playwright/package.json", { paths: [cwd] });
1122
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
1123
+ if (!packageJson.version) return null;
1124
+ const playwrightPath = __require.resolve("playwright", { paths: [cwd] });
1125
+ const { chromium: chromium5 } = __require(playwrightPath);
1126
+ const executablePath = chromium5?.executablePath?.();
1127
+ if (typeof executablePath !== "string" || executablePath.length === 0) return null;
1128
+ return executablePath;
1129
+ } catch {
1130
+ return null;
1131
+ }
1132
+ }
1133
+ async function isWritableBrowserPath(browserPath) {
1134
+ const candidate = fs.existsSync(browserPath) ? browserPath : path.dirname(browserPath);
1135
+ try {
1136
+ await promises.access(candidate, fs.constants.W_OK);
1137
+ return true;
1138
+ } catch {
1139
+ return false;
1140
+ }
1141
+ }
1142
+ function detectPackageManager(cwd = process.cwd()) {
1143
+ if (fs.existsSync(path.join(cwd, "bun.lock")) || fs.existsSync(path.join(cwd, "bun.lockb"))) return "bun";
1144
+ if (fs.existsSync(path.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
1145
+ if (fs.existsSync(path.join(cwd, "yarn.lock"))) return "yarn";
1146
+ return "npm";
1147
+ }
1148
+ function hasLikelyInstalledDependencies(cwd = process.cwd()) {
1149
+ return fs.existsSync(path.join(cwd, "node_modules"));
1150
+ }
1151
+ function getMissingHarnessDependencies(cwd = process.cwd()) {
1152
+ return REQUIRED_HARNESS_DEPENDENCIES.filter((dependencyName) => {
1153
+ try {
1154
+ __require.resolve(dependencyName, { paths: [cwd] });
1155
+ return false;
1156
+ } catch {
1157
+ return true;
1158
+ }
1159
+ });
1160
+ }
1161
+ function extractPlaywrightBrowserPath(message) {
1162
+ const match = message.match(/Executable doesn't exist at\s+([^\n]+)/i);
1163
+ return match?.[1]?.trim() ?? null;
1164
+ }
1165
+
1166
+ // src/doctor-commands.ts
1016
1167
  function collectSourceFiles(dir) {
1017
1168
  if (!fs.existsSync(dir)) return [];
1018
1169
  const results = [];
@@ -1026,13 +1177,43 @@ function collectSourceFiles(dir) {
1026
1177
  }
1027
1178
  return results;
1028
1179
  }
1180
+ var TAILWIND_CONFIG_FILES = [
1181
+ "tailwind.config.js",
1182
+ "tailwind.config.cjs",
1183
+ "tailwind.config.mjs",
1184
+ "tailwind.config.ts",
1185
+ "postcss.config.js",
1186
+ "postcss.config.cjs",
1187
+ "postcss.config.mjs",
1188
+ "postcss.config.ts"
1189
+ ];
1190
+ function hasTailwindSetup(cwd) {
1191
+ if (TAILWIND_CONFIG_FILES.some((file) => fs.existsSync(path.resolve(cwd, file)))) {
1192
+ return true;
1193
+ }
1194
+ const packageJsonPath = path.resolve(cwd, "package.json");
1195
+ if (!fs.existsSync(packageJsonPath)) return false;
1196
+ try {
1197
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
1198
+ return [pkg.dependencies, pkg.devDependencies].some(
1199
+ (deps) => deps && Object.keys(deps).some(
1200
+ (name) => name === "tailwindcss" || name.startsWith("@tailwindcss/")
1201
+ )
1202
+ );
1203
+ } catch {
1204
+ return false;
1205
+ }
1206
+ }
1207
+ function getPlaywrightInstallCommand(effectiveBrowserPath) {
1208
+ return effectiveBrowserPath === null ? "bunx playwright install chromium" : `PLAYWRIGHT_BROWSERS_PATH=${effectiveBrowserPath} bunx playwright install chromium`;
1209
+ }
1029
1210
  function checkConfig(cwd) {
1030
1211
  const configPath = path.resolve(cwd, "reactscope.config.json");
1031
1212
  if (!fs.existsSync(configPath)) {
1032
1213
  return {
1033
1214
  name: "config",
1034
1215
  status: "error",
1035
- message: "reactscope.config.json not found \u2014 run `scope init`"
1216
+ message: "reactscope.config.json not found \u2014 run `scope init` in the target project root"
1036
1217
  };
1037
1218
  }
1038
1219
  try {
@@ -1080,6 +1261,13 @@ function checkGlobalCss(cwd) {
1080
1261
  }
1081
1262
  }
1082
1263
  if (globalCss.length === 0) {
1264
+ if (!hasTailwindSetup(cwd)) {
1265
+ return {
1266
+ name: "globalCSS",
1267
+ status: "ok",
1268
+ message: "No globalCSS configured \u2014 skipping CSS injection for this non-Tailwind project"
1269
+ };
1270
+ }
1083
1271
  return {
1084
1272
  name: "globalCSS",
1085
1273
  status: "warn",
@@ -1106,7 +1294,7 @@ function checkManifest(cwd) {
1106
1294
  return {
1107
1295
  name: "manifest",
1108
1296
  status: "warn",
1109
- message: "Manifest not found \u2014 run `scope manifest generate`"
1297
+ message: "Manifest not found \u2014 run `scope manifest generate` in the target project root"
1110
1298
  };
1111
1299
  }
1112
1300
  const manifestMtime = fs.statSync(manifestPath).mtimeMs;
@@ -1123,23 +1311,105 @@ function checkManifest(cwd) {
1123
1311
  return { name: "manifest", status: "ok", message: "Manifest present and up to date" };
1124
1312
  }
1125
1313
  var ICONS = { ok: "\u2713", warn: "!", error: "\u2717" };
1314
+ function checkDependencies(cwd) {
1315
+ const packageManager = detectPackageManager(cwd);
1316
+ if (!hasLikelyInstalledDependencies(cwd)) {
1317
+ return {
1318
+ name: "dependencies",
1319
+ status: "error",
1320
+ remediationCode: "TARGET_PROJECT_DEPENDENCIES_MISSING",
1321
+ fixCommand: `${packageManager} install`,
1322
+ message: `node_modules not found \u2014 run \`${packageManager} install\` in ${cwd} before render/site/instrument`
1323
+ };
1324
+ }
1325
+ const missingHarnessDependencies = getMissingHarnessDependencies(cwd);
1326
+ if (missingHarnessDependencies.length > 0) {
1327
+ return {
1328
+ name: "dependencies",
1329
+ status: "error",
1330
+ remediationCode: "TARGET_PROJECT_HARNESS_DEPENDENCIES_MISSING",
1331
+ fixCommand: `${packageManager} install`,
1332
+ message: `Missing React harness dependencies: ${missingHarnessDependencies.join(", ")}. Run \`${packageManager} install\` in ${cwd}, then retry render/site/instrument.`
1333
+ };
1334
+ }
1335
+ return {
1336
+ name: "dependencies",
1337
+ status: "ok",
1338
+ message: "node_modules and React harness dependencies present"
1339
+ };
1340
+ }
1341
+ async function checkPlaywright(cwd) {
1342
+ const status = await getPlaywrightBrowserStatus(cwd);
1343
+ const pathDetails = status.effectiveBrowserPath === null ? "PLAYWRIGHT_BROWSERS_PATH is unset" : `PLAYWRIGHT_BROWSERS_PATH=${status.effectiveBrowserPath}; exists=${status.browserPathExists}; writable=${status.browserPathWritable}`;
1344
+ if (status.available) {
1345
+ return {
1346
+ name: "playwright",
1347
+ status: "ok",
1348
+ message: `Playwright package available (${pathDetails})`
1349
+ };
1350
+ }
1351
+ return {
1352
+ name: "playwright",
1353
+ status: "error",
1354
+ remediationCode: "PLAYWRIGHT_BROWSERS_MISSING",
1355
+ fixCommand: getPlaywrightInstallCommand(status.effectiveBrowserPath),
1356
+ message: `Playwright Chromium unavailable (${pathDetails}) \u2014 ${status.remediation}`
1357
+ };
1358
+ }
1359
+ function collectFixCommands(checks) {
1360
+ return checks.filter((check) => check.status === "error" && check.fixCommand !== void 0).map((check) => check.fixCommand).filter((command, index, commands) => commands.indexOf(command) === index);
1361
+ }
1126
1362
  function formatCheck(check) {
1127
1363
  return ` [${ICONS[check.status]}] ${check.name.padEnd(12)} ${check.message}`;
1128
1364
  }
1129
1365
  function createDoctorCommand() {
1130
- return new commander.Command("doctor").description("Check the health of your Scope setup (config, tokens, CSS, manifest)").option("--json", "Emit structured JSON output", false).action((opts) => {
1366
+ return new commander.Command("doctor").description(
1367
+ `Verify your Scope project setup before running other commands.
1368
+
1369
+ CHECKS PERFORMED:
1370
+ config reactscope.config.json exists and is valid JSON
1371
+ tokens reactscope.tokens.json exists and passes validation
1372
+ css globalCSS files referenced in config actually exist
1373
+ manifest .reactscope/manifest.json exists and is not stale
1374
+ dependencies node_modules exists in the target project root
1375
+ playwright Playwright browser runtime is available
1376
+ (stale = source files modified after last generate)
1377
+
1378
+ STATUS LEVELS: ok | warn | error
1379
+
1380
+ Run this first whenever renders fail or produce unexpected output.
1381
+
1382
+ Examples:
1383
+ scope doctor
1384
+ scope doctor --json
1385
+ scope doctor --print-fix-commands
1386
+ scope doctor --json | jq '.checks[] | select(.status == "error")'`
1387
+ ).option("--json", "Emit structured JSON output", false).option(
1388
+ "--print-fix-commands",
1389
+ "Print deduplicated shell remediation commands for failing checks",
1390
+ false
1391
+ ).action(async (opts) => {
1131
1392
  const cwd = process.cwd();
1132
1393
  const checks = [
1133
1394
  checkConfig(cwd),
1134
1395
  checkTokens(cwd),
1135
1396
  checkGlobalCss(cwd),
1136
- checkManifest(cwd)
1397
+ checkManifest(cwd),
1398
+ checkDependencies(cwd),
1399
+ await checkPlaywright(cwd)
1137
1400
  ];
1138
1401
  const errors = checks.filter((c) => c.status === "error").length;
1139
1402
  const warnings = checks.filter((c) => c.status === "warn").length;
1403
+ const fixCommands = collectFixCommands(checks);
1404
+ if (opts.printFixCommands) {
1405
+ process.stdout.write(`${JSON.stringify({ cwd, fixCommands }, null, 2)}
1406
+ `);
1407
+ if (errors > 0) process.exit(1);
1408
+ return;
1409
+ }
1140
1410
  if (opts.json) {
1141
1411
  process.stdout.write(
1142
- `${JSON.stringify({ passed: checks.length - errors - warnings, warnings, errors, checks }, null, 2)}
1412
+ `${JSON.stringify({ passed: checks.length - errors - warnings, warnings, errors, fixCommands, checks }, null, 2)}
1143
1413
  `
1144
1414
  );
1145
1415
  if (errors > 0) process.exit(1);
@@ -1164,6 +1434,23 @@ function createDoctorCommand() {
1164
1434
  }
1165
1435
  });
1166
1436
  }
1437
+
1438
+ // src/skill-content.ts
1439
+ 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';
1440
+
1441
+ // src/get-skill-command.ts
1442
+ function createGetSkillCommand() {
1443
+ return new commander.Command("get-skill").description(
1444
+ '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'
1445
+ ).option("--json", "Wrap output in JSON { skill: string } instead of raw markdown").action((opts) => {
1446
+ if (opts.json) {
1447
+ process.stdout.write(`${JSON.stringify({ skill: SKILL_CONTENT }, null, 2)}
1448
+ `);
1449
+ } else {
1450
+ process.stdout.write(SKILL_CONTENT);
1451
+ }
1452
+ });
1453
+ }
1167
1454
  function hasConfigFile(dir, stem) {
1168
1455
  if (!fs.existsSync(dir)) return false;
1169
1456
  try {
@@ -1187,7 +1474,7 @@ function detectFramework(rootDir, packageDeps) {
1187
1474
  if ("react-scripts" in packageDeps) return "cra";
1188
1475
  return "unknown";
1189
1476
  }
1190
- function detectPackageManager(rootDir) {
1477
+ function detectPackageManager2(rootDir) {
1191
1478
  if (fs.existsSync(path.join(rootDir, "bun.lock"))) return "bun";
1192
1479
  if (fs.existsSync(path.join(rootDir, "yarn.lock"))) return "yarn";
1193
1480
  if (fs.existsSync(path.join(rootDir, "pnpm-lock.yaml"))) return "pnpm";
@@ -1263,6 +1550,31 @@ var TAILWIND_STEMS = ["tailwind.config"];
1263
1550
  var CSS_EXTS = [".css", ".scss", ".sass", ".less"];
1264
1551
  var THEME_SUFFIXES = [".theme.ts", ".theme.js", ".theme.tsx"];
1265
1552
  var CSS_CUSTOM_PROPS_RE = /:root\s*\{[^}]*--[a-zA-Z]/;
1553
+ var TAILWIND_V4_THEME_RE = /@theme\s*(?:inline\s*)?\{[^}]*--[a-zA-Z]/;
1554
+ var MAX_SCAN_DEPTH = 4;
1555
+ var SKIP_CSS_NAMES = ["compiled", ".min."];
1556
+ function collectCSSFiles(dir, depth) {
1557
+ if (depth > MAX_SCAN_DEPTH) return [];
1558
+ const results = [];
1559
+ try {
1560
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
1561
+ for (const entry of entries) {
1562
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".next") {
1563
+ continue;
1564
+ }
1565
+ const full = path.join(dir, entry.name);
1566
+ if (entry.isFile() && CSS_EXTS.some((x) => entry.name.endsWith(x))) {
1567
+ if (!SKIP_CSS_NAMES.some((skip) => entry.name.includes(skip))) {
1568
+ results.push(full);
1569
+ }
1570
+ } else if (entry.isDirectory()) {
1571
+ results.push(...collectCSSFiles(full, depth + 1));
1572
+ }
1573
+ }
1574
+ } catch {
1575
+ }
1576
+ return results;
1577
+ }
1266
1578
  function detectTokenSources(rootDir) {
1267
1579
  const sources = [];
1268
1580
  for (const stem of TAILWIND_STEMS) {
@@ -1278,32 +1590,53 @@ function detectTokenSources(rootDir) {
1278
1590
  }
1279
1591
  }
1280
1592
  const srcDir = path.join(rootDir, "src");
1281
- const dirsToScan = fs.existsSync(srcDir) ? [srcDir] : [];
1282
- for (const scanDir of dirsToScan) {
1283
- try {
1284
- const entries = fs.readdirSync(scanDir, { withFileTypes: true });
1285
- for (const entry of entries) {
1286
- if (entry.isFile() && CSS_EXTS.some((x) => entry.name.endsWith(x))) {
1287
- const filePath = path.join(scanDir, entry.name);
1288
- const content = readSafe(filePath);
1289
- if (content !== null && CSS_CUSTOM_PROPS_RE.test(content)) {
1290
- sources.push({ kind: "css-custom-properties", path: filePath });
1291
- }
1292
- }
1593
+ if (fs.existsSync(srcDir)) {
1594
+ const cssFiles = collectCSSFiles(srcDir, 0);
1595
+ const seen = /* @__PURE__ */ new Set();
1596
+ for (const filePath of cssFiles) {
1597
+ const content = readSafe(filePath);
1598
+ if (content === null) continue;
1599
+ if (TAILWIND_V4_THEME_RE.test(content) && !seen.has(filePath)) {
1600
+ sources.push({ kind: "tailwind-v4-theme", path: filePath });
1601
+ seen.add(filePath);
1602
+ }
1603
+ if (CSS_CUSTOM_PROPS_RE.test(content) && !seen.has(filePath)) {
1604
+ sources.push({ kind: "css-custom-properties", path: filePath });
1605
+ seen.add(filePath);
1606
+ }
1607
+ }
1608
+ }
1609
+ for (const tokenDir of ["tokens", "styles", "theme"]) {
1610
+ const dir = path.join(rootDir, tokenDir);
1611
+ if (!fs.existsSync(dir)) continue;
1612
+ const cssFiles = collectCSSFiles(dir, 0);
1613
+ for (const filePath of cssFiles) {
1614
+ const content = readSafe(filePath);
1615
+ if (content === null) continue;
1616
+ if (TAILWIND_V4_THEME_RE.test(content)) {
1617
+ sources.push({ kind: "tailwind-v4-theme", path: filePath });
1618
+ } else if (CSS_CUSTOM_PROPS_RE.test(content)) {
1619
+ sources.push({ kind: "css-custom-properties", path: filePath });
1293
1620
  }
1294
- } catch {
1295
1621
  }
1296
1622
  }
1297
1623
  if (fs.existsSync(srcDir)) {
1298
- try {
1299
- const entries = fs.readdirSync(srcDir);
1300
- for (const entry of entries) {
1301
- if (THEME_SUFFIXES.some((s) => entry.endsWith(s))) {
1302
- sources.push({ kind: "theme-file", path: path.join(srcDir, entry) });
1624
+ const scanThemeFiles = (dir, depth) => {
1625
+ if (depth > MAX_SCAN_DEPTH) return;
1626
+ try {
1627
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
1628
+ for (const entry of entries) {
1629
+ if (entry.name === "node_modules" || entry.name === "dist") continue;
1630
+ if (entry.isFile() && THEME_SUFFIXES.some((s) => entry.name.endsWith(s))) {
1631
+ sources.push({ kind: "theme-file", path: path.join(dir, entry.name) });
1632
+ } else if (entry.isDirectory()) {
1633
+ scanThemeFiles(path.join(dir, entry.name), depth + 1);
1634
+ }
1303
1635
  }
1636
+ } catch {
1304
1637
  }
1305
- } catch {
1306
- }
1638
+ };
1639
+ scanThemeFiles(srcDir, 0);
1307
1640
  }
1308
1641
  return sources;
1309
1642
  }
@@ -1323,7 +1656,7 @@ function detectProject(rootDir) {
1323
1656
  }
1324
1657
  const framework = detectFramework(rootDir, packageDeps);
1325
1658
  const { typescript, tsconfigPath } = detectTypeScript(rootDir);
1326
- const packageManager = detectPackageManager(rootDir);
1659
+ const packageManager = detectPackageManager2(rootDir);
1327
1660
  const componentPatterns = detectComponentPatterns(rootDir, typescript);
1328
1661
  const tokenSources = detectTokenSources(rootDir);
1329
1662
  const globalCSSFiles = detectGlobalCSSFiles(rootDir);
@@ -1374,9 +1707,9 @@ function createRL() {
1374
1707
  });
1375
1708
  }
1376
1709
  async function ask(rl, question) {
1377
- return new Promise((resolve19) => {
1710
+ return new Promise((resolve21) => {
1378
1711
  rl.question(question, (answer) => {
1379
- resolve19(answer.trim());
1712
+ resolve21(answer.trim());
1380
1713
  });
1381
1714
  });
1382
1715
  }
@@ -1637,7 +1970,9 @@ async function runInit(options) {
1637
1970
  };
1638
1971
  }
1639
1972
  function createInitCommand() {
1640
- return new commander.Command("init").description("Initialise a Scope project \u2014 scaffold reactscope.config.json and friends").option("-y, --yes", "Accept all detected defaults without prompting", false).option("--force", "Overwrite existing reactscope.config.json if present", false).action(async (opts) => {
1973
+ return new commander.Command("init").description(
1974
+ "Auto-detect your project layout and scaffold reactscope.config.json.\n\nWHAT IT DOES:\n - Detects component glob patterns from tsconfig / package.json / directory scan\n - Detects globalCSS files (Tailwind, PostCSS)\n - Writes reactscope.config.json with all detected values\n - Adds .reactscope/ to .gitignore\n - Creates .reactscope/ output directory\n\nCONFIG FIELDS GENERATED:\n components.include glob patterns for component discovery\n components.wrappers providers + globalCSS to inject on every render\n render.viewport default viewport (1280\xD7800)\n tokens.file path to reactscope.tokens.json\n output.dir .reactscope/ (all outputs go here)\n ci.complianceThreshold 0.90 (90% on-token required to pass CI)\n\nSafe to re-run \u2014 will not overwrite existing config unless --force.\n\nExamples:\n scope init\n scope init --yes # accept all detected defaults, no prompts\n scope init --force # overwrite existing config"
1975
+ ).option("-y, --yes", "Accept all detected defaults without prompting", false).option("--force", "Overwrite existing reactscope.config.json if present", false).action(async (opts) => {
1641
1976
  try {
1642
1977
  const result = await runInit({ yes: opts.yes, force: opts.force });
1643
1978
  if (!result.success && !result.skipped) {
@@ -1666,34 +2001,56 @@ function resolveFormat(formatFlag) {
1666
2001
  return isTTY() ? "table" : "json";
1667
2002
  }
1668
2003
  function registerList(manifestCmd) {
1669
- manifestCmd.command("list").description("List all components in the manifest").option("--format <fmt>", "Output format: json or table (default: auto-detect)").option("--filter <glob>", "Filter components by name glob pattern").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH).action((opts) => {
1670
- try {
1671
- const manifest = loadManifest(opts.manifest);
1672
- const format = resolveFormat(opts.format);
1673
- let entries = Object.entries(manifest.components);
1674
- if (opts.filter !== void 0) {
1675
- const filterPattern = opts.filter ?? "";
1676
- entries = entries.filter(([name]) => matchGlob(filterPattern, name));
1677
- }
1678
- const rows = entries.map(([name, descriptor]) => ({
1679
- name,
1680
- file: descriptor.filePath,
1681
- complexityClass: descriptor.complexityClass,
1682
- hookCount: descriptor.detectedHooks.length,
1683
- contextCount: descriptor.requiredContexts.length
1684
- }));
1685
- const output = format === "json" ? formatListJson(rows) : formatListTable(rows);
1686
- process.stdout.write(`${output}
2004
+ manifestCmd.command("list").description(
2005
+ `List all components in the manifest as a table (TTY) or JSON (piped).
2006
+
2007
+ Examples:
2008
+ scope manifest list
2009
+ scope manifest list --format json | jq '.[].name'
2010
+ scope manifest list --filter "Button*"`
2011
+ ).option("--format <fmt>", "Output format: json or table (default: auto-detect)").option("--filter <glob>", "Filter components by name glob pattern").option("--collection <name>", "Filter to only components in the named collection").option("--internal", "Show only internal components").option("--no-internal", "Hide internal components from output").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH).action(
2012
+ (opts) => {
2013
+ try {
2014
+ const manifest = loadManifest(opts.manifest);
2015
+ const format = resolveFormat(opts.format);
2016
+ let entries = Object.entries(manifest.components);
2017
+ if (opts.filter !== void 0) {
2018
+ const filterPattern = opts.filter ?? "";
2019
+ entries = entries.filter(([name]) => matchGlob(filterPattern, name));
2020
+ }
2021
+ if (opts.collection !== void 0) {
2022
+ const col = opts.collection;
2023
+ entries = entries.filter(([, d]) => d.collection === col);
2024
+ }
2025
+ if (opts.internal === true) {
2026
+ entries = entries.filter(([, d]) => d.internal);
2027
+ } else if (opts.internal === false) {
2028
+ entries = entries.filter(([, d]) => !d.internal);
2029
+ }
2030
+ const rows = entries.map(([name, descriptor]) => ({
2031
+ name,
2032
+ file: descriptor.filePath,
2033
+ complexityClass: descriptor.complexityClass,
2034
+ hookCount: descriptor.detectedHooks.length,
2035
+ contextCount: descriptor.requiredContexts.length,
2036
+ collection: descriptor.collection,
2037
+ internal: descriptor.internal
2038
+ }));
2039
+ const output = format === "json" ? formatListJson(rows) : formatListTable(rows);
2040
+ process.stdout.write(`${output}
1687
2041
  `);
1688
- } catch (err) {
1689
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
2042
+ } catch (err) {
2043
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
1690
2044
  `);
1691
- process.exit(1);
2045
+ process.exit(1);
2046
+ }
1692
2047
  }
1693
- });
2048
+ );
1694
2049
  }
1695
2050
  function registerGet(manifestCmd) {
1696
- manifestCmd.command("get <name>").description("Get full details of a single component by name").option("--format <fmt>", "Output format: json or table (default: auto-detect)").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH).action((name, opts) => {
2051
+ manifestCmd.command("get <name>").description(
2052
+ "Get full details of a single component: props, hooks, complexity class, file path.\n\nExamples:\n scope manifest get Button\n scope manifest get Button --format json\n scope manifest get Button --format json | jq '.complexity'"
2053
+ ).option("--format <fmt>", "Output format: json or table (default: auto-detect)").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH).action((name, opts) => {
1697
2054
  try {
1698
2055
  const manifest = loadManifest(opts.manifest);
1699
2056
  const format = resolveFormat(opts.format);
@@ -1717,10 +2074,12 @@ Available: ${available}${hint}`
1717
2074
  });
1718
2075
  }
1719
2076
  function registerQuery(manifestCmd) {
1720
- manifestCmd.command("query").description("Query components by attributes").option("--context <name>", "Find components consuming a context").option("--hook <name>", "Find components using a specific hook").option("--complexity <class>", "Filter by complexity class: simple or complex").option("--side-effects", "Find components with any side effects", false).option("--has-fetch", "Find components with fetch calls", false).option(
2077
+ manifestCmd.command("query").description(
2078
+ 'Filter components by structural attributes. All flags are AND-combined.\n\nCOMPLEXITY CLASSES:\n simple \u2014 pure/presentational, no side effects, Satori-renderable\n complex \u2014 uses context/hooks/effects, requires BrowserPool to render\n\nExamples:\n scope manifest query --complexity simple\n scope manifest query --has-fetch\n scope manifest query --hook useContext --side-effects\n scope manifest query --has-prop "variant:union" --format json\n scope manifest query --composed-by Layout'
2079
+ ).option("--context <name>", "Find components consuming a context").option("--hook <name>", "Find components using a specific hook").option("--complexity <class>", "Filter by complexity class: simple or complex").option("--side-effects", "Find components with any side effects", false).option("--has-fetch", "Find components with fetch calls", false).option(
1721
2080
  "--has-prop <spec>",
1722
2081
  "Find components with a prop matching name or name:type (e.g. 'loading' or 'variant:union')"
1723
- ).option("--composed-by <name>", "Find components that compose the named component").option("--format <fmt>", "Output format: json or table (default: auto-detect)").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH).action(
2082
+ ).option("--composed-by <name>", "Find components that compose the named component").option("--internal", "Find only internal components", false).option("--collection <name>", "Filter to only components in the named collection").option("--format <fmt>", "Output format: json or table (default: auto-detect)").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH).action(
1724
2083
  (opts) => {
1725
2084
  try {
1726
2085
  const manifest = loadManifest(opts.manifest);
@@ -1733,9 +2092,11 @@ function registerQuery(manifestCmd) {
1733
2092
  if (opts.hasFetch) queryParts.push("has-fetch");
1734
2093
  if (opts.hasProp !== void 0) queryParts.push(`has-prop=${opts.hasProp}`);
1735
2094
  if (opts.composedBy !== void 0) queryParts.push(`composed-by=${opts.composedBy}`);
2095
+ if (opts.internal) queryParts.push("internal");
2096
+ if (opts.collection !== void 0) queryParts.push(`collection=${opts.collection}`);
1736
2097
  if (queryParts.length === 0) {
1737
2098
  process.stderr.write(
1738
- "No query flags specified. Use --context, --hook, --complexity, --side-effects, --has-fetch, --has-prop, or --composed-by.\n"
2099
+ "No query flags specified. Use --context, --hook, --complexity, --side-effects, --has-fetch, --has-prop, --composed-by, --internal, or --collection.\n"
1739
2100
  );
1740
2101
  process.exit(1);
1741
2102
  }
@@ -1780,15 +2141,24 @@ function registerQuery(manifestCmd) {
1780
2141
  const targetName = opts.composedBy;
1781
2142
  entries = entries.filter(([, d]) => {
1782
2143
  const composedBy = d.composedBy;
1783
- return composedBy !== void 0 && composedBy.includes(targetName);
2144
+ return composedBy?.includes(targetName);
1784
2145
  });
1785
2146
  }
2147
+ if (opts.internal) {
2148
+ entries = entries.filter(([, d]) => d.internal);
2149
+ }
2150
+ if (opts.collection !== void 0) {
2151
+ const col = opts.collection;
2152
+ entries = entries.filter(([, d]) => d.collection === col);
2153
+ }
1786
2154
  const rows = entries.map(([name, d]) => ({
1787
2155
  name,
1788
2156
  file: d.filePath,
1789
2157
  complexityClass: d.complexityClass,
1790
2158
  hooks: d.detectedHooks.join(", ") || "\u2014",
1791
- contexts: d.requiredContexts.join(", ") || "\u2014"
2159
+ contexts: d.requiredContexts.join(", ") || "\u2014",
2160
+ collection: d.collection,
2161
+ internal: d.internal
1792
2162
  }));
1793
2163
  const output = format === "json" ? formatQueryJson(rows) : formatQueryTable(rows, queryDesc);
1794
2164
  process.stdout.write(`${output}
@@ -1801,21 +2171,64 @@ function registerQuery(manifestCmd) {
1801
2171
  }
1802
2172
  );
1803
2173
  }
2174
+ function loadReactScopeConfig(rootDir) {
2175
+ const configPath = path.resolve(rootDir, "reactscope.config.json");
2176
+ if (!fs.existsSync(configPath)) return null;
2177
+ try {
2178
+ const raw = fs.readFileSync(configPath, "utf-8");
2179
+ const cfg = JSON.parse(raw);
2180
+ const result = {};
2181
+ const components = cfg.components;
2182
+ if (components !== void 0 && typeof components === "object" && components !== null) {
2183
+ if (Array.isArray(components.include)) {
2184
+ result.include = components.include;
2185
+ }
2186
+ if (Array.isArray(components.exclude)) {
2187
+ result.exclude = components.exclude;
2188
+ }
2189
+ }
2190
+ if (Array.isArray(cfg.internalPatterns)) {
2191
+ result.internalPatterns = cfg.internalPatterns;
2192
+ }
2193
+ if (Array.isArray(cfg.collections)) {
2194
+ result.collections = cfg.collections;
2195
+ }
2196
+ const icons = cfg.icons;
2197
+ if (icons !== void 0 && typeof icons === "object" && icons !== null) {
2198
+ if (Array.isArray(icons.patterns)) {
2199
+ result.iconPatterns = icons.patterns;
2200
+ }
2201
+ }
2202
+ return result;
2203
+ } catch {
2204
+ return null;
2205
+ }
2206
+ }
1804
2207
  function registerGenerate(manifestCmd) {
1805
2208
  manifestCmd.command("generate").description(
1806
- "Generate the component manifest from source and write to .reactscope/manifest.json"
2209
+ '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'
1807
2210
  ).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) => {
1808
2211
  try {
1809
2212
  const rootDir = path.resolve(process.cwd(), opts.root ?? ".");
1810
2213
  const outputPath = path.resolve(process.cwd(), opts.output);
1811
- const include = opts.include?.split(",").map((s) => s.trim());
1812
- const exclude = opts.exclude?.split(",").map((s) => s.trim());
2214
+ const configValues = loadReactScopeConfig(rootDir);
2215
+ const include = opts.include?.split(",").map((s) => s.trim()) ?? configValues?.include;
2216
+ const exclude = opts.exclude?.split(",").map((s) => s.trim()) ?? configValues?.exclude;
1813
2217
  process.stderr.write(`Scanning ${rootDir} for React components...
1814
2218
  `);
1815
2219
  const manifest$1 = await manifest.generateManifest({
1816
2220
  rootDir,
1817
2221
  ...include !== void 0 && { include },
1818
- ...exclude !== void 0 && { exclude }
2222
+ ...exclude !== void 0 && { exclude },
2223
+ ...configValues?.internalPatterns !== void 0 && {
2224
+ internalPatterns: configValues.internalPatterns
2225
+ },
2226
+ ...configValues?.collections !== void 0 && {
2227
+ collections: configValues.collections
2228
+ },
2229
+ ...configValues?.iconPatterns !== void 0 && {
2230
+ iconPatterns: configValues.iconPatterns
2231
+ }
1819
2232
  });
1820
2233
  const componentCount = Object.keys(manifest$1.components).length;
1821
2234
  process.stderr.write(`Found ${componentCount} components.
@@ -1838,7 +2251,7 @@ function registerGenerate(manifestCmd) {
1838
2251
  }
1839
2252
  function createManifestCommand() {
1840
2253
  const manifestCmd = new commander.Command("manifest").description(
1841
- "Query and explore the component manifest"
2254
+ "Query and explore the component manifest (.reactscope/manifest.json).\n\nThe manifest is the source-of-truth registry of every React component\nin your codebase \u2014 generated by static analysis (no runtime needed).\n\nRun `scope manifest generate` first, then use list/get/query to explore.\n\nExamples:\n scope manifest generate\n scope manifest list\n scope manifest get Button\n scope manifest query --complexity complex --has-fetch"
1842
2255
  );
1843
2256
  registerList(manifestCmd);
1844
2257
  registerGet(manifestCmd);
@@ -2154,7 +2567,19 @@ async function runHooksProfiling(componentName, filePath, props) {
2154
2567
  }
2155
2568
  function createInstrumentHooksCommand() {
2156
2569
  const cmd = new commander.Command("hooks").description(
2157
- "Profile per-hook-instance data for a component: update counts, cache hit rates, effect counts, and more"
2570
+ `Profile per-hook-instance data for a component.
2571
+
2572
+ METRICS CAPTURED per hook instance:
2573
+ useState update count, current value
2574
+ useCallback cache hit rate (stable reference %)
2575
+ useMemo cache hit rate (recompute %)
2576
+ useEffect execution count
2577
+ useRef current value snapshot
2578
+
2579
+ Examples:
2580
+ scope instrument hooks SearchInput
2581
+ scope instrument hooks SearchInput --props '{"value":"hello"}' --json
2582
+ scope instrument hooks Dropdown --json | jq '.hooks[] | select(.type == "useMemo")' `
2158
2583
  ).argument("<component>", "Component name (must exist in the manifest)").option("--props <json>", "Inline props JSON passed to the component", "{}").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH2).option("--format <fmt>", "Output format: json|text (default: auto)", "json").option("--show-flags", "Show heuristic flags only (useful for CI checks)", false).action(
2159
2584
  async (componentName, opts) => {
2160
2585
  try {
@@ -2192,7 +2617,7 @@ Available: ${available}`
2192
2617
  process.stdout.write(`${JSON.stringify(result, null, 2)}
2193
2618
  `);
2194
2619
  } catch (err) {
2195
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
2620
+ process.stderr.write(`${formatScopeDiagnostic(err)}
2196
2621
  `);
2197
2622
  process.exit(1);
2198
2623
  }
@@ -2295,13 +2720,11 @@ function buildProfilingCollectScript() {
2295
2720
  // mount commit), it *may* have been wasted if it didn't actually need to re-render.
2296
2721
  // For the initial snapshot we approximate: wastedRenders = max(0, totalCommits - 1) * 0.3
2297
2722
  // This is a heuristic \u2014 real wasted render detection needs shouldComponentUpdate/React.memo tracing.
2298
- var wastedRenders = Math.max(0, Math.round((totalCommits - 1) * uniqueNames.length * 0.3));
2299
-
2300
2723
  return {
2301
2724
  commitCount: totalCommits,
2302
2725
  uniqueComponents: uniqueNames.length,
2303
2726
  componentNames: uniqueNames,
2304
- wastedRenders: wastedRenders,
2727
+ wastedRenders: null,
2305
2728
  layoutTime: window.__scopeLayoutTime || 0,
2306
2729
  paintTime: window.__scopePaintTime || 0,
2307
2730
  layoutShifts: window.__scopeLayoutShifts || { count: 0, score: 0 }
@@ -2349,7 +2772,7 @@ async function replayInteraction(page, steps) {
2349
2772
  }
2350
2773
  function analyzeProfileFlags(totalRenders, wastedRenders, timing, layoutShifts) {
2351
2774
  const flags = /* @__PURE__ */ new Set();
2352
- if (wastedRenders > 0 && wastedRenders / Math.max(1, totalRenders) > 0.3) {
2775
+ if (wastedRenders !== null && wastedRenders > 0 && wastedRenders / Math.max(1, totalRenders) > 0.3) {
2353
2776
  flags.add("WASTED_RENDER");
2354
2777
  }
2355
2778
  if (totalRenders > 10) {
@@ -2420,13 +2843,18 @@ async function runInteractionProfile(componentName, filePath, props, interaction
2420
2843
  };
2421
2844
  const totalRenders = profileData.commitCount ?? 0;
2422
2845
  const uniqueComponents = profileData.uniqueComponents ?? 0;
2423
- const wastedRenders = profileData.wastedRenders ?? 0;
2846
+ const wastedRenders = profileData.wastedRenders ?? null;
2424
2847
  const flags = analyzeProfileFlags(totalRenders, wastedRenders, timing, layoutShifts);
2425
2848
  return {
2426
2849
  component: componentName,
2427
2850
  totalRenders,
2428
2851
  uniqueComponents,
2429
2852
  wastedRenders,
2853
+ wastedRendersHeuristic: {
2854
+ measured: false,
2855
+ value: null,
2856
+ note: "profile.wastedRenders is retained for compatibility but set to null because Scope does not directly measure wasted renders yet."
2857
+ },
2430
2858
  timing,
2431
2859
  layoutShifts,
2432
2860
  flags,
@@ -2438,7 +2866,19 @@ async function runInteractionProfile(componentName, filePath, props, interaction
2438
2866
  }
2439
2867
  function createInstrumentProfileCommand() {
2440
2868
  const cmd = new commander.Command("profile").description(
2441
- "Capture a full interaction-scoped performance profile: renders, timing, layout shifts"
2869
+ `Capture a full performance profile for an interaction sequence.
2870
+
2871
+ PROFILE INCLUDES:
2872
+ renders total re-renders triggered by the interaction
2873
+ timing interaction start \u2192 paint time (ms)
2874
+ layoutShifts cumulative layout shift (CLS) score
2875
+ scriptTime JS execution time (ms)
2876
+ longTasks count of tasks >50ms
2877
+
2878
+ Examples:
2879
+ scope instrument profile Button --interaction '[{"action":"click","target":"button"}]'
2880
+ scope instrument profile Modal --interaction '[{"action":"click","target":".open-btn"}]' --json
2881
+ scope instrument profile Form --json | jq '.summary.renderCount'`
2442
2882
  ).argument("<component>", "Component name (must exist in the manifest)").option(
2443
2883
  "--interaction <json>",
2444
2884
  `Interaction steps JSON, e.g. '[{"action":"click","target":"button.primary"}]'`,
@@ -2489,7 +2929,7 @@ Available: ${available}`
2489
2929
  process.stdout.write(`${JSON.stringify(result, null, 2)}
2490
2930
  `);
2491
2931
  } catch (err) {
2492
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
2932
+ process.stderr.write(`${formatScopeDiagnostic(err)}
2493
2933
  `);
2494
2934
  process.exit(1);
2495
2935
  }
@@ -2774,7 +3214,21 @@ async function runInstrumentTree(options) {
2774
3214
  }
2775
3215
  }
2776
3216
  function createInstrumentTreeCommand() {
2777
- return new commander.Command("tree").description("Render a component via BrowserPool and output a structured instrumentation tree").argument("<component>", "Component name to instrument (must exist in the manifest)").option("--sort-by <field>", "Sort nodes by field: renderCount | depth").option("--limit <n>", "Limit output to the first N nodes (depth-first)").option("--uses-context <name>", "Filter to components that use a specific context").option("--provider-depth", "Annotate each node with its context-provider nesting depth", false).option(
3217
+ return new commander.Command("tree").description(
3218
+ `Render a component and output the full instrumentation tree:
3219
+ DOM structure, computed styles per node, a11y roles, and React fibers.
3220
+
3221
+ OUTPUT STRUCTURE per node:
3222
+ tag / id / className DOM identity
3223
+ computedStyles resolved CSS properties
3224
+ a11y role, name, focusable
3225
+ children nested child nodes
3226
+
3227
+ Examples:
3228
+ scope instrument tree Card
3229
+ scope instrument tree Button --props '{"variant":"primary"}' --json
3230
+ scope instrument tree Input --json | jq '.tree.computedStyles'`
3231
+ ).argument("<component>", "Component name to instrument (must exist in the manifest)").option("--sort-by <field>", "Sort nodes by field: renderCount | depth").option("--limit <n>", "Limit output to the first N nodes (depth-first)").option("--uses-context <name>", "Filter to components that use a specific context").option("--provider-depth", "Annotate each node with its context-provider nesting depth", false).option(
2778
3232
  "--wasted-renders",
2779
3233
  "Filter to components with wasted renders (no prop/state/context changes, not memoized)",
2780
3234
  false
@@ -2820,7 +3274,7 @@ Available: ${available}`
2820
3274
  `);
2821
3275
  }
2822
3276
  } catch (err) {
2823
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
3277
+ process.stderr.write(`${formatScopeDiagnostic(err)}
2824
3278
  `);
2825
3279
  process.exit(1);
2826
3280
  }
@@ -3168,7 +3622,8 @@ Available: ${available}`
3168
3622
  }
3169
3623
  const rootDir = process.cwd();
3170
3624
  const filePath = path.resolve(rootDir, descriptor.filePath);
3171
- const preScript = playwright.getBrowserEntryScript() + "\n" + buildInstrumentationScript();
3625
+ const preScript = `${playwright.getBrowserEntryScript()}
3626
+ ${buildInstrumentationScript()}`;
3172
3627
  const htmlHarness = await buildComponentHarness(
3173
3628
  filePath,
3174
3629
  options.componentName,
@@ -3257,7 +3712,24 @@ function formatRendersTable(result) {
3257
3712
  return lines.join("\n");
3258
3713
  }
3259
3714
  function createInstrumentRendersCommand() {
3260
- return new commander.Command("renders").description("Trace re-render causality chains for a component during an interaction sequence").argument("<component>", "Component name to instrument (must be in manifest)").option(
3715
+ return new commander.Command("renders").description(
3716
+ `Trace every re-render triggered by an interaction and identify root causes.
3717
+
3718
+ OUTPUT INCLUDES per render event:
3719
+ component which component re-rendered
3720
+ trigger why it re-rendered: state_change | props_change | context_change |
3721
+ parent_rerender | force_update | hook_dependency
3722
+ wasted true if re-rendered with no changed inputs and not memoized
3723
+ chain full causality chain from root cause to this render
3724
+
3725
+ WASTED RENDERS: propsChanged=false AND stateChanged=false AND contextChanged=false
3726
+ AND memoized=false \u2014 these are optimisation opportunities.
3727
+
3728
+ Examples:
3729
+ scope instrument renders SearchPage --interaction '[{"action":"type","target":"input","text":"hello"}]'
3730
+ scope instrument renders Button --interaction '[{"action":"click","target":"button"}]' --json
3731
+ scope instrument renders Form --json | jq '.events[] | select(.wasted == true)'`
3732
+ ).argument("<component>", "Component name to instrument (must be in manifest)").option(
3261
3733
  "--interaction <json>",
3262
3734
  `Interaction sequence JSON, e.g. '[{"action":"click","target":"button"}]'`,
3263
3735
  "[]"
@@ -3294,7 +3766,7 @@ function createInstrumentRendersCommand() {
3294
3766
  }
3295
3767
  } catch (err) {
3296
3768
  await shutdownPool2();
3297
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
3769
+ process.stderr.write(`${formatScopeDiagnostic(err)}
3298
3770
  `);
3299
3771
  process.exit(1);
3300
3772
  }
@@ -3303,7 +3775,28 @@ function createInstrumentRendersCommand() {
3303
3775
  }
3304
3776
  function createInstrumentCommand() {
3305
3777
  const instrumentCmd = new commander.Command("instrument").description(
3306
- "Structured instrumentation commands for React component analysis"
3778
+ `Runtime instrumentation for React component behaviour analysis.
3779
+
3780
+ All instrument commands:
3781
+ 1. Build an esbuild harness for the component
3782
+ 2. Load it in a Playwright browser
3783
+ 3. Inject instrumentation hooks into React DevTools fiber
3784
+ 4. Execute interactions and collect events
3785
+
3786
+ PREREQUISITES:
3787
+ scope manifest generate (component must be in manifest)
3788
+ reactscope.config.json (for wrappers/globalCSS)
3789
+
3790
+ INTERACTION FORMAT:
3791
+ JSON array of step objects: [{action, target, text?}]
3792
+ Actions: click | type | focus | blur | hover | key
3793
+ Target: CSS selector for the element to interact with
3794
+
3795
+ Examples:
3796
+ scope instrument renders Button --interaction '[{"action":"click","target":"button"}]'
3797
+ scope instrument hooks SearchInput --props '{"value":"hello"}'
3798
+ scope instrument profile Modal --interaction '[{"action":"click","target":".open-btn"}]'
3799
+ scope instrument tree Card`
3307
3800
  );
3308
3801
  instrumentCmd.addCommand(createInstrumentRendersCommand());
3309
3802
  instrumentCmd.addCommand(createInstrumentHooksCommand());
@@ -3351,6 +3844,54 @@ function writeReportToFile(report, outputPath, pretty) {
3351
3844
  const json = pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report);
3352
3845
  fs.writeFileSync(outputPath, json, "utf-8");
3353
3846
  }
3847
+ var RUN_SUMMARY_PATH = ".reactscope/run-summary.json";
3848
+ function buildNextActions(summary) {
3849
+ const actions = /* @__PURE__ */ new Set();
3850
+ for (const failure of summary.failures) {
3851
+ if (failure.stage === "render" || failure.stage === "matrix") {
3852
+ actions.add(
3853
+ `Inspect ${failure.outputPath ?? ".reactscope/renders"} and add/fix ${failure.component}.scope.tsx scenarios or wrappers.`
3854
+ );
3855
+ } else if (failure.stage === "playground") {
3856
+ actions.add(
3857
+ `Open the generated component page and inspect the playground bundling error for ${failure.component}.`
3858
+ );
3859
+ } else if (failure.stage === "compliance") {
3860
+ actions.add(
3861
+ "Run `scope render all` first, then inspect .reactscope/compliance-styles.json and reactscope.tokens.json."
3862
+ );
3863
+ } else if (failure.stage === "site") {
3864
+ actions.add(
3865
+ "Inspect .reactscope/site output and rerun `scope site build` after fixing render/playground failures."
3866
+ );
3867
+ }
3868
+ }
3869
+ if (summary.compliance && summary.compliance.auditedProperties === 0) {
3870
+ actions.add(
3871
+ "No CSS properties were audited. Verify renders produced computed styles and your token file contains matching token categories."
3872
+ );
3873
+ } else if (summary.compliance && summary.compliance.threshold !== void 0 && summary.compliance.score < summary.compliance.threshold) {
3874
+ actions.add(
3875
+ "Inspect .reactscope/compliance-report.json for off-system values and update tokens or component styles."
3876
+ );
3877
+ }
3878
+ if (actions.size === 0) {
3879
+ actions.add("No follow-up needed. Outputs are ready for inspection.");
3880
+ }
3881
+ return [...actions];
3882
+ }
3883
+ function writeRunSummary(summary, summaryPath = RUN_SUMMARY_PATH) {
3884
+ const outputPath = path.resolve(process.cwd(), summaryPath);
3885
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
3886
+ const payload = {
3887
+ ...summary,
3888
+ generatedAt: summary.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
3889
+ nextActions: summary.nextActions ?? buildNextActions(summary)
3890
+ };
3891
+ fs.writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}
3892
+ `, "utf-8");
3893
+ return outputPath;
3894
+ }
3354
3895
  var SCOPE_EXTENSIONS = [".scope.tsx", ".scope.ts", ".scope.jsx", ".scope.js"];
3355
3896
  function findScopeFile(componentFilePath) {
3356
3897
  const dir = path.dirname(componentFilePath);
@@ -3478,6 +4019,63 @@ function loadGlobalCssFilesFromConfig(cwd) {
3478
4019
  return [];
3479
4020
  }
3480
4021
  }
4022
+ var TAILWIND_CONFIG_FILES2 = [
4023
+ "tailwind.config.js",
4024
+ "tailwind.config.cjs",
4025
+ "tailwind.config.mjs",
4026
+ "tailwind.config.ts",
4027
+ "postcss.config.js",
4028
+ "postcss.config.cjs",
4029
+ "postcss.config.mjs",
4030
+ "postcss.config.ts"
4031
+ ];
4032
+ function shouldWarnForMissingGlobalCss(cwd) {
4033
+ if (TAILWIND_CONFIG_FILES2.some((file) => fs.existsSync(path.resolve(cwd, file)))) {
4034
+ return true;
4035
+ }
4036
+ const packageJsonPath = path.resolve(cwd, "package.json");
4037
+ if (!fs.existsSync(packageJsonPath)) return false;
4038
+ try {
4039
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
4040
+ return [pkg.dependencies, pkg.devDependencies].some(
4041
+ (deps) => deps && Object.keys(deps).some(
4042
+ (name) => name === "tailwindcss" || name.startsWith("@tailwindcss/")
4043
+ )
4044
+ );
4045
+ } catch {
4046
+ return false;
4047
+ }
4048
+ }
4049
+ function loadIconPatternsFromConfig(cwd) {
4050
+ const configPath = path.resolve(cwd, "reactscope.config.json");
4051
+ if (!fs.existsSync(configPath)) return [];
4052
+ try {
4053
+ const raw = fs.readFileSync(configPath, "utf-8");
4054
+ const cfg = JSON.parse(raw);
4055
+ return cfg.icons?.patterns ?? [];
4056
+ } catch {
4057
+ return [];
4058
+ }
4059
+ }
4060
+ function matchGlob2(pattern, value) {
4061
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
4062
+ const regexStr = escaped.replace(/\*\*/g, "\xA7GLOBSTAR\xA7").replace(/\*/g, "[^/]*").replace(/§GLOBSTAR§/g, ".*");
4063
+ return new RegExp(`^${regexStr}$`, "i").test(value);
4064
+ }
4065
+ function isIconComponent(filePath, displayName, patterns) {
4066
+ return patterns.length > 0 && patterns.some((p) => matchGlob2(p, filePath) || matchGlob2(p, displayName));
4067
+ }
4068
+ function formatAggregateRenderFailureJson(componentName, failures, scenarioCount, runSummaryPath) {
4069
+ return {
4070
+ command: `scope render component ${componentName}`,
4071
+ status: "failed",
4072
+ component: componentName,
4073
+ scenarioCount,
4074
+ failureCount: failures.length,
4075
+ failures,
4076
+ runSummaryPath
4077
+ };
4078
+ }
3481
4079
  var MANIFEST_PATH6 = ".reactscope/manifest.json";
3482
4080
  var DEFAULT_OUTPUT_DIR = ".reactscope/renders";
3483
4081
  var _pool3 = null;
@@ -3498,7 +4096,7 @@ async function shutdownPool3() {
3498
4096
  _pool3 = null;
3499
4097
  }
3500
4098
  }
3501
- function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, globalCssFiles = [], projectCwd = process.cwd(), wrapperScript) {
4099
+ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, globalCssFiles = [], projectCwd = process.cwd(), wrapperScript, iconMode = false) {
3502
4100
  const satori = new render.SatoriRenderer({
3503
4101
  defaultViewport: { width: viewportWidth, height: viewportHeight }
3504
4102
  });
@@ -3508,13 +4106,15 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
3508
4106
  const startMs = performance.now();
3509
4107
  const pool = await getPool3(viewportWidth, viewportHeight);
3510
4108
  const projectCss = await loadGlobalCss(globalCssFiles, projectCwd);
4109
+ const PAD = 8;
3511
4110
  const htmlHarness = await buildComponentHarness(
3512
4111
  filePath,
3513
4112
  componentName,
3514
4113
  props,
3515
4114
  viewportWidth,
3516
4115
  projectCss ?? void 0,
3517
- wrapperScript
4116
+ wrapperScript,
4117
+ PAD
3518
4118
  );
3519
4119
  const slot = await pool.acquire();
3520
4120
  const { page } = slot;
@@ -3555,17 +4155,28 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
3555
4155
  `Component "${componentName}" rendered with zero bounding box \u2014 it may be invisible or not mounted`
3556
4156
  );
3557
4157
  }
3558
- const PAD = 8;
3559
4158
  const clipX = Math.max(0, boundingBox.x - PAD);
3560
4159
  const clipY = Math.max(0, boundingBox.y - PAD);
3561
4160
  const rawW = boundingBox.width + PAD * 2;
3562
4161
  const rawH = boundingBox.height + PAD * 2;
3563
4162
  const safeW = Math.min(rawW, viewportWidth - clipX);
3564
4163
  const safeH = Math.min(rawH, viewportHeight - clipY);
3565
- const screenshot = await page.screenshot({
3566
- clip: { x: clipX, y: clipY, width: safeW, height: safeH },
3567
- type: "png"
3568
- });
4164
+ let svgContent;
4165
+ let screenshot;
4166
+ if (iconMode) {
4167
+ svgContent = await page.evaluate((sel) => {
4168
+ const root = document.querySelector(sel);
4169
+ const el = root?.firstElementChild;
4170
+ if (!el) return void 0;
4171
+ return el.outerHTML;
4172
+ }, "[data-reactscope-root]") ?? void 0;
4173
+ screenshot = Buffer.alloc(0);
4174
+ } else {
4175
+ screenshot = await page.screenshot({
4176
+ clip: { x: clipX, y: clipY, width: safeW, height: safeH },
4177
+ type: "png"
4178
+ });
4179
+ }
3569
4180
  const STYLE_PROPS = [
3570
4181
  "display",
3571
4182
  "width",
@@ -3688,7 +4299,7 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
3688
4299
  name: a11yInfo.name,
3689
4300
  violations: imgViolations
3690
4301
  };
3691
- return {
4302
+ const renderResult = {
3692
4303
  screenshot,
3693
4304
  width: Math.round(safeW),
3694
4305
  height: Math.round(safeH),
@@ -3697,6 +4308,10 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
3697
4308
  dom,
3698
4309
  accessibility
3699
4310
  };
4311
+ if (iconMode && svgContent) {
4312
+ renderResult.svgContent = svgContent;
4313
+ }
4314
+ return renderResult;
3700
4315
  } finally {
3701
4316
  pool.release(slot);
3702
4317
  }
@@ -3733,7 +4348,27 @@ Available: ${available}`
3733
4348
  return { __default__: {} };
3734
4349
  }
3735
4350
  function registerRenderSingle(renderCmd) {
3736
- renderCmd.command("component <component>", { isDefault: true }).description("Render a single component to PNG or JSON").option("--props <json>", `Inline props JSON, e.g. '{"variant":"primary"}'`).option("--scenario <name>", "Run a named scenario from the component's .scope file").option("--viewport <WxH>", "Viewport size e.g. 1280x720", "375x812").option("--theme <name>", "Theme name from the token system").option("-o, --output <path>", "Write PNG to file instead of stdout").option("--format <fmt>", "Output format: png or json (default: auto)").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH6).action(
4351
+ renderCmd.command("component <component>", { isDefault: true }).description(
4352
+ `Render one component to a PNG screenshot or JSON data object.
4353
+
4354
+ PROP SOURCES (in priority order):
4355
+ --scenario <name> named scenario from <ComponentName>.scope file
4356
+ --props <json> inline props JSON string
4357
+ (no flag) component rendered with all-default props
4358
+
4359
+ FORMAT DETECTION:
4360
+ --format png always write PNG
4361
+ --format json always write JSON render data
4362
+ auto (default) PNG when -o has .png extension or stdout is file;
4363
+ JSON when stdout is a pipe
4364
+
4365
+ Examples:
4366
+ scope render component Button
4367
+ scope render component Button --props '{"variant":"primary","size":"lg"}'
4368
+ scope render component Button --scenario hover-state -o button-hover.png
4369
+ scope render component Card --viewport 375x812 --theme dark
4370
+ scope render component Badge --format json | jq '.a11y'`
4371
+ ).option("--props <json>", `Inline props JSON, e.g. '{"variant":"primary"}'`).option("--scenario <name>", "Run a named scenario from the component's .scope file").option("--viewport <WxH>", "Viewport size e.g. 1280x720", "375x812").option("--theme <name>", "Theme name from the token system").option("-o, --output <path>", "Write PNG to file instead of stdout").option("--format <fmt>", "Output format: png or json (default: auto)").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH6).action(
3737
4372
  async (componentName, opts) => {
3738
4373
  try {
3739
4374
  const manifest = loadManifest(opts.manifest);
@@ -3776,7 +4411,7 @@ Available: ${available}`
3776
4411
  const wrapperScript = scopeData?.hasWrapper === true ? await buildWrapperScript(scopeData.filePath) : void 0;
3777
4412
  const scenarios = buildScenarioMap(opts, scopeData);
3778
4413
  const globalCssFiles = loadGlobalCssFilesFromConfig(rootDir);
3779
- if (globalCssFiles.length === 0) {
4414
+ if (globalCssFiles.length === 0 && shouldWarnForMissingGlobalCss(rootDir)) {
3780
4415
  process.stderr.write(
3781
4416
  "warning: No globalCSS files configured. Tailwind/CSS styles will not be applied to renders.\n Add `components.wrappers.globalCSS` to reactscope.config.json\n"
3782
4417
  );
@@ -3795,7 +4430,8 @@ Available: ${available}`
3795
4430
  `
3796
4431
  );
3797
4432
  const fmt2 = resolveSingleFormat(opts.format);
3798
- let anyFailed = false;
4433
+ const failures = [];
4434
+ const outputPaths = [];
3799
4435
  for (const [scenarioName, props2] of Object.entries(scenarios)) {
3800
4436
  const isNamed = scenarioName !== "__default__";
3801
4437
  const label = isNamed ? `${componentName}:${scenarioName}` : componentName;
@@ -3818,7 +4454,14 @@ Available: ${available}`
3818
4454
  process.stderr.write(` Hints: ${hintList}
3819
4455
  `);
3820
4456
  }
3821
- anyFailed = true;
4457
+ failures.push({
4458
+ component: componentName,
4459
+ scenario: isNamed ? scenarioName : void 0,
4460
+ stage: "render",
4461
+ message: outcome.error.message,
4462
+ outputPath: `${DEFAULT_OUTPUT_DIR}/${isNamed ? `${componentName}-${scenarioName}.error.json` : `${componentName}.error.json`}`,
4463
+ hints: outcome.error.heuristicFlags
4464
+ });
3822
4465
  continue;
3823
4466
  }
3824
4467
  const result = outcome.result;
@@ -3826,6 +4469,7 @@ Available: ${available}`
3826
4469
  if (opts.output !== void 0 && !isNamed) {
3827
4470
  const outPath = path.resolve(process.cwd(), opts.output);
3828
4471
  fs.writeFileSync(outPath, result.screenshot);
4472
+ outputPaths.push(outPath);
3829
4473
  process.stdout.write(
3830
4474
  `\u2713 ${label} \u2192 ${opts.output} (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
3831
4475
  `
@@ -3840,17 +4484,36 @@ Available: ${available}`
3840
4484
  const outPath = path.resolve(dir, outFileName);
3841
4485
  fs.writeFileSync(outPath, result.screenshot);
3842
4486
  const relPath = `${DEFAULT_OUTPUT_DIR}/${outFileName}`;
4487
+ outputPaths.push(relPath);
3843
4488
  process.stdout.write(
3844
4489
  `\u2713 ${label} \u2192 ${relPath} (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
3845
4490
  `
3846
4491
  );
3847
4492
  }
3848
4493
  }
4494
+ const summaryPath = writeRunSummary({
4495
+ command: `scope render ${componentName}`,
4496
+ status: failures.length > 0 ? "failed" : "success",
4497
+ outputPaths,
4498
+ failures
4499
+ });
4500
+ process.stderr.write(`[scope/render] Run summary written to ${summaryPath}
4501
+ `);
4502
+ if (fmt2 === "json" && failures.length > 0) {
4503
+ const aggregateFailure = formatAggregateRenderFailureJson(
4504
+ componentName,
4505
+ failures,
4506
+ Object.keys(scenarios).length,
4507
+ summaryPath
4508
+ );
4509
+ process.stderr.write(`${JSON.stringify(aggregateFailure, null, 2)}
4510
+ `);
4511
+ }
3849
4512
  await shutdownPool3();
3850
- if (anyFailed) process.exit(1);
4513
+ if (failures.length > 0) process.exit(1);
3851
4514
  } catch (err) {
3852
4515
  await shutdownPool3();
3853
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
4516
+ process.stderr.write(`${formatScopeDiagnostic(err)}
3854
4517
  `);
3855
4518
  process.exit(1);
3856
4519
  }
@@ -3858,7 +4521,9 @@ Available: ${available}`
3858
4521
  );
3859
4522
  }
3860
4523
  function registerRenderMatrix(renderCmd) {
3861
- renderCmd.command("matrix <component>").description("Render a component across a matrix of prop axes").option(
4524
+ renderCmd.command("matrix <component>").description(
4525
+ 'Render every combination of values across one or more prop axes.\nProduces a matrix of screenshots \u2014 one cell per combination.\n\nAXES FORMAT (two equivalent forms):\n Short: --axes "variant:primary,ghost size:sm,md,lg"\n JSON: --axes {"variant":["primary","ghost"],"size":["sm","md","lg"]}\n\nCOMPOSITION CONTEXTS (--contexts):\n Test component in different layout environments.\n Available IDs: centered, rtl, sidebar, dark-bg, light-bg\n (Define custom contexts in reactscope.config.json)\n\nSTRESS PRESETS (--stress):\n Inject adversarial content to test edge cases.\n Available IDs: text.long, text.unicode, text.empty\n\nExamples:\n scope render matrix Button --axes "variant:primary,ghost,destructive"\n scope render matrix Button --axes "variant:primary,ghost size:sm,lg" --sprite matrix.png\n scope render matrix Badge --axes "type:info,warn,error" --contexts centered,rtl\n scope render matrix Input --stress text.long,text.unicode --format json'
4526
+ ).option(
3862
4527
  "--axes <spec>",
3863
4528
  `Axis definitions: key:v1,v2 space-separated OR JSON object e.g. 'variant:primary,ghost size:sm,lg' or '{"variant":["primary","ghost"],"size":["sm","lg"]}'`
3864
4529
  ).option(
@@ -4009,7 +4674,7 @@ Available: ${available}`
4009
4674
  }
4010
4675
  } catch (err) {
4011
4676
  await shutdownPool3();
4012
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
4677
+ process.stderr.write(`${formatScopeDiagnostic(err)}
4013
4678
  `);
4014
4679
  process.exit(1);
4015
4680
  }
@@ -4017,7 +4682,9 @@ Available: ${available}`
4017
4682
  );
4018
4683
  }
4019
4684
  function registerRenderAll(renderCmd) {
4020
- renderCmd.command("all").description("Render all components from the manifest").option("--concurrency <n>", "Max parallel renders", "4").option("--output-dir <dir>", "Output directory for renders", DEFAULT_OUTPUT_DIR).option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH6).option("--format <fmt>", "Output format: json|png (default: png)", "png").action(
4685
+ renderCmd.command("all").description(
4686
+ "Render every component in the manifest and write to .reactscope/renders/.\n\nAlso emits .reactscope/compliance-styles.json (computed CSS class\u2192value map)\nwhich is required by `scope tokens compliance`.\n\nSCENARIO SELECTION:\n Each component is rendered using its default scenario from its .scope file\n if one exists, otherwise with all-default props.\n\nMATRIX AUTO-DETECTION:\n If a component has a .scope file with a matrix block, render all\n will render the matrix cells in addition to the default screenshot.\n\nExamples:\n scope render all\n scope render all --concurrency 8\n scope render all --format json --output-dir .reactscope/renders\n scope render all --manifest ./custom/manifest.json"
4687
+ ).option("--concurrency <n>", "Max parallel renders", "4").option("--output-dir <dir>", "Output directory for renders", DEFAULT_OUTPUT_DIR).option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH6).option("--format <fmt>", "Output format: json|png (default: png)", "png").action(
4021
4688
  async (opts) => {
4022
4689
  try {
4023
4690
  const manifest = loadManifest(opts.manifest);
@@ -4025,7 +4692,21 @@ function registerRenderAll(renderCmd) {
4025
4692
  const total = componentNames.length;
4026
4693
  if (total === 0) {
4027
4694
  process.stderr.write("No components found in manifest.\n");
4028
- return;
4695
+ const summaryPath2 = writeRunSummary({
4696
+ command: "scope render all",
4697
+ status: "failed",
4698
+ outputPaths: [],
4699
+ failures: [
4700
+ {
4701
+ component: "*",
4702
+ stage: "render",
4703
+ message: "No components found in manifest; refusing to report a false-green batch render."
4704
+ }
4705
+ ]
4706
+ });
4707
+ process.stderr.write(`[scope/render] Run summary written to ${summaryPath2}
4708
+ `);
4709
+ process.exit(1);
4029
4710
  }
4030
4711
  const concurrency = Math.max(1, parseInt(opts.concurrency, 10) || 4);
4031
4712
  const outputDir = path.resolve(process.cwd(), opts.outputDir);
@@ -4034,13 +4715,17 @@ function registerRenderAll(renderCmd) {
4034
4715
  process.stderr.write(`Rendering ${total} components (concurrency: ${concurrency})\u2026
4035
4716
  `);
4036
4717
  const results = [];
4718
+ const failures = [];
4719
+ const outputPaths = [];
4037
4720
  const complianceStylesMap = {};
4038
4721
  let completed = 0;
4722
+ const iconPatterns = loadIconPatternsFromConfig(process.cwd());
4039
4723
  const renderOne = async (name) => {
4040
4724
  const descriptor = manifest.components[name];
4041
4725
  if (descriptor === void 0) return;
4042
4726
  const filePath = path.resolve(rootDir, descriptor.filePath);
4043
4727
  const allCssFiles = loadGlobalCssFilesFromConfig(process.cwd());
4728
+ const isIcon = isIconComponent(descriptor.filePath, name, iconPatterns);
4044
4729
  const scopeData = await loadScopeFileForComponent(filePath);
4045
4730
  const scenarioEntries = scopeData !== null ? Object.entries(scopeData.scenarios) : [];
4046
4731
  const defaultEntry = scenarioEntries.find(([k]) => k === "default") ?? scenarioEntries[0];
@@ -4053,7 +4738,8 @@ function registerRenderAll(renderCmd) {
4053
4738
  812,
4054
4739
  allCssFiles,
4055
4740
  process.cwd(),
4056
- wrapperScript
4741
+ wrapperScript,
4742
+ isIcon
4057
4743
  );
4058
4744
  const outcome = await render.safeRender(
4059
4745
  () => renderer.renderCell(renderProps, descriptor.complexityClass),
@@ -4090,14 +4776,32 @@ function registerRenderAll(renderCmd) {
4090
4776
  2
4091
4777
  )
4092
4778
  );
4779
+ failures.push({
4780
+ component: name,
4781
+ stage: "render",
4782
+ message: outcome.error.message,
4783
+ outputPath: errPath,
4784
+ hints: outcome.error.heuristicFlags
4785
+ });
4786
+ outputPaths.push(errPath);
4093
4787
  return;
4094
4788
  }
4095
4789
  const result = outcome.result;
4096
4790
  results.push({ name, renderTimeMs: result.renderTimeMs, success: true });
4097
- const pngPath = path.resolve(outputDir, `${name}.png`);
4098
- fs.writeFileSync(pngPath, result.screenshot);
4791
+ if (!isIcon) {
4792
+ const pngPath = path.resolve(outputDir, `${name}.png`);
4793
+ fs.writeFileSync(pngPath, result.screenshot);
4794
+ outputPaths.push(pngPath);
4795
+ }
4099
4796
  const jsonPath = path.resolve(outputDir, `${name}.json`);
4100
- fs.writeFileSync(jsonPath, JSON.stringify(formatRenderJson(name, {}, result), null, 2));
4797
+ const renderJson = formatRenderJson(name, {}, result);
4798
+ const extResult = result;
4799
+ if (isIcon && extResult.svgContent) {
4800
+ renderJson.svgContent = extResult.svgContent;
4801
+ delete renderJson.screenshot;
4802
+ }
4803
+ fs.writeFileSync(jsonPath, JSON.stringify(renderJson, null, 2));
4804
+ outputPaths.push(jsonPath);
4101
4805
  const rawStyles = result.computedStyles["[data-reactscope-root] > *"] ?? {};
4102
4806
  const compStyles = {
4103
4807
  colors: {},
@@ -4163,15 +4867,21 @@ function registerRenderAll(renderCmd) {
4163
4867
  existingJson.axisLabels = [scenarioAxis.values];
4164
4868
  fs.writeFileSync(jsonPath, JSON.stringify(existingJson, null, 2));
4165
4869
  } catch (matrixErr) {
4166
- process.stderr.write(
4167
- ` [warn] Matrix render for ${name} failed: ${matrixErr instanceof Error ? matrixErr.message : String(matrixErr)}
4168
- `
4169
- );
4870
+ const message = matrixErr instanceof Error ? matrixErr.message : String(matrixErr);
4871
+ process.stderr.write(` [warn] Matrix render for ${name} failed: ${message}
4872
+ `);
4873
+ failures.push({
4874
+ component: name,
4875
+ stage: "matrix",
4876
+ message,
4877
+ outputPath: jsonPath
4878
+ });
4170
4879
  }
4171
4880
  }
4172
4881
  if (isTTY()) {
4882
+ const suffix = isIcon ? " [icon/svg]" : "";
4173
4883
  process.stdout.write(
4174
- `\u2713 ${name} \u2192 ${opts.outputDir}/${name}.png (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
4884
+ `\u2713 ${name} \u2192 ${opts.outputDir}/${name}${isIcon ? ".json" : ".png"} (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)${suffix}
4175
4885
  `
4176
4886
  );
4177
4887
  }
@@ -4198,15 +4908,25 @@ function registerRenderAll(renderCmd) {
4198
4908
  "compliance-styles.json"
4199
4909
  );
4200
4910
  fs.writeFileSync(compStylesPath, JSON.stringify(complianceStylesMap, null, 2));
4911
+ outputPaths.push(compStylesPath);
4201
4912
  process.stderr.write(`[scope/render] \u2713 Wrote compliance-styles.json
4202
4913
  `);
4203
4914
  process.stderr.write("\n");
4204
4915
  const summary = formatSummaryText(results, outputDir);
4205
4916
  process.stderr.write(`${summary}
4206
4917
  `);
4918
+ const summaryPath = writeRunSummary({
4919
+ command: "scope render all",
4920
+ status: failures.length > 0 ? "failed" : "success",
4921
+ outputPaths,
4922
+ failures
4923
+ });
4924
+ process.stderr.write(`[scope/render] Run summary written to ${summaryPath}
4925
+ `);
4926
+ if (failures.length > 0) process.exit(1);
4207
4927
  } catch (err) {
4208
4928
  await shutdownPool3();
4209
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
4929
+ process.stderr.write(`${formatScopeDiagnostic(err)}
4210
4930
  `);
4211
4931
  process.exit(1);
4212
4932
  }
@@ -4239,7 +4959,7 @@ function resolveMatrixFormat(formatFlag, spriteAlreadyWritten) {
4239
4959
  }
4240
4960
  function createRenderCommand() {
4241
4961
  const renderCmd = new commander.Command("render").description(
4242
- "Render components to PNG or JSON via esbuild + BrowserPool"
4962
+ 'Render React components to PNG screenshots or JSON render data.\n\nScope uses two render engines depending on component complexity:\n Satori \u2014 fast SVG\u2192PNG renderer for simple/presentational components\n BrowserPool \u2014 Playwright headless browser for complex components\n (context providers, hooks, async, global CSS)\n\nPREREQUISITES:\n 1. reactscope.config.json exists (scope init)\n 2. manifest.json is up to date (scope manifest generate)\n 3. If using globalCSS: Tailwind/PostCSS is configured in the project\n\nOUTPUTS (written to .reactscope/renders/<ComponentName>/):\n screenshot.png retina-quality PNG (2\xD7 physical pixels, displayed at 1\xD7)\n render.json props, dimensions, DOM snapshot, a11y, computed styles\n compliance-styles.json (render all only) \u2014 token matching input\n\nExamples:\n scope render component Button\n scope render matrix Button --axes "variant:primary,ghost size:sm,md,lg"\n scope render all\n scope render all --format json --output-dir ./out'
4243
4963
  );
4244
4964
  registerRenderSingle(renderCmd);
4245
4965
  registerRenderMatrix(renderCmd);
@@ -4266,8 +4986,17 @@ async function shutdownPool4() {
4266
4986
  }
4267
4987
  }
4268
4988
  async function renderComponent2(filePath, componentName, props, viewportWidth, viewportHeight) {
4989
+ const PAD = 24;
4269
4990
  const pool = await getPool4(viewportWidth, viewportHeight);
4270
- const htmlHarness = await buildComponentHarness(filePath, componentName, props, viewportWidth);
4991
+ const htmlHarness = await buildComponentHarness(
4992
+ filePath,
4993
+ componentName,
4994
+ props,
4995
+ viewportWidth,
4996
+ void 0,
4997
+ void 0,
4998
+ PAD
4999
+ );
4271
5000
  const slot = await pool.acquire();
4272
5001
  const { page } = slot;
4273
5002
  try {
@@ -4307,7 +5036,6 @@ async function renderComponent2(filePath, componentName, props, viewportWidth, v
4307
5036
  `Component "${componentName}" rendered with zero bounding box \u2014 it may be invisible or not mounted`
4308
5037
  );
4309
5038
  }
4310
- const PAD = 24;
4311
5039
  const MIN_W = 320;
4312
5040
  const MIN_H = 200;
4313
5041
  const clipX = Math.max(0, boundingBox.x - PAD);
@@ -4399,12 +5127,12 @@ async function runBaseline(options = {}) {
4399
5127
  fs.mkdirSync(rendersDir, { recursive: true });
4400
5128
  let manifest$1;
4401
5129
  if (manifestPath !== void 0) {
4402
- const { readFileSync: readFileSync14 } = await import('fs');
5130
+ const { readFileSync: readFileSync18 } = await import('fs');
4403
5131
  const absPath = path.resolve(rootDir, manifestPath);
4404
5132
  if (!fs.existsSync(absPath)) {
4405
5133
  throw new Error(`Manifest not found at ${absPath}.`);
4406
5134
  }
4407
- manifest$1 = JSON.parse(readFileSync14(absPath, "utf-8"));
5135
+ manifest$1 = JSON.parse(readFileSync18(absPath, "utf-8"));
4408
5136
  process.stderr.write(`Loaded manifest from ${manifestPath}
4409
5137
  `);
4410
5138
  } else {
@@ -4596,8 +5324,17 @@ async function shutdownPool5() {
4596
5324
  }
4597
5325
  }
4598
5326
  async function renderComponent3(filePath, componentName, props, viewportWidth, viewportHeight) {
5327
+ const PAD = 24;
4599
5328
  const pool = await getPool5(viewportWidth, viewportHeight);
4600
- const htmlHarness = await buildComponentHarness(filePath, componentName, props, viewportWidth);
5329
+ const htmlHarness = await buildComponentHarness(
5330
+ filePath,
5331
+ componentName,
5332
+ props,
5333
+ viewportWidth,
5334
+ void 0,
5335
+ void 0,
5336
+ PAD
5337
+ );
4601
5338
  const slot = await pool.acquire();
4602
5339
  const { page } = slot;
4603
5340
  try {
@@ -4637,7 +5374,6 @@ async function renderComponent3(filePath, componentName, props, viewportWidth, v
4637
5374
  `Component "${componentName}" rendered with zero bounding box \u2014 it may be invisible or not mounted`
4638
5375
  );
4639
5376
  }
4640
- const PAD = 24;
4641
5377
  const MIN_W = 320;
4642
5378
  const MIN_H = 200;
4643
5379
  const clipX = Math.max(0, boundingBox.x - PAD);
@@ -4734,6 +5470,7 @@ function classifyComponent(entry, regressionThreshold) {
4734
5470
  async function runDiff(options = {}) {
4735
5471
  const {
4736
5472
  baselineDir: baselineDirRaw = DEFAULT_BASELINE_DIR2,
5473
+ complianceTokens = [],
4737
5474
  componentsGlob,
4738
5475
  manifestPath,
4739
5476
  viewportWidth = 375,
@@ -4849,7 +5586,7 @@ async function runDiff(options = {}) {
4849
5586
  if (isTTY() && total > 0) {
4850
5587
  process.stderr.write("\n");
4851
5588
  }
4852
- const resolver = new tokens.TokenResolver([]);
5589
+ const resolver = new tokens.TokenResolver(complianceTokens);
4853
5590
  const engine = new tokens.ComplianceEngine(resolver);
4854
5591
  const currentBatchReport = engine.auditBatch(computedStylesMap);
4855
5592
  const entries = [];
@@ -5445,6 +6182,161 @@ function buildStructuredReport(report) {
5445
6182
  route: report.route?.pattern ?? null
5446
6183
  };
5447
6184
  }
6185
+ async function buildPlaygroundHarness(filePath, componentName, projectCss, wrapperScript) {
6186
+ const bundledScript = await bundlePlaygroundIIFE(filePath, componentName);
6187
+ return wrapPlaygroundHtml(bundledScript, projectCss);
6188
+ }
6189
+ async function bundlePlaygroundIIFE(filePath, componentName) {
6190
+ const wrapperCode = (
6191
+ /* ts */
6192
+ `
6193
+ import * as __scopeMod from ${JSON.stringify(filePath)};
6194
+ import { createRoot } from "react-dom/client";
6195
+ import { createElement, Component as ReactComponent } from "react";
6196
+
6197
+ (function scopePlaygroundHarness() {
6198
+ var Target =
6199
+ __scopeMod["default"] ||
6200
+ __scopeMod[${JSON.stringify(componentName)}] ||
6201
+ (Object.values(__scopeMod).find(
6202
+ function(v) { return typeof v === "function" && /^[A-Z]/.test(v.name || ""); }
6203
+ ));
6204
+
6205
+ if (!Target) {
6206
+ document.getElementById("scope-root").innerHTML =
6207
+ '<p style="color:#dc2626;font-family:system-ui;font-size:13px">No renderable component found.</p>';
6208
+ return;
6209
+ }
6210
+
6211
+ // Error boundary to catch async render errors (React unmounts the whole
6212
+ // root when an error is uncaught \u2014 this keeps the error visible instead).
6213
+ var errorStyle = "color:#dc2626;font-family:system-ui;font-size:13px;padding:12px";
6214
+ class ScopeBoundary extends ReactComponent {
6215
+ constructor(p) { super(p); this.state = { error: null }; }
6216
+ static getDerivedStateFromError(err) { return { error: err }; }
6217
+ render() {
6218
+ if (this.state.error) {
6219
+ return createElement("pre", { style: errorStyle },
6220
+ "Render error: " + (this.state.error.message || String(this.state.error)));
6221
+ }
6222
+ return this.props.children;
6223
+ }
6224
+ }
6225
+
6226
+ var rootEl = document.getElementById("scope-root");
6227
+ var root = createRoot(rootEl);
6228
+ var Wrapper = window.__SCOPE_WRAPPER__;
6229
+
6230
+ function render(props) {
6231
+ var inner = createElement(Target, props);
6232
+ if (Wrapper) inner = createElement(Wrapper, null, inner);
6233
+ root.render(createElement(ScopeBoundary, null, inner));
6234
+ }
6235
+
6236
+ // Render immediately with empty props
6237
+ render({});
6238
+
6239
+ // Listen for messages from the parent frame
6240
+ window.addEventListener("message", function(e) {
6241
+ if (!e.data) return;
6242
+ if (e.data.type === "scope-playground-props") {
6243
+ render(e.data.props || {});
6244
+ } else if (e.data.type === "scope-playground-theme") {
6245
+ document.documentElement.classList.toggle("dark", e.data.theme === "dark");
6246
+ }
6247
+ });
6248
+
6249
+ // Report content height changes to the parent frame
6250
+ var ro = new ResizeObserver(function() {
6251
+ var h = rootEl.scrollHeight;
6252
+ if (parent !== window) {
6253
+ parent.postMessage({ type: "scope-playground-height", height: h }, "*");
6254
+ }
6255
+ });
6256
+ ro.observe(rootEl);
6257
+ })();
6258
+ `
6259
+ );
6260
+ const result = await esbuild2__namespace.build({
6261
+ stdin: {
6262
+ contents: wrapperCode,
6263
+ resolveDir: path.dirname(filePath),
6264
+ loader: "tsx",
6265
+ sourcefile: "__scope_playground__.tsx"
6266
+ },
6267
+ bundle: true,
6268
+ format: "iife",
6269
+ write: false,
6270
+ platform: "browser",
6271
+ jsx: "automatic",
6272
+ jsxImportSource: "react",
6273
+ target: "es2020",
6274
+ external: [],
6275
+ define: {
6276
+ "process.env.NODE_ENV": '"production"',
6277
+ global: "globalThis"
6278
+ },
6279
+ logLevel: "silent",
6280
+ banner: {
6281
+ js: "/* @agent-scope/cli playground harness */"
6282
+ },
6283
+ loader: {
6284
+ ".css": "empty",
6285
+ ".svg": "dataurl",
6286
+ ".png": "dataurl",
6287
+ ".jpg": "dataurl",
6288
+ ".jpeg": "dataurl",
6289
+ ".gif": "dataurl",
6290
+ ".webp": "dataurl",
6291
+ ".ttf": "dataurl",
6292
+ ".woff": "dataurl",
6293
+ ".woff2": "dataurl"
6294
+ }
6295
+ });
6296
+ if (result.errors.length > 0) {
6297
+ const msg = result.errors.map((e) => `${e.text}${e.location ? ` (${e.location.file}:${e.location.line})` : ""}`).join("\n");
6298
+ throw new Error(`esbuild failed to bundle playground component:
6299
+ ${msg}`);
6300
+ }
6301
+ const outputFile = result.outputFiles?.[0];
6302
+ if (outputFile === void 0 || outputFile.text.length === 0) {
6303
+ throw new Error("esbuild produced no playground output");
6304
+ }
6305
+ return outputFile.text;
6306
+ }
6307
+ function wrapPlaygroundHtml(bundledScript, projectCss, wrapperScript) {
6308
+ const projectStyleBlock = projectCss != null && projectCss.length > 0 ? `<style id="scope-project-css">
6309
+ ${projectCss.replace(/<\/style>/gi, "<\\/style>")}
6310
+ </style>` : "";
6311
+ const wrapperScriptBlock = "";
6312
+ return `<!DOCTYPE html>
6313
+ <html lang="en">
6314
+ <head>
6315
+ <meta charset="UTF-8" />
6316
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6317
+ <script>
6318
+ window.__SCOPE_WRAPPER__ = null;
6319
+ // Prevent React DevTools from interfering with the embedded playground.
6320
+ // The hook causes render instability in same-origin iframes.
6321
+ delete window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
6322
+ </script>
6323
+ <style>
6324
+ *, *::before, *::after { box-sizing: border-box; }
6325
+ html, body { margin: 0; padding: 0; font-family: system-ui, sans-serif; }
6326
+ #scope-root { padding: 16px; min-width: 1px; min-height: 1px; }
6327
+ </style>
6328
+ ${projectStyleBlock}
6329
+ <style>html, body { background: transparent !important; }</style>
6330
+ </head>
6331
+ <body>
6332
+ <div id="scope-root" data-reactscope-root></div>
6333
+ ${wrapperScriptBlock}
6334
+ <script>${bundledScript}</script>
6335
+ </body>
6336
+ </html>`;
6337
+ }
6338
+
6339
+ // src/site-commands.ts
5448
6340
  var MIME_TYPES = {
5449
6341
  ".html": "text/html; charset=utf-8",
5450
6342
  ".css": "text/css; charset=utf-8",
@@ -5456,8 +6348,419 @@ var MIME_TYPES = {
5456
6348
  ".svg": "image/svg+xml",
5457
6349
  ".ico": "image/x-icon"
5458
6350
  };
6351
+ function slugify(name) {
6352
+ return name.replace(/([A-Z])/g, (m) => `-${m.toLowerCase()}`).replace(/^-/, "").replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
6353
+ }
6354
+ function loadGlobalCssFilesFromConfig2(cwd) {
6355
+ const configPath = path.resolve(cwd, "reactscope.config.json");
6356
+ if (!fs.existsSync(configPath)) return [];
6357
+ try {
6358
+ const raw = fs.readFileSync(configPath, "utf-8");
6359
+ const cfg = JSON.parse(raw);
6360
+ return cfg.components?.wrappers?.globalCSS ?? [];
6361
+ } catch {
6362
+ return [];
6363
+ }
6364
+ }
6365
+ function loadIconPatternsFromConfig2(cwd) {
6366
+ const configPath = path.resolve(cwd, "reactscope.config.json");
6367
+ if (!fs.existsSync(configPath)) return [];
6368
+ try {
6369
+ const raw = fs.readFileSync(configPath, "utf-8");
6370
+ const cfg = JSON.parse(raw);
6371
+ return cfg.icons?.patterns ?? [];
6372
+ } catch {
6373
+ return [];
6374
+ }
6375
+ }
6376
+ 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>`;
6377
+ function injectLiveReloadScript(html) {
6378
+ const idx = html.lastIndexOf("</body>");
6379
+ if (idx >= 0) return html.slice(0, idx) + LIVERELOAD_SCRIPT + html.slice(idx);
6380
+ return html + LIVERELOAD_SCRIPT;
6381
+ }
6382
+ function loadWatchConfig(rootDir) {
6383
+ const configPath = path.resolve(rootDir, "reactscope.config.json");
6384
+ if (!fs.existsSync(configPath)) return null;
6385
+ try {
6386
+ const raw = fs.readFileSync(configPath, "utf-8");
6387
+ const cfg = JSON.parse(raw);
6388
+ const result = {};
6389
+ const components = cfg.components;
6390
+ if (components && typeof components === "object") {
6391
+ if (Array.isArray(components.include)) result.include = components.include;
6392
+ if (Array.isArray(components.exclude)) result.exclude = components.exclude;
6393
+ }
6394
+ if (Array.isArray(cfg.internalPatterns))
6395
+ result.internalPatterns = cfg.internalPatterns;
6396
+ if (Array.isArray(cfg.collections)) result.collections = cfg.collections;
6397
+ const icons = cfg.icons;
6398
+ if (icons && typeof icons === "object" && Array.isArray(icons.patterns)) {
6399
+ result.iconPatterns = icons.patterns;
6400
+ }
6401
+ return result;
6402
+ } catch {
6403
+ return null;
6404
+ }
6405
+ }
6406
+ function watchGlob(pattern, filePath) {
6407
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
6408
+ const regexStr = escaped.replace(/\*\*/g, "\xA7GLOBSTAR\xA7").replace(/\*/g, "[^/]*").replace(/\u00a7GLOBSTAR\u00a7/g, ".*");
6409
+ return new RegExp(`^${regexStr}$`, "i").test(filePath);
6410
+ }
6411
+ function matchesWatchPatterns(filePath, include, exclude) {
6412
+ for (const pattern of exclude) {
6413
+ if (watchGlob(pattern, filePath)) return false;
6414
+ }
6415
+ for (const pattern of include) {
6416
+ if (watchGlob(pattern, filePath)) return true;
6417
+ }
6418
+ return false;
6419
+ }
6420
+ function findAffectedComponents(manifest, changedFiles, previousManifest) {
6421
+ const affected = /* @__PURE__ */ new Set();
6422
+ const normalised = changedFiles.map((f) => f.replace(/\\/g, "/"));
6423
+ for (const [name, descriptor] of Object.entries(manifest.components)) {
6424
+ const componentFile = descriptor.filePath.replace(/\\/g, "/");
6425
+ for (const changed of normalised) {
6426
+ if (componentFile === changed) {
6427
+ affected.add(name);
6428
+ break;
6429
+ }
6430
+ const scopeBase = changed.replace(/\.scope\.(ts|tsx|js|jsx)$/, "");
6431
+ const compBase = componentFile.replace(/\.(tsx|ts|jsx|js)$/, "");
6432
+ if (scopeBase !== changed && compBase === scopeBase) {
6433
+ affected.add(name);
6434
+ break;
6435
+ }
6436
+ }
6437
+ }
6438
+ if (previousManifest) {
6439
+ const oldNames = new Set(Object.keys(previousManifest.components));
6440
+ for (const name of Object.keys(manifest.components)) {
6441
+ if (!oldNames.has(name)) affected.add(name);
6442
+ }
6443
+ }
6444
+ return [...affected];
6445
+ }
6446
+ async function renderComponentsForWatch(manifest, componentNames, rootDir, inputDir) {
6447
+ if (componentNames.length === 0) return;
6448
+ const rendersDir = path.join(inputDir, "renders");
6449
+ await promises.mkdir(rendersDir, { recursive: true });
6450
+ const cssFiles = loadGlobalCssFilesFromConfig2(rootDir);
6451
+ const iconPatterns = loadIconPatternsFromConfig2(rootDir);
6452
+ const complianceStylesPath = path.join(inputDir, "compliance-styles.json");
6453
+ let complianceStyles = {};
6454
+ if (fs.existsSync(complianceStylesPath)) {
6455
+ try {
6456
+ complianceStyles = JSON.parse(fs.readFileSync(complianceStylesPath, "utf-8"));
6457
+ } catch {
6458
+ }
6459
+ }
6460
+ for (const name of componentNames) {
6461
+ const descriptor = manifest.components[name];
6462
+ if (!descriptor) continue;
6463
+ const filePath = path.resolve(rootDir, descriptor.filePath);
6464
+ const isIcon = isIconComponent(descriptor.filePath, name, iconPatterns);
6465
+ let scopeData = null;
6466
+ try {
6467
+ scopeData = await loadScopeFileForComponent(filePath);
6468
+ } catch {
6469
+ }
6470
+ const scenarioEntries = scopeData ? Object.entries(scopeData.scenarios) : [];
6471
+ const defaultEntry = scenarioEntries.find(([k]) => k === "default") ?? scenarioEntries[0];
6472
+ const renderProps = defaultEntry?.[1] ?? {};
6473
+ let wrapperScript;
6474
+ try {
6475
+ wrapperScript = scopeData?.hasWrapper ? await buildWrapperScript(scopeData.filePath) : void 0;
6476
+ } catch {
6477
+ }
6478
+ const renderer = buildRenderer(
6479
+ filePath,
6480
+ name,
6481
+ 375,
6482
+ 812,
6483
+ cssFiles,
6484
+ rootDir,
6485
+ wrapperScript,
6486
+ isIcon
6487
+ );
6488
+ const outcome = await render.safeRender(
6489
+ () => renderer.renderCell(renderProps, descriptor.complexityClass),
6490
+ {
6491
+ props: renderProps,
6492
+ sourceLocation: { file: descriptor.filePath, line: descriptor.loc.start, column: 0 }
6493
+ }
6494
+ );
6495
+ if (outcome.crashed) {
6496
+ process.stderr.write(` \u2717 ${name}: ${outcome.error.message}
6497
+ `);
6498
+ continue;
6499
+ }
6500
+ const result = outcome.result;
6501
+ if (!isIcon) {
6502
+ fs.writeFileSync(path.join(rendersDir, `${name}.png`), result.screenshot);
6503
+ }
6504
+ const renderJson = formatRenderJson(name, renderProps, result);
6505
+ const extResult = result;
6506
+ if (isIcon && extResult.svgContent) {
6507
+ renderJson.svgContent = extResult.svgContent;
6508
+ delete renderJson.screenshot;
6509
+ }
6510
+ fs.writeFileSync(path.join(rendersDir, `${name}.json`), JSON.stringify(renderJson, null, 2));
6511
+ const rawStyles = result.computedStyles["[data-reactscope-root] > *"] ?? {};
6512
+ const compStyles = {
6513
+ colors: {},
6514
+ spacing: {},
6515
+ typography: {},
6516
+ borders: {},
6517
+ shadows: {}
6518
+ };
6519
+ for (const [prop, val] of Object.entries(rawStyles)) {
6520
+ if (!val || val === "none" || val === "") continue;
6521
+ const lower = prop.toLowerCase();
6522
+ if (lower.includes("color") || lower.includes("background")) {
6523
+ compStyles.colors[prop] = val;
6524
+ } else if (lower.includes("padding") || lower.includes("margin") || lower.includes("gap") || lower.includes("width") || lower.includes("height")) {
6525
+ compStyles.spacing[prop] = val;
6526
+ } else if (lower.includes("font") || lower.includes("lineheight") || lower.includes("letterspacing") || lower.includes("texttransform")) {
6527
+ compStyles.typography[prop] = val;
6528
+ } else if (lower.includes("border") || lower.includes("radius") || lower.includes("outline")) {
6529
+ compStyles.borders[prop] = val;
6530
+ } else if (lower.includes("shadow")) {
6531
+ compStyles.shadows[prop] = val;
6532
+ }
6533
+ }
6534
+ complianceStyles[name] = compStyles;
6535
+ process.stderr.write(` \u2713 ${name} (${result.renderTimeMs.toFixed(0)}ms)
6536
+ `);
6537
+ }
6538
+ await shutdownPool3();
6539
+ fs.writeFileSync(complianceStylesPath, JSON.stringify(complianceStyles, null, 2), "utf-8");
6540
+ }
6541
+ async function watchRebuildSite(inputDir, outputDir, title, basePath) {
6542
+ const rootDir = process.cwd();
6543
+ await generatePlaygrounds(inputDir, outputDir);
6544
+ const iconPatterns = loadIconPatternsFromConfig2(rootDir);
6545
+ let tokenFilePath;
6546
+ const autoPath = path.resolve(rootDir, "reactscope.tokens.json");
6547
+ if (fs.existsSync(autoPath)) tokenFilePath = autoPath;
6548
+ let compliancePath;
6549
+ const crPath = path.join(inputDir, "compliance-report.json");
6550
+ if (fs.existsSync(crPath)) compliancePath = crPath;
6551
+ await site.buildSite({
6552
+ inputDir,
6553
+ outputDir,
6554
+ basePath,
6555
+ ...compliancePath && { compliancePath },
6556
+ ...tokenFilePath && { tokenFilePath },
6557
+ title,
6558
+ iconPatterns
6559
+ });
6560
+ }
6561
+ function findStaleComponents(manifest, previousManifest, rendersDir) {
6562
+ const stale = [];
6563
+ for (const [name, descriptor] of Object.entries(manifest.components)) {
6564
+ const jsonPath = path.join(rendersDir, `${name}.json`);
6565
+ if (!fs.existsSync(jsonPath)) {
6566
+ stale.push(name);
6567
+ continue;
6568
+ }
6569
+ if (!previousManifest) continue;
6570
+ const prev = previousManifest.components[name];
6571
+ if (!prev) {
6572
+ stale.push(name);
6573
+ continue;
6574
+ }
6575
+ if (JSON.stringify(prev) !== JSON.stringify(descriptor)) {
6576
+ stale.push(name);
6577
+ }
6578
+ }
6579
+ return stale;
6580
+ }
6581
+ async function runFullBuild(rootDir, inputDir, outputDir, title, basePath) {
6582
+ process.stderr.write("[watch] Starting\u2026\n");
6583
+ const config = loadWatchConfig(rootDir);
6584
+ const manifestPath = path.join(inputDir, "manifest.json");
6585
+ let previousManifest = null;
6586
+ if (fs.existsSync(manifestPath)) {
6587
+ try {
6588
+ previousManifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
6589
+ } catch {
6590
+ }
6591
+ }
6592
+ process.stderr.write("[watch] Generating manifest\u2026\n");
6593
+ const manifest$1 = await manifest.generateManifest({
6594
+ rootDir,
6595
+ ...config?.include && { include: config.include },
6596
+ ...config?.exclude && { exclude: config.exclude },
6597
+ ...config?.internalPatterns && { internalPatterns: config.internalPatterns },
6598
+ ...config?.collections && { collections: config.collections },
6599
+ ...config?.iconPatterns && { iconPatterns: config.iconPatterns }
6600
+ });
6601
+ await promises.mkdir(inputDir, { recursive: true });
6602
+ fs.writeFileSync(path.join(inputDir, "manifest.json"), JSON.stringify(manifest$1, null, 2), "utf-8");
6603
+ const count = Object.keys(manifest$1.components).length;
6604
+ process.stderr.write(`[watch] Found ${count} components
6605
+ `);
6606
+ const rendersDir = path.join(inputDir, "renders");
6607
+ const stale = findStaleComponents(manifest$1, previousManifest, rendersDir);
6608
+ if (stale.length > 0) {
6609
+ process.stderr.write(
6610
+ `[watch] Rendering ${stale.length} component(s) (${count - stale.length} already up-to-date)
6611
+ `
6612
+ );
6613
+ await renderComponentsForWatch(manifest$1, stale, rootDir, inputDir);
6614
+ } else {
6615
+ process.stderr.write("[watch] All renders up-to-date, skipping render step\n");
6616
+ }
6617
+ process.stderr.write("[watch] Building site\u2026\n");
6618
+ await watchRebuildSite(inputDir, outputDir, title, basePath);
6619
+ process.stderr.write("[watch] Ready\n");
6620
+ return manifest$1;
6621
+ }
6622
+ function startFileWatcher(opts) {
6623
+ const { rootDir, inputDir, outputDir, title, basePath, notifyReload } = opts;
6624
+ let previousManifest = opts.previousManifest;
6625
+ const config = loadWatchConfig(rootDir);
6626
+ const includePatterns = config?.include ?? ["src/**/*.tsx", "src/**/*.ts"];
6627
+ const excludePatterns = config?.exclude ?? [
6628
+ "**/node_modules/**",
6629
+ "**/*.test.*",
6630
+ "**/*.spec.*",
6631
+ "**/dist/**",
6632
+ "**/*.d.ts"
6633
+ ];
6634
+ let debounceTimer = null;
6635
+ const pendingFiles = /* @__PURE__ */ new Set();
6636
+ let isRunning = false;
6637
+ const IGNORE_PREFIXES = ["node_modules/", ".reactscope/", "dist/", ".git/", ".next/", ".turbo/"];
6638
+ const handleChange = async () => {
6639
+ if (isRunning) return;
6640
+ isRunning = true;
6641
+ const changedFiles = [...pendingFiles];
6642
+ pendingFiles.clear();
6643
+ try {
6644
+ process.stderr.write(`
6645
+ [watch] ${changedFiles.length} file(s) changed
6646
+ `);
6647
+ process.stderr.write("[watch] Regenerating manifest\u2026\n");
6648
+ const newManifest = await manifest.generateManifest({
6649
+ rootDir,
6650
+ ...config?.include && { include: config.include },
6651
+ ...config?.exclude && { exclude: config.exclude },
6652
+ ...config?.internalPatterns && { internalPatterns: config.internalPatterns },
6653
+ ...config?.collections && { collections: config.collections },
6654
+ ...config?.iconPatterns && { iconPatterns: config.iconPatterns }
6655
+ });
6656
+ fs.writeFileSync(path.join(inputDir, "manifest.json"), JSON.stringify(newManifest, null, 2), "utf-8");
6657
+ const affected = findAffectedComponents(newManifest, changedFiles, previousManifest);
6658
+ if (affected.length > 0) {
6659
+ process.stderr.write(`[watch] Re-rendering: ${affected.join(", ")}
6660
+ `);
6661
+ await renderComponentsForWatch(newManifest, affected, rootDir, inputDir);
6662
+ } else {
6663
+ process.stderr.write("[watch] No components directly affected\n");
6664
+ }
6665
+ process.stderr.write("[watch] Rebuilding site\u2026\n");
6666
+ await watchRebuildSite(inputDir, outputDir, title, basePath);
6667
+ previousManifest = newManifest;
6668
+ process.stderr.write("[watch] Done\n");
6669
+ notifyReload();
6670
+ } catch (err) {
6671
+ process.stderr.write(`[watch] Error: ${err instanceof Error ? err.message : String(err)}
6672
+ `);
6673
+ } finally {
6674
+ isRunning = false;
6675
+ if (pendingFiles.size > 0) {
6676
+ handleChange();
6677
+ }
6678
+ }
6679
+ };
6680
+ const onFileChange = (_eventType, filename) => {
6681
+ if (!filename) return;
6682
+ const normalised = filename.replace(/\\/g, "/");
6683
+ for (const prefix of IGNORE_PREFIXES) {
6684
+ if (normalised.startsWith(prefix)) return;
6685
+ }
6686
+ if (!matchesWatchPatterns(normalised, includePatterns, excludePatterns)) return;
6687
+ pendingFiles.add(normalised);
6688
+ if (debounceTimer) clearTimeout(debounceTimer);
6689
+ debounceTimer = setTimeout(() => {
6690
+ debounceTimer = null;
6691
+ handleChange();
6692
+ }, 500);
6693
+ };
6694
+ try {
6695
+ fs.watch(rootDir, { recursive: true }, onFileChange);
6696
+ process.stderr.write(`[watch] Watching for changes (${includePatterns.join(", ")})
6697
+ `);
6698
+ } catch (err) {
6699
+ process.stderr.write(
6700
+ `[watch] Warning: Could not start watcher: ${err instanceof Error ? err.message : String(err)}
6701
+ `
6702
+ );
6703
+ }
6704
+ }
6705
+ async function generatePlaygrounds(inputDir, outputDir) {
6706
+ const manifestPath = path.join(inputDir, "manifest.json");
6707
+ const raw = fs.readFileSync(manifestPath, "utf-8");
6708
+ const manifest = JSON.parse(raw);
6709
+ const rootDir = process.cwd();
6710
+ const componentNames = Object.keys(manifest.components);
6711
+ if (componentNames.length === 0) return [];
6712
+ const playgroundDir = path.join(outputDir, "playground");
6713
+ await promises.mkdir(playgroundDir, { recursive: true });
6714
+ const cssFiles = loadGlobalCssFilesFromConfig2(rootDir);
6715
+ const projectCss = await loadGlobalCss(cssFiles, rootDir) ?? void 0;
6716
+ let succeeded = 0;
6717
+ const failures = [];
6718
+ const allDefaults = {};
6719
+ for (const name of componentNames) {
6720
+ const descriptor = manifest.components[name];
6721
+ if (!descriptor) continue;
6722
+ const filePath = path.resolve(rootDir, descriptor.filePath);
6723
+ const slug = slugify(name);
6724
+ try {
6725
+ const scopeData = await loadScopeFileForComponent(filePath);
6726
+ if (scopeData) {
6727
+ const defaultScenario = scopeData.scenarios.default ?? Object.values(scopeData.scenarios)[0];
6728
+ if (defaultScenario) allDefaults[name] = defaultScenario;
6729
+ }
6730
+ } catch {
6731
+ }
6732
+ try {
6733
+ const html = await buildPlaygroundHarness(filePath, name, projectCss);
6734
+ await promises.writeFile(path.join(playgroundDir, `${slug}.html`), html, "utf-8");
6735
+ succeeded++;
6736
+ } catch (err) {
6737
+ process.stderr.write(
6738
+ `[scope/site] \u26A0 playground skip: ${name} \u2014 ${err instanceof Error ? err.message : String(err)}
6739
+ `
6740
+ );
6741
+ failures.push({
6742
+ component: name,
6743
+ stage: "playground",
6744
+ message: err instanceof Error ? err.message : String(err),
6745
+ outputPath: path.join(playgroundDir, `${slug}.html`)
6746
+ });
6747
+ }
6748
+ }
6749
+ await promises.writeFile(
6750
+ path.join(inputDir, "playground-defaults.json"),
6751
+ JSON.stringify(allDefaults, null, 2),
6752
+ "utf-8"
6753
+ );
6754
+ process.stderr.write(
6755
+ `[scope/site] Playgrounds: ${succeeded} built${failures.length > 0 ? `, ${failures.length} failed` : ""}
6756
+ `
6757
+ );
6758
+ return failures;
6759
+ }
5459
6760
  function registerBuild(siteCmd) {
5460
- siteCmd.command("build").description("Build a static HTML gallery from .reactscope/ output").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(
6761
+ siteCmd.command("build").description(
6762
+ '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'
6763
+ ).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(
5461
6764
  async (opts) => {
5462
6765
  try {
5463
6766
  const inputDir = path.resolve(process.cwd(), opts.input);
@@ -5477,6 +6780,16 @@ Run \`scope manifest generate\` first.`
5477
6780
  }
5478
6781
  process.stderr.write(`Building site from ${inputDir}\u2026
5479
6782
  `);
6783
+ process.stderr.write("Bundling playgrounds\u2026\n");
6784
+ const failures = await generatePlaygrounds(inputDir, outputDir);
6785
+ const iconPatterns = loadIconPatternsFromConfig2(process.cwd());
6786
+ let tokenFilePath = opts.tokens ? path.resolve(process.cwd(), opts.tokens) : void 0;
6787
+ if (tokenFilePath === void 0) {
6788
+ const autoPath = path.resolve(process.cwd(), "reactscope.tokens.json");
6789
+ if (fs.existsSync(autoPath)) {
6790
+ tokenFilePath = autoPath;
6791
+ }
6792
+ }
5480
6793
  await site.buildSite({
5481
6794
  inputDir,
5482
6795
  outputDir,
@@ -5484,14 +6797,44 @@ Run \`scope manifest generate\` first.`
5484
6797
  ...opts.compliance !== void 0 && {
5485
6798
  compliancePath: path.resolve(process.cwd(), opts.compliance)
5486
6799
  },
5487
- title: opts.title
6800
+ ...tokenFilePath !== void 0 && { tokenFilePath },
6801
+ title: opts.title,
6802
+ iconPatterns
6803
+ });
6804
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
6805
+ const componentCount = Object.keys(manifest.components).length;
6806
+ const generatedPlaygroundCount = componentCount === 0 ? 0 : fs.statSync(path.join(outputDir, "playground")).isDirectory() ? componentCount - failures.length : 0;
6807
+ const siteFailures = [...failures];
6808
+ if (componentCount === 0) {
6809
+ siteFailures.push({
6810
+ component: "*",
6811
+ stage: "site",
6812
+ message: "Manifest contains zero components; generated site is structurally degraded.",
6813
+ outputPath: manifestPath
6814
+ });
6815
+ } else if (generatedPlaygroundCount === 0) {
6816
+ siteFailures.push({
6817
+ component: "*",
6818
+ stage: "site",
6819
+ message: "No playground pages were generated successfully; site build is degraded and should not be treated as green.",
6820
+ outputPath: path.join(outputDir, "playground")
6821
+ });
6822
+ }
6823
+ const summaryPath = writeRunSummary({
6824
+ command: "scope site build",
6825
+ status: siteFailures.length > 0 ? "failed" : "success",
6826
+ outputPaths: [outputDir, path.join(outputDir, "index.html")],
6827
+ failures: siteFailures
5488
6828
  });
5489
6829
  process.stderr.write(`Site written to ${outputDir}
6830
+ `);
6831
+ process.stderr.write(`[scope/site] Run summary written to ${summaryPath}
5490
6832
  `);
5491
6833
  process.stdout.write(`${outputDir}
5492
6834
  `);
6835
+ if (siteFailures.length > 0) process.exit(1);
5493
6836
  } catch (err) {
5494
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
6837
+ process.stderr.write(`${formatScopeDiagnostic(err)}
5495
6838
  `);
5496
6839
  process.exit(1);
5497
6840
  }
@@ -5499,71 +6842,143 @@ Run \`scope manifest generate\` first.`
5499
6842
  );
5500
6843
  }
5501
6844
  function registerServe(siteCmd) {
5502
- siteCmd.command("serve").description("Serve the built static site locally").option("-p, --port <number>", "Port to listen on", "3000").option("-d, --dir <path>", "Directory to serve", ".reactscope/site").action((opts) => {
5503
- try {
5504
- const port = Number.parseInt(opts.port, 10);
5505
- if (Number.isNaN(port) || port < 1 || port > 65535) {
5506
- throw new Error(`Invalid port: ${opts.port}`);
5507
- }
5508
- const serveDir = path.resolve(process.cwd(), opts.dir);
5509
- if (!fs.existsSync(serveDir)) {
5510
- throw new Error(
5511
- `Serve directory not found: ${serveDir}
5512
- Run \`scope site build\` first.`
5513
- );
5514
- }
5515
- const server = http.createServer((req, res) => {
5516
- const rawUrl = req.url ?? "/";
5517
- const urlPath = decodeURIComponent(rawUrl.split("?")[0] ?? "/");
5518
- const filePath = path.join(serveDir, urlPath.endsWith("/") ? `${urlPath}index.html` : urlPath);
5519
- if (!filePath.startsWith(serveDir)) {
5520
- res.writeHead(403, { "Content-Type": "text/plain" });
5521
- res.end("Forbidden");
5522
- return;
6845
+ siteCmd.command("serve").description(
6846
+ "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"
6847
+ ).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(
6848
+ "-i, --input <path>",
6849
+ "Input directory for .reactscope data (watch mode)",
6850
+ ".reactscope"
6851
+ ).option("--title <text>", "Site title (watch mode)", "Scope \u2014 Component Gallery").option("--base-path <path>", "Base URL path prefix (watch mode)", "/").action(
6852
+ async (opts) => {
6853
+ try {
6854
+ let notifyReload2 = function() {
6855
+ for (const client of sseClients) {
6856
+ client.write("data: reload\n\n");
6857
+ }
6858
+ };
6859
+ var notifyReload = notifyReload2;
6860
+ const port = Number.parseInt(opts.port, 10);
6861
+ if (Number.isNaN(port) || port < 1 || port > 65535) {
6862
+ throw new Error(`Invalid port: ${opts.port}`);
5523
6863
  }
5524
- if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
5525
- const ext = path.extname(filePath).toLowerCase();
5526
- const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
5527
- res.writeHead(200, { "Content-Type": contentType });
5528
- fs.createReadStream(filePath).pipe(res);
5529
- return;
6864
+ const serveDir = path.resolve(process.cwd(), opts.dir);
6865
+ const watchMode = opts.watch === true;
6866
+ const sseClients = /* @__PURE__ */ new Set();
6867
+ if (watchMode) {
6868
+ await promises.mkdir(serveDir, { recursive: true });
5530
6869
  }
5531
- const htmlPath = `${filePath}.html`;
5532
- if (fs.existsSync(htmlPath) && fs.statSync(htmlPath).isFile()) {
5533
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
5534
- fs.createReadStream(htmlPath).pipe(res);
5535
- return;
6870
+ if (!watchMode && !fs.existsSync(serveDir)) {
6871
+ throw new Error(
6872
+ `Serve directory not found: ${serveDir}
6873
+ Run \`scope site build\` first.`
6874
+ );
5536
6875
  }
5537
- res.writeHead(404, { "Content-Type": "text/plain" });
5538
- res.end(`Not found: ${urlPath}`);
5539
- });
5540
- server.listen(port, () => {
5541
- process.stderr.write(`Scope site running at http://localhost:${port}
6876
+ const server = http.createServer((req, res) => {
6877
+ const rawUrl = req.url ?? "/";
6878
+ const urlPath = decodeURIComponent(rawUrl.split("?")[0] ?? "/");
6879
+ if (watchMode && urlPath === "/__livereload") {
6880
+ res.writeHead(200, {
6881
+ "Content-Type": "text/event-stream",
6882
+ "Cache-Control": "no-cache",
6883
+ Connection: "keep-alive",
6884
+ "Access-Control-Allow-Origin": "*"
6885
+ });
6886
+ res.write("data: connected\n\n");
6887
+ sseClients.add(res);
6888
+ req.on("close", () => sseClients.delete(res));
6889
+ return;
6890
+ }
6891
+ const filePath = path.join(
6892
+ serveDir,
6893
+ urlPath.endsWith("/") ? `${urlPath}index.html` : urlPath
6894
+ );
6895
+ if (!filePath.startsWith(serveDir)) {
6896
+ res.writeHead(403, { "Content-Type": "text/plain" });
6897
+ res.end("Forbidden");
6898
+ return;
6899
+ }
6900
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
6901
+ const ext = path.extname(filePath).toLowerCase();
6902
+ const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
6903
+ if (watchMode && ext === ".html") {
6904
+ const html = injectLiveReloadScript(fs.readFileSync(filePath, "utf-8"));
6905
+ res.writeHead(200, { "Content-Type": contentType });
6906
+ res.end(html);
6907
+ return;
6908
+ }
6909
+ res.writeHead(200, { "Content-Type": contentType });
6910
+ fs.createReadStream(filePath).pipe(res);
6911
+ return;
6912
+ }
6913
+ const htmlPath = `${filePath}.html`;
6914
+ if (fs.existsSync(htmlPath) && fs.statSync(htmlPath).isFile()) {
6915
+ if (watchMode) {
6916
+ const html = injectLiveReloadScript(fs.readFileSync(htmlPath, "utf-8"));
6917
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
6918
+ res.end(html);
6919
+ return;
6920
+ }
6921
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
6922
+ fs.createReadStream(htmlPath).pipe(res);
6923
+ return;
6924
+ }
6925
+ res.writeHead(404, { "Content-Type": "text/plain" });
6926
+ res.end(`Not found: ${urlPath}`);
6927
+ });
6928
+ server.listen(port, () => {
6929
+ process.stderr.write(`Scope site running at http://localhost:${port}
5542
6930
  `);
5543
- process.stderr.write(`Serving ${serveDir}
6931
+ process.stderr.write(`Serving ${serveDir}
5544
6932
  `);
5545
- process.stderr.write("Press Ctrl+C to stop.\n");
5546
- });
5547
- server.on("error", (err) => {
5548
- if (err.code === "EADDRINUSE") {
5549
- process.stderr.write(`Error: Port ${port} is already in use.
6933
+ if (watchMode) {
6934
+ process.stderr.write(
6935
+ "Watch mode enabled \u2014 source changes trigger rebuild + browser reload\n"
6936
+ );
6937
+ }
6938
+ process.stderr.write("Press Ctrl+C to stop.\n");
6939
+ });
6940
+ server.on("error", (err) => {
6941
+ if (err.code === "EADDRINUSE") {
6942
+ process.stderr.write(`Error: Port ${port} is already in use.
5550
6943
  `);
5551
- } else {
5552
- process.stderr.write(`Server error: ${err.message}
6944
+ } else {
6945
+ process.stderr.write(`Server error: ${err.message}
5553
6946
  `);
6947
+ }
6948
+ process.exit(1);
6949
+ });
6950
+ if (watchMode) {
6951
+ const rootDir = process.cwd();
6952
+ const inputDir = path.resolve(rootDir, opts.input);
6953
+ const initialManifest = await runFullBuild(
6954
+ rootDir,
6955
+ inputDir,
6956
+ serveDir,
6957
+ opts.title,
6958
+ opts.basePath
6959
+ );
6960
+ notifyReload2();
6961
+ startFileWatcher({
6962
+ rootDir,
6963
+ inputDir,
6964
+ outputDir: serveDir,
6965
+ title: opts.title,
6966
+ basePath: opts.basePath,
6967
+ previousManifest: initialManifest,
6968
+ notifyReload: notifyReload2
6969
+ });
5554
6970
  }
5555
- process.exit(1);
5556
- });
5557
- } catch (err) {
5558
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
6971
+ } catch (err) {
6972
+ process.stderr.write(`${formatScopeDiagnostic(err)}
5559
6973
  `);
5560
- process.exit(1);
6974
+ process.exit(1);
6975
+ }
5561
6976
  }
5562
- });
6977
+ );
5563
6978
  }
5564
6979
  function createSiteCommand() {
5565
6980
  const siteCmd = new commander.Command("site").description(
5566
- "Build and serve the static component gallery site"
6981
+ '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'
5567
6982
  );
5568
6983
  registerBuild(siteCmd);
5569
6984
  registerServe(siteCmd);
@@ -5607,11 +7022,11 @@ function categoryForProperty(property) {
5607
7022
  }
5608
7023
  function buildCategorySummary(batch) {
5609
7024
  const cats = {
5610
- color: { total: 0, onSystem: 0, offSystem: 0, compliance: 1 },
5611
- spacing: { total: 0, onSystem: 0, offSystem: 0, compliance: 1 },
5612
- typography: { total: 0, onSystem: 0, offSystem: 0, compliance: 1 },
5613
- border: { total: 0, onSystem: 0, offSystem: 0, compliance: 1 },
5614
- shadow: { total: 0, onSystem: 0, offSystem: 0, compliance: 1 }
7025
+ color: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 },
7026
+ spacing: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 },
7027
+ typography: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 },
7028
+ border: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 },
7029
+ shadow: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 }
5615
7030
  };
5616
7031
  for (const report of Object.values(batch.components)) {
5617
7032
  for (const [property, result] of Object.entries(report.properties)) {
@@ -5627,7 +7042,7 @@ function buildCategorySummary(batch) {
5627
7042
  }
5628
7043
  }
5629
7044
  for (const summary of Object.values(cats)) {
5630
- summary.compliance = summary.total === 0 ? 1 : summary.onSystem / summary.total;
7045
+ summary.compliance = summary.total === 0 ? 0 : summary.onSystem / summary.total;
5631
7046
  }
5632
7047
  return cats;
5633
7048
  }
@@ -5668,6 +7083,11 @@ function formatComplianceReport(batch, threshold) {
5668
7083
  const lines = [];
5669
7084
  const thresholdLabel = threshold !== void 0 ? pct >= threshold ? " \u2713 (pass)" : ` \u2717 (below threshold ${threshold}%)` : "";
5670
7085
  lines.push(`Overall compliance score: ${pct}%${thresholdLabel}`);
7086
+ if (batch.totalProperties === 0) {
7087
+ lines.push(
7088
+ "No CSS properties were audited; run `scope render all` and inspect .reactscope/compliance-styles.json before treating compliance as green."
7089
+ );
7090
+ }
5671
7091
  lines.push("");
5672
7092
  const cats = buildCategorySummary(batch);
5673
7093
  const catEntries = Object.entries(cats).filter(([, s]) => s.total > 0);
@@ -5701,41 +7121,85 @@ function formatComplianceReport(batch, threshold) {
5701
7121
  return lines.join("\n");
5702
7122
  }
5703
7123
  function registerCompliance(tokensCmd) {
5704
- tokensCmd.command("compliance").description("Aggregate token compliance report across all components (Token Spec \xA73.3 format)").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) => {
5705
- try {
5706
- const tokenFilePath = resolveTokenFilePath(opts.file);
5707
- const { tokens: tokens$1 } = loadTokens(tokenFilePath);
5708
- const resolver = new tokens.TokenResolver(tokens$1);
5709
- const engine = new tokens.ComplianceEngine(resolver);
5710
- const stylesPath = opts.styles ?? DEFAULT_STYLES_PATH;
5711
- const stylesFile = loadStylesFile(stylesPath);
5712
- const componentMap = /* @__PURE__ */ new Map();
5713
- for (const [name, styles] of Object.entries(stylesFile)) {
5714
- componentMap.set(name, styles);
5715
- }
5716
- if (componentMap.size === 0) {
5717
- process.stderr.write(`Warning: No components found in styles file at ${stylesPath}
7124
+ tokensCmd.command("compliance").description(
7125
+ "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"
7126
+ ).option("--file <path>", "Path to token file (overrides config)").option("--styles <path>", `Path to compliance styles JSON (default: ${DEFAULT_STYLES_PATH})`).option(
7127
+ "--out <path>",
7128
+ "Write JSON report to file (for use with scope site build --compliance)"
7129
+ ).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(
7130
+ (opts) => {
7131
+ try {
7132
+ const tokenFilePath = resolveTokenFilePath(opts.file);
7133
+ const { tokens: tokens$1 } = loadTokens(tokenFilePath);
7134
+ const resolver = new tokens.TokenResolver(tokens$1);
7135
+ const engine = new tokens.ComplianceEngine(resolver);
7136
+ const stylesPath = opts.styles ?? DEFAULT_STYLES_PATH;
7137
+ const stylesFile = loadStylesFile(stylesPath);
7138
+ const componentMap = /* @__PURE__ */ new Map();
7139
+ for (const [name, styles] of Object.entries(stylesFile)) {
7140
+ componentMap.set(name, styles);
7141
+ }
7142
+ if (componentMap.size === 0) {
7143
+ process.stderr.write(`Warning: No components found in styles file at ${stylesPath}
5718
7144
  `);
5719
- }
5720
- const batch = engine.auditBatch(componentMap);
5721
- const useJson = opts.format === "json" || opts.format !== "text" && !isTTY();
5722
- const threshold = opts.threshold !== void 0 ? Number.parseInt(opts.threshold, 10) : void 0;
5723
- if (useJson) {
5724
- process.stdout.write(`${JSON.stringify(batch, null, 2)}
7145
+ }
7146
+ const batch = engine.auditBatch(componentMap);
7147
+ const threshold = opts.threshold !== void 0 ? Number.parseInt(opts.threshold, 10) : void 0;
7148
+ const failures = [];
7149
+ if (batch.totalProperties === 0) {
7150
+ failures.push({
7151
+ component: "*",
7152
+ stage: "compliance",
7153
+ message: `No CSS properties were audited from ${stylesPath}; refusing to report silent success.`,
7154
+ outputPath: stylesPath
7155
+ });
7156
+ } else if (threshold !== void 0 && Math.round(batch.aggregateCompliance * 100) < threshold) {
7157
+ failures.push({
7158
+ component: "*",
7159
+ stage: "compliance",
7160
+ message: `Compliance ${Math.round(batch.aggregateCompliance * 100)}% is below threshold ${threshold}%.`,
7161
+ outputPath: opts.out ?? ".reactscope/compliance-report.json"
7162
+ });
7163
+ }
7164
+ if (opts.out !== void 0) {
7165
+ const outPath = path.resolve(process.cwd(), opts.out);
7166
+ fs.writeFileSync(outPath, JSON.stringify(batch, null, 2), "utf-8");
7167
+ process.stderr.write(`Compliance report written to ${outPath}
5725
7168
  `);
5726
- } else {
5727
- process.stdout.write(`${formatComplianceReport(batch, threshold)}
7169
+ }
7170
+ const useJson = opts.format === "json" || opts.format !== "text" && !isTTY();
7171
+ if (useJson) {
7172
+ process.stdout.write(`${JSON.stringify(batch, null, 2)}
7173
+ `);
7174
+ } else {
7175
+ process.stdout.write(`${formatComplianceReport(batch, threshold)}
7176
+ `);
7177
+ }
7178
+ const summaryPath = writeRunSummary({
7179
+ command: "scope tokens compliance",
7180
+ status: failures.length > 0 ? "failed" : "success",
7181
+ outputPaths: [opts.out ?? ".reactscope/compliance-report.json", stylesPath],
7182
+ compliance: {
7183
+ auditedProperties: batch.totalProperties,
7184
+ onSystemProperties: batch.totalOnSystem,
7185
+ offSystemProperties: batch.totalOffSystem,
7186
+ score: Math.round(batch.aggregateCompliance * 100),
7187
+ threshold
7188
+ },
7189
+ failures
7190
+ });
7191
+ process.stderr.write(`[scope/tokens] Run summary written to ${summaryPath}
7192
+ `);
7193
+ if (failures.length > 0) {
7194
+ process.exit(1);
7195
+ }
7196
+ } catch (err) {
7197
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
5728
7198
  `);
5729
- }
5730
- if (threshold !== void 0 && Math.round(batch.aggregateCompliance * 100) < threshold) {
5731
7199
  process.exit(1);
5732
7200
  }
5733
- } catch (err) {
5734
- process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
5735
- `);
5736
- process.exit(1);
5737
7201
  }
5738
- });
7202
+ );
5739
7203
  }
5740
7204
  var DEFAULT_TOKEN_FILE = "reactscope.tokens.json";
5741
7205
  var CONFIG_FILE = "reactscope.config.json";
@@ -5759,7 +7223,9 @@ function resolveTokenFilePath2(fileFlag) {
5759
7223
  return path.resolve(process.cwd(), DEFAULT_TOKEN_FILE);
5760
7224
  }
5761
7225
  function createTokensExportCommand() {
5762
- return new commander.Command("export").description("Export design tokens to a downstream format").requiredOption("--format <fmt>", `Output format: ${SUPPORTED_FORMATS.join(", ")}`).option("--file <path>", "Path to token file (overrides config)").option("--out <path>", "Write output to file instead of stdout").option("--prefix <prefix>", "CSS/SCSS: prefix for variable names (e.g. 'scope')").option("--selector <selector>", "CSS: custom root selector (default: ':root')").option(
7226
+ return new commander.Command("export").description(
7227
+ 'Export design tokens to CSS variables, TypeScript, SCSS, Tailwind config, Figma, or flat JSON.\n\nFORMATS:\n css CSS custom properties (:root { --color-primary-500: #3b82f6; })\n scss SCSS variables ($color-primary-500: #3b82f6;)\n ts TypeScript const export (export const tokens = {...})\n tailwind Tailwind theme.extend block (paste into tailwind.config.js)\n flat-json Flat { path: value } map (useful for tooling integration)\n figma Figma Tokens plugin format\n\nExamples:\n scope tokens export --format css --out src/tokens.css\n scope tokens export --format css --prefix brand --selector ":root, [data-theme]"\n scope tokens export --format tailwind --out tailwind-tokens.js\n scope tokens export --format ts --out src/tokens.ts\n scope tokens export --format css --theme dark --out dark-tokens.css'
7228
+ ).requiredOption("--format <fmt>", `Output format: ${SUPPORTED_FORMATS.join(", ")}`).option("--file <path>", "Path to token file (overrides config)").option("--out <path>", "Write output to file instead of stdout").option("--prefix <prefix>", "CSS/SCSS: prefix for variable names (e.g. 'scope')").option("--selector <selector>", "CSS: custom root selector (default: ':root')").option(
5763
7229
  "--theme <name>",
5764
7230
  "Include theme overrides for the named theme (applies to css, ts, scss, tailwind, figma)"
5765
7231
  ).action(
@@ -5899,7 +7365,9 @@ function formatImpactSummary(report) {
5899
7365
  return `\u2192 ${parts.join(", ")}`;
5900
7366
  }
5901
7367
  function registerImpact(tokensCmd) {
5902
- tokensCmd.command("impact <path>").description("List all components and elements that consume a given token (Token Spec \xA74.3)").option("--file <path>", "Path to token file (overrides config)").option("--styles <path>", `Path to compliance styles JSON (default: ${DEFAULT_STYLES_PATH2})`).option("--new-value <value>", "Proposed new value \u2014 report visual severity of the change").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action(
7368
+ tokensCmd.command("impact <path>").description(
7369
+ "List every component and CSS element that uses a given token.\nUse this to understand the blast radius before changing a token value.\n\nPREREQUISITE: scope render all (populates compliance-styles.json)\n\nExamples:\n scope tokens impact color.primary.500\n scope tokens impact spacing.4 --format json\n scope tokens impact font.size.base | grep Button"
7370
+ ).option("--file <path>", "Path to token file (overrides config)").option("--styles <path>", `Path to compliance styles JSON (default: ${DEFAULT_STYLES_PATH2})`).option("--new-value <value>", "Proposed new value \u2014 report visual severity of the change").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action(
5903
7371
  (tokenPath, opts) => {
5904
7372
  try {
5905
7373
  const tokenFilePath = resolveTokenFilePath(opts.file);
@@ -5936,18 +7404,242 @@ ${formatImpactSummary(report)}
5936
7404
  }
5937
7405
  );
5938
7406
  }
7407
+ var DEFAULT_TOKEN_FILE2 = "reactscope.tokens.json";
7408
+ var CONFIG_FILE2 = "reactscope.config.json";
7409
+ function resolveOutputPath(fileFlag) {
7410
+ if (fileFlag !== void 0) {
7411
+ return path.resolve(process.cwd(), fileFlag);
7412
+ }
7413
+ const configPath = path.resolve(process.cwd(), CONFIG_FILE2);
7414
+ if (fs.existsSync(configPath)) {
7415
+ try {
7416
+ const raw = fs.readFileSync(configPath, "utf-8");
7417
+ const config = JSON.parse(raw);
7418
+ if (typeof config === "object" && config !== null && "tokens" in config && typeof config.tokens === "object" && config.tokens !== null && typeof config.tokens?.file === "string") {
7419
+ const file = config.tokens.file;
7420
+ return path.resolve(process.cwd(), file);
7421
+ }
7422
+ } catch {
7423
+ }
7424
+ }
7425
+ return path.resolve(process.cwd(), DEFAULT_TOKEN_FILE2);
7426
+ }
7427
+ var CSS_VAR_RE = /--([\w-]+)\s*:\s*([^;]+)/g;
7428
+ var HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
7429
+ var COLOR_FN_RE = /^(?:rgba?|hsla?|oklch|oklab|lch|lab|color|hwb)\(/;
7430
+ var DIMENSION_RE = /^-?\d+(?:\.\d+)?(?:px|rem|em|%|vw|vh|ch|ex|cap|lh|dvh|svh|lvh)$/;
7431
+ var DURATION_RE = /^-?\d+(?:\.\d+)?(?:ms|s)$/;
7432
+ var FONT_FAMILY_RE = /^["']|,\s*(?:sans-serif|serif|monospace|cursive|fantasy|system-ui)/;
7433
+ var NUMBER_RE = /^-?\d+(?:\.\d+)?$/;
7434
+ var CUBIC_BEZIER_RE = /^cubic-bezier\(/;
7435
+ var SHADOW_RE = /^\d.*(?:px|rem|em)\s+(?:#|rgba?|hsla?|oklch|oklab)/i;
7436
+ function inferTokenType(value) {
7437
+ const v = value.trim();
7438
+ if (HEX_COLOR_RE.test(v) || COLOR_FN_RE.test(v)) return "color";
7439
+ if (DURATION_RE.test(v)) return "duration";
7440
+ if (DIMENSION_RE.test(v)) return "dimension";
7441
+ if (FONT_FAMILY_RE.test(v)) return "fontFamily";
7442
+ if (CUBIC_BEZIER_RE.test(v)) return "cubicBezier";
7443
+ if (SHADOW_RE.test(v)) return "shadow";
7444
+ if (NUMBER_RE.test(v)) return "number";
7445
+ return "color";
7446
+ }
7447
+ function setNestedToken(root, segments, value, type) {
7448
+ let node = root;
7449
+ for (let i = 0; i < segments.length - 1; i++) {
7450
+ const seg = segments[i];
7451
+ if (seg === void 0) continue;
7452
+ if (!(seg in node) || typeof node[seg] !== "object" || node[seg] === null) {
7453
+ node[seg] = {};
7454
+ }
7455
+ node = node[seg];
7456
+ }
7457
+ const leaf = segments[segments.length - 1];
7458
+ if (leaf === void 0) return;
7459
+ node[leaf] = { value, type };
7460
+ }
7461
+ function extractBlockBody(css, openBrace) {
7462
+ let depth = 0;
7463
+ let end = -1;
7464
+ for (let i = openBrace; i < css.length; i++) {
7465
+ if (css[i] === "{") depth++;
7466
+ else if (css[i] === "}") {
7467
+ depth--;
7468
+ if (depth === 0) {
7469
+ end = i;
7470
+ break;
7471
+ }
7472
+ }
7473
+ }
7474
+ if (end === -1) return "";
7475
+ return css.slice(openBrace + 1, end);
7476
+ }
7477
+ function parseScopedBlocks(css) {
7478
+ const blocks = [];
7479
+ const blockRe = /(?::root|@theme(?:\s+inline)?|\.dark\.high-contrast|\.dark)\s*\{/g;
7480
+ let match = blockRe.exec(css);
7481
+ while (match !== null) {
7482
+ const selector = match[0];
7483
+ const braceIdx = css.indexOf("{", match.index);
7484
+ if (braceIdx === -1) {
7485
+ match = blockRe.exec(css);
7486
+ continue;
7487
+ }
7488
+ const body = extractBlockBody(css, braceIdx);
7489
+ let scope;
7490
+ if (selector.includes(".dark.high-contrast")) scope = "dark-high-contrast";
7491
+ else if (selector.includes(".dark")) scope = "dark";
7492
+ else if (selector.includes("@theme")) scope = "theme";
7493
+ else scope = "root";
7494
+ blocks.push({ scope, body });
7495
+ match = blockRe.exec(css);
7496
+ }
7497
+ return blocks;
7498
+ }
7499
+ function extractVarsFromBody(body) {
7500
+ const results = [];
7501
+ for (const m of body.matchAll(CSS_VAR_RE)) {
7502
+ const name = m[1];
7503
+ const value = m[2]?.trim();
7504
+ if (name === void 0 || value === void 0 || value.length === 0) continue;
7505
+ if (value.startsWith("var(") || value.startsWith("calc(")) continue;
7506
+ results.push({ name, value });
7507
+ }
7508
+ return results;
7509
+ }
7510
+ function extractCSSCustomProperties(tokenSources) {
7511
+ const cssSources = tokenSources.filter(
7512
+ (s) => s.kind === "css-custom-properties" || s.kind === "tailwind-v4-theme"
7513
+ );
7514
+ if (cssSources.length === 0) return null;
7515
+ const tokens = {};
7516
+ const themes = {};
7517
+ let found = false;
7518
+ for (const source of cssSources) {
7519
+ try {
7520
+ if (source.path.includes("compiled") || source.path.includes(".min.")) continue;
7521
+ const raw = fs.readFileSync(source.path, "utf-8");
7522
+ const blocks = parseScopedBlocks(raw);
7523
+ for (const block of blocks) {
7524
+ const vars = extractVarsFromBody(block.body);
7525
+ for (const { name, value } of vars) {
7526
+ const segments = name.split("-").filter(Boolean);
7527
+ if (segments.length === 0) continue;
7528
+ if (block.scope === "root" || block.scope === "theme") {
7529
+ const type = inferTokenType(value);
7530
+ setNestedToken(tokens, segments, value, type);
7531
+ found = true;
7532
+ } else {
7533
+ const themeName = block.scope;
7534
+ if (!themes[themeName]) themes[themeName] = {};
7535
+ const path = segments.join(".");
7536
+ themes[themeName][path] = value;
7537
+ found = true;
7538
+ }
7539
+ }
7540
+ }
7541
+ } catch {
7542
+ }
7543
+ }
7544
+ return found ? { tokens, themes } : null;
7545
+ }
7546
+ function registerTokensInit(tokensCmd) {
7547
+ tokensCmd.command("init").description(
7548
+ "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"
7549
+ ).option("--file <path>", "Output path for the token file (overrides config)").option("--force", "Overwrite existing token file", false).action((opts) => {
7550
+ try {
7551
+ const outPath = resolveOutputPath(opts.file);
7552
+ if (fs.existsSync(outPath) && !opts.force) {
7553
+ process.stderr.write(
7554
+ `Token file already exists at ${outPath}.
7555
+ Run with --force to overwrite.
7556
+ `
7557
+ );
7558
+ process.exit(1);
7559
+ }
7560
+ const rootDir = process.cwd();
7561
+ const detected = detectProject(rootDir);
7562
+ const tailwindTokens = extractTailwindTokens(detected.tokenSources);
7563
+ const cssResult = extractCSSCustomProperties(detected.tokenSources);
7564
+ const mergedTokens = {};
7565
+ const mergedThemes = {};
7566
+ if (tailwindTokens !== null) {
7567
+ Object.assign(mergedTokens, tailwindTokens);
7568
+ }
7569
+ if (cssResult !== null) {
7570
+ for (const [key, value] of Object.entries(cssResult.tokens)) {
7571
+ if (!(key in mergedTokens)) {
7572
+ mergedTokens[key] = value;
7573
+ }
7574
+ }
7575
+ for (const [themeName, overrides] of Object.entries(cssResult.themes)) {
7576
+ if (!mergedThemes[themeName]) mergedThemes[themeName] = {};
7577
+ Object.assign(mergedThemes[themeName], overrides);
7578
+ }
7579
+ }
7580
+ const tokenFile = {
7581
+ $schema: "https://raw.githubusercontent.com/FlatFilers/Scope/main/packages/tokens/schema.json",
7582
+ version: "1.0.0",
7583
+ meta: {
7584
+ name: "Design Tokens",
7585
+ lastUpdated: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
7586
+ },
7587
+ tokens: mergedTokens
7588
+ };
7589
+ if (Object.keys(mergedThemes).length > 0) {
7590
+ tokenFile.themes = mergedThemes;
7591
+ }
7592
+ fs.writeFileSync(outPath, `${JSON.stringify(tokenFile, null, 2)}
7593
+ `);
7594
+ const tokenGroupCount = Object.keys(mergedTokens).length;
7595
+ const themeNames = Object.keys(mergedThemes);
7596
+ if (detected.tokenSources.length > 0) {
7597
+ process.stdout.write("Detected token sources:\n");
7598
+ for (const source of detected.tokenSources) {
7599
+ process.stdout.write(` ${source.kind}: ${source.path}
7600
+ `);
7601
+ }
7602
+ process.stdout.write("\n");
7603
+ }
7604
+ if (tokenGroupCount > 0) {
7605
+ process.stdout.write(`Extracted ${tokenGroupCount} token group(s) \u2192 ${outPath}
7606
+ `);
7607
+ if (themeNames.length > 0) {
7608
+ for (const name of themeNames) {
7609
+ const count = Object.keys(mergedThemes[name] ?? {}).length;
7610
+ process.stdout.write(` theme "${name}": ${count} override(s)
7611
+ `);
7612
+ }
7613
+ }
7614
+ } else {
7615
+ process.stdout.write(
7616
+ `No token sources detected. Created empty token file \u2192 ${outPath}
7617
+ Add tokens manually or re-run after configuring a design system.
7618
+ `
7619
+ );
7620
+ }
7621
+ } catch (err) {
7622
+ process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
7623
+ `);
7624
+ process.exit(1);
7625
+ }
7626
+ });
7627
+ }
5939
7628
  var DEFAULT_STYLES_PATH3 = ".reactscope/compliance-styles.json";
5940
7629
  var DEFAULT_MANIFEST_PATH = ".reactscope/manifest.json";
5941
7630
  var DEFAULT_OUTPUT_DIR2 = ".reactscope/previews";
5942
7631
  async function renderComponentWithCssOverride(filePath, componentName, cssOverride, vpWidth, vpHeight, timeoutMs) {
7632
+ const PAD = 16;
5943
7633
  const htmlHarness = await buildComponentHarness(
5944
7634
  filePath,
5945
7635
  componentName,
5946
7636
  {},
5947
7637
  // no props
5948
7638
  vpWidth,
5949
- cssOverride
7639
+ cssOverride,
5950
7640
  // injected as <style>
7641
+ void 0,
7642
+ PAD
5951
7643
  );
5952
7644
  const pool = new render.BrowserPool({
5953
7645
  size: { browsers: 1, pagesPerBrowser: 1 },
@@ -5968,7 +7660,6 @@ async function renderComponentWithCssOverride(filePath, componentName, cssOverri
5968
7660
  );
5969
7661
  const rootLocator = page.locator("[data-reactscope-root]");
5970
7662
  const bb = await rootLocator.boundingBox();
5971
- const PAD = 16;
5972
7663
  const MIN_W = 320;
5973
7664
  const MIN_H = 120;
5974
7665
  const clipX = Math.max(0, (bb?.x ?? 0) - PAD);
@@ -5988,7 +7679,9 @@ async function renderComponentWithCssOverride(filePath, componentName, cssOverri
5988
7679
  }
5989
7680
  }
5990
7681
  function registerPreview(tokensCmd) {
5991
- tokensCmd.command("preview <path>").description("Render before/after sprite sheet for components affected by a token change").requiredOption("--new-value <value>", "The proposed new resolved value for the token").option("--sprite", "Output a PNG sprite sheet (default when TTY)", false).option("-o, --output <path>", "Output PNG path (default: .reactscope/previews/<token>.png)").option("--file <path>", "Path to token file (overrides config)").option("--styles <path>", `Path to compliance styles JSON (default: ${DEFAULT_STYLES_PATH3})`).option("--manifest <path>", "Path to manifest.json", DEFAULT_MANIFEST_PATH).option("--format <fmt>", "Output format: json or text (default: auto-detect)").option("--timeout <ms>", "Browser timeout per render (ms)", "10000").option("--viewport-width <px>", "Viewport width in pixels", "1280").option("--viewport-height <px>", "Viewport height in pixels", "720").action(
7682
+ tokensCmd.command("preview <path>").description(
7683
+ 'Render before/after screenshots of all components affected by a token change.\nUseful for visual review before committing a token value update.\n\nPREREQUISITE: scope render all (provides baseline renders)\n\nExamples:\n scope tokens preview color.primary.500\n scope tokens preview color.primary.500 --new-value "#2563eb" -o preview.png'
7684
+ ).requiredOption("--new-value <value>", "The proposed new resolved value for the token").option("--sprite", "Output a PNG sprite sheet (default when TTY)", false).option("-o, --output <path>", "Output PNG path (default: .reactscope/previews/<token>.png)").option("--file <path>", "Path to token file (overrides config)").option("--styles <path>", `Path to compliance styles JSON (default: ${DEFAULT_STYLES_PATH3})`).option("--manifest <path>", "Path to manifest.json", DEFAULT_MANIFEST_PATH).option("--format <fmt>", "Output format: json or text (default: auto-detect)").option("--timeout <ms>", "Browser timeout per render (ms)", "10000").option("--viewport-width <px>", "Viewport width in pixels", "1280").option("--viewport-height <px>", "Viewport height in pixels", "720").action(
5992
7685
  async (tokenPath, opts) => {
5993
7686
  try {
5994
7687
  const tokenFilePath = resolveTokenFilePath(opts.file);
@@ -6155,8 +7848,8 @@ function registerPreview(tokensCmd) {
6155
7848
  }
6156
7849
 
6157
7850
  // src/tokens/commands.ts
6158
- var DEFAULT_TOKEN_FILE2 = "reactscope.tokens.json";
6159
- var CONFIG_FILE2 = "reactscope.config.json";
7851
+ var DEFAULT_TOKEN_FILE3 = "reactscope.tokens.json";
7852
+ var CONFIG_FILE3 = "reactscope.config.json";
6160
7853
  function isTTY2() {
6161
7854
  return process.stdout.isTTY === true;
6162
7855
  }
@@ -6178,7 +7871,7 @@ function resolveTokenFilePath(fileFlag) {
6178
7871
  if (fileFlag !== void 0) {
6179
7872
  return path.resolve(process.cwd(), fileFlag);
6180
7873
  }
6181
- const configPath = path.resolve(process.cwd(), CONFIG_FILE2);
7874
+ const configPath = path.resolve(process.cwd(), CONFIG_FILE3);
6182
7875
  if (fs.existsSync(configPath)) {
6183
7876
  try {
6184
7877
  const raw = fs.readFileSync(configPath, "utf-8");
@@ -6190,7 +7883,7 @@ function resolveTokenFilePath(fileFlag) {
6190
7883
  } catch {
6191
7884
  }
6192
7885
  }
6193
- return path.resolve(process.cwd(), DEFAULT_TOKEN_FILE2);
7886
+ return path.resolve(process.cwd(), DEFAULT_TOKEN_FILE3);
6194
7887
  }
6195
7888
  function loadTokens(absPath) {
6196
7889
  if (!fs.existsSync(absPath)) {
@@ -6235,7 +7928,9 @@ function buildResolutionChain(startPath, rawTokens) {
6235
7928
  return chain;
6236
7929
  }
6237
7930
  function registerGet2(tokensCmd) {
6238
- tokensCmd.command("get <path>").description("Resolve a token path to its computed value").option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action((tokenPath, opts) => {
7931
+ tokensCmd.command("get <path>").description(
7932
+ "Resolve a token path and print its final computed value.\nFollows all {ref} chains to the raw value.\n\nExamples:\n scope tokens get color.primary.500\n scope tokens get spacing.4 --format json\n scope tokens get font.size.base --file ./tokens/brand.json"
7933
+ ).option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action((tokenPath, opts) => {
6239
7934
  try {
6240
7935
  const filePath = resolveTokenFilePath(opts.file);
6241
7936
  const { tokens: tokens$1 } = loadTokens(filePath);
@@ -6260,7 +7955,18 @@ function registerGet2(tokensCmd) {
6260
7955
  });
6261
7956
  }
6262
7957
  function registerList2(tokensCmd) {
6263
- tokensCmd.command("list [category]").description("List tokens, optionally filtered by category or type").option("--type <type>", "Filter by token type (color, dimension, fontFamily, etc.)").option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or table (default: auto-detect)").action(
7958
+ tokensCmd.command("list [category]").description(
7959
+ `List all tokens, optionally filtered by category prefix or type.
7960
+
7961
+ CATEGORY: top-level token namespace (e.g. "color", "spacing", "typography")
7962
+ TYPE: token value type \u2014 color | spacing | typography | shadow | radius | opacity
7963
+
7964
+ Examples:
7965
+ scope tokens list
7966
+ scope tokens list color
7967
+ scope tokens list --type spacing
7968
+ scope tokens list color --format json | jq '.[].path'`
7969
+ ).option("--type <type>", "Filter by token type (color, dimension, fontFamily, etc.)").option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or table (default: auto-detect)").action(
6264
7970
  (category, opts) => {
6265
7971
  try {
6266
7972
  const filePath = resolveTokenFilePath(opts.file);
@@ -6290,7 +7996,9 @@ function registerList2(tokensCmd) {
6290
7996
  );
6291
7997
  }
6292
7998
  function registerSearch(tokensCmd) {
6293
- tokensCmd.command("search <value>").description("Find which token(s) match a computed value (supports fuzzy color matching)").option("--type <type>", "Restrict search to a specific token type").option("--fuzzy", "Return nearest match even if no exact match exists", false).option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or table (default: auto-detect)").action(
7999
+ tokensCmd.command("search <value>").description(
8000
+ 'Find the token(s) whose computed value matches the given raw value.\nSupports fuzzy color matching (hex \u2194 rgb \u2194 hsl equivalence).\n\nExamples:\n scope tokens search "#3b82f6"\n scope tokens search "16px"\n scope tokens search "rgb(59, 130, 246)" # fuzzy-matches #3b82f6'
8001
+ ).option("--type <type>", "Restrict search to a specific token type").option("--fuzzy", "Return nearest match even if no exact match exists", false).option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or table (default: auto-detect)").action(
6294
8002
  (value, opts) => {
6295
8003
  try {
6296
8004
  const filePath = resolveTokenFilePath(opts.file);
@@ -6373,7 +8081,9 @@ Tip: use --fuzzy for nearest-match search.
6373
8081
  );
6374
8082
  }
6375
8083
  function registerResolve(tokensCmd) {
6376
- tokensCmd.command("resolve <path>").description("Show the full resolution chain for a token").option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action((tokenPath, opts) => {
8084
+ tokensCmd.command("resolve <path>").description(
8085
+ "Print the full reference chain from a token path down to its raw value.\nUseful for debugging circular references or understanding token inheritance.\n\nExamples:\n scope tokens resolve color.primary.500\n scope tokens resolve button.background --format json"
8086
+ ).option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action((tokenPath, opts) => {
6377
8087
  try {
6378
8088
  const filePath = resolveTokenFilePath(opts.file);
6379
8089
  const absFilePath = filePath;
@@ -6409,7 +8119,19 @@ function registerResolve(tokensCmd) {
6409
8119
  }
6410
8120
  function registerValidate(tokensCmd) {
6411
8121
  tokensCmd.command("validate").description(
6412
- "Validate the token file for errors (circular refs, missing refs, type mismatches)"
8122
+ `Validate the token file and report errors.
8123
+
8124
+ CHECKS:
8125
+ - Circular reference chains (A \u2192 B \u2192 A)
8126
+ - Broken references ({path.that.does.not.exist})
8127
+ - Type mismatches (token declared as "color" but value is a number)
8128
+ - Duplicate paths
8129
+
8130
+ Exits 1 if any errors are found (suitable for CI).
8131
+
8132
+ Examples:
8133
+ scope tokens validate
8134
+ scope tokens validate --format json | jq '.errors'`
6413
8135
  ).option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action((opts) => {
6414
8136
  try {
6415
8137
  const filePath = resolveTokenFilePath(opts.file);
@@ -6488,8 +8210,9 @@ function outputValidationResult(filePath, errors, useJson) {
6488
8210
  }
6489
8211
  function createTokensCommand() {
6490
8212
  const tokensCmd = new commander.Command("tokens").description(
6491
- "Query and validate design tokens from a reactscope.tokens.json file"
8213
+ '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'
6492
8214
  );
8215
+ registerTokensInit(tokensCmd);
6493
8216
  registerGet2(tokensCmd);
6494
8217
  registerList2(tokensCmd);
6495
8218
  registerSearch(tokensCmd);
@@ -6504,8 +8227,12 @@ function createTokensCommand() {
6504
8227
 
6505
8228
  // src/program.ts
6506
8229
  function createProgram(options = {}) {
6507
- const program = new commander.Command("scope").version(options.version ?? "0.1.0").description("Scope \u2014 React instrumentation toolkit");
6508
- program.command("capture <url>").description("Capture a React component tree from a live URL and output as JSON").option("-o, --output <path>", "Write JSON to file instead of stdout").option("--pretty", "Pretty-print JSON output (default: minified)", false).option("--timeout <ms>", "Max wait time for React to mount (ms)", "10000").option("--wait <ms>", "Additional wait after page load before capture (ms)", "0").action(
8230
+ const program = new commander.Command("scope").version(options.version ?? "0.1.0").description(
8231
+ '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.'
8232
+ );
8233
+ program.command("capture <url>").description(
8234
+ "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"
8235
+ ).option("-o, --output <path>", "Write JSON to file instead of stdout").option("--pretty", "Pretty-print JSON output (default: minified)", false).option("--timeout <ms>", "Max wait time for React to mount (ms)", "10000").option("--wait <ms>", "Additional wait after page load before capture (ms)", "0").action(
6509
8236
  async (url, opts) => {
6510
8237
  try {
6511
8238
  const { report } = await browserCapture({
@@ -6529,7 +8256,9 @@ function createProgram(options = {}) {
6529
8256
  }
6530
8257
  }
6531
8258
  );
6532
- program.command("tree <url>").description("Display the React component tree from a live URL").option("--depth <n>", "Max depth to display (default: unlimited)").option("--show-props", "Include prop names next to components", false).option("--show-hooks", "Show hook counts per component", false).option("--timeout <ms>", "Max wait time for React to mount (ms)", "10000").option("--wait <ms>", "Additional wait after page load before capture (ms)", "0").action(
8259
+ program.command("tree <url>").description(
8260
+ "Print a formatted React component tree from a running app.\nUseful for quickly understanding component hierarchy without full capture.\n\nExamples:\n scope tree http://localhost:5173\n scope tree http://localhost:5173 --show-props --show-hooks\n scope tree http://localhost:5173 --depth 4"
8261
+ ).option("--depth <n>", "Max depth to display (default: unlimited)").option("--show-props", "Include prop names next to components", false).option("--show-hooks", "Show hook counts per component", false).option("--timeout <ms>", "Max wait time for React to mount (ms)", "10000").option("--wait <ms>", "Additional wait after page load before capture (ms)", "0").action(
6533
8262
  async (url, opts) => {
6534
8263
  try {
6535
8264
  const { report } = await browserCapture({
@@ -6552,7 +8281,9 @@ function createProgram(options = {}) {
6552
8281
  }
6553
8282
  }
6554
8283
  );
6555
- program.command("report <url>").description("Capture and display a human-readable summary of a React app").option("--json", "Output as structured JSON instead of human-readable text", false).option("--timeout <ms>", "Max wait time for React to mount (ms)", "10000").option("--wait <ms>", "Additional wait after page load before capture (ms)", "0").action(
8284
+ program.command("report <url>").description(
8285
+ "Capture a React app and print a human-readable analysis summary.\nIncludes component count, hook usage, side-effect summary, and more.\n\nExamples:\n scope report http://localhost:5173\n scope report http://localhost:5173 --json\n scope report http://localhost:5173 --json -o report.json"
8286
+ ).option("--json", "Output as structured JSON instead of human-readable text", false).option("--timeout <ms>", "Max wait time for React to mount (ms)", "10000").option("--wait <ms>", "Additional wait after page load before capture (ms)", "0").action(
6556
8287
  async (url, opts) => {
6557
8288
  try {
6558
8289
  const { report } = await browserCapture({
@@ -6576,7 +8307,9 @@ function createProgram(options = {}) {
6576
8307
  }
6577
8308
  }
6578
8309
  );
6579
- program.command("generate").description("Generate a Playwright test from a Scope trace file").argument("<trace>", "Path to a serialized Scope trace (.json)").option("-o, --output <path>", "Output file path", "scope.spec.ts").option("-d, --description <text>", "Test description").action((tracePath, opts) => {
8310
+ program.command("generate").description(
8311
+ 'Generate a Playwright test file from a Scope trace (.json).\nTraces are produced by scope instrument renders or scope capture.\n\nExamples:\n scope generate trace.json\n scope generate trace.json -o tests/scope.spec.ts -d "User login flow"'
8312
+ ).argument("<trace>", "Path to a serialized Scope trace (.json)").option("-o, --output <path>", "Output file path", "scope.spec.ts").option("-d, --description <text>", "Test description").action((tracePath, opts) => {
6580
8313
  const raw = fs.readFileSync(tracePath, "utf-8");
6581
8314
  const trace = playwright.loadTrace(raw);
6582
8315
  const source = playwright.generateTest(trace, {
@@ -6593,6 +8326,7 @@ function createProgram(options = {}) {
6593
8326
  program.addCommand(createInitCommand());
6594
8327
  program.addCommand(createCiCommand());
6595
8328
  program.addCommand(createDoctorCommand());
8329
+ program.addCommand(createGetSkillCommand());
6596
8330
  const existingReportCmd = program.commands.find((c) => c.name() === "report");
6597
8331
  if (existingReportCmd !== void 0) {
6598
8332
  registerBaselineSubCommand(existingReportCmd);
@@ -6606,6 +8340,7 @@ function createProgram(options = {}) {
6606
8340
  exports.CI_EXIT = CI_EXIT;
6607
8341
  exports.createCiCommand = createCiCommand;
6608
8342
  exports.createDoctorCommand = createDoctorCommand;
8343
+ exports.createGetSkillCommand = createGetSkillCommand;
6609
8344
  exports.createInitCommand = createInitCommand;
6610
8345
  exports.createInstrumentCommand = createInstrumentCommand;
6611
8346
  exports.createManifestCommand = createManifestCommand;