@loworbitstudio/visor 1.0.0 → 1.2.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.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync as readFileSync22 } from "fs";
5
- import { dirname as dirname8, join as join20 } from "path";
6
- import { fileURLToPath as fileURLToPath3 } from "url";
4
+ import { readFileSync as readFileSync26 } from "fs";
5
+ import { dirname as dirname10, join as join24 } from "path";
6
+ import { fileURLToPath as fileURLToPath4 } from "url";
7
7
  import { Command as Command2 } from "commander";
8
8
 
9
9
  // src/commands/check.ts
@@ -3843,7 +3843,7 @@ import {
3843
3843
  unlinkSync as unlinkSync2,
3844
3844
  copyFileSync
3845
3845
  } from "fs";
3846
- import { join as join15, basename as basename3, resolve as resolve11, sep } from "path";
3846
+ import { join as join15, basename as basename3, resolve as resolve11, sep, relative as relative2 } from "path";
3847
3847
  import { parse as parseYaml2 } from "yaml";
3848
3848
  import { generateThemeData as generateThemeData4, validateFontCoverage, formatFontCoverageError } from "@loworbitstudio/visor-theme-engine";
3849
3849
  import { docsAdapter as docsAdapter3 } from "@loworbitstudio/visor-theme-engine/adapters";
@@ -3939,6 +3939,32 @@ function resolveCustomSources(repoRoot, mainRepoRoot, warn) {
3939
3939
  }
3940
3940
  return { files: [...merged.values()], deprecationWarnings };
3941
3941
  }
