@loworbitstudio/visor 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": "0.4.0",
3
- "generated_at": "2026-05-20T04:45:51.978Z",
3
+ "generated_at": "2026-05-21T04:13:27.641Z",
4
4
  "components": {
5
5
  "accessibility-specimen": {
6
6
  "changeType": "current",
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync as readFileSync26 } from "fs";
5
- import { dirname as dirname10, join as join24 } from "path";
4
+ import { readFileSync as readFileSync27 } from "fs";
5
+ import { dirname as dirname10, join as join25 } from "path";
6
6
  import { fileURLToPath as fileURLToPath4 } from "url";
7
7
  import { Command as Command2 } from "commander";
8
8
 
@@ -5779,8 +5779,8 @@ function migrateTokenSubstitutionCommand(targetArg, cwd, options) {
5779
5779
  }
5780
5780
 
5781
5781
  // src/commands/sandbox/init.ts
5782
- import { existsSync as existsSync21, mkdirSync as mkdirSync8, readFileSync as readFileSync23, rmSync as rmSync2 } from "fs";
5783
- import { isAbsolute as isAbsolute2, join as join21, resolve as resolve15, dirname as dirname9 } from "path";
5782
+ import { existsSync as existsSync21, mkdirSync as mkdirSync9, readFileSync as readFileSync24, rmSync as rmSync2 } from "fs";
5783
+ import { isAbsolute as isAbsolute2, join as join22, resolve as resolve15, dirname as dirname9 } from "path";
5784
5784
  import { fileURLToPath as fileURLToPath3 } from "url";
5785
5785
  import * as childProcess2 from "child_process";
5786
5786
 
@@ -6022,11 +6022,18 @@ function tsconfigTemplate() {
6022
6022
  }
6023
6023
  function nextConfigTemplate() {
6024
6024
  return `import type { NextConfig } from "next"
6025
+ import path from "node:path"
6026
+ import { fileURLToPath } from "node:url"
6027
+
6028
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
6025
6029
 
6026
6030
  const nextConfig: NextConfig = {
6027
6031
  reactStrictMode: true,
6028
6032
  // Sandbox is local-only \u2014 disable production telemetry and image opt to keep startup fast.
6029
6033
  images: { unoptimized: true },
6034
+ // Anchor turbopack root to the sandbox dir so a parent-repo package-lock.json
6035
+ // doesn't pull the workspace root upstream (VI-440).
6036
+ turbopack: { root: __dirname },
6030
6037
  }
6031
6038
 
6032
6039
  export default nextConfig
@@ -6160,6 +6167,43 @@ export default async function ScreenPage({ params }: { params: Promise<Params> }
6160
6167
  }
6161
6168
  `;
6162
6169
  }
6170
+ function prototypeScreenRouteTemplate(screenMap) {
6171
+ const mapLiteral = JSON.stringify(screenMap, null, 2);
6172
+ return `import { notFound } from "next/navigation"
6173
+ import { manifest } from "@/lib/sandbox-manifest"
6174
+ import { ScreenSample } from "@/components/sandbox-sample"
6175
+
6176
+ const SCREEN_HTML: Record<string, string> = ${mapLiteral}
6177
+
6178
+ interface Params { name: string }
6179
+
6180
+ export function generateStaticParams() {
6181
+ return manifest.screens.map((s) => ({ name: s.name }))
6182
+ }
6183
+
6184
+ export default async function ScreenPage({ params }: { params: Promise<Params> }) {
6185
+ const { name } = await params
6186
+ const entry = manifest.screens.find((s) => s.name === name)
6187
+ if (!entry) notFound()
6188
+ const htmlFile = SCREEN_HTML[entry.name]
6189
+ return (
6190
+ <main style={{ padding: "32px", maxWidth: "1440px", margin: "0 auto" }}>
6191
+ <h1>{entry.title}</h1>
6192
+ {entry.route ? <p style={{ color: "var(--text-secondary, #555)" }}><code>{entry.route}</code></p> : null}
6193
+ {htmlFile ? (
6194
+ <iframe
6195
+ src={\`/prototype/\${htmlFile}\`}
6196
+ title={entry.title}
6197
+ style={{ width: "100%", height: "calc(100vh - 160px)", border: "1px solid var(--border-default, #e5e7eb)", borderRadius: "8px", background: "var(--bg-surface, #f7f7f8)" }}
6198
+ />
6199
+ ) : (
6200
+ <ScreenSample name={entry.name} />
6201
+ )}
6202
+ </main>
6203
+ )
6204
+ }
6205
+ `;
6206
+ }
6163
6207
  function sandboxManifestModule(manifest) {
6164
6208
  const payload = {
6165
6209
  pattern: manifest.pattern,
@@ -6173,7 +6217,8 @@ function sandboxManifestModule(manifest) {
6173
6217
  screens: manifest.screens.map((s) => ({
6174
6218
  name: s.name,
6175
6219
  title: s.title,
6176
- route: s.route ?? null
6220
+ route: s.route ?? null,
6221
+ kind: s.kind ?? "named"
6177
6222
  }))
6178
6223
  };
6179
6224
  return `export const manifest = ${JSON.stringify(payload, null, 2)} as const
@@ -6312,8 +6357,10 @@ function readmeTemplate(manifest, port) {
6312
6357
  }
6313
6358
  function captureScriptTemplate() {
6314
6359
  return `// Auto-generated by 'visor sandbox approve'. Do not edit by hand \u2014 rewrite via approve.
6360
+ // Always captures to captures/pending/ and pixel-diffs against captures/approved/ if a baseline exists.
6361
+ // Promotion of pending \u2192 approved happens via 'visor sandbox approve --approve' (operator review gate).
6315
6362
  import { chromium } from "@playwright/test"
6316
- import { readFile, writeFile, mkdir, readdir } from "node:fs/promises"
6363
+ import { readFile, writeFile, mkdir, rm } from "node:fs/promises"
6317
6364
  import { existsSync } from "node:fs"
6318
6365
  import { join, dirname } from "node:path"
6319
6366
  import { fileURLToPath } from "node:url"
@@ -6324,7 +6371,6 @@ const sandboxDir = dirname(fileURLToPath(import.meta.url))
6324
6371
  const configPath = join(sandboxDir, "sandbox.json")
6325
6372
  const config = JSON.parse(await readFile(configPath, "utf-8"))
6326
6373
 
6327
- const diffMode = process.argv.includes("--diff")
6328
6374
  const port = Number(process.env.SANDBOX_PORT ?? config.port)
6329
6375
  const baseUrl = \`http://localhost:\${port}\`
6330
6376
 
@@ -6333,9 +6379,11 @@ const routes = ["/", ...config.primitives.map((p) => \`/primitives/\${p.name}\`)
6333
6379
  const approvedDir = join(sandboxDir, "captures", "approved")
6334
6380
  const pendingDir = join(sandboxDir, "captures", "pending")
6335
6381
  const diffsDir = join(sandboxDir, "captures", "diffs")
6382
+ await rm(pendingDir, { recursive: true, force: true })
6383
+ await rm(diffsDir, { recursive: true, force: true })
6336
6384
  await mkdir(approvedDir, { recursive: true })
6337
6385
  await mkdir(pendingDir, { recursive: true })
6338
- if (diffMode) await mkdir(diffsDir, { recursive: true })
6386
+ await mkdir(diffsDir, { recursive: true })
6339
6387
 
6340
6388
  function slugify(route) {
6341
6389
  if (route === "/") return "index"
@@ -6343,56 +6391,56 @@ function slugify(route) {
6343
6391
  }
6344
6392
 
6345
6393
  const browser = await chromium.launch()
6346
- const context = await browser.newContext({ viewport: { width: 1280, height: 800 } })
6394
+ const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, deviceScaleFactor: 2 })
6347
6395
  const page = await context.newPage()
6348
6396
  const results = []
6349
6397
 
6350
6398
  for (const route of routes) {
6351
6399
  const slug = slugify(route)
6352
- const targetDir = diffMode ? pendingDir : approvedDir
6353
- const pngPath = join(targetDir, \`\${slug}.png\`)
6400
+ const pngPath = join(pendingDir, \`\${slug}.png\`)
6354
6401
  await page.goto(baseUrl + route, { waitUntil: "networkidle" })
6355
6402
  const buf = await page.screenshot({ fullPage: true })
6356
6403
  await writeFile(pngPath, buf)
6357
- results.push({ route, png: pngPath })
6404
+ const entry = { route, png: pngPath }
6405
+ results.push(entry)
6358
6406
 
6359
- if (diffMode) {
6360
- const baselinePath = join(approvedDir, \`\${slug}.png\`)
6361
- if (!existsSync(baselinePath)) {
6362
- results[results.length - 1].diff = "no-baseline"
6363
- continue
6364
- }
6365
- const baselineBuf = await readFile(baselinePath)
6366
- const baseline = PNG.sync.read(baselineBuf)
6367
- const candidate = PNG.sync.read(buf)
6368
- if (baseline.width !== candidate.width || baseline.height !== candidate.height) {
6369
- const diffPath = join(diffsDir, \`\${slug}.diff.png\`)
6370
- await writeFile(diffPath, buf)
6371
- results[results.length - 1].diff = diffPath
6372
- results[results.length - 1].dimensionsMismatch = true
6373
- continue
6374
- }
6375
- const diffPng = new PNG({ width: baseline.width, height: baseline.height })
6376
- const pixels = pixelmatch(
6377
- baseline.data,
6378
- candidate.data,
6379
- diffPng.data,
6380
- baseline.width,
6381
- baseline.height,
6382
- { threshold: 0.1 }
6383
- )
6384
- if (pixels > 0) {
6385
- const diffPath = join(diffsDir, \`\${slug}.diff.png\`)
6386
- await writeFile(diffPath, PNG.sync.write(diffPng))
6387
- results[results.length - 1].diff = diffPath
6388
- results[results.length - 1].changedPixels = pixels
6389
- }
6407
+ const baselinePath = join(approvedDir, \`\${slug}.png\`)
6408
+ if (!existsSync(baselinePath)) {
6409
+ entry.diff = "no-baseline"
6410
+ continue
6411
+ }
6412
+ const baselineBuf = await readFile(baselinePath)
6413
+ const baseline = PNG.sync.read(baselineBuf)
6414
+ const candidate = PNG.sync.read(buf)
6415
+ if (baseline.width !== candidate.width || baseline.height !== candidate.height) {
6416
+ const diffPath = join(diffsDir, \`\${slug}.diff.png\`)
6417
+ await writeFile(diffPath, buf)
6418
+ entry.diff = diffPath
6419
+ entry.dimensionsMismatch = true
6420
+ continue
6421
+ }
6422
+ const diffPng = new PNG({ width: baseline.width, height: baseline.height })
6423
+ const pixels = pixelmatch(
6424
+ baseline.data,
6425
+ candidate.data,
6426
+ diffPng.data,
6427
+ baseline.width,
6428
+ baseline.height,
6429
+ { threshold: 0.1 }
6430
+ )
6431
+ if (pixels > 0) {
6432
+ const diffPath = join(diffsDir, \`\${slug}.diff.png\`)
6433
+ await writeFile(diffPath, PNG.sync.write(diffPng))
6434
+ entry.diff = diffPath
6435
+ entry.changedPixels = pixels
6436
+ } else {
6437
+ entry.diff = "clean"
6390
6438
  }
6391
6439
  }
6392
6440
 
6393
6441
  await browser.close()
6394
- await writeFile(join(sandboxDir, "captures", "last-run.json"), JSON.stringify({ diffMode, baseUrl, routes: results }, null, 2))
6395
- console.log(JSON.stringify({ ok: true, diffMode, routes: results }, null, 2))
6442
+ await writeFile(join(sandboxDir, "captures", "last-run.json"), JSON.stringify({ baseUrl, routes: results }, null, 2))
6443
+ console.log(JSON.stringify({ ok: true, mode: "pending", routes: results }, null, 2))
6396
6444
  `;
6397
6445
  }
6398
6446
  function toPascalCase(s) {
@@ -6400,7 +6448,7 @@ function toPascalCase(s) {
6400
6448
  }
6401
6449
 
6402
6450
  // src/commands/sandbox/scaffold.ts
6403
- function writeScaffold(sandboxDir, manifest, port) {
6451
+ function writeScaffold(sandboxDir, manifest, port, options = {}) {
6404
6452
  mkdirSync7(sandboxDir, { recursive: true });
6405
6453
  const created = [];
6406
6454
  const writeIfNew = (rel, contents) => {
@@ -6419,7 +6467,10 @@ function writeScaffold(sandboxDir, manifest, port) {
6419
6467
  writeIfNew("app/globals.css", globalsCssPlaceholder());
6420
6468
  writeIfNew("app/page.tsx", indexPageTemplate());
6421
6469
  writeIfNew("app/primitives/[name]/page.tsx", primitiveRouteTemplate());
6422
- writeIfNew("app/screens/[name]/page.tsx", screenRouteTemplate());
6470
+ writeIfNew(
6471
+ "app/screens/[name]/page.tsx",
6472
+ options.prototypeImport ? prototypeScreenRouteTemplate(options.prototypeImport.screenMap) : screenRouteTemplate()
6473
+ );
6423
6474
  writeIfNew("components/sandbox-sample.tsx", sandboxSampleComponent());
6424
6475
  writeIfNew("lib/sandbox-manifest.ts", sandboxManifestModule(manifest));
6425
6476
  writeIfNew("lib/sandbox-mocks.ts", sandboxMocksModule(manifest.mockShapes));
@@ -6447,9 +6498,21 @@ function writeSandboxConfig(sandboxDir, manifest, port, options) {
6447
6498
  primitives: manifest.primitives.map((p) => ({
6448
6499
  name: p.name,
6449
6500
  status: p.status,
6450
- viTicket: p.viTicket ?? null
6501
+ viTicket: p.viTicket ?? null,
6502
+ kind: p.kind ?? null
6451
6503
  })),
6452
- screens: manifest.screens.map((s) => ({ name: s.name, title: s.title, route: s.route ?? null }))
6504
+ screens: manifest.screens.map((s) => ({
6505
+ name: s.name,
6506
+ title: s.title,
6507
+ route: s.route ?? null,
6508
+ kind: s.kind ?? "named"
6509
+ })),
6510
+ fromHtmlPrototype: options.prototypeImport ? {
6511
+ sourceDir: options.prototypeImport.sourceDir,
6512
+ screenMap: options.prototypeImport.screenMap,
6513
+ stateCoverageScreens: options.prototypeImport.stateCoverageScreens,
6514
+ stripChromeSelectors: options.prototypeImport.stripChromeSelectors
6515
+ } : null
6453
6516
  };
6454
6517
  writeFileSync12(join20(sandboxDir, "sandbox.json"), JSON.stringify(config, null, 2) + "\n", "utf-8");
6455
6518
  }
@@ -6462,6 +6525,278 @@ function sandboxIsEmpty(sandboxDir) {
6462
6525
  }
6463
6526
  }
6464
6527
 
6528
+ // src/commands/sandbox/html-prototype.ts
6529
+ import { mkdirSync as mkdirSync8, readdirSync as readdirSync13, readFileSync as readFileSync23, statSync as statSync9, writeFileSync as writeFileSync13 } from "fs";
6530
+ import { join as join21 } from "path";
6531
+
6532
+ // src/commands/sandbox/strip-chrome.ts
6533
+ var DEFAULT_STRIP_SELECTORS = [
6534
+ ".state-callout",
6535
+ ".state-section__header",
6536
+ ".proto-nav",
6537
+ "[data-documentary-chrome]",
6538
+ '[style*="mint"]'
6539
+ ];
6540
+ var CLASS_SELECTOR = /^\.([a-zA-Z_][\w-]*)$/;
6541
+ var ATTR_SUBSTRING_SELECTOR = /^\[([a-zA-Z_][\w-]*)\*=(?:"([^"]*)"|'([^']*)')\]$/;
6542
+ var ATTR_PRESENCE_SELECTOR = /^\[([a-zA-Z_][\w-]*)\]$/;
6543
+ function parseSelector(selector) {
6544
+ const trimmed = selector.trim();
6545
+ let m;
6546
+ if (m = trimmed.match(CLASS_SELECTOR)) {
6547
+ return { kind: "class", name: m[1] };
6548
+ }
6549
+ if (m = trimmed.match(ATTR_SUBSTRING_SELECTOR)) {
6550
+ return { kind: "attr-substring", attr: m[1], value: m[2] ?? m[3] ?? "" };
6551
+ }
6552
+ if (m = trimmed.match(ATTR_PRESENCE_SELECTOR)) {
6553
+ return { kind: "attr-presence", attr: m[1] };
6554
+ }
6555
+ return { kind: "unknown" };
6556
+ }
6557
+ var VOID_ELEMENTS = /* @__PURE__ */ new Set([
6558
+ "area",
6559
+ "base",
6560
+ "br",
6561
+ "col",
6562
+ "embed",
6563
+ "hr",
6564
+ "img",
6565
+ "input",
6566
+ "link",
6567
+ "meta",
6568
+ "param",
6569
+ "source",
6570
+ "track",
6571
+ "wbr"
6572
+ ]);
6573
+ function findOpenTagEnd(html, openTagStart) {
6574
+ let i = openTagStart;
6575
+ let quote = null;
6576
+ while (i < html.length) {
6577
+ const ch = html[i];
6578
+ if (quote) {
6579
+ if (ch === quote) quote = null;
6580
+ } else {
6581
+ if (ch === '"' || ch === "'") quote = ch;
6582
+ else if (ch === ">") return i + 1;
6583
+ }
6584
+ i++;
6585
+ }
6586
+ return -1;
6587
+ }
6588
+ function findMatchingCloseTag(html, tagName, searchFrom) {
6589
+ const openRe = new RegExp(`<${tagName}(?=[\\s/>])`, "gi");
6590
+ const closeRe = new RegExp(`</${tagName}\\s*>`, "gi");
6591
+ let depth = 1;
6592
+ let pos = searchFrom;
6593
+ while (pos < html.length) {
6594
+ openRe.lastIndex = pos;
6595
+ closeRe.lastIndex = pos;
6596
+ const openMatch = openRe.exec(html);
6597
+ const closeMatch = closeRe.exec(html);
6598
+ if (!closeMatch) return -1;
6599
+ if (openMatch && openMatch.index < closeMatch.index) {
6600
+ depth++;
6601
+ const past = findOpenTagEnd(html, openMatch.index);
6602
+ pos = past === -1 ? openMatch.index + tagName.length + 1 : past;
6603
+ continue;
6604
+ }
6605
+ depth--;
6606
+ if (depth === 0) return closeMatch.index;
6607
+ pos = closeMatch.index + closeMatch[0].length;
6608
+ }
6609
+ return -1;
6610
+ }
6611
+ function openTagMatches(openTag, selector) {
6612
+ if (selector.kind === "class") {
6613
+ const classAttr = /\sclass\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(openTag);
6614
+ if (!classAttr) return false;
6615
+ const value = classAttr[1] ?? classAttr[2] ?? "";
6616
+ return value.split(/\s+/).some((token) => token === selector.name);
6617
+ }
6618
+ if (selector.kind === "attr-substring") {
6619
+ const attrRe = new RegExp(
6620
+ `\\s${escapeRegex2(selector.attr)}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`,
6621
+ "i"
6622
+ );
6623
+ const m = openTag.match(attrRe);
6624
+ if (!m) return false;
6625
+ const value = m[1] ?? m[2] ?? "";
6626
+ return value.includes(selector.value);
6627
+ }
6628
+ if (selector.kind === "attr-presence") {
6629
+ const attrRe = new RegExp(
6630
+ `\\s${escapeRegex2(selector.attr)}(?:\\s*=|\\s|/?>)`,
6631
+ "i"
6632
+ );
6633
+ return attrRe.test(openTag);
6634
+ }
6635
+ return false;
6636
+ }
6637
+ function escapeRegex2(s) {
6638
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6639
+ }
6640
+ var TAG_NAME_RE = /^<([a-zA-Z][a-zA-Z0-9-]*)/;
6641
+ function stripOnce(html, selectors) {
6642
+ const tagRe = /<([a-zA-Z][a-zA-Z0-9-]*)\b/g;
6643
+ let m;
6644
+ while (m = tagRe.exec(html)) {
6645
+ const openTagStart = m.index;
6646
+ const tagNameMatch = html.slice(openTagStart, openTagStart + 200).match(TAG_NAME_RE);
6647
+ if (!tagNameMatch) continue;
6648
+ const tagName = tagNameMatch[1].toLowerCase();
6649
+ const openTagEnd = findOpenTagEnd(html, openTagStart);
6650
+ if (openTagEnd === -1) continue;
6651
+ const openTag = html.slice(openTagStart, openTagEnd);
6652
+ const matched = selectors.some((sel) => openTagMatches(openTag, sel));
6653
+ if (!matched) continue;
6654
+ let elementEnd;
6655
+ if (VOID_ELEMENTS.has(tagName) || /\/\s*>$/.test(openTag)) {
6656
+ elementEnd = openTagEnd;
6657
+ } else {
6658
+ const closeStart = findMatchingCloseTag(html, tagName, openTagEnd);
6659
+ if (closeStart === -1) continue;
6660
+ const closeRe = new RegExp(`</${tagName}\\s*>`, "i");
6661
+ const closeMatch = html.slice(closeStart).match(closeRe);
6662
+ if (!closeMatch) continue;
6663
+ elementEnd = closeStart + closeMatch[0].length;
6664
+ }
6665
+ let cutEnd = elementEnd;
6666
+ if (html[cutEnd] === "\n") cutEnd++;
6667
+ let cutStart = openTagStart;
6668
+ while (cutStart > 0 && (html[cutStart - 1] === " " || html[cutStart - 1] === " ")) {
6669
+ cutStart--;
6670
+ }
6671
+ if (cutStart > 0 && html[cutStart - 1] === "\n" && cutEnd <= html.length && (html[cutEnd - 1] === "\n" || cutEnd === html.length)) {
6672
+ cutStart--;
6673
+ }
6674
+ return {
6675
+ html: html.slice(0, cutStart) + html.slice(cutEnd),
6676
+ changed: true
6677
+ };
6678
+ }
6679
+ return { html, changed: false };
6680
+ }
6681
+ function resolveStripSelectors(stripChrome, additional) {
6682
+ if (stripChrome === void 0 || stripChrome === false) {
6683
+ if (additional && additional.trim() !== "") {
6684
+ return [...DEFAULT_STRIP_SELECTORS, ...parseList(additional)];
6685
+ }
6686
+ return [];
6687
+ }
6688
+ const base = stripChrome === true || typeof stripChrome === "string" && stripChrome.trim() === "" ? [...DEFAULT_STRIP_SELECTORS] : parseList(stripChrome);
6689
+ if (additional && additional.trim() !== "") {
6690
+ return [...base, ...parseList(additional)];
6691
+ }
6692
+ return base;
6693
+ }
6694
+ function parseList(input) {
6695
+ return input.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
6696
+ }
6697
+ function stripDocumentaryChrome(html, selectors) {
6698
+ if (selectors.length === 0) return html;
6699
+ const parsed = selectors.map(parseSelector).filter((s) => s.kind !== "unknown");
6700
+ if (parsed.length === 0) return html;
6701
+ let current = html;
6702
+ for (let i = 0; i < 1e3; i++) {
6703
+ const { html: next, changed } = stripOnce(current, parsed);
6704
+ if (!changed) break;
6705
+ current = next;
6706
+ }
6707
+ return current;
6708
+ }
6709
+
6710
+ // src/commands/sandbox/html-prototype.ts
6711
+ var SCREEN_FILE_PATTERN = /^screen-(\d+)-([^/.]+)\.html$/i;
6712
+ function copyHtmlPrototype(sourceDir, sandboxDir, manifest, options = {}) {
6713
+ const warnings = [];
6714
+ const destAbs = join21(sandboxDir, "public", "prototype");
6715
+ mkdirSync8(destAbs, { recursive: true });
6716
+ const stripChromeSelectors = options.stripChromeSelectors ?? [];
6717
+ const copiedFiles = copyTreeRelative(sourceDir, destAbs, "", stripChromeSelectors);
6718
+ const screenFiles = listOrderedScreenFiles(sourceDir);
6719
+ if (screenFiles.length === 0) {
6720
+ warnings.push(
6721
+ `HTML prototype at ${sourceDir} has no 'screen-N-*.html' files \u2014 screen routes will fall back to placeholder.`
6722
+ );
6723
+ }
6724
+ const screenMap = {};
6725
+ manifest.screens.forEach((screen, idx) => {
6726
+ const file = screenFiles[idx];
6727
+ if (file) {
6728
+ screenMap[screen.name] = file;
6729
+ } else {
6730
+ warnings.push(
6731
+ `Manifest screen '${screen.name}' has no matching prototype HTML at position ${idx + 1} \u2014 route will use placeholder.`
6732
+ );
6733
+ }
6734
+ });
6735
+ const stateCoverageScreens = [];
6736
+ if (screenFiles.length > manifest.screens.length) {
6737
+ const extras = screenFiles.slice(manifest.screens.length);
6738
+ const existingNames = new Set(manifest.screens.map((s) => s.name));
6739
+ for (const file of extras) {
6740
+ const entry = deriveStateCoverageScreen(file, existingNames);
6741
+ manifest.screens.push(entry);
6742
+ existingNames.add(entry.name);
6743
+ screenMap[entry.name] = file;
6744
+ stateCoverageScreens.push(entry.name);
6745
+ }
6746
+ }
6747
+ return {
6748
+ sourceDir,
6749
+ destDir: "public/prototype",
6750
+ copiedFiles,
6751
+ screenMap,
6752
+ stateCoverageScreens,
6753
+ stripChromeSelectors,
6754
+ warnings
6755
+ };
6756
+ }
6757
+ function deriveStateCoverageScreen(file, existingNames) {
6758
+ const m = file.match(SCREEN_FILE_PATTERN);
6759
+ const idx = m ? m[1] : "0";
6760
+ const suffix = m ? m[2].toLowerCase() : "extra";
6761
+ let slug2 = `state-coverage-${suffix}`;
6762
+ if (existingNames.has(slug2)) slug2 = `${slug2}-${idx}`;
6763
+ const title = `State coverage: ${suffix.replace(/-/g, " ")}`;
6764
+ return { name: slug2, title, kind: "state-coverage" };
6765
+ }
6766
+ function copyTreeRelative(srcDir, destDir, relDir = "", stripChromeSelectors = []) {
6767
+ const out = [];
6768
+ const entries = readdirSync13(join21(srcDir, relDir));
6769
+ for (const name of entries) {
6770
+ if (name.startsWith(".")) continue;
6771
+ const srcPath = join21(srcDir, relDir, name);
6772
+ const destPath = join21(destDir, relDir, name);
6773
+ const stat = statSync9(srcPath);
6774
+ if (stat.isDirectory()) {
6775
+ mkdirSync8(destPath, { recursive: true });
6776
+ out.push(...copyTreeRelative(srcDir, destDir, join21(relDir, name), stripChromeSelectors));
6777
+ } else if (stat.isFile()) {
6778
+ if (stripChromeSelectors.length > 0 && name.toLowerCase().endsWith(".html")) {
6779
+ const html = readFileSync23(srcPath, "utf-8");
6780
+ const stripped = stripDocumentaryChrome(html, stripChromeSelectors);
6781
+ writeFileSync13(destPath, stripped);
6782
+ } else {
6783
+ writeFileSync13(destPath, readFileSync23(srcPath));
6784
+ }
6785
+ out.push(join21("public", "prototype", relDir, name).replace(/\\/g, "/"));
6786
+ }
6787
+ }
6788
+ return out;
6789
+ }
6790
+ function listOrderedScreenFiles(srcDir) {
6791
+ const names = readdirSync13(srcDir).filter((n) => SCREEN_FILE_PATTERN.test(n));
6792
+ names.sort((a, b) => {
6793
+ const na = Number(a.match(SCREEN_FILE_PATTERN)?.[1] ?? 0);
6794
+ const nb = Number(b.match(SCREEN_FILE_PATTERN)?.[1] ?? 0);
6795
+ return na - nb;
6796
+ });
6797
+ return names;
6798
+ }
6799
+
6465
6800
  // src/commands/sandbox/init.ts
6466
6801
  async function sandboxInitCommand(name, cwd, options) {
6467
6802
  const json = options.json ?? false;
@@ -6509,7 +6844,7 @@ async function runInit(name, cwd, options) {
6509
6844
  `Handoff has no primitives \u2014 refusing to scaffold an empty sandbox. Check the manifest at ${handoffPath}`
6510
6845
  );
6511
6846
  }
6512
- const sandboxDir = join21(cwd, ".lo", "sandbox", name);
6847
+ const sandboxDir = join22(cwd, ".lo", "sandbox", name);
6513
6848
  if (!sandboxIsEmpty(sandboxDir)) {
6514
6849
  if (!options.overwrite) {
6515
6850
  throw new Error(
@@ -6518,23 +6853,48 @@ async function runInit(name, cwd, options) {
6518
6853
  }
6519
6854
  rmSync2(sandboxDir, { recursive: true, force: true });
6520
6855
  }
6521
- mkdirSync8(sandboxDir, { recursive: true });
6856
+ mkdirSync9(sandboxDir, { recursive: true });
6522
6857
  const port = await findOpenPort();
6523
- writeScaffold(sandboxDir, manifest, port);
6858
+ let prototypeImport;
6859
+ if (options.fromHtmlPrototype) {
6860
+ const prototypeDir = isAbsolute2(options.fromHtmlPrototype) ? options.fromHtmlPrototype : resolve15(cwd, options.fromHtmlPrototype);
6861
+ if (!existsSync21(prototypeDir)) {
6862
+ throw new Error(`HTML prototype directory not found: ${prototypeDir}`);
6863
+ }
6864
+ const stripChromeSelectors = resolveStripSelectors(
6865
+ options.stripChrome,
6866
+ options.stripChromeAdditional
6867
+ );
6868
+ prototypeImport = copyHtmlPrototype(prototypeDir, sandboxDir, manifest, {
6869
+ stripChromeSelectors
6870
+ });
6871
+ for (const w of prototypeImport.warnings) manifest.warnings.push(w);
6872
+ }
6873
+ const known = loadKnownPrimitives();
6874
+ for (const p of manifest.primitives) {
6875
+ if (p.status !== "shipped" && p.status !== "gap-inflight") continue;
6876
+ if (known.has(p.name)) continue;
6877
+ p.status = "compose-recipe";
6878
+ p.viTicket = void 0;
6879
+ manifest.warnings.push(
6880
+ `'${p.name}' declared shipped in the handoff but absent from the registry \u2014 reclassified as compose-recipe`
6881
+ );
6882
+ }
6883
+ writeScaffold(sandboxDir, manifest, port, { prototypeImport });
6524
6884
  if (!options.skipInstall) {
6525
6885
  runNpmInstall(sandboxDir, options.json ?? false);
6526
6886
  }
6527
- applyThemeIfPossible(sandboxDir, manifest, options.theme, cwd, options.json ?? false);
6528
- const known = loadKnownPrimitives();
6887
+ applyThemeIfPossible(
6888
+ sandboxDir,
6889
+ manifest,
6890
+ options.theme,
6891
+ cwd,
6892
+ options.json ?? false,
6893
+ options.themeFile
6894
+ );
6529
6895
  const shipped = [];
6530
6896
  for (const p of manifest.primitives) {
6531
6897
  if (p.status !== "shipped" && p.status !== "gap-inflight") continue;
6532
- if (!known.has(p.name)) {
6533
- manifest.warnings.push(
6534
- `'${p.name}' is declared shipped in the handoff but is not in the registry \u2014 skipped`
6535
- );
6536
- continue;
6537
- }
6538
6898
  const ok = tryAddPrimitive(p, sandboxDir, options.json ?? false);
6539
6899
  if (ok) shipped.push(p.name);
6540
6900
  }
@@ -6542,34 +6902,50 @@ async function runInit(name, cwd, options) {
6542
6902
  writeSandboxConfig(sandboxDir, manifest, port, {
6543
6903
  handoffPath,
6544
6904
  theme: options.theme,
6545
- visorVersion: readCliVersion()
6905
+ visorVersion: readCliVersion(),
6906
+ prototypeImport
6546
6907
  });
6547
6908
  return {
6548
6909
  success: true,
6549
6910
  sandboxDir,
6550
6911
  port,
6551
6912
  primitives: { shipped, gaps },
6913
+ prototypeImport,
6552
6914
  warnings: manifest.warnings
6553
6915
  };
6554
6916
  }
6555
- function applyThemeIfPossible(sandboxDir, manifest, theme2, cwd, json) {
6556
- const directPath = isAbsolute2(theme2) ? theme2 : resolve15(cwd, theme2);
6557
- const candidates = [
6558
- directPath,
6559
- join21(cwd, "themes", `${theme2}.visor.yaml`),
6560
- join21(cwd, "custom-themes", `${theme2}.visor.yaml`)
6561
- ];
6917
+ function applyThemeIfPossible(sandboxDir, manifest, theme2, cwd, json, themeFile) {
6918
+ const candidates = [];
6919
+ if (themeFile) {
6920
+ candidates.push(isAbsolute2(themeFile) ? themeFile : resolve15(cwd, themeFile));
6921
+ }
6922
+ candidates.push(isAbsolute2(theme2) ? theme2 : resolve15(cwd, theme2));
6923
+ const privateRoot = process.env.VISOR_THEMES_PRIVATE_PATH;
6924
+ if (privateRoot && privateRoot.length > 0) {
6925
+ candidates.push(join22(privateRoot, "themes", theme2, "theme.visor.yaml"));
6926
+ }
6927
+ candidates.push(
6928
+ join22(cwd, "themes", `${theme2}.visor.yaml`),
6929
+ join22(cwd, "custom-themes", `${theme2}.visor.yaml`)
6930
+ );
6562
6931
  const yamlPath = candidates.find((p) => existsSync21(p));
6563
6932
  if (!yamlPath) {
6933
+ const searched = candidates.join(", ");
6564
6934
  manifest.warnings.push(
6565
- `Theme '${theme2}' not found in themes/ or custom-themes/ \u2014 sandbox uses placeholder globals.css. Run 'visor theme apply <path> --adapter nextjs -o app/globals.css' manually.`
6935
+ `Theme '${theme2}' not found (searched: ${searched}) \u2014 sandbox uses placeholder globals.css. Run 'npx visor theme apply <path-to-theme.visor.yaml> --adapter nextjs -o ${join22(sandboxDir, "app", "globals.css")}' manually, or re-run with --theme-file <path>, or set VISOR_THEMES_PRIVATE_PATH to a directory containing themes/${theme2}/theme.visor.yaml.`
6566
6936
  );
6567
6937
  if (!json) {
6568
6938
  logger.warn(`Theme '${theme2}' not found \u2014 leaving placeholder globals.css.`);
6939
+ logger.item(
6940
+ `Re-run with --theme-file <path>, or set VISOR_THEMES_PRIVATE_PATH=<dir-containing-themes/${theme2}/theme.visor.yaml>.`
6941
+ );
6942
+ logger.item(
6943
+ `Or apply manually: npx visor theme apply <path> --adapter nextjs -o ${join22(sandboxDir, "app", "globals.css")}`
6944
+ );
6569
6945
  }
6570
6946
  return;
6571
6947
  }
6572
- const globalsOut = join21(sandboxDir, "app", "globals.css");
6948
+ const globalsOut = join22(sandboxDir, "app", "globals.css");
6573
6949
  try {
6574
6950
  themeApplyCommand(yamlPath, sandboxDir, {
6575
6951
  output: globalsOut,
@@ -6625,9 +7001,9 @@ function readCliVersion() {
6625
7001
  const here = dirname9(fileURLToPath3(import.meta.url));
6626
7002
  for (let i = 0; i < 6; i++) {
6627
7003
  const segments = new Array(i).fill("..");
6628
- const candidate = join21(here, ...segments, "package.json");
7004
+ const candidate = join22(here, ...segments, "package.json");
6629
7005
  try {
6630
- const pkg2 = JSON.parse(readFileSync23(candidate, "utf-8"));
7006
+ const pkg2 = JSON.parse(readFileSync24(candidate, "utf-8"));
6631
7007
  if (pkg2.name === "@loworbitstudio/visor" && pkg2.version) return pkg2.version;
6632
7008
  } catch {
6633
7009
  }
@@ -6638,13 +7014,13 @@ function readCliVersion() {
6638
7014
  }
6639
7015
 
6640
7016
  // src/commands/sandbox/dev.ts
6641
- import { existsSync as existsSync22, readFileSync as readFileSync24 } from "fs";
6642
- import { join as join22 } from "path";
7017
+ import { existsSync as existsSync22, readFileSync as readFileSync25 } from "fs";
7018
+ import { join as join23 } from "path";
6643
7019
  import * as childProcess3 from "child_process";
6644
7020
  function sandboxDevCommand(cwd, options) {
6645
7021
  const json = options.json ?? false;
6646
- const sandboxDir = join22(cwd, ".lo", "sandbox", options.name);
6647
- const configPath = join22(sandboxDir, "sandbox.json");
7022
+ const sandboxDir = join23(cwd, ".lo", "sandbox", options.name);
7023
+ const configPath = join23(sandboxDir, "sandbox.json");
6648
7024
  if (!existsSync22(configPath)) {
6649
7025
  fail(
6650
7026
  json,
@@ -6654,7 +7030,7 @@ function sandboxDevCommand(cwd, options) {
6654
7030
  }
6655
7031
  let config;
6656
7032
  try {
6657
- config = JSON.parse(readFileSync24(configPath, "utf-8"));
7033
+ config = JSON.parse(readFileSync25(configPath, "utf-8"));
6658
7034
  } catch (err) {
6659
7035
  fail(json, `Invalid sandbox.json at ${configPath}: ${err.message}`);
6660
7036
  return;
@@ -6710,13 +7086,21 @@ function fail(json, message) {
6710
7086
  }
6711
7087
 
6712
7088
  // src/commands/sandbox/approve.ts
6713
- import { existsSync as existsSync23, readFileSync as readFileSync25, writeFileSync as writeFileSync13, readdirSync as readdirSync13 } from "fs";
6714
- import { join as join23 } from "path";
7089
+ import {
7090
+ copyFileSync as copyFileSync2,
7091
+ existsSync as existsSync23,
7092
+ mkdirSync as mkdirSync10,
7093
+ readFileSync as readFileSync26,
7094
+ readdirSync as readdirSync14,
7095
+ rmSync as rmSync3,
7096
+ writeFileSync as writeFileSync14
7097
+ } from "fs";
7098
+ import { basename as basename6, join as join24 } from "path";
6715
7099
  import * as childProcess4 from "child_process";
6716
7100
  function sandboxApproveCommand(cwd, options) {
6717
7101
  const json = options.json ?? false;
6718
- const sandboxDir = join23(cwd, ".lo", "sandbox", options.name);
6719
- const configPath = join23(sandboxDir, "sandbox.json");
7102
+ const sandboxDir = join24(cwd, ".lo", "sandbox", options.name);
7103
+ const configPath = join24(sandboxDir, "sandbox.json");
6720
7104
  if (!existsSync23(configPath)) {
6721
7105
  fail2(
6722
7106
  json,
@@ -6724,18 +7108,27 @@ function sandboxApproveCommand(cwd, options) {
6724
7108
  );
6725
7109
  return;
6726
7110
  }
6727
- const config = JSON.parse(readFileSync25(configPath, "utf-8"));
6728
- const captureScriptPath = join23(sandboxDir, "playwright.capture.mjs");
6729
- writeFileSync13(captureScriptPath, captureScriptTemplate(), "utf-8");
7111
+ if (options.approve) {
7112
+ runPromotion(sandboxDir, options.name, json);
7113
+ return;
7114
+ }
7115
+ runCapture(sandboxDir, configPath, options.name, json);
7116
+ }
7117
+ function runCapture(sandboxDir, configPath, sandboxName, json) {
7118
+ const config = JSON.parse(readFileSync26(configPath, "utf-8"));
7119
+ const captureScriptPath = join24(sandboxDir, "playwright.capture.mjs");
7120
+ writeFileSync14(captureScriptPath, captureScriptTemplate(), "utf-8");
6730
7121
  ensurePlaywrightInstalled(sandboxDir, json);
6731
- const args = ["--no-install", "node", "playwright.capture.mjs"];
6732
- if (options.diff) args.push("--diff");
6733
- const result = childProcess4.spawnSync("npx", args, {
6734
- cwd: sandboxDir,
6735
- stdio: "pipe",
6736
- env: { ...process.env, SANDBOX_PORT: String(config.port) },
6737
- encoding: "utf-8"
6738
- });
7122
+ const result = childProcess4.spawnSync(
7123
+ "npx",
7124
+ ["--no-install", "node", "playwright.capture.mjs"],
7125
+ {
7126
+ cwd: sandboxDir,
7127
+ stdio: "pipe",
7128
+ env: { ...process.env, SANDBOX_PORT: String(config.port) },
7129
+ encoding: "utf-8"
7130
+ }
7131
+ );
6739
7132
  if (result.error) {
6740
7133
  fail2(json, `Capture failed to start: ${result.error.message}`);
6741
7134
  return;
@@ -6745,20 +7138,24 @@ function sandboxApproveCommand(cwd, options) {
6745
7138
  ${result.stderr}`);
6746
7139
  return;
6747
7140
  }
6748
- const approvedDir = join23(sandboxDir, "captures", "approved");
6749
- const diffsDir = join23(sandboxDir, "captures", "diffs");
6750
- const approvedFiles = safeListPngs(approvedDir);
6751
- const diffFiles = options.diff ? safeListPngs(diffsDir) : [];
7141
+ const pendingDir = join24(sandboxDir, "captures", "pending");
7142
+ const diffsDir = join24(sandboxDir, "captures", "diffs");
7143
+ const approvedDir = join24(sandboxDir, "captures", "approved");
7144
+ const pendingFiles = safeListPngs(pendingDir);
7145
+ const diffFiles = safeListPngs(diffsDir);
7146
+ const hasBaseline = safeListPngs(approvedDir).length > 0;
6752
7147
  if (json) {
6753
7148
  console.log(
6754
7149
  JSON.stringify(
6755
7150
  {
6756
7151
  success: true,
6757
- mode: options.diff ? "diff" : "approve",
7152
+ mode: "pending",
7153
+ pendingDir,
7154
+ diffsDir,
6758
7155
  approvedDir,
6759
- diffsDir: options.diff ? diffsDir : null,
6760
- approved: approvedFiles,
7156
+ pending: pendingFiles,
6761
7157
  diffs: diffFiles,
7158
+ hasBaseline,
6762
7159
  captureOutput: tryParseJson(result.stdout)
6763
7160
  },
6764
7161
  null,
@@ -6767,34 +7164,75 @@ ${result.stderr}`);
6767
7164
  );
6768
7165
  } else {
6769
7166
  logger.success(
6770
- options.diff ? `Pixel-diff complete. ${diffFiles.length} diff PNGs in ${diffsDir}` : `Captured ${approvedFiles.length} PNGs in ${approvedDir}`
7167
+ `Captured ${pendingFiles.length} PNGs in ${pendingDir}` + (hasBaseline ? ` (${diffFiles.length} differ from approved baseline)` : " (no prior baseline \u2014 first capture)")
6771
7168
  );
6772
- if (options.diff && diffFiles.length > 0) {
6773
- logger.blank();
7169
+ logger.blank();
7170
+ if (diffFiles.length > 0) {
6774
7171
  logger.info("Changed routes:");
6775
7172
  for (const f of diffFiles) logger.item(f);
7173
+ logger.blank();
6776
7174
  }
7175
+ logger.info("Review captures/pending/ (and captures/diffs/), then promote with:");
7176
+ logger.item(`npx visor sandbox approve --name ${sandboxName} --approve`);
7177
+ }
7178
+ }
7179
+ function runPromotion(sandboxDir, sandboxName, json) {
7180
+ const pendingDir = join24(sandboxDir, "captures", "pending");
7181
+ const approvedDir = join24(sandboxDir, "captures", "approved");
7182
+ const diffsDir = join24(sandboxDir, "captures", "diffs");
7183
+ const pendingFiles = safeListPngs(pendingDir);
7184
+ if (pendingFiles.length === 0) {
7185
+ fail2(
7186
+ json,
7187
+ `No pending captures found at ${pendingDir}. Run 'visor sandbox approve --name ${sandboxName}' (without --approve) first to capture and review.`
7188
+ );
7189
+ return;
7190
+ }
7191
+ mkdirSync10(approvedDir, { recursive: true });
7192
+ const promoted = [];
7193
+ for (const src of pendingFiles) {
7194
+ const dest = join24(approvedDir, basename6(src));
7195
+ copyFileSync2(src, dest);
7196
+ promoted.push(dest);
7197
+ }
7198
+ rmSync3(pendingDir, { recursive: true, force: true });
7199
+ rmSync3(diffsDir, { recursive: true, force: true });
7200
+ if (json) {
7201
+ console.log(
7202
+ JSON.stringify(
7203
+ { success: true, mode: "approve", approvedDir, promoted },
7204
+ null,
7205
+ 2
7206
+ )
7207
+ );
7208
+ } else {
7209
+ logger.success(`Promoted ${promoted.length} pending captures \u2192 ${approvedDir}`);
7210
+ logger.info("Pending and diff directories cleared.");
6777
7211
  }
6778
7212
  }
6779
7213
  function ensurePlaywrightInstalled(sandboxDir, json) {
6780
- const markerPath = join23(sandboxDir, ".playwright-installed");
7214
+ const markerPath = join24(sandboxDir, ".playwright-installed");
6781
7215
  if (existsSync23(markerPath)) return;
6782
7216
  if (!json) logger.info("Installing Playwright Chromium (one-time)...");
6783
- const result = childProcess4.spawnSync("npx", ["--no-install", "playwright", "install", "chromium"], {
6784
- cwd: sandboxDir,
6785
- stdio: json ? "ignore" : "inherit"
6786
- });
7217
+ const result = childProcess4.spawnSync(
7218
+ "npx",
7219
+ ["--no-install", "playwright", "install", "chromium"],
7220
+ {
7221
+ cwd: sandboxDir,
7222
+ stdio: json ? "ignore" : "inherit"
7223
+ }
7224
+ );
6787
7225
  if (result.error) {
6788
7226
  throw new Error(`playwright install failed to start: ${result.error.message}`);
6789
7227
  }
6790
7228
  if (typeof result.status === "number" && result.status !== 0) {
6791
7229
  throw new Error(`playwright install exited with code ${result.status}`);
6792
7230
  }
6793
- writeFileSync13(markerPath, (/* @__PURE__ */ new Date()).toISOString(), "utf-8");
7231
+ writeFileSync14(markerPath, (/* @__PURE__ */ new Date()).toISOString(), "utf-8");
6794
7232
  }
6795
7233
  function safeListPngs(dir) {
6796
7234
  if (!existsSync23(dir)) return [];
6797
- return readdirSync13(dir).filter((f) => f.endsWith(".png")).map((f) => join23(dir, f));
7235
+ return readdirSync14(dir).filter((f) => f.endsWith(".png")).map((f) => join24(dir, f));
6798
7236
  }
6799
7237
  function tryParseJson(s) {
6800
7238
  try {
@@ -6815,7 +7253,7 @@ function fail2(json, message) {
6815
7253
  // src/index.ts
6816
7254
  var __dirname2 = dirname10(fileURLToPath4(import.meta.url));
6817
7255
  var pkg = JSON.parse(
6818
- readFileSync26(join24(__dirname2, "..", "package.json"), "utf-8")
7256
+ readFileSync27(join25(__dirname2, "..", "package.json"), "utf-8")
6819
7257
  );
6820
7258
  var program = new Command2();
6821
7259
  program.name("visor").description("CLI for the Visor design system").version(pkg.version);
@@ -6970,7 +7408,19 @@ migrate.command("token-substitution").description(
6970
7408
  var sandbox = program.command("sandbox").description("Scaffold and iterate on a Next.js sandbox for new primitives");
6971
7409
  sandbox.command("init").description(
6972
7410
  "Scaffold a Next.js sandbox at .lo/sandbox/<name>/ from a design-handoff manifest"
6973
- ).argument("<name>", "sandbox name (used as directory and pattern slug)").requiredOption("--handoff <path>", "path to design-handoff.md manifest").requiredOption("--theme <theme>", "theme slug (e.g. 'space') or path to .visor.yaml").option("--overwrite", "replace an existing sandbox at this name", false).option("--skip-install", "skip npm install (test fixture mode)", false).option("--json", "output structured JSON (for AI agents)").action(
7411
+ ).argument("<name>", "sandbox name (used as directory and pattern slug)").requiredOption("--handoff <path>", "path to design-handoff.md manifest").requiredOption("--theme <theme>", "theme slug (e.g. 'space') or path to .visor.yaml").option(
7412
+ "--theme-file <path>",
7413
+ "explicit path to a theme.visor.yaml \u2014 bypasses name resolution and the VISOR_THEMES_PRIVATE_PATH env var"
7414
+ ).option(
7415
+ "--from-html-prototype <path>",
7416
+ "import a Phase 1.5 HTML prototype directory (its screen-N-*.html files become screen routes via iframe)"
7417
+ ).option(
7418
+ "--strip-chrome [selectors]",
7419
+ "strip Phase 1.5 documentary chrome from imported prototype HTML (no arg = defaults; comma list = replace defaults)"
7420
+ ).option(
7421
+ "--strip-chrome-additional <selectors>",
7422
+ "extra selectors to merge with the strip-chrome base list (comma-separated)"
7423
+ ).option("--overwrite", "replace an existing sandbox at this name", false).option("--skip-install", "skip npm install (test fixture mode)", false).option("--json", "output structured JSON (for AI agents)").action(
6974
7424
  async (name, options) => {
6975
7425
  await sandboxInitCommand(name, process.cwd(), options);
6976
7426
  }
@@ -6978,7 +7428,19 @@ sandbox.command("init").description(
6978
7428
  sandbox.command("dev").description("Boot the Next.js dev server for a sandbox on its allocated port").requiredOption("--name <name>", "sandbox name (created via 'sandbox init')").option("--json", "output structured JSON (for AI agents)").action((options) => {
6979
7429
  sandboxDevCommand(process.cwd(), options);
6980
7430
  });
6981
- sandbox.command("approve").description("Capture Playwright screenshots of every sandbox route as the visual spec").requiredOption("--name <name>", "sandbox name (created via 'sandbox init')").option("--diff", "pixel-diff against the prior approved baseline", false).option("--json", "output structured JSON (for AI agents)").action((options) => {
6982
- sandboxApproveCommand(process.cwd(), options);
6983
- });
7431
+ sandbox.command("approve").description(
7432
+ "Capture sandbox routes into captures/pending/ (with auto-diff vs the approved baseline). Pass --approve to promote pending \u2192 approved."
7433
+ ).requiredOption("--name <name>", "sandbox name (created via 'sandbox init')").option(
7434
+ "--approve",
7435
+ "promote captures/pending/ \u2192 captures/approved/ (skips capture; assumes prior run populated pending)",
7436
+ false
7437
+ ).option(
7438
+ "--diff",
7439
+ "[deprecated, no-op] default behavior already captures to pending and diffs against approved",
7440
+ false
7441
+ ).option("--json", "output structured JSON (for AI agents)").action(
7442
+ (options) => {
7443
+ sandboxApproveCommand(process.cwd(), options);
7444
+ }
7445
+ );
6984
7446
  program.parse();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": "0.4.0",
3
- "generated_at": "2026-05-20T04:45:51.975Z",
3
+ "generated_at": "2026-05-21T04:13:27.636Z",
4
4
  "components": {
5
5
  "accessibility-specimen": {
6
6
  "category": "specimen",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loworbitstudio/visor",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "CLI for the Visor design system — add components, hooks, and utilities to your project.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -45,7 +45,7 @@
45
45
  "dependencies": {
46
46
  "@aws-sdk/client-s3": "^3.1021.0",
47
47
  "@babel/parser": "^7.26.0",
48
- "@loworbitstudio/visor-theme-engine": "^0.8.1",
48
+ "@loworbitstudio/visor-theme-engine": "^0.9.0",
49
49
  "commander": "^13.1.0",
50
50
  "diff": "^8.0.4",
51
51
  "picocolors": "^1.1.1",