@nick848/fet 1.1.10 → 1.1.11

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/cli/index.js CHANGED
@@ -2090,6 +2090,12 @@ function renderFetConfig(scan, language = "zh-CN") {
2090
2090
  mode: "require_before_apply",
2091
2091
  whenNoTestScript: "block"
2092
2092
  },
2093
+ visual: {
2094
+ enabled: true,
2095
+ compareMode: "layout-only",
2096
+ requireBeforeVerify: "when_figma",
2097
+ whenNoCapture: "warn"
2098
+ },
2093
2099
  specLanguage: {
2094
2100
  style: "layered_bilingual",
2095
2101
  canonical: "en",
@@ -2274,7 +2280,7 @@ function tddResultsRelativePath(changeId) {
2274
2280
 
2275
2281
  // src/templates/tdd.ts
2276
2282
  function renderTddInstructions(changeId, manifest, language) {
2277
- const manifestPath2 = tddManifestRelativePath(changeId);
2283
+ const manifestPath3 = tddManifestRelativePath(changeId);
2278
2284
  const specPath = tddSpecRelativePath(changeId);
2279
2285
  const caseList = manifest.cases.map((item) => `- \`${item.id}\`: ${item.title} \u2192 \`${item.testFile}\` (${item.testIds.join(", ")})`).join("\n");
2280
2286
  if (language === "en") {
@@ -2293,13 +2299,13 @@ Create or update unit tests **before** marking implementation tasks done in \`ta
2293
2299
  ## Sources
2294
2300
  ${manifest.sources.map((s) => `- ${s}`).join("\n")}
2295
2301
 
2296
- ## Cases (from ${manifestPath2})
2302
+ ## Cases (from ${manifestPath3})
2297
2303
  ${caseList}
2298
2304
 
2299
2305
  ## Rules
2300
2306
  1. Each case must map to a real test file under the repo test tree.
2301
2307
  2. Tests should fail until implementation lands (red \u2192 green).
2302
- 3. Do not edit \`${manifestPath2}\` by hand unless fixing IDs; re-run \`fet tdd --change ${changeId}\` after planning changes.
2308
+ 3. Do not edit \`${manifestPath3}\` by hand unless fixing IDs; re-run \`fet tdd --change ${changeId}\` after planning changes.
2303
2309
  4. When tests exist, run \`fet test --change ${changeId}\` before \`fet verify\`.
2304
2310
 
2305
2311
  Human-readable matrix: \`${specPath}\`
@@ -2320,13 +2326,13 @@ generatedAt: ${manifest.generatedAt}
2320
2326
  ## \u6765\u6E90
2321
2327
  ${manifest.sources.map((s) => `- ${s}`).join("\n")}
2322
2328
 
2323
- ## \u7528\u4F8B\uFF08\u89C1 ${manifestPath2}\uFF09
2329
+ ## \u7528\u4F8B\uFF08\u89C1 ${manifestPath3}\uFF09
2324
2330
  ${caseList}
2325
2331
 
2326
2332
  ## \u89C4\u5219
2327
2333
  1. \u6BCF\u4E2A\u7528\u4F8B\u5FC5\u987B\u5BF9\u5E94\u4ED3\u5E93\u5185\u771F\u5B9E\u6D4B\u8BD5\u6587\u4EF6\u3002
2328
2334
  2. \u5B9E\u73B0\u843D\u5730\u524D\u6D4B\u8BD5\u5E94\u5904\u4E8E\u5931\u8D25\uFF08\u7EA2\uFF09\u72B6\u6001\uFF0C\u843D\u5730\u540E\u5E94\u53D8\u7EFF\u3002
2329
- 3. \u9664\u975E\u4FEE\u6B63 ID\uFF0C\u4E0D\u8981\u624B\u6539 \`${manifestPath2}\`\uFF1B\u89C4\u5212\u53D8\u66F4\u540E\u8BF7\u91CD\u65B0\u6267\u884C \`fet tdd --change ${changeId}\`\u3002
2335
+ 3. \u9664\u975E\u4FEE\u6B63 ID\uFF0C\u4E0D\u8981\u624B\u6539 \`${manifestPath3}\`\uFF1B\u89C4\u5212\u53D8\u66F4\u540E\u8BF7\u91CD\u65B0\u6267\u884C \`fet tdd --change ${changeId}\`\u3002
2330
2336
  4. \u6D4B\u8BD5\u5C31\u7EEA\u540E\u5148 \`fet test --change ${changeId}\`\uFF0C\u518D \`fet verify\`\u3002
2331
2337
 
2332
2338
  \u53EF\u8BFB\u77E9\u9635\u89C1 \`${specPath}\`
@@ -2371,19 +2377,151 @@ function escapeTable(value) {
2371
2377
  return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
2372
2378
  }
2373
2379
  function renderTddApplyNextSteps(changeId, language) {
2374
- const manifestPath2 = tddManifestRelativePath(changeId);
2380
+ const manifestPath3 = tddManifestRelativePath(changeId);
2375
2381
  if (language === "en") {
2376
2382
  return [
2377
- `Read ${manifestPath2} and tdd-instructions.md; implement code until fet test passes for this change.`,
2383
+ `Read ${manifestPath3} and tdd-instructions.md; implement code until fet test passes for this change.`,
2378
2384
  `Run fet test --change ${changeId} before fet verify.`
2379
2385
  ];
2380
2386
  }
2381
2387
  return [
2382
- `\u9605\u8BFB ${manifestPath2} \u4E0E tdd-instructions.md\uFF1B\u5B9E\u73B0\u4EE3\u7801\u76F4\u81F3\u672C change \u7684 fet test \u901A\u8FC7\u3002`,
2388
+ `\u9605\u8BFB ${manifestPath3} \u4E0E tdd-instructions.md\uFF1B\u5B9E\u73B0\u4EE3\u7801\u76F4\u81F3\u672C change \u7684 fet test \u901A\u8FC7\u3002`,
2383
2389
  `\u5728 fet verify \u4E4B\u524D\u5148\u6267\u884C fet test --change ${changeId}\u3002`
2384
2390
  ];
2385
2391
  }
2386
2392
 
2393
+ // src/visual/paths.ts
2394
+ function visualFetDirRelative(changeId) {
2395
+ return `openspec/changes/${changeId}/.fet`;
2396
+ }
2397
+ function visualManifestRelativePath(changeId) {
2398
+ return `${visualFetDirRelative(changeId)}/visual-manifest.yaml`;
2399
+ }
2400
+ function visualSpecRelativePath(changeId) {
2401
+ return `${visualFetDirRelative(changeId)}/visual-spec.md`;
2402
+ }
2403
+ function visualInstructionsRelativePath(changeId) {
2404
+ return `${visualFetDirRelative(changeId)}/visual-instructions.md`;
2405
+ }
2406
+ function visualCaptureRelativePath(changeId) {
2407
+ return `${visualFetDirRelative(changeId)}/visual-capture.json`;
2408
+ }
2409
+ function visualResultsRelativePath(changeId) {
2410
+ return `${visualFetDirRelative(changeId)}/visual-results.json`;
2411
+ }
2412
+ function visualBaselinesDirRelative(changeId) {
2413
+ return `${visualFetDirRelative(changeId)}/visual-baselines`;
2414
+ }
2415
+ function visualPageScreenshotRelative(changeId, pageId) {
2416
+ return `${visualBaselinesDirRelative(changeId)}/${pageId}/implementation.png`;
2417
+ }
2418
+
2419
+ // src/templates/visual.ts
2420
+ function renderVisualInstructions(changeId, manifest, language) {
2421
+ const pages = manifest.pages.map(
2422
+ (page) => `- \`${page.id}\`: ${page.title} \u2192 \`${page.route}\` (ignore: ${page.ignoreSelectors.join(", ") || "none"})`
2423
+ ).join("\n");
2424
+ if (language === "en") {
2425
+ return `---
2426
+ schemaVersion: 1
2427
+ fetVersion: ${FET_VERSION}
2428
+ changeId: ${changeId}
2429
+ purpose: visual-instructions
2430
+ generatedAt: ${manifest.generatedAt}
2431
+ compareMode: layout-only
2432
+ ---
2433
+
2434
+ # Visual verification (layout-only)
2435
+
2436
+ Compare **layout / spacing / shell regions** only. Do **not** pixel-match dynamic API text, list rows, or images.
2437
+
2438
+ ## Figma references
2439
+ ${manifest.figmaUrls.map((url) => `- ${url}`).join("\n") || "- (none detected)"}
2440
+
2441
+ ## Pages
2442
+ ${pages}
2443
+
2444
+ ## Default command
2445
+
2446
+ \`\`\`sh
2447
+ fet visual --change ${changeId}
2448
+ \`\`\`
2449
+
2450
+ Runs manifest refresh, capture (Playwright + \`--base-url\`), and layout checks in one step.
2451
+
2452
+ See \`${visualSpecRelativePath(changeId)}\` for the human-readable matrix.
2453
+ `;
2454
+ }
2455
+ return `---
2456
+ schemaVersion: 1
2457
+ fetVersion: ${FET_VERSION}
2458
+ changeId: ${changeId}
2459
+ purpose: visual-instructions
2460
+ generatedAt: ${manifest.generatedAt}
2461
+ compareMode: layout-only
2462
+ ---
2463
+
2464
+ # \u89C6\u89C9\u9A8C\u6536\uFF08\u4EC5 layout-only\uFF09
2465
+
2466
+ \u53EA\u9A8C\u6536 **\u6392\u7248 / \u95F4\u8DDD / \u58F3\u5C42\u533A\u57DF**\uFF0C**\u4E0D\u8981**\u5BF9\u52A8\u6001\u63A5\u53E3\u6587\u6848\u3001\u5217\u8868\u884C\u3001\u56FE\u7247\u505A\u50CF\u7D20\u7EA7\u5BF9\u6BD4\u3002
2467
+
2468
+ ## Figma \u5F15\u7528
2469
+ ${manifest.figmaUrls.map((url) => `- ${url}`).join("\n") || "- \uFF08\u672A\u68C0\u6D4B\u5230\uFF09"}
2470
+
2471
+ ## \u9875\u9762
2472
+ ${pages}
2473
+
2474
+ ## \u9ED8\u8BA4\u547D\u4EE4
2475
+
2476
+ \`\`\`sh
2477
+ fet visual --change ${changeId}
2478
+ \`\`\`
2479
+
2480
+ \u4E00\u6761\u547D\u4EE4\u5B8C\u6210\u6E05\u5355\u66F4\u65B0\u3001\u622A\u56FE\uFF08Playwright + \`--base-url\`\uFF09\u4E0E\u5E03\u5C40\u68C0\u67E5\u3002
2481
+
2482
+ \u8BE6\u89C1 \`${visualSpecRelativePath(changeId)}\`\u3002
2483
+ `;
2484
+ }
2485
+ function renderVisualSpec(changeId, manifest, language) {
2486
+ const rows = manifest.pages.flatMap(
2487
+ (page) => page.checkRegions.map(
2488
+ (region) => `| ${page.id} | ${page.route} | \`${region.selector}\` | ${region.checks.join(", ")} | ${page.ignoreSelectors.join(", ") || "-"} |`
2489
+ )
2490
+ ).join("\n");
2491
+ if (language === "en") {
2492
+ return `---
2493
+ changeId: ${changeId}
2494
+ compareMode: layout-only
2495
+ planningFingerprint: ${manifest.planningFingerprint}
2496
+ ---
2497
+
2498
+ # Visual case matrix (layout-only)
2499
+
2500
+ | Page | Route | Region | Checks | Ignored dynamic |
2501
+ |------|-------|--------|--------|-----------------|
2502
+ ${rows}
2503
+ `;
2504
+ }
2505
+ return `---
2506
+ changeId: ${changeId}
2507
+ compareMode: layout-only
2508
+ planningFingerprint: ${manifest.planningFingerprint}
2509
+ ---
2510
+
2511
+ # \u89C6\u89C9\u7528\u4F8B\u77E9\u9635\uFF08\u4EC5 layout-only\uFF09
2512
+
2513
+ | \u9875\u9762 | \u8DEF\u7531 | \u533A\u57DF | \u68C0\u67E5\u9879 | \u5FFD\u7565\u7684\u52A8\u6001\u533A |
2514
+ |------|------|------|--------|--------------|
2515
+ ${rows}
2516
+ `;
2517
+ }
2518
+ function renderVisualVerifyNextSteps(changeId, language) {
2519
+ if (language === "en") {
2520
+ return [`Run fet visual --change ${changeId} before fet verify when this change references Figma.`];
2521
+ }
2522
+ return [`\u82E5\u672C change \u5F15\u7528 Figma\uFF0C\u8BF7\u5728 fet verify \u4E4B\u524D\u6267\u884C fet visual --change ${changeId}\u3002`];
2523
+ }
2524
+
2387
2525
  // src/templates/figma-guard.ts
2388
2526
  var FIGMA_URL_PATTERN = /https?:\/\/(?:www\.)?figma\.com\/(?:file|design|proto)\/[^\s)\]"'<>]+/gi;
2389
2527
  function figmaStopHandoffRelativePath(changeId) {
@@ -3818,6 +3956,8 @@ function createChangeState(fetVersion, changeId, phase) {
3818
3956
  manualVerify: null,
3819
3957
  tdd: null,
3820
3958
  testRun: null,
3959
+ visual: null,
3960
+ visualRun: null,
3821
3961
  lastOpenSpecCommand: null,
3822
3962
  warnings: []
3823
3963
  };
@@ -4104,6 +4244,7 @@ async function applyWorkflowCommand(ctx, args) {
4104
4244
  }
4105
4245
  if (figmaGuard) {
4106
4246
  applyNextSteps.unshift(...renderFigmaApplyNextSteps(changeId, ctx.language, figmaGuard.mode));
4247
+ applyNextSteps.splice(applyNextSteps.length - 1, 0, ...renderVisualVerifyNextSteps(changeId, ctx.language));
4107
4248
  }
4108
4249
  ctx.output.result({
4109
4250
  ok: true,
@@ -5043,7 +5184,7 @@ async function tddCommand(ctx) {
5043
5184
  cases,
5044
5185
  testCommand: testCommand2
5045
5186
  });
5046
- const manifestPath2 = await writeTddManifest(ctx.projectRoot, manifest);
5187
+ const manifestPath3 = await writeTddManifest(ctx.projectRoot, manifest);
5047
5188
  const fetDir = join24(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
5048
5189
  await mkdir8(fetDir, { recursive: true });
5049
5190
  const instructionsPath = tddInstructionsRelativePath(changeId);
@@ -5055,7 +5196,7 @@ async function tddCommand(ctx) {
5055
5196
  status: "ready",
5056
5197
  generatedAt: manifest.generatedAt,
5057
5198
  planningFingerprint,
5058
- manifestPath: manifestPath2
5199
+ manifestPath: manifestPath3
5059
5200
  };
5060
5201
  invalidateTestRun(changeState);
5061
5202
  changeState.currentPhase = "implement";
@@ -5086,7 +5227,7 @@ async function tddCommand(ctx) {
5086
5227
  ],
5087
5228
  data: {
5088
5229
  changeId,
5089
- manifestPath: manifestPath2,
5230
+ manifestPath: manifestPath3,
5090
5231
  specPath,
5091
5232
  instructionsPath,
5092
5233
  caseCount: cases.length,
@@ -5291,10 +5432,674 @@ function truncate(value, max = 2e3) {
5291
5432
  return value.length > max ? `${value.slice(0, max)}\u2026` : value;
5292
5433
  }
5293
5434
 
5435
+ // src/commands/visual.ts
5436
+ import { mkdir as mkdir10 } from "fs/promises";
5437
+ import { join as join28 } from "path";
5438
+
5439
+ // src/visual/config.ts
5440
+ import { readFile as readFile19 } from "fs/promises";
5441
+ import { join as join25 } from "path";
5442
+ import { parseDocument as parseDocument5 } from "yaml";
5443
+ var DEFAULT_CONFIG4 = {
5444
+ enabled: true,
5445
+ compareMode: "layout-only",
5446
+ requireBeforeVerify: "when_figma",
5447
+ whenNoCapture: "warn"
5448
+ };
5449
+ async function loadVisualConfig(projectRoot) {
5450
+ try {
5451
+ const raw = await readFile19(join25(projectRoot, "openspec", "config.yaml"), "utf8");
5452
+ const doc = parseDocument5(raw);
5453
+ const fetNode = doc.get("fet", true);
5454
+ const node = fetNode?.get?.("visual");
5455
+ if (!node || typeof node.get !== "function") {
5456
+ return DEFAULT_CONFIG4;
5457
+ }
5458
+ const enabled = node.get("enabled");
5459
+ const requireBeforeVerify = node.get("requireBeforeVerify");
5460
+ const whenNoCapture = node.get("whenNoCapture");
5461
+ return {
5462
+ enabled: enabled === void 0 ? true : Boolean(enabled),
5463
+ compareMode: "layout-only",
5464
+ requireBeforeVerify: parseRequireBeforeVerify(requireBeforeVerify),
5465
+ whenNoCapture: parseWhenNoCapture(whenNoCapture)
5466
+ };
5467
+ } catch {
5468
+ return DEFAULT_CONFIG4;
5469
+ }
5470
+ }
5471
+ function parseRequireBeforeVerify(value) {
5472
+ if (value === "off" || value === "always" || value === "when_figma") {
5473
+ return value;
5474
+ }
5475
+ return DEFAULT_CONFIG4.requireBeforeVerify;
5476
+ }
5477
+ function parseWhenNoCapture(value) {
5478
+ if (value === "block" || value === "warn" || value === "skip") {
5479
+ return value;
5480
+ }
5481
+ return DEFAULT_CONFIG4.whenNoCapture;
5482
+ }
5483
+ function isVisualRequiredForVerify(config, hasFigma) {
5484
+ if (!config.enabled) {
5485
+ return false;
5486
+ }
5487
+ if (config.requireBeforeVerify === "off") {
5488
+ return false;
5489
+ }
5490
+ if (config.requireBeforeVerify === "always") {
5491
+ return true;
5492
+ }
5493
+ return hasFigma;
5494
+ }
5495
+
5496
+ // src/visual/playwright.ts
5497
+ import { createRequire } from "module";
5498
+ import { dirname as dirname9, join as join26 } from "path";
5499
+ import { pathToFileURL } from "url";
5500
+ async function resolvePlaywright(projectRoot) {
5501
+ const require2 = createRequire(join26(projectRoot, "package.json"));
5502
+ const candidates = ["playwright", "@playwright/test"];
5503
+ for (const name of candidates) {
5504
+ try {
5505
+ const resolved = require2.resolve(name);
5506
+ const mod = await import(pathToFileURL(resolved).href);
5507
+ if (mod?.chromium) {
5508
+ return mod;
5509
+ }
5510
+ } catch {
5511
+ continue;
5512
+ }
5513
+ }
5514
+ return null;
5515
+ }
5516
+ async function capturePagesWithPlaywright(projectRoot, manifest, baseUrl) {
5517
+ const playwright = await resolvePlaywright(projectRoot);
5518
+ if (!playwright) {
5519
+ return [];
5520
+ }
5521
+ const browser = await playwright.chromium.launch({ headless: true });
5522
+ const results = [];
5523
+ try {
5524
+ for (const page of manifest.pages) {
5525
+ results.push(await captureSinglePage(projectRoot, browser, manifest.changeId, page, baseUrl));
5526
+ }
5527
+ } finally {
5528
+ await browser.close();
5529
+ }
5530
+ return results;
5531
+ }
5532
+ async function captureSinglePage(projectRoot, browser, changeId, pageDef, baseUrl) {
5533
+ const { mkdir: mkdir15 } = await import("fs/promises");
5534
+ const { join: joinPath } = await import("path");
5535
+ const screenshotRelative = visualPageScreenshotRelative(changeId, pageDef.id);
5536
+ const screenshotAbsolute = joinPath(projectRoot, screenshotRelative);
5537
+ await mkdir15(dirname9(screenshotAbsolute), { recursive: true });
5538
+ const url = new URL(pageDef.route, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
5539
+ const pwPage = await browser.newPage();
5540
+ try {
5541
+ await pwPage.setViewportSize(pageDef.viewport);
5542
+ await pwPage.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
5543
+ await pwPage.screenshot({ path: screenshotAbsolute, fullPage: true });
5544
+ const regions = await extractRegions(pwPage, pageDef);
5545
+ return {
5546
+ pageId: pageDef.id,
5547
+ route: pageDef.route,
5548
+ screenshotPath: screenshotRelative,
5549
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
5550
+ regions
5551
+ };
5552
+ } catch {
5553
+ return {
5554
+ pageId: pageDef.id,
5555
+ route: pageDef.route,
5556
+ screenshotPath: null,
5557
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
5558
+ regions: []
5559
+ };
5560
+ } finally {
5561
+ await pwPage.close();
5562
+ }
5563
+ }
5564
+ var EXTRACT_REGIONS_SCRIPT = `
5565
+ (inputSelectors) => {
5566
+ const layouts = [];
5567
+ for (const selector of inputSelectors) {
5568
+ const element = document.querySelector(selector);
5569
+ if (!element) {
5570
+ layouts.push({
5571
+ selector,
5572
+ exists: false,
5573
+ visible: false,
5574
+ width: 0,
5575
+ height: 0,
5576
+ display: "none",
5577
+ position: "static"
5578
+ });
5579
+ continue;
5580
+ }
5581
+ const style = window.getComputedStyle(element);
5582
+ const rect = element.getBoundingClientRect();
5583
+ layouts.push({
5584
+ selector,
5585
+ exists: true,
5586
+ visible: style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0,
5587
+ width: rect.width,
5588
+ height: rect.height,
5589
+ display: style.display,
5590
+ position: style.position
5591
+ });
5592
+ }
5593
+ return layouts;
5594
+ }
5595
+ `;
5596
+ async function extractRegions(page, pageDef) {
5597
+ const selectors = pageDef.checkRegions.map((region) => region.selector);
5598
+ return page.evaluate(EXTRACT_REGIONS_SCRIPT, selectors);
5599
+ }
5600
+
5601
+ // src/visual/manifest.ts
5602
+ import { mkdir as mkdir9, readFile as readFile20, stat as stat11 } from "fs/promises";
5603
+ import { dirname as dirname10, join as join27 } from "path";
5604
+ import { parse as parse5, stringify as stringify4 } from "yaml";
5605
+ function visualManifestPath(projectRoot, changeId) {
5606
+ return join27(projectRoot, visualManifestRelativePath(changeId));
5607
+ }
5608
+ async function readVisualManifest(projectRoot, changeId) {
5609
+ const path = visualManifestPath(projectRoot, changeId);
5610
+ try {
5611
+ await stat11(path);
5612
+ } catch {
5613
+ return null;
5614
+ }
5615
+ const doc = parse5(await readFile20(path, "utf8"));
5616
+ if (!doc || doc.schemaVersion !== 1 || doc.changeId !== changeId) {
5617
+ return null;
5618
+ }
5619
+ return doc;
5620
+ }
5621
+ async function writeVisualManifest(projectRoot, manifest) {
5622
+ const relative6 = visualManifestRelativePath(manifest.changeId);
5623
+ const path = join27(projectRoot, relative6);
5624
+ await mkdir9(dirname10(path), { recursive: true });
5625
+ await atomicWrite(path, stringify4(manifest));
5626
+ return relative6;
5627
+ }
5628
+ async function readVisualCapture(projectRoot, changeId) {
5629
+ const path = join27(projectRoot, visualCaptureRelativePath(changeId));
5630
+ try {
5631
+ const doc = JSON.parse(await readFile20(path, "utf8"));
5632
+ if (doc?.schemaVersion === 1 && doc.changeId === changeId) {
5633
+ return doc;
5634
+ }
5635
+ } catch {
5636
+ return null;
5637
+ }
5638
+ return null;
5639
+ }
5640
+ async function writeVisualCapture(projectRoot, capture) {
5641
+ const relative6 = visualCaptureRelativePath(capture.changeId);
5642
+ const path = join27(projectRoot, relative6);
5643
+ await mkdir9(dirname10(path), { recursive: true });
5644
+ await atomicWrite(path, `${JSON.stringify(capture, null, 2)}
5645
+ `);
5646
+ return relative6;
5647
+ }
5648
+ async function writeVisualResults(projectRoot, results) {
5649
+ const relative6 = visualResultsRelativePath(results.changeId);
5650
+ const path = join27(projectRoot, relative6);
5651
+ await mkdir9(dirname10(path), { recursive: true });
5652
+ await atomicWrite(path, `${JSON.stringify(results, null, 2)}
5653
+ `);
5654
+ return relative6;
5655
+ }
5656
+ function createVisualManifest(input) {
5657
+ return {
5658
+ schemaVersion: 1,
5659
+ changeId: input.changeId,
5660
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
5661
+ fetVersion: FET_VERSION,
5662
+ planningFingerprint: input.planningFingerprint,
5663
+ compareMode: "layout-only",
5664
+ figmaUrls: input.figmaUrls,
5665
+ figmaSources: input.figmaSources,
5666
+ run: { baseUrl: input.baseUrl },
5667
+ pages: input.pages
5668
+ };
5669
+ }
5670
+
5671
+ // src/visual/capture.ts
5672
+ async function runVisualCapture(options) {
5673
+ const warnings = [];
5674
+ const baseUrl = options.baseUrl ?? options.manifest.run.baseUrl;
5675
+ if (!baseUrl) {
5676
+ return handleMissingBaseUrl(options.config, options.language, warnings);
5677
+ }
5678
+ const playwright = await resolvePlaywright(options.projectRoot);
5679
+ if (!playwright) {
5680
+ return handleMissingPlaywright(options.config, options.language, warnings);
5681
+ }
5682
+ const pages = await capturePagesWithPlaywright(options.projectRoot, options.manifest, baseUrl);
5683
+ const failedPages = pages.filter((page) => !page.screenshotPath);
5684
+ const capture = {
5685
+ schemaVersion: 1,
5686
+ changeId: options.changeId,
5687
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
5688
+ baseUrl,
5689
+ pages
5690
+ };
5691
+ await writeVisualCapture(options.projectRoot, capture);
5692
+ if (failedPages.length) {
5693
+ return {
5694
+ status: "failed",
5695
+ capture,
5696
+ warnings,
5697
+ message: options.language === "en" ? `Capture failed for ${failedPages.length} page(s). Check dev server and routes.` : `${failedPages.length} \u4E2A\u9875\u9762\u622A\u56FE\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5 dev \u670D\u52A1\u4E0E\u8DEF\u7531\u3002`
5698
+ };
5699
+ }
5700
+ return { status: "passed", capture, warnings };
5701
+ }
5702
+ function handleMissingBaseUrl(config, language, warnings) {
5703
+ const message = language === "en" ? "No base URL for capture. Pass --base-url or set manifest.run.baseUrl." : "\u672A\u914D\u7F6E base URL\uFF0C\u65E0\u6CD5\u622A\u56FE\u3002\u8BF7\u4F7F\u7528 --base-url \u6216\u5728 manifest \u4E2D\u8BBE\u7F6E run.baseUrl\u3002";
5704
+ warnings.push(message);
5705
+ if (config.whenNoCapture === "block") {
5706
+ return { status: "failed", capture: null, warnings, message };
5707
+ }
5708
+ return { status: "skipped", capture: null, warnings, message };
5709
+ }
5710
+ function handleMissingPlaywright(config, language, warnings) {
5711
+ const message = language === "en" ? "Playwright not found in project. Install playwright or use --check-layout-only after manual capture." : "\u9879\u76EE\u672A\u5B89\u88C5 Playwright\u3002\u8BF7\u5B89\u88C5 playwright\uFF0C\u6216\u5728\u624B\u52A8\u622A\u56FE\u540E\u4F7F\u7528 --check-layout-only\u3002";
5712
+ warnings.push(message);
5713
+ if (config.whenNoCapture === "block") {
5714
+ return { status: "failed", capture: null, warnings, message };
5715
+ }
5716
+ return { status: "skipped", capture: null, warnings, message };
5717
+ }
5718
+
5719
+ // src/visual/check-layout.ts
5720
+ function runLayoutCheck(manifest, capture, language, options) {
5721
+ const regions = [];
5722
+ if (!capture && options?.allowManifestOnly) {
5723
+ return runManifestOnlyValidation(manifest, language);
5724
+ }
5725
+ if (capture?.pages.length) {
5726
+ for (const page of manifest.pages) {
5727
+ const captured = capture.pages.find((item) => item.pageId === page.id);
5728
+ for (const region of page.checkRegions) {
5729
+ const layout = captured?.regions.find((item) => item.selector === region.selector);
5730
+ const result = evaluateRegion(page.id, region.selector, region.checks, layout, language);
5731
+ regions.push(result);
5732
+ }
5733
+ }
5734
+ } else {
5735
+ for (const page of manifest.pages) {
5736
+ if (!page.checkRegions.length) {
5737
+ regions.push({
5738
+ pageId: page.id,
5739
+ selector: "(page)",
5740
+ status: "failed",
5741
+ message: language === "en" ? "No checkRegions defined" : "\u672A\u5B9A\u4E49 checkRegions"
5742
+ });
5743
+ continue;
5744
+ }
5745
+ for (const region of page.checkRegions) {
5746
+ regions.push({
5747
+ pageId: page.id,
5748
+ selector: region.selector,
5749
+ status: capture ? "failed" : "skipped",
5750
+ message: language === "en" ? "No capture data; run capture with --base-url or install Playwright" : "\u65E0\u622A\u56FE\u6570\u636E\uFF1B\u8BF7\u5E26 --base-url \u8FD0\u884C capture \u6216\u5B89\u88C5 Playwright"
5751
+ });
5752
+ }
5753
+ }
5754
+ }
5755
+ const failed = regions.filter((item) => item.status === "failed");
5756
+ if (failed.length) {
5757
+ return {
5758
+ status: "failed",
5759
+ regions,
5760
+ message: language === "en" ? `Layout check failed for ${failed.length} region(s).` : `${failed.length} \u4E2A\u533A\u57DF\u7684\u5E03\u5C40\u68C0\u67E5\u672A\u901A\u8FC7\u3002`
5761
+ };
5762
+ }
5763
+ const skipped = regions.filter((item) => item.status === "skipped");
5764
+ if (skipped.length && !capture) {
5765
+ return {
5766
+ status: "failed",
5767
+ regions,
5768
+ message: language === "en" ? "Layout check requires capture data when regions are defined." : "\u5DF2\u5B9A\u4E49\u68C0\u67E5\u533A\u57DF\u65F6\u9700\u8981 capture \u6570\u636E\u624D\u80FD\u5B8C\u6210\u5E03\u5C40\u9A8C\u6536\u3002"
5769
+ };
5770
+ }
5771
+ return { status: "passed", regions };
5772
+ }
5773
+ function runManifestOnlyValidation(manifest, language) {
5774
+ const regions = [];
5775
+ if (!manifest.pages.length) {
5776
+ return {
5777
+ status: "failed",
5778
+ regions,
5779
+ message: language === "en" ? "Visual manifest has no pages." : "\u89C6\u89C9\u6E05\u5355\u4E2D\u6CA1\u6709\u4EFB\u4F55\u9875\u9762\u3002"
5780
+ };
5781
+ }
5782
+ for (const page of manifest.pages) {
5783
+ for (const region of page.checkRegions) {
5784
+ regions.push({
5785
+ pageId: page.id,
5786
+ selector: region.selector,
5787
+ status: "skipped",
5788
+ message: language === "en" ? "Manifest-only mode (layout-only, no pixel/content compare)" : "\u4EC5 manifest \u6821\u9A8C\uFF08layout-only\uFF0C\u4E0D\u5BF9\u6BD4\u50CF\u7D20/\u52A8\u6001\u5185\u5BB9\uFF09"
5789
+ });
5790
+ }
5791
+ }
5792
+ return {
5793
+ status: "passed",
5794
+ regions,
5795
+ message: language === "en" ? "Manifest validated; capture skipped \u2014 layout-only checklist recorded." : "\u6E05\u5355\u6821\u9A8C\u901A\u8FC7\uFF1B\u5DF2\u8DF3\u8FC7\u622A\u56FE \u2014 \u5DF2\u8BB0\u5F55 layout-only \u68C0\u67E5\u9879\u3002"
5796
+ };
5797
+ }
5798
+ function evaluateRegion(pageId, selector, checks, layout, language) {
5799
+ if (!layout) {
5800
+ return {
5801
+ pageId,
5802
+ selector,
5803
+ status: "failed",
5804
+ message: language === "en" ? "Region not found in capture" : "capture \u4E2D\u672A\u627E\u5230\u8BE5\u533A\u57DF"
5805
+ };
5806
+ }
5807
+ for (const check of checks) {
5808
+ if (check === "exists" && !layout.exists) {
5809
+ return { pageId, selector, status: "failed", message: language === "en" ? "Missing element" : "\u5143\u7D20\u4E0D\u5B58\u5728" };
5810
+ }
5811
+ if (check === "visible" && !layout.visible) {
5812
+ return { pageId, selector, status: "failed", message: language === "en" ? "Not visible" : "\u4E0D\u53EF\u89C1" };
5813
+ }
5814
+ if (check === "has-box" && (layout.width <= 0 || layout.height <= 0)) {
5815
+ return { pageId, selector, status: "failed", message: language === "en" ? "Zero layout box" : "\u5E03\u5C40\u5C3A\u5BF8\u4E3A 0" };
5816
+ }
5817
+ }
5818
+ return { pageId, selector, status: "passed" };
5819
+ }
5820
+ function buildVisualResults(input) {
5821
+ return {
5822
+ schemaVersion: 1,
5823
+ changeId: input.changeId,
5824
+ ranAt: (/* @__PURE__ */ new Date()).toISOString(),
5825
+ compareMode: "layout-only",
5826
+ planningFingerprint: input.planningFingerprint,
5827
+ steps: input.steps,
5828
+ captureStatus: input.captureStatus,
5829
+ layoutStatus: input.layoutStatus,
5830
+ regions: input.regions
5831
+ };
5832
+ }
5833
+
5834
+ // src/visual/generate.ts
5835
+ var DEFAULT_VIEWPORT = { width: 1280, height: 720 };
5836
+ async function buildVisualManifestPages(projectRoot, changeId, figmaUrls) {
5837
+ if (figmaUrls.length) {
5838
+ return [
5839
+ {
5840
+ id: `${changeId}-main`,
5841
+ title: "Main UI screen",
5842
+ route: "/",
5843
+ viewport: DEFAULT_VIEWPORT,
5844
+ dataMode: "mock",
5845
+ mockFixture: null,
5846
+ ignoreSelectors: [".dynamic-list", "[data-dynamic]", "[data-testid='dynamic-content']"],
5847
+ checkRegions: [
5848
+ { selector: "header, [role='banner'], .app-header", checks: ["exists", "visible", "has-box"] },
5849
+ { selector: "main, [role='main'], .app-main", checks: ["exists", "visible", "has-box"] },
5850
+ { selector: "nav, [role='navigation'], .app-nav", checks: ["exists", "has-box"] }
5851
+ ]
5852
+ }
5853
+ ];
5854
+ }
5855
+ return [
5856
+ {
5857
+ id: `${changeId}-shell`,
5858
+ title: "Application shell",
5859
+ route: "/",
5860
+ viewport: DEFAULT_VIEWPORT,
5861
+ dataMode: "mock",
5862
+ mockFixture: null,
5863
+ ignoreSelectors: [".dynamic-list", "[data-dynamic]"],
5864
+ checkRegions: [{ selector: "body", checks: ["exists", "visible", "has-box"] }]
5865
+ }
5866
+ ];
5867
+ }
5868
+ async function generateVisualManifestInput(projectRoot, changeId, baseUrl) {
5869
+ const { urls, sources } = await collectFigmaUrlsFromChange(projectRoot, changeId);
5870
+ const planningFingerprint = await computePlanningFingerprint(projectRoot, changeId);
5871
+ const pages = await buildVisualManifestPages(projectRoot, changeId, urls);
5872
+ return {
5873
+ planningFingerprint,
5874
+ figmaUrls: urls,
5875
+ figmaSources: sources,
5876
+ pages
5877
+ };
5878
+ }
5879
+ function mergeBaseUrl(manifest, baseUrl) {
5880
+ if (!baseUrl) {
5881
+ return manifest;
5882
+ }
5883
+ return {
5884
+ ...manifest,
5885
+ run: { baseUrl }
5886
+ };
5887
+ }
5888
+
5889
+ // src/visual/gates.ts
5890
+ async function assertVisualPassed(ctx, changeId) {
5891
+ const config = await loadVisualConfig(ctx.projectRoot);
5892
+ const { urls } = await collectFigmaUrlsFromChange(ctx.projectRoot, changeId);
5893
+ if (!isVisualRequiredForVerify(config, urls.length > 0)) {
5894
+ return;
5895
+ }
5896
+ const change = await ctx.stateStore.readChange(changeId);
5897
+ const visualRun = change?.visualRun;
5898
+ if (visualRun?.status === "skipped") {
5899
+ return;
5900
+ }
5901
+ if (visualRun?.status === "passed" && await fingerprintMatches2(ctx, changeId, visualRun)) {
5902
+ return;
5903
+ }
5904
+ throw new FetError({
5905
+ code: "STATE_CORRUPTED" /* StateCorrupted */,
5906
+ message: msg(ctx.language, "\u672C change \u5C1A\u672A\u901A\u8FC7 fet visual\u3002", "This change has not passed fet visual yet."),
5907
+ details: { changeId, visualRun: visualRun ?? null, figmaUrlCount: urls.length },
5908
+ suggestedCommand: `fet visual --change ${changeId}`,
5909
+ recoverable: true
5910
+ });
5911
+ }
5912
+ async function fingerprintMatches2(ctx, changeId, visualRun) {
5913
+ const current = await computePlanningFingerprint(ctx.projectRoot, changeId);
5914
+ return visualRun.planningFingerprint === current;
5915
+ }
5916
+ function invalidateVisualRun(state) {
5917
+ state.visualRun = null;
5918
+ }
5919
+ function manifestPath2(changeId) {
5920
+ return visualManifestRelativePath(changeId);
5921
+ }
5922
+
5923
+ // src/commands/visual.ts
5924
+ async function visualCommand(ctx, options) {
5925
+ await withProjectLock(ctx.projectRoot, { command: "visual", cwd: ctx.cwd, fetVersion: ctx.fetVersion }, async () => {
5926
+ const changeId = await resolveChangeId(ctx);
5927
+ await assertChangeExists(ctx, changeId);
5928
+ const config = await loadVisualConfig(ctx.projectRoot);
5929
+ const steps = [];
5930
+ const warnings = [];
5931
+ const manifest = await ensureManifest(ctx, changeId, options.baseUrl ?? null);
5932
+ steps.push("manifest");
5933
+ if (options.plan) {
5934
+ ctx.output.result({
5935
+ ok: true,
5936
+ command: "visual",
5937
+ summary: msg(ctx.language, "\u5DF2\u751F\u6210 fet visual \u6267\u884C\u8BA1\u5212\u3002", "Generated fet visual execution plan."),
5938
+ data: {
5939
+ changeId,
5940
+ compareMode: manifest.compareMode,
5941
+ steps: options.checkLayoutOnly ? ["manifest", "check-layout"] : options.captureOnly ? ["manifest", "capture"] : ["manifest", "capture", "check-layout"],
5942
+ baseUrl: options.baseUrl ?? manifest.run.baseUrl,
5943
+ pages: manifest.pages.map((page) => ({ id: page.id, route: page.route }))
5944
+ },
5945
+ nextSteps: [`fet visual --change ${changeId}${options.baseUrl ? ` --base-url ${options.baseUrl}` : ""}`]
5946
+ });
5947
+ return;
5948
+ }
5949
+ let captureStatus = "skipped";
5950
+ let captureDoc = await readVisualCapture(ctx.projectRoot, changeId);
5951
+ if (!options.checkLayoutOnly) {
5952
+ const captureResult = await runVisualCapture({
5953
+ projectRoot: ctx.projectRoot,
5954
+ changeId,
5955
+ manifest,
5956
+ baseUrl: options.baseUrl ?? null,
5957
+ config,
5958
+ language: ctx.language
5959
+ });
5960
+ warnings.push(...captureResult.warnings);
5961
+ captureStatus = captureResult.status;
5962
+ captureDoc = captureResult.capture;
5963
+ steps.push("capture");
5964
+ if (captureResult.status === "failed") {
5965
+ await recordVisualFailure(ctx, changeId, manifest.planningFingerprint, steps, captureStatus, "failed", [], captureResult.message);
5966
+ throw new FetError({
5967
+ code: "OPENSPEC_COMMAND_FAILED" /* OpenSpecCommandFailed */,
5968
+ message: captureResult.message ?? msg(ctx.language, "fet visual \u622A\u56FE\u5931\u8D25\u3002", "fet visual capture failed."),
5969
+ details: { changeId },
5970
+ suggestedCommand: `fet visual --change ${changeId} --base-url http://localhost:3000`,
5971
+ recoverable: true
5972
+ });
5973
+ }
5974
+ } else {
5975
+ steps.push("capture");
5976
+ captureStatus = captureDoc ? "passed" : "skipped";
5977
+ }
5978
+ if (!options.captureOnly) {
5979
+ const layout = runLayoutCheck(manifest, captureDoc, ctx.language, {
5980
+ allowManifestOnly: captureStatus === "skipped" && config.whenNoCapture !== "block"
5981
+ });
5982
+ steps.push("check-layout");
5983
+ const results = buildVisualResults({
5984
+ changeId,
5985
+ planningFingerprint: manifest.planningFingerprint,
5986
+ steps,
5987
+ captureStatus,
5988
+ layoutStatus: layout.status,
5989
+ regions: layout.regions
5990
+ });
5991
+ const resultsPath = await writeVisualResults(ctx.projectRoot, results);
5992
+ if (layout.status === "failed") {
5993
+ await recordVisualFailure(ctx, changeId, manifest.planningFingerprint, steps, captureStatus, layout.status, layout.regions, layout.message);
5994
+ throw new FetError({
5995
+ code: "OPENSPEC_COMMAND_FAILED" /* OpenSpecCommandFailed */,
5996
+ message: layout.message ?? msg(ctx.language, "fet visual \u5E03\u5C40\u68C0\u67E5\u672A\u901A\u8FC7\u3002", "fet visual layout check failed."),
5997
+ details: { changeId, resultsPath: visualResultsRelativePath(changeId) },
5998
+ suggestedCommand: `fet visual --change ${changeId}`,
5999
+ recoverable: true
6000
+ });
6001
+ }
6002
+ await recordVisualSuccess(ctx, changeId, manifest.planningFingerprint, resultsPath, captureStatus);
6003
+ ctx.output.result({
6004
+ ok: true,
6005
+ command: "visual",
6006
+ summary: msg(
6007
+ ctx.language,
6008
+ `change "${changeId}" \u5DF2\u901A\u8FC7 layout-only \u89C6\u89C9\u9A8C\u6536\u3002`,
6009
+ `Layout-only visual verification passed for change "${changeId}".`
6010
+ ),
6011
+ warnings: warnings.length ? warnings : void 0,
6012
+ nextSteps: [`fet verify --change ${changeId}`],
6013
+ data: { changeId, results, capturePath: captureDoc ? visualCaptureRelativePath(changeId) : null }
6014
+ });
6015
+ return;
6016
+ }
6017
+ ctx.output.result({
6018
+ ok: true,
6019
+ command: "visual",
6020
+ summary: msg(ctx.language, "fet visual \u622A\u56FE\u6B65\u9AA4\u5DF2\u5B8C\u6210\u3002", "fet visual capture step completed."),
6021
+ warnings: warnings.length ? warnings : void 0,
6022
+ data: { changeId, captureStatus, capturePath: captureDoc ? visualCaptureRelativePath(changeId) : null }
6023
+ });
6024
+ });
6025
+ }
6026
+ async function ensureManifest(ctx, changeId, baseUrl) {
6027
+ const fingerprint2 = await computePlanningFingerprint(ctx.projectRoot, changeId);
6028
+ const existing = await readVisualManifest(ctx.projectRoot, changeId);
6029
+ if (existing && existing.planningFingerprint === fingerprint2 && (!baseUrl || existing.run.baseUrl === baseUrl)) {
6030
+ return existing;
6031
+ }
6032
+ const input = await generateVisualManifestInput(ctx.projectRoot, changeId, baseUrl);
6033
+ const manifest = mergeBaseUrl(
6034
+ createVisualManifest({
6035
+ changeId,
6036
+ planningFingerprint: input.planningFingerprint,
6037
+ figmaUrls: input.figmaUrls,
6038
+ figmaSources: input.figmaSources,
6039
+ baseUrl,
6040
+ pages: input.pages
6041
+ }),
6042
+ baseUrl
6043
+ );
6044
+ await writeVisualManifest(ctx.projectRoot, manifest);
6045
+ const fetDir = join28(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
6046
+ await mkdir10(fetDir, { recursive: true });
6047
+ await atomicWrite(join28(ctx.projectRoot, visualInstructionsRelativePath(changeId)), renderVisualInstructions(changeId, manifest, ctx.language));
6048
+ await atomicWrite(join28(ctx.projectRoot, visualSpecRelativePath(changeId)), renderVisualSpec(changeId, manifest, ctx.language));
6049
+ const changeState = await ctx.stateStore.getOrCreateChange(changeId, "verify");
6050
+ changeState.visual = {
6051
+ status: "ready",
6052
+ generatedAt: manifest.generatedAt,
6053
+ planningFingerprint: fingerprint2,
6054
+ manifestPath: manifestPath2(changeId)
6055
+ };
6056
+ invalidateVisualRun(changeState);
6057
+ await ctx.stateStore.writeChange(changeState);
6058
+ return manifest;
6059
+ }
6060
+ async function recordVisualSuccess(ctx, changeId, planningFingerprint, resultsPath, captureStatus) {
6061
+ const changeState = await ctx.stateStore.getOrCreateChange(changeId, "verify");
6062
+ changeState.visualRun = {
6063
+ status: "passed",
6064
+ ranAt: (/* @__PURE__ */ new Date()).toISOString(),
6065
+ compareMode: "layout-only",
6066
+ planningFingerprint,
6067
+ manifestPath: manifestPath2(changeId),
6068
+ resultsPath,
6069
+ captureStatus
6070
+ };
6071
+ await ctx.stateStore.writeChange(changeState);
6072
+ }
6073
+ async function recordVisualFailure(ctx, changeId, planningFingerprint, steps, captureStatus, layoutStatus, regions, message) {
6074
+ const results = buildVisualResults({
6075
+ changeId,
6076
+ planningFingerprint,
6077
+ steps,
6078
+ captureStatus,
6079
+ layoutStatus,
6080
+ regions
6081
+ });
6082
+ await writeVisualResults(ctx.projectRoot, results);
6083
+ const changeState = await ctx.stateStore.getOrCreateChange(changeId, "verify");
6084
+ changeState.visualRun = {
6085
+ status: "failed",
6086
+ ranAt: (/* @__PURE__ */ new Date()).toISOString(),
6087
+ compareMode: "layout-only",
6088
+ planningFingerprint,
6089
+ manifestPath: manifestPath2(changeId),
6090
+ resultsPath: visualResultsRelativePath(changeId),
6091
+ captureStatus
6092
+ };
6093
+ await ctx.stateStore.writeChange(changeState);
6094
+ if (message) {
6095
+ void message;
6096
+ }
6097
+ }
6098
+
5294
6099
  // src/commands/verify.ts
5295
6100
  import { createHash as createHash2 } from "crypto";
5296
- import { mkdir as mkdir9, readFile as readFile19, stat as stat11 } from "fs/promises";
5297
- import { join as join25 } from "path";
6101
+ import { mkdir as mkdir11, readFile as readFile21, stat as stat12 } from "fs/promises";
6102
+ import { join as join29 } from "path";
5298
6103
  async function verifyCommand(ctx, options) {
5299
6104
  if (options.auto) {
5300
6105
  const scan = await ctx.scanner.scan(ctx.projectRoot, {});
@@ -5361,10 +6166,11 @@ async function verifyCommand(ctx, options) {
5361
6166
  async function writeInstructions(ctx, changeId) {
5362
6167
  await assertChangeExists(ctx, changeId);
5363
6168
  await assertTestPassed(ctx, changeId);
6169
+ await assertVisualPassed(ctx, changeId);
5364
6170
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
5365
- const dir = join25(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
5366
- const instructionsPath = join25(dir, "verify-instructions.md");
5367
- await mkdir9(dir, { recursive: true });
6171
+ const dir = join29(ctx.projectRoot, "openspec", "changes", changeId, ".fet");
6172
+ const instructionsPath = join29(dir, "verify-instructions.md");
6173
+ await mkdir11(dir, { recursive: true });
5368
6174
  await atomicWrite(instructionsPath, renderVerifyInstructions(changeId, generatedAt));
5369
6175
  const state = await ctx.stateStore.getOrCreateChange(changeId, "verify");
5370
6176
  state.currentPhase = "verify";
@@ -5380,8 +6186,9 @@ async function writeInstructions(ctx, changeId) {
5380
6186
  async function markDone(ctx, changeId) {
5381
6187
  await assertChangeExists(ctx, changeId);
5382
6188
  await assertTestPassed(ctx, changeId);
6189
+ await assertVisualPassed(ctx, changeId);
5383
6190
  const declaredAt = (/* @__PURE__ */ new Date()).toISOString();
5384
- const instructionsPath = join25(ctx.projectRoot, "openspec", "changes", changeId, ".fet", "verify-instructions.md");
6191
+ const instructionsPath = join29(ctx.projectRoot, "openspec", "changes", changeId, ".fet", "verify-instructions.md");
5385
6192
  const instructions = await readInstructions(ctx, instructionsPath, changeId);
5386
6193
  const instructionsGeneratedAt = readFrontMatterValue(instructions, "generatedAt") ?? declaredAt;
5387
6194
  const state = await ctx.stateStore.getOrCreateChange(changeId, "verify");
@@ -5405,8 +6212,8 @@ async function markDone(ctx, changeId) {
5405
6212
  }
5406
6213
  async function readInstructions(ctx, path, changeId) {
5407
6214
  try {
5408
- await stat11(path);
5409
- const content = await readFile19(path, "utf8");
6215
+ await stat12(path);
6216
+ const content = await readFile21(path, "utf8");
5410
6217
  const fileChangeId = readFrontMatterValue(content, "changeId");
5411
6218
  if (fileChangeId !== changeId) {
5412
6219
  throw new FetError({
@@ -5529,12 +6336,12 @@ function renderIdeModelPolicy(command, language = "zh-CN") {
5529
6336
  import { resolve } from "path";
5530
6337
 
5531
6338
  // src/adapters/codex/index.ts
5532
- import { mkdir as mkdir10, readFile as readFile20, stat as stat12 } from "fs/promises";
6339
+ import { mkdir as mkdir12, readFile as readFile22, stat as stat13 } from "fs/promises";
5533
6340
  import { homedir } from "os";
5534
- import { dirname as dirname9, join as join26 } from "path";
6341
+ import { dirname as dirname11, join as join30 } from "path";
5535
6342
 
5536
6343
  // src/adapters/commands.ts
5537
- var FET_STANDALONE_COMMANDS = ["tdd", "test"];
6344
+ var FET_STANDALONE_COMMANDS = ["tdd", "test", "visual"];
5538
6345
  var FET_WORKFLOW_COMMANDS = [
5539
6346
  "explore",
5540
6347
  "propose",
@@ -6617,7 +7424,7 @@ var CodexAdapter = class {
6617
7424
  adapterVersion = 1;
6618
7425
  async detect(projectRoot) {
6619
7426
  return {
6620
- detected: await exists6(join26(projectRoot, ".codex")) || await exists6(join26(projectRoot, "AGENTS.md")),
7427
+ detected: await exists6(join30(projectRoot, ".codex")) || await exists6(join30(projectRoot, "AGENTS.md")),
6621
7428
  reason: "Codex adapter is available for projects that use AGENTS.md"
6622
7429
  };
6623
7430
  }
@@ -6656,7 +7463,7 @@ var CodexAdapter = class {
6656
7463
  if (existing && !existing.includes("FET:MANAGED") && force) {
6657
7464
  await createBackup(target);
6658
7465
  }
6659
- await mkdir10(dirname9(target), { recursive: true });
7466
+ await mkdir12(dirname11(target), { recursive: true });
6660
7467
  await atomicWrite(target, file.content);
6661
7468
  written.push(displayPath);
6662
7469
  }
@@ -6683,9 +7490,9 @@ var CodexAdapter = class {
6683
7490
  };
6684
7491
  function resolveTarget(projectRoot, file) {
6685
7492
  if (file.root === "codex-home") {
6686
- return join26(resolveCodexHome(), file.path);
7493
+ return join30(resolveCodexHome(), file.path);
6687
7494
  }
6688
- return join26(projectRoot, file.path);
7495
+ return join30(projectRoot, file.path);
6689
7496
  }
6690
7497
  function displayPathFor(file) {
6691
7498
  if (file.root === "codex-home") {
@@ -6694,18 +7501,18 @@ function displayPathFor(file) {
6694
7501
  return file.path;
6695
7502
  }
6696
7503
  function resolveCodexHome() {
6697
- return process.env.FET_CODEX_HOME ?? process.env.CODEX_HOME ?? join26(homedir(), ".codex");
7504
+ return process.env.FET_CODEX_HOME ?? process.env.CODEX_HOME ?? join30(homedir(), ".codex");
6698
7505
  }
6699
7506
  async function readExisting(path) {
6700
7507
  try {
6701
- return await readFile20(path, "utf8");
7508
+ return await readFile22(path, "utf8");
6702
7509
  } catch {
6703
7510
  return null;
6704
7511
  }
6705
7512
  }
6706
7513
  async function exists6(path) {
6707
7514
  try {
6708
- await stat12(path);
7515
+ await stat13(path);
6709
7516
  return true;
6710
7517
  } catch {
6711
7518
  return false;
@@ -6713,8 +7520,8 @@ async function exists6(path) {
6713
7520
  }
6714
7521
 
6715
7522
  // src/adapters/cursor/index.ts
6716
- import { mkdir as mkdir11, readFile as readFile21, stat as stat13 } from "fs/promises";
6717
- import { dirname as dirname10, join as join27 } from "path";
7523
+ import { mkdir as mkdir13, readFile as readFile23, stat as stat14 } from "fs/promises";
7524
+ import { dirname as dirname12, join as join31 } from "path";
6718
7525
 
6719
7526
  // src/adapters/cursor/templates.ts
6720
7527
  function cursorFigmaStopRuleFile(language = DEFAULT_LANGUAGE) {
@@ -6849,6 +7656,9 @@ After installation, verify with \`gitnexus --version\`, then run \`fet graph ini
6849
7656
  if (command === "tdd" || command === "test") {
6850
7657
  return renderTddTestSkill(command, usage, language);
6851
7658
  }
7659
+ if (command === "visual") {
7660
+ return renderVisualSkill(usage, language);
7661
+ }
6852
7662
  if (command === "propose" || command === "continue" || command === "ff") {
6853
7663
  return renderPlanningSkill(command, usage, language);
6854
7664
  }
@@ -6970,6 +7780,35 @@ ${uiContractBlock}
6970
7780
  \u5B9E\u65BD\u524D\u987B\u5DF2\u8FD0\u884C \`fet tdd\`\uFF1B\u5B58\u5728 \`openspec/changes/<change-id>/.fet/tdd-manifest.yaml\` \u65F6\u6309\u6E05\u5355\u7F16\u5199\u6D4B\u8BD5\u4E0E\u5B9E\u73B0\uFF0C\u5E76\u5728 \`fet verify\` \u524D\u5148 \`fet test\`\u3002
6971
7781
  `;
6972
7782
  }
7783
+ function renderVisualSkill(usage, language) {
7784
+ const body = language === "en" ? `Default \`fet visual\` runs manifest refresh, Playwright capture (needs \`--base-url\`), and **layout-only** checks (no pixel match on dynamic API content). Use \`--plan\`, \`--capture-only\`, or \`--check-layout-only\` only when debugging.` : `\u9ED8\u8BA4 \`fet visual\` \u4F1A\u66F4\u65B0\u6E05\u5355\u3001Playwright \u622A\u56FE\uFF08\u9700 \`--base-url\`\uFF09\u5E76\u505A **layout-only** \u68C0\u67E5\uFF08\u4E0D\u5BF9\u52A8\u6001\u63A5\u53E3\u5185\u5BB9\u505A\u50CF\u7D20\u5BF9\u6BD4\uFF09\u3002\u4EC5\u5728\u8C03\u8BD5\u65F6\u4F7F\u7528 \`--plan\`\u3001\`--capture-only\`\u3001\`--check-layout-only\`\u3002`;
7785
+ return `<!-- FET:MANAGED
7786
+ schemaVersion: 1
7787
+ fetVersion: ${FET_VERSION}
7788
+ generator: cursor-adapter
7789
+ adapterVersion: 1
7790
+ command: ${usage}
7791
+ FET:END -->
7792
+
7793
+ ---
7794
+ name: fet-visual
7795
+ description: ${language === "en" ? "Layout-only visual verification for a change" : "change \u7EA7 layout-only \u89C6\u89C9\u9A8C\u6536"}
7796
+ disable-model-invocation: true
7797
+ ---
7798
+
7799
+ ${renderIdeModelPolicy("visual", language)}
7800
+
7801
+ ${languageInstruction(language)}
7802
+
7803
+ \u8BF7\u5728\u7EC8\u7AEF\u4E2D\u6267\u884C\uFF1A
7804
+
7805
+ \`\`\`sh
7806
+ ${usage}
7807
+ \`\`\`
7808
+
7809
+ ${body}
7810
+ `;
7811
+ }
6973
7812
  function renderTddTestSkill(command, usage, language) {
6974
7813
  const description = command === "tdd" ? language === "en" ? "Generate per-change TDD manifest and test instructions" : "\u751F\u6210\u672C change \u7684 TDD \u6E05\u5355\u4E0E\u6D4B\u8BD5\u6307\u5F15" : language === "en" ? "Run unit tests scoped to the change TDD manifest" : "\u6309 change TDD \u6E05\u5355\u8FD0\u884C\u5355\u6D4B";
6975
7814
  const body = command === "tdd" ? language === "en" ? `After planning artifacts exist, run this before \`fet apply\`. It writes \`openspec/changes/<change-id>/.fet/tdd-manifest.yaml\`, \`tdd-spec.md\`, and \`tdd-instructions.md\`. Then create failing tests in the repo before implementation.` : `\u89C4\u5212\u4EA7\u7269\u5C31\u7EEA\u540E\u3001\`fet apply\` \u4E4B\u524D\u8FD0\u884C\u3002\u4F1A\u5199\u5165 \`openspec/changes/<change-id>/.fet/tdd-manifest.yaml\`\u3001\`tdd-spec.md\`\u3001\`tdd-instructions.md\`\uFF0C\u518D\u5728\u4ED3\u5E93\u4E2D\u7F16\u5199\u9884\u671F\u5931\u8D25\u7684\u6D4B\u8BD5\u3002` : language === "en" ? `Run after implementation. Requires a current \`tdd-manifest.yaml\`. Records pass/fail in FET state; \`fet verify\` is blocked until this passes (unless configured to skip).` : `\u5B9E\u73B0\u5B8C\u6210\u540E\u8FD0\u884C\u3002\u9700\u8981\u6709\u6548\u7684 \`tdd-manifest.yaml\`\u3002\u7ED3\u679C\u5199\u5165 FET \u72B6\u6001\uFF1B\u672A\u901A\u8FC7\u524D \`fet verify\` \u4F1A\u88AB\u62E6\u622A\uFF08\u9664\u975E\u914D\u7F6E\u4E3A skip\uFF09\u3002`;
@@ -7007,7 +7846,7 @@ var CursorAdapter = class {
7007
7846
  adapterVersion = 1;
7008
7847
  async detect(projectRoot) {
7009
7848
  return {
7010
- detected: await exists7(join27(projectRoot, ".cursor")),
7849
+ detected: await exists7(join31(projectRoot, ".cursor")),
7011
7850
  reason: "Cursor adapter is available for any project"
7012
7851
  };
7013
7852
  }
@@ -7024,7 +7863,7 @@ var CursorAdapter = class {
7024
7863
  const written = [];
7025
7864
  const skipped = [];
7026
7865
  for (const file of plan.files) {
7027
- const target = join27(projectRoot, file.path);
7866
+ const target = join31(projectRoot, file.path);
7028
7867
  const existing = await readExisting2(target);
7029
7868
  if (existing && !existing.includes("FET:MANAGED") && !force) {
7030
7869
  throw new FetError({
@@ -7037,7 +7876,7 @@ var CursorAdapter = class {
7037
7876
  if (existing && !existing.includes("FET:MANAGED") && force) {
7038
7877
  await createBackup(target);
7039
7878
  }
7040
- await mkdir11(dirname10(target), { recursive: true });
7879
+ await mkdir13(dirname12(target), { recursive: true });
7041
7880
  await atomicWrite(target, file.content);
7042
7881
  written.push(file.path);
7043
7882
  }
@@ -7047,7 +7886,7 @@ var CursorAdapter = class {
7047
7886
  const plan = await this.planInstall(projectRoot);
7048
7887
  const checks = [];
7049
7888
  for (const file of plan.files) {
7050
- const target = join27(projectRoot, file.path);
7889
+ const target = join31(projectRoot, file.path);
7051
7890
  const content = await readExisting2(target);
7052
7891
  const managed = Boolean(content?.includes("FET:MANAGED"));
7053
7892
  const versionMatches = Boolean(content?.includes(`adapterVersion: ${this.adapterVersion}`));
@@ -7063,14 +7902,14 @@ var CursorAdapter = class {
7063
7902
  };
7064
7903
  async function readExisting2(path) {
7065
7904
  try {
7066
- return await readFile21(path, "utf8");
7905
+ return await readFile23(path, "utf8");
7067
7906
  } catch {
7068
7907
  return null;
7069
7908
  }
7070
7909
  }
7071
7910
  async function exists7(path) {
7072
7911
  try {
7073
- await stat13(path);
7912
+ await stat14(path);
7074
7913
  return true;
7075
7914
  } catch {
7076
7915
  return false;
@@ -7082,13 +7921,13 @@ import { execFile as execFile4 } from "child_process";
7082
7921
  import { promisify as promisify4 } from "util";
7083
7922
 
7084
7923
  // src/openspec/inspector.ts
7085
- import { readdir as readdir6, stat as stat14 } from "fs/promises";
7086
- import { join as join28 } from "path";
7924
+ import { readdir as readdir6, stat as stat15 } from "fs/promises";
7925
+ import { join as join32 } from "path";
7087
7926
  async function inspectOpenSpecProject(projectRoot) {
7088
- const openspecPath = join28(projectRoot, "openspec");
7089
- const changesPath = join28(openspecPath, "changes");
7090
- const legacyArchivePath = join28(openspecPath, "archive");
7091
- const changesArchivePath = join28(changesPath, "archive");
7927
+ const openspecPath = join32(projectRoot, "openspec");
7928
+ const changesPath = join32(openspecPath, "changes");
7929
+ const legacyArchivePath = join32(openspecPath, "archive");
7930
+ const changesArchivePath = join32(changesPath, "archive");
7092
7931
  return {
7093
7932
  exists: await exists8(openspecPath),
7094
7933
  changes: await listDirectories(changesPath, { exclude: ["archive"] }),
@@ -7096,13 +7935,13 @@ async function inspectOpenSpecProject(projectRoot) {
7096
7935
  };
7097
7936
  }
7098
7937
  async function inspectOpenSpecChange(projectRoot, changeId) {
7099
- const changePath = join28(projectRoot, "openspec", "changes", changeId);
7100
- const tasksPath = join28(changePath, "tasks.md");
7101
- const specsPath = join28(changePath, "specs");
7938
+ const changePath = join32(projectRoot, "openspec", "changes", changeId);
7939
+ const tasksPath = join32(changePath, "tasks.md");
7940
+ const specsPath = join32(changePath, "specs");
7102
7941
  return {
7103
7942
  changeId,
7104
7943
  exists: await exists8(changePath),
7105
- hasProposal: await exists8(join28(changePath, "proposal.md")),
7944
+ hasProposal: await exists8(join32(changePath, "proposal.md")),
7106
7945
  hasTasks: await exists8(tasksPath),
7107
7946
  hasSpecs: await exists8(specsPath),
7108
7947
  tasksPath,
@@ -7120,7 +7959,7 @@ async function listDirectories(path, options = {}) {
7120
7959
  }
7121
7960
  async function exists8(path) {
7122
7961
  try {
7123
- await stat14(path);
7962
+ await stat15(path);
7124
7963
  return true;
7125
7964
  } catch {
7126
7965
  return false;
@@ -7303,13 +8142,13 @@ function escapeRegExp(value) {
7303
8142
  }
7304
8143
 
7305
8144
  // src/scanner/routes.ts
7306
- import { readdir as readdir7, stat as stat15 } from "fs/promises";
7307
- import { join as join29, relative as relative5, sep } from "path";
8145
+ import { readdir as readdir7, stat as stat16 } from "fs/promises";
8146
+ import { join as join33, relative as relative5, sep } from "path";
7308
8147
  async function scanRoutes(projectRoot) {
7309
8148
  const candidates = ["src/routes", "src/pages", "app", "pages"];
7310
8149
  const routes = [];
7311
8150
  for (const candidate of candidates) {
7312
- const root = join29(projectRoot, candidate);
8151
+ const root = join33(projectRoot, candidate);
7313
8152
  if (!await exists9(root)) {
7314
8153
  continue;
7315
8154
  }
@@ -7337,7 +8176,7 @@ async function listFiles(root) {
7337
8176
  const entries = await readdir7(root, { withFileTypes: true });
7338
8177
  const files = [];
7339
8178
  for (const entry of entries) {
7340
- const path = join29(root, entry.name);
8179
+ const path = join33(root, entry.name);
7341
8180
  if (entry.isDirectory()) {
7342
8181
  files.push(...await listFiles(path));
7343
8182
  } else {
@@ -7348,7 +8187,7 @@ async function listFiles(root) {
7348
8187
  }
7349
8188
  async function exists9(path) {
7350
8189
  try {
7351
- await stat15(path);
8190
+ await stat16(path);
7352
8191
  return true;
7353
8192
  } catch {
7354
8193
  return false;
@@ -7505,9 +8344,9 @@ async function createCommandContext(command, options) {
7505
8344
  import { createInterface as createInterface2 } from "readline/promises";
7506
8345
 
7507
8346
  // src/update/check.ts
7508
- import { mkdir as mkdir12, readFile as readFile22, writeFile } from "fs/promises";
8347
+ import { mkdir as mkdir14, readFile as readFile24, writeFile } from "fs/promises";
7509
8348
  import { homedir as homedir2 } from "os";
7510
- import { dirname as dirname11, join as join30 } from "path";
8349
+ import { dirname as dirname13, join as join34 } from "path";
7511
8350
  var DEFAULT_CACHE_TTL_MS = 6 * 60 * 60 * 1e3;
7512
8351
  function getFetUpdateCheckMode(env = process.env) {
7513
8352
  const value = env.FET_UPDATE_CHECK?.trim().toLowerCase();
@@ -7580,11 +8419,11 @@ function formatFetUpdateWarning(availability, language) {
7580
8419
  }
7581
8420
  function cachePath() {
7582
8421
  const home = process.env.FET_UPDATE_CHECK_CACHE_HOME?.trim() || homedir2();
7583
- return join30(home, ".fet", "update-check-cache.json");
8422
+ return join34(home, ".fet", "update-check-cache.json");
7584
8423
  }
7585
8424
  async function readUpdateCheckCache() {
7586
8425
  try {
7587
- const raw = await readFile22(cachePath(), "utf8");
8426
+ const raw = await readFile24(cachePath(), "utf8");
7588
8427
  const parsed = JSON.parse(raw);
7589
8428
  if (typeof parsed.latestVersion !== "string" || typeof parsed.checkedAt !== "string") {
7590
8429
  return null;
@@ -7600,7 +8439,7 @@ async function readUpdateCheckCache() {
7600
8439
  }
7601
8440
  async function writeUpdateCheckCache(cache) {
7602
8441
  const path = cachePath();
7603
- await mkdir12(dirname11(path), { recursive: true });
8442
+ await mkdir14(dirname13(path), { recursive: true });
7604
8443
  await writeFile(path, `${JSON.stringify(cache, null, 2)}
7605
8444
  `, "utf8");
7606
8445
  }
@@ -7691,6 +8530,19 @@ addGlobalOptions(program.command("tdd").description("\u6839\u636E\u89C4\u5212\u4
7691
8530
  addGlobalOptions(program.command("test").description("\u6309 change TDD \u6E05\u5355\u8FD0\u884C\u5355\u6D4B\u5E76\u8BB0\u5F55\u7EFF\u706F\u72B6\u6001").option("--plan", "\u4EC5\u8F93\u51FA\u5C06\u6267\u884C\u7684\u6D4B\u8BD5\u547D\u4EE4\uFF0C\u4E0D\u8FD0\u884C")).action(
7692
8531
  wrap("test", (ctx, options) => testCommand(ctx, { plan: Boolean(options.plan) }))
7693
8532
  );
8533
+ addGlobalOptions(
8534
+ program.command("visual").description("layout-only \u89C6\u89C9\u9A8C\u6536\uFF08\u9ED8\u8BA4\uFF1A\u66F4\u65B0\u6E05\u5355 + \u622A\u56FE + \u5E03\u5C40\u68C0\u67E5\uFF09").option("--plan", "\u4EC5\u8F93\u51FA\u6267\u884C\u8BA1\u5212").option("--capture-only", "\u4EC5\u6267\u884C\u622A\u56FE").option("--check-layout-only", "\u4EC5\u6267\u884C\u5E03\u5C40\u68C0\u67E5\uFF08\u4F9D\u8D56\u5DF2\u6709 capture\uFF09").option("--base-url <url>", "\u5E94\u7528 base URL\uFF0C\u7528\u4E8E Playwright \u622A\u56FE")
8535
+ ).action(
8536
+ wrap(
8537
+ "visual",
8538
+ (ctx, options) => visualCommand(ctx, {
8539
+ plan: Boolean(options.plan),
8540
+ captureOnly: Boolean(options.captureOnly),
8541
+ checkLayoutOnly: Boolean(options.checkLayoutOnly),
8542
+ baseUrl: options.baseUrl
8543
+ })
8544
+ )
8545
+ );
7694
8546
  addGlobalOptions(program.command("verify").description("\u6700\u7EC8\u8D28\u91CF\u9A8C\u8BC1").option("--done", "\u58F0\u660E\u624B\u52A8\u9A8C\u8BC1\u5DF2\u5B8C\u6210").option("--auto", "\u751F\u6210\u6216\u6267\u884C\u81EA\u52A8\u9A8C\u8BC1\u8BA1\u5212")).action(
7695
8547
  wrap("verify", verifyCommand)
7696
8548
  );