afterbefore 0.1.5 → 0.1.7

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/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # afterbefore
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/afterbefore?color=blue)](https://www.npmjs.com/package/afterbefore)
4
- [![npm downloads](https://img.shields.io/npm/dm/afterbefore?color=green)](https://www.npmjs.com/package/afterbefore)
4
+ [![npm downloads](https://img.shields.io/npm/dm/afterbefore)](https://www.npmjs.com/package/afterbefore)
5
5
 
6
6
  Automatic before/after screenshot capture for Next.js pull requests.
7
7
 
@@ -17,15 +17,13 @@ Run it from a feature branch in any Next.js app router project:
17
17
  npx afterbefore
18
18
  ```
19
19
 
20
- It spins up two dev servers (your branch and the base branch), captures screenshots of affected routes, diffs them pixel by pixel, and generates an HTML report with interactive before/after sliders.
20
+ It spins up two dev servers (your branch and the base branch), captures screenshots of affected routes, and diffs them pixel by pixel.
21
21
 
22
22
  Output lands in `.afterbefore/`:
23
23
 
24
24
  ```
25
25
  .afterbefore/
26
26
  └── feature-branch_2026-02-26/
27
- ├── index.html # visual report
28
- ├── summary.md # markdown table
29
27
  ├── about-before.png
30
28
  ├── about-after.png
31
29
  ├── about-diff.png
@@ -33,12 +31,6 @@ Output lands in `.afterbefore/`:
33
31
  └── about-slider.html # interactive slider
34
32
  ```
35
33
 
36
- Open the report automatically:
37
-
38
- ```bash
39
- npx afterbefore --open
40
- ```
41
-
42
34
  ## How it works
43
35
 
44
36
  1. Reads `git diff` to find changed files
@@ -49,7 +41,6 @@ npx afterbefore --open
49
41
  6. Starts two Next.js dev servers in parallel (base branch + current branch)
50
42
  7. Captures full-page screenshots of each affected route on both servers using Playwright
51
43
  8. Compares screenshots with pixelmatch and generates diff images
52
- 9. Builds an HTML report with side-by-side comparisons and interactive sliders
53
44
 
54
45
  Layouts work the same way. Edit `app/dashboard/layout.tsx` and it captures every page under `app/dashboard/`.
55
46
 
@@ -64,7 +55,6 @@ If only global files changed (like `globals.css` or `tailwind.config.ts`) and no
64
55
  | `--post` | `false` | Post results as a GitHub PR comment |
65
56
  | `--threshold <percent>` | `0.1` | Ignore diffs below this percentage |
66
57
  | `--max-routes <count>` | `6` | Cap the number of routes captured (0 = unlimited) |
67
- | `--open` | `false` | Open the HTML report in your browser |
68
58
  | `--width <pixels>` | `1280` | Viewport width |
69
59
  | `--height <pixels>` | `720` | Viewport height |
70
60
  | `--device <name>` | — | Playwright device, e.g. `"iPhone 14"` |
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@
4
4
  import { Command } from "commander";
5
5
 
6
6
  // src/logger.ts
7
+ import { writeFileSync } from "fs";
7
8
  import chalk from "chalk";
8
9
  import ora from "ora";
9
10
  var BAR_WIDTH = 20;
@@ -14,7 +15,13 @@ var Logger = class {
14
15
  lastStep = 0;
15
16
  lastLabel = "";
16
17
  pipelineActive = false;
18
+ logBuffer = [];
19
+ log(level, message) {
20
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
21
+ this.logBuffer.push(`${ts} [${level}] ${message}`);
22
+ }
17
23
  info(message) {
24
+ this.log("info", message);
18
25
  if (this.pipelineActive) return;
19
26
  if (this.spinner) {
20
27
  this.spinner.clear();
@@ -25,6 +32,7 @@ var Logger = class {
25
32
  }
26
33
  }
27
34
  success(message) {
35
+ this.log("ok", message);
28
36
  if (this.pipelineActive) return;
29
37
  if (this.spinner) {
30
38
  this.spinner.clear();
@@ -35,6 +43,7 @@ var Logger = class {
35
43
  }
36
44
  }
37
45
  warn(message) {
46
+ this.log("warn", message);
38
47
  if (this.spinner) {
39
48
  this.spinner.clear();
40
49
  console.log(chalk.yellow("\u26A0"), message);
@@ -44,6 +53,7 @@ var Logger = class {
44
53
  }
45
54
  }
46
55
  error(message) {
56
+ this.log("error", message);
47
57
  if (this.spinner) {
48
58
  this.spinner.clear();
49
59
  console.error(chalk.red("\u2716"), message);
@@ -53,6 +63,7 @@ var Logger = class {
53
63
  }
54
64
  }
55
65
  dim(message) {
66
+ this.log("debug", message);
56
67
  if (this.pipelineActive) return;
57
68
  if (this.spinner) {
58
69
  this.spinner.clear();
@@ -63,6 +74,7 @@ var Logger = class {
63
74
  }
64
75
  }
65
76
  spin(message) {
77
+ this.log("info", message);
66
78
  this.clearSpinner();
67
79
  this.spinner = ora(message).start();
68
80
  return this.spinner;
@@ -76,6 +88,7 @@ var Logger = class {
76
88
  this.lastLabel = "";
77
89
  this.pipelineActive = true;
78
90
  this.clearSpinner();
91
+ this.log("info", `Pipeline started (${total} steps)`);
79
92
  const text = this.renderPipeline(0, "Starting...");
80
93
  this.spinner = ora({
81
94
  text: chalk.dim(text),
@@ -86,6 +99,7 @@ var Logger = class {
86
99
  if (step === this.lastStep && label === this.lastLabel) return;
87
100
  this.lastStep = step;
88
101
  this.lastLabel = label;
102
+ this.log("step", `${step}/${this.pipelineTotal} ${label}`);
89
103
  const text = chalk.dim(this.renderPipeline(step, label));
90
104
  if (!this.spinner) {
91
105
  this.spinner = ora({
@@ -98,6 +112,7 @@ var Logger = class {
98
112
  }
99
113
  completePipeline(finished = false) {
100
114
  this.pipelineActive = false;
115
+ this.log("info", `Pipeline ${finished ? "completed" : "stopped"}`);
101
116
  if (this.spinner) {
102
117
  if (finished) {
103
118
  this.spinner.stop();
@@ -115,6 +130,10 @@ var Logger = class {
115
130
  this.lastStep = 0;
116
131
  this.lastLabel = "";
117
132
  }
133
+ writeLogFile(filePath) {
134
+ if (this.logBuffer.length === 0) return;
135
+ writeFileSync(filePath, this.logBuffer.join("\n") + "\n", "utf-8");
136
+ }
118
137
  renderPipeline(step, label) {
119
138
  const total = this.pipelineTotal || 1;
120
139
  const clampedStep = Math.max(0, Math.min(step, total));
@@ -222,7 +241,6 @@ function getCurrentBranch(cwd) {
222
241
  // src/pipeline.ts
223
242
  import { resolve as resolve4 } from "path";
224
243
  import { unlinkSync } from "fs";
225
- import { exec } from "child_process";
226
244
 
227
245
  // src/config.ts
228
246
  import { resolve } from "path";
@@ -881,8 +899,8 @@ function waitForServer(url, timeoutMs) {
881
899
  async function startServer(projectDir, port) {
882
900
  const url = `http://localhost:${port}`;
883
901
  const pm = detectPackageManager(projectDir);
884
- const exec2 = pmExec(pm);
885
- const [cmd, ...baseArgs] = exec2.split(" ");
902
+ const exec = pmExec(pm);
903
+ const [cmd, ...baseArgs] = exec.split(" ");
886
904
  const lockFile = join5(projectDir, ".next", "dev", "lock");
887
905
  if (existsSync5(lockFile)) {
888
906
  throw new AfterbeforeError(
@@ -1182,6 +1200,49 @@ async function tagChangedComponentInstances(page, changedComponents, maxPerSourc
1182
1200
  });
1183
1201
  bySource.set(match.source, list);
1184
1202
  }
1203
+ for (const target of targets) {
1204
+ if (bySource.has(target.original) && bySource.get(target.original).length > 0) continue;
1205
+ const fileName = target.original.split("/").pop() ?? "";
1206
+ const componentName = fileName.replace(/\.[a-z0-9]+$/i, "");
1207
+ if (!componentName || componentName.length < 2) continue;
1208
+ const pattern = new RegExp("\\b" + componentName + "\\b", "i");
1209
+ const headings = Array.from(document.querySelectorAll("h1, h2, h3, h4"));
1210
+ for (const heading of headings) {
1211
+ const text = (heading.textContent ?? "").trim();
1212
+ if (!pattern.test(text)) continue;
1213
+ let container = heading;
1214
+ const headingTag = heading.tagName.toLowerCase();
1215
+ let walkParent = heading.parentElement;
1216
+ while (walkParent && walkParent !== document.body) {
1217
+ const parentTag = walkParent.tagName.toLowerCase();
1218
+ if (parentTag === "section" || parentTag === "article") {
1219
+ container = walkParent;
1220
+ break;
1221
+ }
1222
+ if (walkParent.getAttribute("role") === "region") {
1223
+ container = walkParent;
1224
+ break;
1225
+ }
1226
+ if (parentTag === "body" || parentTag === "main") break;
1227
+ const siblingHeadings = walkParent.querySelectorAll(`:scope > * ${headingTag}, :scope > ${headingTag}`);
1228
+ if (siblingHeadings.length > 1) break;
1229
+ container = walkParent;
1230
+ walkParent = walkParent.parentElement;
1231
+ }
1232
+ const rect = container.getBoundingClientRect();
1233
+ if (rect.width < 4 || rect.height < 4) continue;
1234
+ const list = bySource.get(target.original) ?? [];
1235
+ list.push({
1236
+ el: container,
1237
+ parent: pickParentContainer(container),
1238
+ name: componentName,
1239
+ top: rect.top + window.scrollY,
1240
+ left: rect.left + window.scrollX
1241
+ });
1242
+ bySource.set(target.original, list);
1243
+ break;
1244
+ }
1245
+ }
1185
1246
  const tagged = [];
1186
1247
  for (let sourceIndex = 0; sourceIndex < targets.length; sourceIndex++) {
1187
1248
  const source = targets[sourceIndex].original;
@@ -1208,6 +1269,94 @@ async function tagChangedComponentInstances(page, changedComponents, maxPerSourc
1208
1269
  { changed: normalized, maxPerSource }
1209
1270
  );
1210
1271
  }
1272
+ async function captureComponentInstances(afterPage, beforePage, changedComponents, capturePrefix, captureLabel, outputDir, contextBeforePath, contextAfterPath, results) {
1273
+ const deduped = Array.from(new Set(changedComponents.map(normalizePath)));
1274
+ const [afterInstances, beforeInstances] = await Promise.all([
1275
+ tagChangedComponentInstances(afterPage, deduped),
1276
+ tagChangedComponentInstances(beforePage, deduped)
1277
+ ]);
1278
+ logger.dim(` Component detection on ${captureLabel}: ${deduped.length} source(s), ${afterInstances.length} after / ${beforeInstances.length} before instance(s)`);
1279
+ const afterBySource = groupBySource(afterInstances);
1280
+ const beforeBySource = groupBySource(beforeInstances);
1281
+ for (const source of deduped) {
1282
+ const afterList = afterBySource.get(source) ?? [];
1283
+ const beforeList = beforeBySource.get(source) ?? [];
1284
+ const pairCount = Math.min(afterList.length, beforeList.length);
1285
+ if (pairCount === 0) {
1286
+ if (afterList.length > 0 || beforeList.length > 0) {
1287
+ logger.dim(` ${source}: ${afterList.length} after / ${beforeList.length} before (skipping unpaired)`);
1288
+ }
1289
+ continue;
1290
+ }
1291
+ for (let pairIndex = 0; pairIndex < pairCount; pairIndex++) {
1292
+ const afterInstance = afterList[pairIndex];
1293
+ const beforeInstance = beforeList[pairIndex];
1294
+ const sourceSlug = sanitizeComponentLabel(source) || "component";
1295
+ const itemSlug = `${sourceSlug}-${pairIndex + 1}`;
1296
+ const componentName = afterInstance.name || beforeInstance.name || source.split("/").pop() || source;
1297
+ const baseLabel = `${captureLabel} [${componentName} #${pairIndex + 1}]`;
1298
+ const parentPrefix = `${capturePrefix}~cmp.${itemSlug}~parent`;
1299
+ const parentBeforePath = join6(outputDir, `${parentPrefix}-before.png`);
1300
+ const parentAfterPath = join6(outputDir, `${parentPrefix}-after.png`);
1301
+ const [parentBeforeOk, parentAfterOk] = await Promise.all([
1302
+ captureByAttr(
1303
+ beforePage,
1304
+ "data-ab-parent-key",
1305
+ beforeInstance.parentKey,
1306
+ parentBeforePath
1307
+ ),
1308
+ captureByAttr(
1309
+ afterPage,
1310
+ "data-ab-parent-key",
1311
+ afterInstance.parentKey,
1312
+ parentAfterPath
1313
+ )
1314
+ ]);
1315
+ if (parentBeforeOk && parentAfterOk) {
1316
+ results.push({
1317
+ route: `${baseLabel} [parent]`,
1318
+ prefix: parentPrefix,
1319
+ beforePath: parentBeforePath,
1320
+ afterPath: parentAfterPath
1321
+ });
1322
+ }
1323
+ const componentPrefix = `${capturePrefix}~cmp.${itemSlug}~component`;
1324
+ const componentBeforePath = join6(outputDir, `${componentPrefix}-before.png`);
1325
+ const componentAfterPath = join6(outputDir, `${componentPrefix}-after.png`);
1326
+ const [componentBeforeOk, componentAfterOk] = await Promise.all([
1327
+ captureByAttr(
1328
+ beforePage,
1329
+ "data-ab-comp-key",
1330
+ beforeInstance.componentKey,
1331
+ componentBeforePath
1332
+ ),
1333
+ captureByAttr(
1334
+ afterPage,
1335
+ "data-ab-comp-key",
1336
+ afterInstance.componentKey,
1337
+ componentAfterPath
1338
+ )
1339
+ ]);
1340
+ if (componentBeforeOk && componentAfterOk) {
1341
+ results.push({
1342
+ route: `${baseLabel} [component]`,
1343
+ prefix: componentPrefix,
1344
+ beforePath: componentBeforePath,
1345
+ afterPath: componentAfterPath
1346
+ });
1347
+ }
1348
+ if (parentBeforeOk && parentAfterOk || componentBeforeOk && componentAfterOk) {
1349
+ const contextPrefix = `${capturePrefix}~cmp.${itemSlug}~context`;
1350
+ results.push({
1351
+ route: `${baseLabel} [context]`,
1352
+ prefix: contextPrefix,
1353
+ beforePath: contextBeforePath,
1354
+ afterPath: contextAfterPath
1355
+ });
1356
+ }
1357
+ }
1358
+ }
1359
+ }
1211
1360
  async function captureAutoSections(afterPage, beforePage, parentPrefix, parentLabel, outputDir, options, settle, results) {
1212
1361
  const sections = await detectSections(afterPage, options.maxSectionsPerRoute);
1213
1362
  if (sections.length === 0) return;
@@ -1304,86 +1453,17 @@ async function captureRoutes(tasks, beforeUrl, afterUrl, outputDir, options) {
1304
1453
  ]);
1305
1454
  results.push({ route: task.label, prefix: task.prefix, beforePath, afterPath });
1306
1455
  if ((task.changedComponents?.length ?? 0) > 0 && !task.actions && !task.selector) {
1307
- const changedComponents = Array.from(
1308
- new Set(task.changedComponents.map(normalizePath))
1456
+ await captureComponentInstances(
1457
+ afterPage,
1458
+ beforePage,
1459
+ task.changedComponents,
1460
+ task.prefix,
1461
+ task.label,
1462
+ outputDir,
1463
+ beforePath,
1464
+ afterPath,
1465
+ results
1309
1466
  );
1310
- const [afterInstances, beforeInstances] = await Promise.all([
1311
- tagChangedComponentInstances(afterPage, changedComponents),
1312
- tagChangedComponentInstances(beforePage, changedComponents)
1313
- ]);
1314
- const afterBySource = groupBySource(afterInstances);
1315
- const beforeBySource = groupBySource(beforeInstances);
1316
- for (const source of changedComponents) {
1317
- const afterList = afterBySource.get(source) ?? [];
1318
- const beforeList = beforeBySource.get(source) ?? [];
1319
- const pairCount = Math.min(afterList.length, beforeList.length);
1320
- if (pairCount === 0) continue;
1321
- for (let pairIndex = 0; pairIndex < pairCount; pairIndex++) {
1322
- const afterInstance = afterList[pairIndex];
1323
- const beforeInstance = beforeList[pairIndex];
1324
- const sourceSlug = sanitizeComponentLabel(source) || "component";
1325
- const itemSlug = `${sourceSlug}-${pairIndex + 1}`;
1326
- const componentName = afterInstance.name || beforeInstance.name || source.split("/").pop() || source;
1327
- const baseLabel = `${task.label} [${componentName} #${pairIndex + 1}]`;
1328
- const contextPrefix = `${task.prefix}~cmp.${itemSlug}~context`;
1329
- results.push({
1330
- route: `${baseLabel} [context]`,
1331
- prefix: contextPrefix,
1332
- beforePath,
1333
- afterPath
1334
- });
1335
- const parentPrefix = `${task.prefix}~cmp.${itemSlug}~parent`;
1336
- const parentBeforePath = join6(outputDir, `${parentPrefix}-before.png`);
1337
- const parentAfterPath = join6(outputDir, `${parentPrefix}-after.png`);
1338
- const [parentBeforeOk, parentAfterOk] = await Promise.all([
1339
- captureByAttr(
1340
- beforePage,
1341
- "data-ab-parent-key",
1342
- beforeInstance.parentKey,
1343
- parentBeforePath
1344
- ),
1345
- captureByAttr(
1346
- afterPage,
1347
- "data-ab-parent-key",
1348
- afterInstance.parentKey,
1349
- parentAfterPath
1350
- )
1351
- ]);
1352
- if (parentBeforeOk && parentAfterOk) {
1353
- results.push({
1354
- route: `${baseLabel} [parent]`,
1355
- prefix: parentPrefix,
1356
- beforePath: parentBeforePath,
1357
- afterPath: parentAfterPath
1358
- });
1359
- }
1360
- const componentPrefix = `${task.prefix}~cmp.${itemSlug}~component`;
1361
- const componentBeforePath = join6(outputDir, `${componentPrefix}-before.png`);
1362
- const componentAfterPath = join6(outputDir, `${componentPrefix}-after.png`);
1363
- const [componentBeforeOk, componentAfterOk] = await Promise.all([
1364
- captureByAttr(
1365
- beforePage,
1366
- "data-ab-comp-key",
1367
- beforeInstance.componentKey,
1368
- componentBeforePath
1369
- ),
1370
- captureByAttr(
1371
- afterPage,
1372
- "data-ab-comp-key",
1373
- afterInstance.componentKey,
1374
- componentAfterPath
1375
- )
1376
- ]);
1377
- if (componentBeforeOk && componentAfterOk) {
1378
- results.push({
1379
- route: `${baseLabel} [component]`,
1380
- prefix: componentPrefix,
1381
- beforePath: componentBeforePath,
1382
- afterPath: componentAfterPath
1383
- });
1384
- }
1385
- }
1386
- }
1387
1467
  }
1388
1468
  if (options.autoSections && !task.actions && !task.selector && !task.skipAutoSections) {
1389
1469
  await captureAutoSections(
@@ -1443,6 +1523,19 @@ async function captureRoutes(tasks, beforeUrl, afterUrl, outputDir, options) {
1443
1523
  beforePath: tabBeforePath,
1444
1524
  afterPath: tabAfterPath
1445
1525
  });
1526
+ if ((task.changedComponents?.length ?? 0) > 0) {
1527
+ await captureComponentInstances(
1528
+ afterPage,
1529
+ beforePage,
1530
+ task.changedComponents,
1531
+ tabPrefix,
1532
+ tabLabel,
1533
+ outputDir,
1534
+ tabBeforePath,
1535
+ tabAfterPath,
1536
+ results
1537
+ );
1538
+ }
1446
1539
  if (options.autoSections && !task.skipAutoSections) {
1447
1540
  await captureAutoSections(
1448
1541
  afterPage,
@@ -1571,79 +1664,8 @@ async function compareScreenshots(captures, outputDir, threshold = 0.1, options)
1571
1664
  }
1572
1665
 
1573
1666
  // src/stages/report.ts
1574
- import { writeFileSync } from "fs";
1575
- import { join as join8 } from "path";
1576
1667
  import { execSync as execSync4 } from "child_process";
1577
1668
 
1578
- // src/templates/report.html.ts
1579
- import { readFileSync as readFileSync6 } from "fs";
1580
- function toBase64(filePath) {
1581
- return readFileSync6(filePath).toString("base64");
1582
- }
1583
- function imgSrc(filePath) {
1584
- return `data:image/png;base64,${toBase64(filePath)}`;
1585
- }
1586
- function generateReportHtml(results, outputDir) {
1587
- const changed = results.filter((r) => r.changed);
1588
- const unchanged = results.filter((r) => !r.changed);
1589
- const card = (r) => {
1590
- return `
1591
- <div class="card ${r.changed ? "changed" : "unchanged"}">
1592
- <div class="card-header">
1593
- <span class="route">${r.route}</span>
1594
- <span class="badge ${r.changed ? "badge-changed" : "badge-unchanged"}">
1595
- ${r.changed ? `${r.diffPercentage.toFixed(2)}% changed` : "No change"}
1596
- </span>
1597
- </div>
1598
- <div class="images">
1599
- <div class="img-col">
1600
- <div class="label">Before</div>
1601
- <img src="${imgSrc(r.beforePath)}" alt="Before" />
1602
- </div>
1603
- <div class="img-col">
1604
- <div class="label">After</div>
1605
- <img src="${imgSrc(r.afterPath)}" alt="After" />
1606
- </div>
1607
- </div>
1608
- </div>`;
1609
- };
1610
- return `<!DOCTYPE html>
1611
- <html lang="en">
1612
- <head>
1613
- <meta charset="utf-8" />
1614
- <meta name="viewport" content="width=device-width, initial-scale=1" />
1615
- <title>afterbefore Report</title>
1616
- <style>
1617
- * { box-sizing: border-box; margin: 0; padding: 0; }
1618
- body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f9fafb; color: #111827; padding: 24px; }
1619
- h1 { font-size: 24px; margin-bottom: 8px; }
1620
- .summary { color: #6b7280; margin-bottom: 24px; font-size: 14px; }
1621
- .grid { display: grid; grid-template-columns: 1fr; gap: 24px; }
1622
- .card { background: #fff; border-radius: 8px; border: 1px solid #e5e7eb; overflow: hidden; }
1623
- .card.changed { border-color: #fbbf24; }
1624
- .card-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid #e5e7eb; }
1625
- .route { font-weight: 600; font-size: 16px; font-family: monospace; }
1626
- .badge { font-size: 12px; padding: 2px 8px; border-radius: 9999px; font-weight: 500; }
1627
- .badge-changed { background: #fef3c7; color: #92400e; }
1628
- .badge-unchanged { background: #d1fae5; color: #065f46; }
1629
- .images { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: #e5e7eb; }
1630
- .img-col { background: #fff; padding: 8px; }
1631
- .img-col img { width: 100%; height: auto; display: block; border-radius: 4px; }
1632
- .label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; margin-bottom: 4px; font-weight: 600; }
1633
- .section-title { font-size: 18px; font-weight: 600; margin: 24px 0 12px; }
1634
- </style>
1635
- </head>
1636
- <body>
1637
- <h1>afterbefore Report</h1>
1638
- <p class="summary">${results.length} route(s) captured, ${changed.length} with visual changes.</p>
1639
-
1640
- ${changed.length > 0 ? `<h2 class="section-title">Changed (${changed.length})</h2><div class="grid">${changed.map(card).join("")}</div>` : ""}
1641
- ${unchanged.length > 0 ? `<h2 class="section-title">Unchanged (${unchanged.length})</h2><div class="grid">${unchanged.map(card).join("")}</div>` : ""}
1642
-
1643
- </body>
1644
- </html>`;
1645
- }
1646
-
1647
1669
  // src/templates/summary.md.ts
1648
1670
  function generateSummaryMd(results, gitDiff, options) {
1649
1671
  const includeFilePaths = options?.includeFilePaths ?? true;
@@ -1741,15 +1763,6 @@ function postOrUpdateComment(prNumber, body) {
1741
1763
  }
1742
1764
  }
1743
1765
  async function generateReport(results, outputDir, options) {
1744
- await ensureDir(outputDir);
1745
- const summaryMd = generateSummaryMd(results);
1746
- const summaryPath = join8(outputDir, "summary.md");
1747
- writeFileSync(summaryPath, summaryMd, "utf-8");
1748
- logger.success(`Written summary to ${summaryPath}`);
1749
- const reportHtml = generateReportHtml(results, outputDir);
1750
- const indexPath = join8(outputDir, "index.html");
1751
- writeFileSync(indexPath, reportHtml, "utf-8");
1752
- logger.success(`Written report to ${indexPath}`);
1753
1766
  if (options.post) {
1754
1767
  const prNumber = findPrNumber();
1755
1768
  if (!prNumber) {
@@ -1841,17 +1854,13 @@ function expandRoutes(routes, config, routeComponentMap) {
1841
1854
  }
1842
1855
  return tasks;
1843
1856
  }
1844
- function openInBrowser(filePath) {
1845
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
1846
- exec(`${cmd} "${filePath}"`);
1847
- }
1848
1857
  async function runPipeline(options) {
1849
1858
  const { base, output, post, cwd } = options;
1850
1859
  const sessionName = generateSessionName(cwd);
1851
1860
  const outputDir = resolve4(cwd, output, sessionName);
1852
1861
  const startTime = Date.now();
1853
1862
  try {
1854
- const version = true ? "0.1.5" : "dev";
1863
+ const version = true ? "0.1.7" : "dev";
1855
1864
  console.log(`
1856
1865
  afterbefore v${version} \xB7 Comparing against ${base}
1857
1866
  `);
@@ -1984,11 +1993,11 @@ afterbefore v${version} \xB7 Comparing against ${base}
1984
1993
  logger.success(
1985
1994
  `Done in ${elapsed}s \u2014 ${results.length} route(s) captured, ${changedCount} with visual changes`
1986
1995
  );
1987
- logger.dim(` Report: ${outputDir}/index.html`);
1988
- if (options.open) {
1989
- openInBrowser(resolve4(outputDir, "index.html"));
1990
- }
1991
1996
  } finally {
1997
+ try {
1998
+ logger.writeLogFile(resolve4(outputDir, "debug.log"));
1999
+ } catch {
2000
+ }
1992
2001
  await cleanupRegistry.runAll();
1993
2002
  }
1994
2003
  }
@@ -1997,7 +2006,7 @@ afterbefore v${version} \xB7 Comparing against ${base}
1997
2006
  var program = new Command();
1998
2007
  program.name("afterbefore").description(
1999
2008
  "Automatic before/after screenshot capture for PRs. Git diff is the config."
2000
- ).version("0.1.5").option("--base <ref>", "Base branch or ref to compare against", "main").option("--output <dir>", "Output directory for screenshots", ".afterbefore").option("--post", "Post results as a PR comment via gh CLI", false).option(
2009
+ ).version("0.1.7").option("--base <ref>", "Base branch or ref to compare against", "main").option("--output <dir>", "Output directory for screenshots", ".afterbefore").option("--post", "Post results as a PR comment via gh CLI", false).option(
2001
2010
  "--threshold <percent>",
2002
2011
  "Diff threshold percentage (changes below this are ignored)",
2003
2012
  "0.1"
@@ -2005,7 +2014,7 @@ program.name("afterbefore").description(
2005
2014
  "--max-routes <count>",
2006
2015
  "Maximum routes to capture (0 = unlimited)",
2007
2016
  "6"
2008
- ).option("--open", "Auto-open the HTML report in your browser", false).option("--width <pixels>", "Viewport width", "1280").option("--height <pixels>", "Viewport height", "720").option("--device <name>", 'Playwright device descriptor (e.g. "iPhone 14")').option("--delay <ms>", "Extra wait time (ms) after page load", "0").option("--no-auto-tabs", "Disable auto-detection of ARIA tab states").option("--max-tabs <count>", "Max auto-detected tabs per route", "5").option("--auto-sections", "Auto-detect and capture heading-labeled sections").option("--max-sections <count>", "Max auto-detected sections per page state", "10").action(async (opts) => {
2017
+ ).option("--width <pixels>", "Viewport width", "1280").option("--height <pixels>", "Viewport height", "720").option("--device <name>", 'Playwright device descriptor (e.g. "iPhone 14")').option("--delay <ms>", "Extra wait time (ms) after page load", "0").option("--no-auto-tabs", "Disable auto-detection of ARIA tab states").option("--max-tabs <count>", "Max auto-detected tabs per route", "5").option("--auto-sections", "Auto-detect and capture heading-labeled sections").option("--max-sections <count>", "Max auto-detected sections per page state", "10").action(async (opts) => {
2009
2018
  const cwd = process.cwd();
2010
2019
  if (!isGitRepo(cwd)) {
2011
2020
  logger.error("Not a git repository. Run this from inside a git repo.");
@@ -2017,7 +2026,6 @@ program.name("afterbefore").description(
2017
2026
  post: opts.post,
2018
2027
  threshold: parseFloat(opts.threshold),
2019
2028
  maxRoutes: parseInt(opts.maxRoutes, 10),
2020
- open: opts.open,
2021
2029
  width: parseInt(opts.width, 10),
2022
2030
  height: parseInt(opts.height, 10),
2023
2031
  device: opts.device,