3942
+ function emitFailureSummary({
3943
+ failures,
3944
+ succeeded,
3945
+ options,
3946
+ cwd
3947
+ }) {
3948
+ if (options.json) {
3949
+ console.log(
3950
+ JSON.stringify({
3951
+ success: false,
3952
+ themes: succeeded,
3953
+ failures: failures.map((f) => ({ filePath: f.filePath, error: f.error }))
3954
+ })
3955
+ );
3956
+ return;
3957
+ }
3958
+ const lines2 = [
3959
+ `Theme sync: ${succeeded} succeeded, ${failures.length} failed.`,
3960
+ "",
3961
+ "Failed:",
3962
+ ...failures.map((f) => ` ${relative2(cwd, f.filePath)} \u2014 ${f.error}`),
3963
+ "",
3964
+ "Re-run after fixing the failed themes."
3965
+ ];
3966
+ logger.error(lines2.join("\n"));
3967
+ }
3942
3968
  function reportBrokenSymlink(err, options) {
3943
3969
  const msg = `Broken symlink in theme source: ${err.path} \u2192 ${err.target}`;
3944
3970
  if (options.json) {
@@ -4220,20 +4246,23 @@ function themeSyncCommand(cwd, options) {
4220
4246
  for (const w of discoveryWarnings) logger.warn(w);
4221
4247
  }
4222
4248
  const manifest = [];
4223
- const errors = [];
4249
+ const failures = [];
4224
4250
  const processFile = (filePath, isCustom, slugOverride) => {
4225
4251
  let yamlContent;
4226
4252
  try {
4227
4253
  yamlContent = readFileSync16(filePath, "utf-8");
4228
4254
  } catch {
4229
- errors.push(`Could not read: ${filePath}`);
4255
+ failures.push({ filePath, error: `Could not read: ${filePath}` });
4230
4256
  return;
4231
4257
  }
4232
4258
  let data;
4233
4259
  try {
4234
4260
  data = generateThemeData4(yamlContent);
4235
4261
  } catch (err) {
4236
- errors.push(`Failed to parse ${basename3(filePath)}: ${err instanceof Error ? err.message : "Unknown error"}`);
4262
+ failures.push({
4263
+ filePath,
4264
+ error: `Failed to parse ${basename3(filePath)}: ${err instanceof Error ? err.message : "Unknown error"}`
4265
+ });
4237
4266
  return;
4238
4267
  }
4239
4268
  const slug2 = slugOverride ?? toSlug(data.config.name);
@@ -4244,7 +4273,10 @@ function themeSyncCommand(cwd, options) {
4244
4273
  const coverage = validateFontCoverage(css);
4245
4274
  if (coverage.errors.length > 0) {
4246
4275
  for (const e of coverage.errors) {
4247
- errors.push(formatFontCoverageError(basename3(filePath), e.declaredAt, e.family));
4276
+ failures.push({
4277
+ filePath,
4278
+ error: formatFontCoverageError(basename3(filePath), e.declaredAt, e.family)
4279
+ });
4248
4280
  }
4249
4281
  return;
4250
4282
  }
@@ -4256,12 +4288,8 @@ function themeSyncCommand(cwd, options) {
4256
4288
  const isNested = c.origin !== "legacy";
4257
4289
  processFile(c.filePath, true, isNested ? c.slug : void 0);
4258
4290
  }
4259
- if (errors.length > 0) {
4260
- if (options.json) {
4261
- console.log(JSON.stringify({ success: false, errors }));
4262
- } else {
4263
- errors.forEach((e) => logger.error(e));
4264
- }
4291
+ if (manifest.length === 0) {
4292
+ emitFailureSummary({ failures, succeeded: 0, options, cwd });
4265
4293
  process.exit(1);
4266
4294
  return;
4267
4295
  }
@@ -4297,6 +4325,7 @@ function themeSyncCommand(cwd, options) {
4297
4325
  const newPublicYamlSet = new Set(manifest.map((e) => `${e.yamlFilename}.visor.yaml`));
4298
4326
  const stalePublicYamls = existingPublicYamls.filter((f) => !newPublicYamlSet.has(f));
4299
4327
  if (options.dryRun) {
4328
+ const dryRunHasFailures = failures.length > 0;
4300
4329
  const changes = {
4301
4330
  themesDiscovered: manifest.map((e) => ({ slug: e.slug, group: e.group, isCustom: e.isCustom })),
4302
4331
  cssFilesGenerated: allSlugs.map((s) => `packages/docs/app/${s}-theme.css`),
@@ -4309,13 +4338,25 @@ function themeSyncCommand(cwd, options) {
4309
4338
  publicYamlsDeleted: stalePublicYamls.map((f) => `packages/docs/public/themes/${f}`)
4310
4339
  };
4311
4340
  if (options.json) {
4312
- console.log(JSON.stringify({ success: true, dryRun: true, changes }));
4341
+ console.log(JSON.stringify({
4342
+ success: !dryRunHasFailures,
4343
+ dryRun: true,
4344
+ changes,
4345
+ ...dryRunHasFailures ? { failures: failures.map((f) => ({ filePath: f.filePath, error: f.error })) } : {}
4346
+ }));
4313
4347
  } else {
4314
4348
  logger.info("Dry run \u2014 no files written");
4315
4349
  logger.item(`Themes discovered: ${manifest.length} (${stockManifest.length} stock, ${customManifest.length} custom)`);
4316
4350
  manifest.forEach((e) => logger.item(` ${e.slug} \u2014 group: ${e.group}`));
4317
4351
  if (staleCssFiles.length > 0) logger.item(`CSS files to delete: ${staleCssFiles.join(", ")}`);
4318
4352
  if (stalePublicYamls.length > 0) logger.item(`Public YAMLs to delete: ${stalePublicYamls.join(", ")}`);
4353
+ if (dryRunHasFailures) {
4354
+ emitFailureSummary({ failures, succeeded: manifest.length, options, cwd });
4355
+ }
4356
+ }
4357
+ if (dryRunHasFailures) {
4358
+ process.exit(1);
4359
+ return;
4319
4360
  }
4320
4361
  return;
4321
4362
  }
@@ -4353,20 +4394,28 @@ function themeSyncCommand(cwd, options) {
4353
4394
  process.exit(2);
4354
4395
  return;
4355
4396
  }
4397
+ const hasFailures = failures.length > 0;
4356
4398
  if (options.json) {
4357
4399
  const warnings = [...deprecationWarnings, ...discoveryWarnings];
4358
4400
  console.log(JSON.stringify({
4359
- success: true,
4401
+ success: !hasFailures,
4360
4402
  themes: manifest.length,
4361
4403
  stock: stockManifest.length,
4362
4404
  custom: customManifest.length,
4363
4405
  staleCssDeleted: staleCssFiles.length,
4364
4406
  staleYamlsDeleted: stalePublicYamls.length,
4365
4407
  slugs: allSlugs,
4366
- ...warnings.length > 0 ? { warnings } : {}
4408
+ ...warnings.length > 0 ? { warnings } : {},
4409
+ ...hasFailures ? {
4410
+ failures: failures.map((f) => ({ filePath: f.filePath, error: f.error }))
4411
+ } : {}
4367
4412
  }));
4368
4413
  } else {
4369
- logger.success(`Theme sync complete \u2014 ${manifest.length} themes registered`);
4414
+ if (hasFailures) {
4415
+ logger.info(`Theme sync partial \u2014 ${manifest.length} themes registered`);
4416
+ } else {
4417
+ logger.success(`Theme sync complete \u2014 ${manifest.length} themes registered`);
4418
+ }
4370
4419
  logger.item(`Stock: ${stockManifest.map((e) => e.slug).join(", ")}`);
4371
4420
  if (customManifest.length > 0) {
4372
4421
  logger.item(`Custom: ${customManifest.map((e) => e.slug).join(", ")}`);
@@ -4374,6 +4423,13 @@ function themeSyncCommand(cwd, options) {
4374
4423
  if (staleCssFiles.length > 0) {
4375
4424
  logger.item(`Removed stale CSS: ${staleCssFiles.join(", ")}`);
4376
4425
  }
4426
+ if (hasFailures) {
4427
+ emitFailureSummary({ failures, succeeded: manifest.length, options, cwd });
4428
+ }
4429
+ }
4430
+ if (hasFailures) {
4431
+ process.exit(1);
4432
+ return;
4377
4433
  }
4378
4434
  }
4379
4435
 
@@ -5486,7 +5542,7 @@ Visor Tokens (${categoryLabel}) \u2014 ${tokens2.length} tokens
5486
5542
 
5487
5543
  // src/commands/migrate-token-substitution.ts
5488
5544
  import { readFileSync as readFileSync21, writeFileSync as writeFileSync11, readdirSync as readdirSync11, statSync as statSync8, existsSync as existsSync18 } from "fs";
5489
- import { resolve as resolve13, join as join19, relative as relative3 } from "path";
5545
+ import { resolve as resolve13, join as join19, relative as relative4 } from "path";
5490
5546
  import { parse as parseYaml3 } from "yaml";
5491
5547
  import pc4 from "picocolors";
5492
5548
  var V7_ENTR_SUBSTITUTION_MAP = {
@@ -5679,7 +5735,7 @@ function migrateTokenSubstitutionCommand(targetArg, cwd, options) {
5679
5735
  process.exit(0);
5680
5736
  return;
5681
5737
  }
5682
- const relTarget = relative3(cwd, targetPath) || ".";
5738
+ const relTarget = relative4(cwd, targetPath) || ".";
5683
5739
  if (dryRun) {
5684
5740
  logger.heading(`visor migrate token-substitution \u2014 dry run`);
5685
5741
  logger.blank();
@@ -5690,7 +5746,7 @@ function migrateTokenSubstitutionCommand(targetArg, cwd, options) {
5690
5746
  logger.heading(`Proposed changes (${result.filesChanged} file(s), ${result.totalSubstitutions} substitution(s)):`);
5691
5747
  logger.blank();
5692
5748
  for (const f of result.files) {
5693
- const relFile = relative3(cwd, f.file);
5749
+ const relFile = relative4(cwd, f.file);
5694
5750
  logger.info(pc4.bold(` ${relFile}`));
5695
5751
  for (const sub of f.substitutions) {
5696
5752
  logger.info(
@@ -5711,7 +5767,7 @@ function migrateTokenSubstitutionCommand(targetArg, cwd, options) {
5711
5767
  logger.info(` Scanned: ${result.filesScanned} file(s)`);
5712
5768
  logger.blank();
5713
5769
  for (const f of result.files) {
5714
- const relFile = relative3(cwd, f.file);
5770
+ const relFile = relative4(cwd, f.file);
5715
5771
  logger.success(` ${relFile} (${f.substitutions.length} substitution(s))`);
5716
5772
  }
5717
5773
  logger.blank();
@@ -5722,10 +5778,1044 @@ function migrateTokenSubstitutionCommand(targetArg, cwd, options) {
5722
5778
  process.exit(0);
5723
5779
  }
5724
5780
 
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";
5784
+ import { fileURLToPath as fileURLToPath3 } from "url";
5785
+ import * as childProcess2 from "child_process";
5786
+
5787
+ // src/commands/sandbox/parse-handoff.ts
5788
+ import { readFileSync as readFileSync22, existsSync as existsSync19 } from "fs";
5789
+ import { dirname as dirname8, resolve as resolve14, isAbsolute } from "path";
5790
+ function parseHandoff(handoffPath) {
5791
+ const text = readFileSync22(handoffPath, "utf-8");
5792
+ const lines2 = text.split("\n");
5793
+ const warnings = [];
5794
+ const pattern2 = extractPatternSlug(lines2, warnings);
5795
+ const theme2 = extractMetaField(lines2, "Theme");
5796
+ const recipePath = extractRecipePath(text, handoffPath, warnings);
5797
+ const primitives = extractPrimitives(text, warnings);
5798
+ const { screens, mockShapes } = recipePath && existsSync19(recipePath) ? extractRecipeArtifacts(recipePath, warnings) : { screens: [], mockShapes: [] };
5799
+ return { pattern: pattern2, theme: theme2, recipePath, primitives, screens, mockShapes, warnings };
5800
+ }
5801
+ function extractPatternSlug(lines2, warnings) {
5802
+ const h1 = lines2.find((l) => /^#\s/.test(l));
5803
+ if (!h1) {
5804
+ warnings.push("Handoff has no H1 \u2014 pattern slug fell back to 'sandbox'");
5805
+ return "sandbox";
5806
+ }
5807
+ const m = h1.match(/^#\s+(?:Design\s+handoff\s+[—-]\s+)?(.+?)\s*$/i);
5808
+ if (!m) {
5809
+ warnings.push(`Could not parse pattern from H1: ${h1}`);
5810
+ return "sandbox";
5811
+ }
5812
+ return m[1].trim().toLowerCase().replace(/\s+/g, "-");
5813
+ }
5814
+ function extractMetaField(lines2, field) {
5815
+ const re = new RegExp(`^\\*\\*${field}:\\*\\*\\s+([^\\s(*]+)`, "i");
5816
+ for (const l of lines2) {
5817
+ const m = l.match(re);
5818
+ if (m) return m[1].trim();
5819
+ }
5820
+ return void 0;
5821
+ }
5822
+ function extractRecipePath(text, handoffPath, warnings) {
5823
+ const m = text.match(/##\s+Recipe[\s\S]*?\[`?([^\]`]+)`?\]\(([^)]+)\)/i);
5824
+ if (!m) {
5825
+ warnings.push("Could not locate Recipe link in handoff");
5826
+ return void 0;
5827
+ }
5828
+ const href = m[2].trim();
5829
+ if (isAbsolute(href)) return href;
5830
+ return resolve14(dirname8(handoffPath), href);
5831
+ }
5832
+ var STATUS_PATTERNS = [
5833
+ { re: /NEW\s+gap/i, status: "gap-new" },
5834
+ { re: /blocked-by/i, status: "gap-new" },
5835
+ { re: /in\s+flight/i, status: "gap-inflight" },
5836
+ { re: /shipped/i, status: "shipped" }
5837
+ ];
5838
+ function classifyStatus(cell) {
5839
+ for (const { re, status } of STATUS_PATTERNS) {
5840
+ if (re.test(cell)) return status;
5841
+ }
5842
+ return "shipped";
5843
+ }
5844
+ function extractVITicket(cell) {
5845
+ const m = cell.match(/VI-(\d+)/i);
5846
+ return m ? `VI-${m[1]}` : void 0;
5847
+ }
5848
+ function extractPrimitives(text, warnings) {
5849
+ const primitives = [];
5850
+ const seen = /* @__PURE__ */ new Set();
5851
+ const tableMatches = text.matchAll(/\|\s*Component\s*\|[^\n]*\n\|[^\n]*\n((?:\|[^\n]*\n?)+)/gi);
5852
+ for (const tm of tableMatches) {
5853
+ const rows = tm[1].split("\n").map((r) => r.trim()).filter((r) => r.startsWith("|"));
5854
+ for (const row of rows) {
5855
+ const cells = row.split("|").map((c) => c.trim()).filter((_, i, arr) => i > 0 && i < arr.length - 1);
5856
+ if (cells.length < 2) continue;
5857
+ const rawName = cells[0].replace(/`/g, "").trim();
5858
+ const nameCell = rawName.replace(/\s*\([^)]*\)\s*/g, "").trim();
5859
+ if (!nameCell || nameCell.includes("---") || nameCell === "Component") continue;
5860
+ if (seen.has(nameCell)) continue;
5861
+ seen.add(nameCell);
5862
+ const joined = cells.slice(1).join(" | ");
5863
+ const status = classifyStatus(joined);
5864
+ const viTicket = status === "shipped" ? void 0 : extractVITicket(joined);
5865
+ const kindCell = cells[1] || "";
5866
+ const annotation = rawName.match(/\(([^)]+)\)/)?.[1] ?? "";
5867
+ const kind = /block/i.test(kindCell) || /block/i.test(annotation) ? "block" : /primitive/i.test(kindCell) || /primitive/i.test(annotation) ? "primitive" : void 0;
5868
+ primitives.push({ name: nameCell, status, viTicket, kind });
5869
+ }
5870
+ }
5871
+ if (primitives.length === 0) {
5872
+ warnings.push("Handoff has no Component inventory rows \u2014 sandbox will be empty");
5873
+ }
5874
+ return primitives;
5875
+ }
5876
+ function extractRecipeArtifacts(recipePath, warnings) {
5877
+ const text = readFileSync22(recipePath, "utf-8");
5878
+ const screens = extractScreens(text);
5879
+ const mockShapes = extractMockFields(text, warnings);
5880
+ return { screens, mockShapes };
5881
+ }
5882
+ function extractScreens(text) {
5883
+ const out = [];
5884
+ const matches = text.matchAll(/^###\s+Screen\s+\d+:\s+(.+?)(?:\s*\(`([^`]+)`\))?\s*$/gim);
5885
+ let idx = 1;
5886
+ for (const m of matches) {
5887
+ const title = m[1].trim();
5888
+ const route = m[2]?.trim();
5889
+ const slug2 = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
5890
+ const name = slug2 || `screen-${idx}`;
5891
+ out.push({ name, title, route });
5892
+ idx++;
5893
+ }
5894
+ return out;
5895
+ }
5896
+ function extractMockFields(text, warnings) {
5897
+ const m = text.match(/##\s+Inputs[^\n]*\n+([\s\S]*?)(?=\n##\s|\n$)/i);
5898
+ if (!m) {
5899
+ warnings.push(
5900
+ "Recipe has no 'Inputs from generation skill' section \u2014 mock data will be empty"
5901
+ );
5902
+ return [];
5903
+ }
5904
+ const rows = m[1].split("\n").filter((r) => r.startsWith("|"));
5905
+ if (rows.length < 2) return [];
5906
+ const out = [];
5907
+ for (const row of rows.slice(2)) {
5908
+ const cells = row.split("|").map((c) => c.trim()).filter((_, i, arr) => i > 0 && i < arr.length - 1);
5909
+ if (cells.length < 2) continue;
5910
+ const field = cells[0].replace(/`/g, "").trim();
5911
+ if (!field) continue;
5912
+ const type = cells[1].replace(/`/g, "").trim();
5913
+ const description = cells[2]?.trim();
5914
+ out.push({ field, type, description });
5915
+ }
5916
+ return out;
5917
+ }
5918
+
5919
+ // src/commands/sandbox/ports.ts
5920
+ import { createServer } from "net";
5921
+ var RESERVED_PORTS = /* @__PURE__ */ new Set([3e3]);
5922
+ var MAX_PORT_PROBES = 50;
5923
+ var DEFAULT_START_PORT = 4060;
5924
+ async function findOpenPort(start = DEFAULT_START_PORT) {
5925
+ let port = start;
5926
+ for (let i = 0; i < MAX_PORT_PROBES; i++) {
5927
+ if (RESERVED_PORTS.has(port)) {
5928
+ port++;
5929
+ continue;
5930
+ }
5931
+ const free = await tryPort(port);
5932
+ if (free) return port;
5933
+ port++;
5934
+ }
5935
+ throw new Error(
5936
+ `Could not find an open port between ${start} and ${start + MAX_PORT_PROBES} (port 3000 excluded)`
5937
+ );
5938
+ }
5939
+ function tryPort(port) {
5940
+ return new Promise((resolveProbe) => {
5941
+ const server = createServer();
5942
+ server.once("error", () => resolveProbe(false));
5943
+ server.once("listening", () => {
5944
+ server.close(() => resolveProbe(true));
5945
+ });
5946
+ server.listen(port, "127.0.0.1");
5947
+ });
5948
+ }
5949
+
5950
+ // src/commands/sandbox/scaffold.ts
5951
+ import { mkdirSync as mkdirSync7, writeFileSync as writeFileSync12, existsSync as existsSync20, readdirSync as readdirSync12 } from "fs";
5952
+ import { join as join20 } from "path";
5953
+
5954
+ // src/commands/sandbox/templates.ts
5955
+ var SANDBOX_PACKAGE_VERSION = "16.2.6";
5956
+ var SANDBOX_REACT_VERSION = "19.0.0";
5957
+ var SANDBOX_REACT_DOM_VERSION = "19.0.0";
5958
+ var SANDBOX_PLAYWRIGHT_VERSION = "1.49.0";
5959
+ var SANDBOX_PIXELMATCH_VERSION = "6.0.0";
5960
+ var SANDBOX_PNGJS_VERSION = "7.0.0";
5961
+ function packageJsonTemplate(name) {
5962
+ return `${JSON.stringify(
5963
+ {
5964
+ name: `sandbox-${name}`,
5965
+ version: "0.0.0",
5966
+ private: true,
5967
+ type: "module",
5968
+ scripts: {
5969
+ dev: "next dev",
5970
+ build: "next build",
5971
+ start: "next start"
5972
+ },
5973
+ dependencies: {
5974
+ next: SANDBOX_PACKAGE_VERSION,
5975
+ react: SANDBOX_REACT_VERSION,
5976
+ "react-dom": SANDBOX_REACT_DOM_VERSION,
5977
+ "@loworbitstudio/visor-core": "*",
5978
+ "@loworbitstudio/visor-theme-engine": "*"
5979
+ },
5980
+ devDependencies: {
5981
+ "@types/node": "^22.0.0",
5982
+ "@types/react": "^19.0.0",
5983
+ "@types/react-dom": "^19.0.0",
5984
+ typescript: "^5.7.2",
5985
+ "@playwright/test": `^${SANDBOX_PLAYWRIGHT_VERSION}`,
5986
+ pixelmatch: `^${SANDBOX_PIXELMATCH_VERSION}`,
5987
+ pngjs: `^${SANDBOX_PNGJS_VERSION}`
5988
+ }
5989
+ },
5990
+ null,
5991
+ 2
5992
+ )}
5993
+ `;
5994
+ }
5995
+ function tsconfigTemplate() {
5996
+ return `${JSON.stringify(
5997
+ {
5998
+ compilerOptions: {
5999
+ target: "ES2022",
6000
+ lib: ["dom", "dom.iterable", "esnext"],
6001
+ allowJs: false,
6002
+ skipLibCheck: true,
6003
+ strict: true,
6004
+ noEmit: true,
6005
+ esModuleInterop: true,
6006
+ module: "esnext",
6007
+ moduleResolution: "bundler",
6008
+ resolveJsonModule: true,
6009
+ isolatedModules: true,
6010
+ jsx: "preserve",
6011
+ incremental: true,
6012
+ plugins: [{ name: "next" }],
6013
+ paths: { "@/*": ["./*"] }
6014
+ },
6015
+ include: ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
6016
+ exclude: ["node_modules", "captures", ".next"]
6017
+ },
6018
+ null,
6019
+ 2
6020
+ )}
6021
+ `;
6022
+ }
6023
+ function nextConfigTemplate() {
6024
+ return `import type { NextConfig } from "next"
6025
+
6026
+ const nextConfig: NextConfig = {
6027
+ reactStrictMode: true,
6028
+ // Sandbox is local-only \u2014 disable production telemetry and image opt to keep startup fast.
6029
+ images: { unoptimized: true },
6030
+ }
6031
+
6032
+ export default nextConfig
6033
+ `;
6034
+ }
6035
+ function nextEnvDtsTemplate() {
6036
+ return `/// <reference types="next" />
6037
+ /// <reference types="next/image-types/global" />
6038
+ `;
6039
+ }
6040
+ function gitignoreTemplate() {
6041
+ return [
6042
+ "node_modules",
6043
+ ".next",
6044
+ "out",
6045
+ "captures/pending",
6046
+ "captures/diffs",
6047
+ "*.log",
6048
+ ""
6049
+ ].join("\n");
6050
+ }
6051
+ function rootLayoutTemplate(pattern2) {
6052
+ return `import "./globals.css"
6053
+ import { FOWT_SCRIPT } from "@loworbitstudio/visor-theme-engine/fowt"
6054
+ import type { Metadata } from "next"
6055
+ import type { ReactNode } from "react"
6056
+
6057
+ export const metadata: Metadata = {
6058
+ title: "Sandbox \u2014 ${pattern2}",
6059
+ description: "Visor sandbox for in-vivo primitive iteration.",
6060
+ }
6061
+
6062
+ export default function RootLayout({ children }: { children: ReactNode }) {
6063
+ return (
6064
+ <html lang="en">
6065
+ <head>
6066
+ <script>{FOWT_SCRIPT}</script>
6067
+ </head>
6068
+ <body>{children}</body>
6069
+ </html>
6070
+ )
6071
+ }
6072
+ `;
6073
+ }
6074
+ function globalsCssPlaceholder() {
6075
+ return `/* This file is overwritten by 'visor theme apply' during sandbox init. */
6076
+ :root {
6077
+ --bg-surface: #f7f7f8;
6078
+ --text-primary: #111827;
6079
+ }
6080
+ body { background: var(--bg-surface); color: var(--text-primary); margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
6081
+ `;
6082
+ }
6083
+ function indexPageTemplate() {
6084
+ return `import Link from "next/link"
6085
+ import { manifest } from "@/lib/sandbox-manifest"
6086
+
6087
+ export default function SandboxIndex() {
6088
+ return (
6089
+ <main style={{ padding: "32px", maxWidth: "880px", margin: "0 auto" }}>
6090
+ <h1>Sandbox: {manifest.pattern}</h1>
6091
+ <p style={{ color: "var(--text-secondary, #555)" }}>{manifest.primitives.length} primitives \xB7 {manifest.screens.length} screens</p>
6092
+ <section>
6093
+ <h2>Primitives</h2>
6094
+ <ul>
6095
+ {manifest.primitives.map((p) => (
6096
+ <li key={p.name}><Link href={\`/primitives/\${p.name}\`}>{p.name}</Link> <small>({p.status}{p.viTicket ? " \xB7 " + p.viTicket : ""})</small></li>
6097
+ ))}
6098
+ </ul>
6099
+ </section>
6100
+ <section>
6101
+ <h2>Screens</h2>
6102
+ <ul>
6103
+ {manifest.screens.map((s) => (
6104
+ <li key={s.name}><Link href={\`/screens/\${s.name}\`}>{s.title}</Link></li>
6105
+ ))}
6106
+ </ul>
6107
+ </section>
6108
+ </main>
6109
+ )
6110
+ }
6111
+ `;
6112
+ }
6113
+ function primitiveRouteTemplate() {
6114
+ return `import { notFound } from "next/navigation"
6115
+ import { manifest } from "@/lib/sandbox-manifest"
6116
+ import { PrimitiveSample } from "@/components/sandbox-sample"
6117
+
6118
+ interface Params { name: string }
6119
+
6120
+ export function generateStaticParams() {
6121
+ return manifest.primitives.map((p) => ({ name: p.name }))
6122
+ }
6123
+
6124
+ export default async function PrimitivePage({ params }: { params: Promise<Params> }) {
6125
+ const { name } = await params
6126
+ const entry = manifest.primitives.find((p) => p.name === name)
6127
+ if (!entry) notFound()
6128
+ return (
6129
+ <main style={{ padding: "32px", maxWidth: "880px", margin: "0 auto" }}>
6130
+ <h1>{entry.name}</h1>
6131
+ <p style={{ color: "var(--text-secondary, #555)" }}>{entry.status}{entry.viTicket ? " \xB7 " + entry.viTicket : ""}</p>
6132
+ <PrimitiveSample name={entry.name} />
6133
+ </main>
6134
+ )
6135
+ }
6136
+ `;
6137
+ }
6138
+ function screenRouteTemplate() {
6139
+ return `import { notFound } from "next/navigation"
6140
+ import { manifest } from "@/lib/sandbox-manifest"
6141
+ import { ScreenSample } from "@/components/sandbox-sample"
6142
+
6143
+ interface Params { name: string }
6144
+
6145
+ export function generateStaticParams() {
6146
+ return manifest.screens.map((s) => ({ name: s.name }))
6147
+ }
6148
+
6149
+ export default async function ScreenPage({ params }: { params: Promise<Params> }) {
6150
+ const { name } = await params
6151
+ const entry = manifest.screens.find((s) => s.name === name)
6152
+ if (!entry) notFound()
6153
+ return (
6154
+ <main style={{ padding: "32px", maxWidth: "1280px", margin: "0 auto" }}>
6155
+ <h1>{entry.title}</h1>
6156
+ {entry.route ? <p style={{ color: "var(--text-secondary, #555)" }}><code>{entry.route}</code></p> : null}
6157
+ <ScreenSample name={entry.name} />
6158
+ </main>
6159
+ )
6160
+ }
6161
+ `;
6162
+ }
6163
+ function sandboxManifestModule(manifest) {
6164
+ const payload = {
6165
+ pattern: manifest.pattern,
6166
+ theme: manifest.theme ?? null,
6167
+ primitives: manifest.primitives.map((p) => ({
6168
+ name: p.name,
6169
+ status: p.status,
6170
+ viTicket: p.viTicket ?? null,
6171
+ kind: p.kind ?? null
6172
+ })),
6173
+ screens: manifest.screens.map((s) => ({
6174
+ name: s.name,
6175
+ title: s.title,
6176
+ route: s.route ?? null
6177
+ }))
6178
+ };
6179
+ return `export const manifest = ${JSON.stringify(payload, null, 2)} as const
6180
+
6181
+ export type ManifestPrimitive = (typeof manifest)["primitives"][number]
6182
+ export type ManifestScreen = (typeof manifest)["screens"][number]
6183
+ `;
6184
+ }
6185
+ function sandboxMocksModule(fields) {
6186
+ if (fields.length === 0) {
6187
+ return `// No mock fields declared in recipe. Hand-edit this file to add fixtures consumed by screens/<name>/page.tsx.
6188
+ export const mocks: Record<string, unknown> = {}
6189
+ `;
6190
+ }
6191
+ const lines2 = [
6192
+ "// Auto-generated mock fixtures from the recipe's 'Inputs from generation skill' table.",
6193
+ "// Hand-edit values to provide realistic data for screens. The shapes below are TODO stubs.",
6194
+ "",
6195
+ "export const mocks = {"
6196
+ ];
6197
+ for (const f of fields) {
6198
+ const isArray = /\[\]\s*$/.test(f.type);
6199
+ const stub = isArray ? "[]" : f.field === "currentUser" ? '{ id: "u1", role: "owner" }' : '""';
6200
+ const safeKey = /^[a-zA-Z_$][\w$]*$/.test(f.field) ? f.field : `["${f.field.replace(/"/g, '\\"')}"]`;
6201
+ const desc = f.description ? ` // ${f.description.slice(0, 80)}` : "";
6202
+ lines2.push(` ${safeKey}: ${stub} as unknown,${desc}`);
6203
+ }
6204
+ lines2.push("} satisfies Record<string, unknown>", "");
6205
+ return lines2.join("\n");
6206
+ }
6207
+ function sandboxSampleComponent() {
6208
+ return `import { manifest, type ManifestPrimitive } from "@/lib/sandbox-manifest"
6209
+ import { mocks } from "@/lib/sandbox-mocks"
6210
+
6211
+ void mocks
6212
+
6213
+ /** Dynamic primitive sample. v1 shows the primitive name in a frame; operator hand-edits routes for richer compositions. */
6214
+ export function PrimitiveSample({ name }: { name: string }) {
6215
+ const entry = manifest.primitives.find((p) => p.name === name) as ManifestPrimitive | undefined
6216
+ if (!entry) return null
6217
+ return (
6218
+ <section data-sandbox-primitive={entry.name} style={{ padding: "24px", border: "1px solid var(--border-default, #e5e7eb)", borderRadius: "12px", marginTop: "16px" }}>
6219
+ <p style={{ margin: 0, fontFamily: "var(--font-mono, monospace)", fontSize: "var(--text-sm, 13px)", color: "var(--text-secondary, #555)" }}>
6220
+ Operator: hand-edit <code>app/primitives/[name]/page.tsx</code> to render this primitive in real states.
6221
+ </p>
6222
+ </section>
6223
+ )
6224
+ }
6225
+
6226
+ export function ScreenSample({ name }: { name: string }) {
6227
+ const entry = manifest.screens.find((s) => s.name === name)
6228
+ if (!entry) return null
6229
+ return (
6230
+ <section data-sandbox-screen={entry.name} style={{ padding: "24px", border: "1px solid var(--border-default, #e5e7eb)", borderRadius: "12px", marginTop: "16px" }}>
6231
+ <p style={{ margin: 0, fontFamily: "var(--font-mono, monospace)", fontSize: "var(--text-sm, 13px)", color: "var(--text-secondary, #555)" }}>
6232
+ Operator: hand-edit <code>app/screens/[name]/page.tsx</code> to assemble the {entry.title} composition.
6233
+ </p>
6234
+ </section>
6235
+ )
6236
+ }
6237
+ `;
6238
+ }
6239
+ function stubTemplate(entry) {
6240
+ const componentName = toPascalCase(entry.name);
6241
+ const ticket = entry.viTicket ?? "VI-???";
6242
+ return `// GAP STUB \u2014 ${ticket}. Hand-edit this file to sketch the primitive in place.
6243
+ import type { ReactNode } from "react"
6244
+
6245
+ export interface ${componentName}Props {
6246
+ children?: ReactNode
6247
+ [key: string]: unknown
6248
+ }
6249
+
6250
+ export function ${componentName}(props: ${componentName}Props) {
6251
+ return (
6252
+ <div
6253
+ role="img"
6254
+ aria-label="GAP: ${ticket} \u2014 ${entry.name}"
6255
+ data-stub-component="${entry.name}"
6256
+ style={{
6257
+ border: "2px dashed var(--border-strong, #888)",
6258
+ padding: "var(--spacing-4, 16px)",
6259
+ borderRadius: "var(--radius-md, 8px)",
6260
+ color: "var(--text-secondary, #555)",
6261
+ fontFamily: "var(--font-mono, monospace)",
6262
+ fontSize: "var(--text-sm, 12px)",
6263
+ display: "inline-flex",
6264
+ flexDirection: "column",
6265
+ gap: "4px",
6266
+ minWidth: "120px",
6267
+ textAlign: "center",
6268
+ }}
6269
+ >
6270
+ <strong>GAP: ${ticket}</strong>
6271
+ <span>${entry.name}</span>
6272
+ <span style={{ opacity: 0.7 }}>see visual spec</span>
6273
+ {props.children}
6274
+ </div>
6275
+ )
6276
+ }
6277
+ `;
6278
+ }
6279
+ function readmeTemplate(manifest, port) {
6280
+ const shipped = manifest.primitives.filter((p) => p.status === "shipped" || p.status === "gap-inflight");
6281
+ const gaps = manifest.primitives.filter((p) => p.status === "gap-new");
6282
+ return [
6283
+ `# Sandbox: ${manifest.pattern}`,
6284
+ "",
6285
+ "Scaffolded by `visor sandbox init`. Iterate visually here, then capture approved states with `visor sandbox approve`.",
6286
+ "",
6287
+ "## Running",
6288
+ "",
6289
+ "```bash",
6290
+ `npx visor sandbox dev --name ${manifest.pattern}`,
6291
+ "```",
6292
+ "",
6293
+ `Dev server on **port ${port}** (port 3000 is reserved).`,
6294
+ "",
6295
+ `## Primitives (${shipped.length} shipped / ${gaps.length} gaps)`,
6296
+ "",
6297
+ ...shipped.map((p) => `- \`${p.name}\` \u2014 shipped`),
6298
+ ...gaps.map((p) => `- \`${p.name}\` \u2014 **GAP** (${p.viTicket ?? "VI-???"}) \u2014 stub at \`components/stubs/${p.name}.tsx\``),
6299
+ "",
6300
+ "## Screens",
6301
+ "",
6302
+ ...manifest.screens.length === 0 ? ["- (none declared in recipe)"] : manifest.screens.map((s) => `- \`/screens/${s.name}\` \u2014 ${s.title}${s.route ? ` (target route: \`${s.route}\`)` : ""}`),
6303
+ "",
6304
+ "## Capturing",
6305
+ "",
6306
+ "```bash",
6307
+ `npx visor sandbox approve --name ${manifest.pattern} # writes captures/approved/*.png`,
6308
+ `npx visor sandbox approve --name ${manifest.pattern} --diff # pixel-diff vs prior approved`,
6309
+ "```",
6310
+ ""
6311
+ ].join("\n");
6312
+ }
6313
+ function captureScriptTemplate() {
6314
+ return `// Auto-generated by 'visor sandbox approve'. Do not edit by hand \u2014 rewrite via approve.
6315
+ import { chromium } from "@playwright/test"
6316
+ import { readFile, writeFile, mkdir, readdir } from "node:fs/promises"
6317
+ import { existsSync } from "node:fs"
6318
+ import { join, dirname } from "node:path"
6319
+ import { fileURLToPath } from "node:url"
6320
+ import pixelmatch from "pixelmatch"
6321
+ import { PNG } from "pngjs"
6322
+
6323
+ const sandboxDir = dirname(fileURLToPath(import.meta.url))
6324
+ const configPath = join(sandboxDir, "sandbox.json")
6325
+ const config = JSON.parse(await readFile(configPath, "utf-8"))
6326
+
6327
+ const diffMode = process.argv.includes("--diff")
6328
+ const port = Number(process.env.SANDBOX_PORT ?? config.port)
6329
+ const baseUrl = \`http://localhost:\${port}\`
6330
+
6331
+ const routes = ["/", ...config.primitives.map((p) => \`/primitives/\${p.name}\`), ...config.screens.map((s) => \`/screens/\${s.name}\`)]
6332
+
6333
+ const approvedDir = join(sandboxDir, "captures", "approved")
6334
+ const pendingDir = join(sandboxDir, "captures", "pending")
6335
+ const diffsDir = join(sandboxDir, "captures", "diffs")
6336
+ await mkdir(approvedDir, { recursive: true })
6337
+ await mkdir(pendingDir, { recursive: true })
6338
+ if (diffMode) await mkdir(diffsDir, { recursive: true })
6339
+
6340
+ function slugify(route) {
6341
+ if (route === "/") return "index"
6342
+ return route.replace(/^\\//, "").replace(/\\//g, "__")
6343
+ }
6344
+
6345
+ const browser = await chromium.launch()
6346
+ const context = await browser.newContext({ viewport: { width: 1280, height: 800 } })
6347
+ const page = await context.newPage()
6348
+ const results = []
6349
+
6350
+ for (const route of routes) {
6351
+ const slug = slugify(route)
6352
+ const targetDir = diffMode ? pendingDir : approvedDir
6353
+ const pngPath = join(targetDir, \`\${slug}.png\`)
6354
+ await page.goto(baseUrl + route, { waitUntil: "networkidle" })
6355
+ const buf = await page.screenshot({ fullPage: true })
6356
+ await writeFile(pngPath, buf)
6357
+ results.push({ route, png: pngPath })
6358
+
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
+ }
6390
+ }
6391
+ }
6392
+
6393
+ 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))
6396
+ `;
6397
+ }
6398
+ function toPascalCase(s) {
6399
+ return s.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join("");
6400
+ }
6401
+
6402
+ // src/commands/sandbox/scaffold.ts
6403
+ function writeScaffold(sandboxDir, manifest, port) {
6404
+ mkdirSync7(sandboxDir, { recursive: true });
6405
+ const created = [];
6406
+ const writeIfNew = (rel, contents) => {
6407
+ const full = join20(sandboxDir, rel);
6408
+ mkdirSync7(dirOf(full), { recursive: true });
6409
+ writeFileSync12(full, contents, "utf-8");
6410
+ created.push(rel);
6411
+ };
6412
+ writeIfNew("package.json", packageJsonTemplate(manifest.pattern));
6413
+ writeIfNew("tsconfig.json", tsconfigTemplate());
6414
+ writeIfNew("next.config.ts", nextConfigTemplate());
6415
+ writeIfNew("next-env.d.ts", nextEnvDtsTemplate());
6416
+ writeIfNew(".gitignore", gitignoreTemplate());
6417
+ writeIfNew("README.md", readmeTemplate(manifest, port));
6418
+ writeIfNew("app/layout.tsx", rootLayoutTemplate(manifest.pattern));
6419
+ writeIfNew("app/globals.css", globalsCssPlaceholder());
6420
+ writeIfNew("app/page.tsx", indexPageTemplate());
6421
+ writeIfNew("app/primitives/[name]/page.tsx", primitiveRouteTemplate());
6422
+ writeIfNew("app/screens/[name]/page.tsx", screenRouteTemplate());
6423
+ writeIfNew("components/sandbox-sample.tsx", sandboxSampleComponent());
6424
+ writeIfNew("lib/sandbox-manifest.ts", sandboxManifestModule(manifest));
6425
+ writeIfNew("lib/sandbox-mocks.ts", sandboxMocksModule(manifest.mockShapes));
6426
+ for (const p of manifest.primitives) {
6427
+ if (p.status === "gap-new") {
6428
+ writeIfNew(`components/stubs/${p.name}.tsx`, stubTemplate(p));
6429
+ }
6430
+ }
6431
+ writeIfNew("playwright.capture.mjs", captureScriptTemplate());
6432
+ mkdirSync7(join20(sandboxDir, "captures", "approved"), { recursive: true });
6433
+ return { created };
6434
+ }
6435
+ function dirOf(p) {
6436
+ const idx = p.lastIndexOf("/");
6437
+ return idx === -1 ? "." : p.slice(0, idx);
6438
+ }
6439
+ function writeSandboxConfig(sandboxDir, manifest, port, options) {
6440
+ const config = {
6441
+ pattern: manifest.pattern,
6442
+ handoffPath: options.handoffPath,
6443
+ theme: options.theme,
6444
+ port,
6445
+ visorVersion: options.visorVersion,
6446
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
6447
+ primitives: manifest.primitives.map((p) => ({
6448
+ name: p.name,
6449
+ status: p.status,
6450
+ viTicket: p.viTicket ?? null
6451
+ })),
6452
+ screens: manifest.screens.map((s) => ({ name: s.name, title: s.title, route: s.route ?? null }))
6453
+ };
6454
+ writeFileSync12(join20(sandboxDir, "sandbox.json"), JSON.stringify(config, null, 2) + "\n", "utf-8");
6455
+ }
6456
+ function sandboxIsEmpty(sandboxDir) {
6457
+ if (!existsSync20(sandboxDir)) return true;
6458
+ try {
6459
+ return readdirSync12(sandboxDir).filter((f) => !f.startsWith(".")).length === 0;
6460
+ } catch {
6461
+ return false;
6462
+ }
6463
+ }
6464
+
6465
+ // src/commands/sandbox/init.ts
6466
+ async function sandboxInitCommand(name, cwd, options) {
6467
+ const json = options.json ?? false;
6468
+ try {
6469
+ const result = await runInit(name, cwd, options);
6470
+ if (json) {
6471
+ console.log(JSON.stringify(result, null, 2));
6472
+ return;
6473
+ }
6474
+ logger.blank();
6475
+ logger.success(`Sandbox ready at ${result.sandboxDir}`);
6476
+ logger.info(`Port: ${result.port} (port 3000 reserved)`);
6477
+ logger.info(`Shipped primitives added: ${result.primitives?.shipped.length ?? 0}`);
6478
+ logger.info(`Gap stubs generated: ${result.primitives?.gaps.length ?? 0}`);
6479
+ if (result.warnings && result.warnings.length > 0) {
6480
+ logger.blank();
6481
+ logger.warn("Warnings:");
6482
+ for (const w of result.warnings) logger.item(w);
6483
+ }
6484
+ logger.blank();
6485
+ logger.info("Next:");
6486
+ logger.item(`npx visor sandbox dev --name ${name}`);
6487
+ logger.item(`npx visor sandbox approve --name ${name}`);
6488
+ } catch (error) {
6489
+ const message = error instanceof Error ? error.message : String(error);
6490
+ if (json) {
6491
+ console.log(JSON.stringify({ success: false, error: message }, null, 2));
6492
+ } else {
6493
+ logger.error(message);
6494
+ }
6495
+ process.exit(1);
6496
+ }
6497
+ }
6498
+ async function runInit(name, cwd, options) {
6499
+ if (!name || !/^[a-z0-9][a-z0-9-_]*$/i.test(name)) {
6500
+ throw new Error(`Invalid sandbox name '${name}'. Use letters, digits, '-' or '_'.`);
6501
+ }
6502
+ const handoffPath = isAbsolute2(options.handoff) ? options.handoff : resolve15(cwd, options.handoff);
6503
+ if (!existsSync21(handoffPath)) {
6504
+ throw new Error(`Handoff manifest not found: ${handoffPath}`);
6505
+ }
6506
+ const manifest = parseHandoff(handoffPath);
6507
+ if (manifest.primitives.length === 0) {
6508
+ throw new Error(
6509
+ `Handoff has no primitives \u2014 refusing to scaffold an empty sandbox. Check the manifest at ${handoffPath}`
6510
+ );
6511
+ }
6512
+ const sandboxDir = join21(cwd, ".lo", "sandbox", name);
6513
+ if (!sandboxIsEmpty(sandboxDir)) {
6514
+ if (!options.overwrite) {
6515
+ throw new Error(
6516
+ `Sandbox directory not empty: ${sandboxDir}. Pass --overwrite to replace it.`
6517
+ );
6518
+ }
6519
+ rmSync2(sandboxDir, { recursive: true, force: true });
6520
+ }
6521
+ mkdirSync8(sandboxDir, { recursive: true });
6522
+ const port = await findOpenPort();
6523
+ writeScaffold(sandboxDir, manifest, port);
6524
+ if (!options.skipInstall) {
6525
+ runNpmInstall(sandboxDir, options.json ?? false);
6526
+ }
6527
+ applyThemeIfPossible(sandboxDir, manifest, options.theme, cwd, options.json ?? false);
6528
+ const known = loadKnownPrimitives();
6529
+ const shipped = [];
6530
+ for (const p of manifest.primitives) {
6531
+ 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
+ const ok = tryAddPrimitive(p, sandboxDir, options.json ?? false);
6539
+ if (ok) shipped.push(p.name);
6540
+ }
6541
+ const gaps = manifest.primitives.filter((p) => p.status === "gap-new").map((p) => p.name);
6542
+ writeSandboxConfig(sandboxDir, manifest, port, {
6543
+ handoffPath,
6544
+ theme: options.theme,
6545
+ visorVersion: readCliVersion()
6546
+ });
6547
+ return {
6548
+ success: true,
6549
+ sandboxDir,
6550
+ port,
6551
+ primitives: { shipped, gaps },
6552
+ warnings: manifest.warnings
6553
+ };
6554
+ }
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
+ ];
6562
+ const yamlPath = candidates.find((p) => existsSync21(p));
6563
+ if (!yamlPath) {
6564
+ 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.`
6566
+ );
6567
+ if (!json) {
6568
+ logger.warn(`Theme '${theme2}' not found \u2014 leaving placeholder globals.css.`);
6569
+ }
6570
+ return;
6571
+ }
6572
+ const globalsOut = join21(sandboxDir, "app", "globals.css");
6573
+ try {
6574
+ themeApplyCommand(yamlPath, sandboxDir, {
6575
+ output: globalsOut,
6576
+ adapter: "nextjs",
6577
+ json: false
6578
+ });
6579
+ } catch (err) {
6580
+ const msg = err instanceof Error ? err.message : String(err);
6581
+ manifest.warnings.push(`Theme apply failed: ${msg}`);
6582
+ if (!json) logger.warn(`Theme apply failed: ${msg}`);
6583
+ }
6584
+ }
6585
+ function tryAddPrimitive(primitive, sandboxDir, json) {
6586
+ try {
6587
+ addCommand([primitive.name], sandboxDir, {
6588
+ overwrite: true,
6589
+ target: "react",
6590
+ json: false
6591
+ });
6592
+ return true;
6593
+ } catch (err) {
6594
+ const msg = err instanceof Error ? err.message : String(err);
6595
+ if (!json) {
6596
+ logger.warn(`Skipped '${primitive.name}': ${msg}`);
6597
+ }
6598
+ return false;
6599
+ }
6600
+ }
6601
+ function runNpmInstall(sandboxDir, json) {
6602
+ if (!json) logger.info("Installing sandbox dependencies...");
6603
+ const result = childProcess2.spawnSync("npm", ["install", "--no-audit", "--no-fund"], {
6604
+ cwd: sandboxDir,
6605
+ stdio: json ? "ignore" : "inherit"
6606
+ });
6607
+ if (result.error) {
6608
+ throw new Error(`npm install failed to start: ${result.error.message}`);
6609
+ }
6610
+ if (typeof result.status === "number" && result.status !== 0) {
6611
+ throw new Error(`npm install exited with code ${result.status}`);
6612
+ }
6613
+ }
6614
+ function loadKnownPrimitives() {
6615
+ try {
6616
+ const reg = loadRegistry();
6617
+ const items = filterItemsByTarget(reg.items, "react");
6618
+ return new Set(items.map((i) => i.name));
6619
+ } catch {
6620
+ return /* @__PURE__ */ new Set();
6621
+ }
6622
+ }
6623
+ function readCliVersion() {
6624
+ try {
6625
+ const here = dirname9(fileURLToPath3(import.meta.url));
6626
+ for (let i = 0; i < 6; i++) {
6627
+ const segments = new Array(i).fill("..");
6628
+ const candidate = join21(here, ...segments, "package.json");
6629
+ try {
6630
+ const pkg2 = JSON.parse(readFileSync23(candidate, "utf-8"));
6631
+ if (pkg2.name === "@loworbitstudio/visor" && pkg2.version) return pkg2.version;
6632
+ } catch {
6633
+ }
6634
+ }
6635
+ } catch {
6636
+ }
6637
+ return "0.0.0-dev";
6638
+ }
6639
+
6640
+ // src/commands/sandbox/dev.ts
6641
+ import { existsSync as existsSync22, readFileSync as readFileSync24 } from "fs";
6642
+ import { join as join22 } from "path";
6643
+ import * as childProcess3 from "child_process";
6644
+ function sandboxDevCommand(cwd, options) {
6645
+ const json = options.json ?? false;
6646
+ const sandboxDir = join22(cwd, ".lo", "sandbox", options.name);
6647
+ const configPath = join22(sandboxDir, "sandbox.json");
6648
+ if (!existsSync22(configPath)) {
6649
+ fail(
6650
+ json,
6651
+ `Sandbox '${options.name}' not found at ${sandboxDir}. Run 'visor sandbox init ${options.name} --handoff ... --theme ...' first.`
6652
+ );
6653
+ return;
6654
+ }
6655
+ let config;
6656
+ try {
6657
+ config = JSON.parse(readFileSync24(configPath, "utf-8"));
6658
+ } catch (err) {
6659
+ fail(json, `Invalid sandbox.json at ${configPath}: ${err.message}`);
6660
+ return;
6661
+ }
6662
+ const baseUrl = `http://localhost:${config.port}`;
6663
+ const routes = [
6664
+ "/",
6665
+ ...config.primitives.map((p) => `/primitives/${p.name}`),
6666
+ ...config.screens.map((s) => `/screens/${s.name}`)
6667
+ ];
6668
+ if (json) {
6669
+ console.log(
6670
+ JSON.stringify({ success: true, baseUrl, port: config.port, routes }, null, 2)
6671
+ );
6672
+ } else {
6673
+ logger.info(`Sandbox: ${config.pattern}`);
6674
+ logger.info(`Dev server on port ${config.port}`);
6675
+ logger.info(`Base URL: ${baseUrl}`);
6676
+ logger.blank();
6677
+ logger.info("Routes:");
6678
+ for (const r of routes) logger.item(`${baseUrl}${r}`);
6679
+ logger.blank();
6680
+ }
6681
+ spawnNextDev(sandboxDir, config.port, json);
6682
+ }
6683
+ function spawnNextDev(sandboxDir, port, json) {
6684
+ const child = childProcess3.spawn(
6685
+ "npx",
6686
+ ["--no-install", "next", "dev", "--port", String(port)],
6687
+ {
6688
+ cwd: sandboxDir,
6689
+ stdio: json ? "ignore" : "inherit"
6690
+ }
6691
+ );
6692
+ child.on("exit", (code) => {
6693
+ if (typeof code === "number" && code !== 0 && !json) {
6694
+ logger.warn(`next dev exited with code ${code}`);
6695
+ }
6696
+ });
6697
+ const stop = () => {
6698
+ if (!child.killed) child.kill("SIGINT");
6699
+ };
6700
+ process.on("SIGINT", stop);
6701
+ process.on("SIGTERM", stop);
6702
+ }
6703
+ function fail(json, message) {
6704
+ if (json) {
6705
+ console.log(JSON.stringify({ success: false, error: message }, null, 2));
6706
+ } else {
6707
+ logger.error(message);
6708
+ }
6709
+ process.exit(1);
6710
+ }
6711
+
6712
+ // 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";
6715
+ import * as childProcess4 from "child_process";
6716
+ function sandboxApproveCommand(cwd, options) {
6717
+ const json = options.json ?? false;
6718
+ const sandboxDir = join23(cwd, ".lo", "sandbox", options.name);
6719
+ const configPath = join23(sandboxDir, "sandbox.json");
6720
+ if (!existsSync23(configPath)) {
6721
+ fail2(
6722
+ json,
6723
+ `Sandbox '${options.name}' not found at ${sandboxDir}. Run 'visor sandbox init' first.`
6724
+ );
6725
+ return;
6726
+ }
6727
+ const config = JSON.parse(readFileSync25(configPath, "utf-8"));
6728
+ const captureScriptPath = join23(sandboxDir, "playwright.capture.mjs");
6729
+ writeFileSync13(captureScriptPath, captureScriptTemplate(), "utf-8");
6730
+ 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
+ });
6739
+ if (result.error) {
6740
+ fail2(json, `Capture failed to start: ${result.error.message}`);
6741
+ return;
6742
+ }
6743
+ if (typeof result.status === "number" && result.status !== 0) {
6744
+ fail2(json, `Capture script exited with code ${result.status}
6745
+ ${result.stderr}`);
6746
+ return;
6747
+ }
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) : [];
6752
+ if (json) {
6753
+ console.log(
6754
+ JSON.stringify(
6755
+ {
6756
+ success: true,
6757
+ mode: options.diff ? "diff" : "approve",
6758
+ approvedDir,
6759
+ diffsDir: options.diff ? diffsDir : null,
6760
+ approved: approvedFiles,
6761
+ diffs: diffFiles,
6762
+ captureOutput: tryParseJson(result.stdout)
6763
+ },
6764
+ null,
6765
+ 2
6766
+ )
6767
+ );
6768
+ } else {
6769
+ logger.success(
6770
+ options.diff ? `Pixel-diff complete. ${diffFiles.length} diff PNGs in ${diffsDir}` : `Captured ${approvedFiles.length} PNGs in ${approvedDir}`
6771
+ );
6772
+ if (options.diff && diffFiles.length > 0) {
6773
+ logger.blank();
6774
+ logger.info("Changed routes:");
6775
+ for (const f of diffFiles) logger.item(f);
6776
+ }
6777
+ }
6778
+ }
6779
+ function ensurePlaywrightInstalled(sandboxDir, json) {
6780
+ const markerPath = join23(sandboxDir, ".playwright-installed");
6781
+ if (existsSync23(markerPath)) return;
6782
+ 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
+ });
6787
+ if (result.error) {
6788
+ throw new Error(`playwright install failed to start: ${result.error.message}`);
6789
+ }
6790
+ if (typeof result.status === "number" && result.status !== 0) {
6791
+ throw new Error(`playwright install exited with code ${result.status}`);
6792
+ }
6793
+ writeFileSync13(markerPath, (/* @__PURE__ */ new Date()).toISOString(), "utf-8");
6794
+ }
6795
+ function safeListPngs(dir) {
6796
+ if (!existsSync23(dir)) return [];
6797
+ return readdirSync13(dir).filter((f) => f.endsWith(".png")).map((f) => join23(dir, f));
6798
+ }
6799
+ function tryParseJson(s) {
6800
+ try {
6801
+ return JSON.parse(s);
6802
+ } catch {
6803
+ return s.trim();
6804
+ }
6805
+ }
6806
+ function fail2(json, message) {
6807
+ if (json) {
6808
+ console.log(JSON.stringify({ success: false, error: message }, null, 2));
6809
+ } else {
6810
+ logger.error(message);
6811
+ }
6812
+ process.exit(1);
6813
+ }
6814
+
5725
6815
  // src/index.ts
5726
- var __dirname2 = dirname8(fileURLToPath3(import.meta.url));
6816
+ var __dirname2 = dirname10(fileURLToPath4(import.meta.url));
5727
6817
  var pkg = JSON.parse(
5728
- readFileSync22(join20(__dirname2, "..", "package.json"), "utf-8")
6818
+ readFileSync26(join24(__dirname2, "..", "package.json"), "utf-8")
5729
6819
  );
5730
6820
  var program = new Command2();
5731
6821
  program.name("visor").description("CLI for the Visor design system").version(pkg.version);
@@ -5877,4 +6967,18 @@ migrate.command("token-substitution").description(
5877
6967
  });
5878
6968
  }
5879
6969
  );
6970
+ var sandbox = program.command("sandbox").description("Scaffold and iterate on a Next.js sandbox for new primitives");
6971
+ sandbox.command("init").description(
6972
+ "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(
6974
+ async (name, options) => {
6975
+ await sandboxInitCommand(name, process.cwd(), options);
6976
+ }
6977
+ );
6978
+ 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
+ sandboxDevCommand(process.cwd(), options);
6980
+ });
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
+ });
5880
6984
  program.parse();