afterbefore 0.1.17 → 0.1.18

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.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { Command } from "commander";
5
+ import chalk3 from "chalk";
5
6
 
6
7
  // src/logger.ts
7
8
  import { writeFileSync } from "fs";
@@ -119,6 +120,18 @@ var Logger = class {
119
120
  this.spinner.text = text;
120
121
  this.spinner.render();
121
122
  }
123
+ stageComplete(name, detail, durationMs) {
124
+ const duration = durationMs < 1e3 ? `${Math.round(durationMs)}ms` : `${(durationMs / 1e3).toFixed(1)}s`;
125
+ const line = ` ${chalk.green("\u2713")} ${name.padEnd(12)} ${chalk.dim(detail.padEnd(50))} ${chalk.dim(duration)}`;
126
+ this.log("stage", `${name}: ${detail} (${duration})`);
127
+ if (this.isTTY && this.spinner) {
128
+ this.spinner.clear();
129
+ console.log(line);
130
+ this.spinner.render();
131
+ } else {
132
+ console.log(line);
133
+ }
134
+ }
122
135
  completePipeline(finished = false) {
123
136
  this.pipelineActive = false;
124
137
  this.log("info", `Pipeline ${finished ? "completed" : "stopped"}`);
@@ -217,11 +230,22 @@ var cleanupRegistry = new CleanupRegistry();
217
230
  // src/utils/git.ts
218
231
  import { execSync } from "child_process";
219
232
  function git(args, cwd) {
220
- return execSync(`git ${args}`, {
221
- cwd,
222
- encoding: "utf-8",
223
- stdio: ["pipe", "pipe", "pipe"]
224
- }).trim();
233
+ try {
234
+ return execSync(`git ${args}`, {
235
+ cwd,
236
+ encoding: "utf-8",
237
+ stdio: ["pipe", "pipe", "pipe"]
238
+ }).trim();
239
+ } catch (err) {
240
+ const message = err instanceof Error ? err.message : String(err);
241
+ if (message.includes("ENOENT") || message.includes("not found")) {
242
+ throw new AfterbeforeError(
243
+ "Git is not installed or not in PATH.",
244
+ "Install git: https://git-scm.com/downloads"
245
+ );
246
+ }
247
+ throw err;
248
+ }
225
249
  }
226
250
  function isGitRepo(cwd) {
227
251
  try {
@@ -232,7 +256,14 @@ function isGitRepo(cwd) {
232
256
  }
233
257
  }
234
258
  function getMergeBase(base, cwd) {
235
- return git(`merge-base ${base} HEAD`, cwd);
259
+ try {
260
+ return git(`merge-base ${base} HEAD`, cwd);
261
+ } catch {
262
+ throw new AfterbeforeError(
263
+ `Could not find merge base for "${base}". The branch or ref may not exist.`,
264
+ `Run "git branch -a" to see available branches. Did you mean "master" instead of "main"?`
265
+ );
266
+ }
236
267
  }
237
268
  function getDiffNameStatus(base, cwd) {
238
269
  const mergeBase = getMergeBase(base, cwd);
@@ -247,8 +278,10 @@ function getCurrentBranch(cwd) {
247
278
  }
248
279
 
249
280
  // src/pipeline.ts
250
- import { resolve as resolve4 } from "path";
251
- import { unlinkSync as unlinkSync2 } from "fs";
281
+ import { resolve as resolve4, basename } from "path";
282
+ import { writeFileSync as writeFileSync2, unlinkSync as unlinkSync2 } from "fs";
283
+ import { execSync as execSync5 } from "child_process";
284
+ import chalk2 from "chalk";
252
285
 
253
286
  // src/config.ts
254
287
  import { resolve } from "path";
@@ -736,8 +769,8 @@ function sanitizeLabel(label, maxLength = 40) {
736
769
  }
737
770
 
738
771
  // src/stages/impact.ts
739
- var MAX_DEPTH = 3;
740
- function findAffectedRoutes(changedFiles, graph, projectRoot, maxRoutes = 0) {
772
+ var DEFAULT_MAX_DEPTH = 10;
773
+ function findAffectedRoutes(changedFiles, graph, projectRoot, maxRoutes = 0, maxDepth = DEFAULT_MAX_DEPTH) {
741
774
  const routeMap = /* @__PURE__ */ new Map();
742
775
  for (const file of changedFiles) {
743
776
  const visited = /* @__PURE__ */ new Set();
@@ -759,7 +792,7 @@ function findAffectedRoutes(changedFiles, graph, projectRoot, maxRoutes = 0) {
759
792
  });
760
793
  }
761
794
  }
762
- if (depth >= MAX_DEPTH) continue;
795
+ if (depth >= maxDepth) continue;
763
796
  const importers = graph.reverse.get(path);
764
797
  if (!importers) continue;
765
798
  for (const importer of importers) {
@@ -802,7 +835,7 @@ function findAffectedRoutes(changedFiles, graph, projectRoot, maxRoutes = 0) {
802
835
  logger.dim(`Found ${routes.length} affected route(s)`);
803
836
  return routes;
804
837
  }
805
- function findRoutesForFile(file, graph) {
838
+ function findRoutesForFile(file, graph, maxDepth = DEFAULT_MAX_DEPTH) {
806
839
  const routes = /* @__PURE__ */ new Set();
807
840
  const start = normalizePath(file);
808
841
  const queue = [{ path: start, depth: 0 }];
@@ -813,7 +846,7 @@ function findRoutesForFile(file, graph) {
813
846
  const route = pagePathToRoute(path);
814
847
  if (route) routes.add(route);
815
848
  }
816
- if (depth >= MAX_DEPTH) continue;
849
+ if (depth >= maxDepth) continue;
817
850
  const importers = graph.reverse.get(path);
818
851
  if (!importers) continue;
819
852
  for (const importer of importers) {
@@ -1118,10 +1151,17 @@ async function launchBrowser() {
1118
1151
  return await chromium.launch();
1119
1152
  } catch {
1120
1153
  logger.dim("Chromium not found, installing...");
1121
- execSync3("npx playwright install chromium", {
1122
- stdio: ["pipe", "pipe", "pipe"]
1123
- });
1124
- return await chromium.launch();
1154
+ try {
1155
+ execSync3("npx playwright install chromium", {
1156
+ stdio: ["pipe", "pipe", "pipe"]
1157
+ });
1158
+ return await chromium.launch();
1159
+ } catch {
1160
+ throw new AfterbeforeError(
1161
+ "Could not install or launch Playwright Chromium.",
1162
+ 'Run "npx playwright install chromium" manually, then try again.'
1163
+ );
1164
+ }
1125
1165
  }
1126
1166
  }
1127
1167
  var MAX_COMPONENT_INSTANCES_PER_SOURCE = 5;
@@ -1671,55 +1711,101 @@ async function trimImage(path) {
1671
1711
  const { data, info } = await sharp(path).trim({ threshold: 50 }).toBuffer({ resolveWithObject: true });
1672
1712
  return { data, width: info.width, height: info.height };
1673
1713
  }
1674
- async function generateComposite(beforePath, afterPath, outputPath, bgColor) {
1714
+ function roundCornersSvg(w, h, r) {
1715
+ return Buffer.from(
1716
+ `<svg width="${w}" height="${h}"><rect x="0" y="0" width="${w}" height="${h}" rx="${r}" ry="${r}" fill="white"/></svg>`
1717
+ );
1718
+ }
1719
+ async function roundImage(buf, width, height, radius) {
1720
+ const mask = roundCornersSvg(width, height, radius);
1721
+ return sharp(buf).resize(width, height, { fit: "fill" }).composite([{ input: mask, blend: "dest-in" }]).png().toBuffer();
1722
+ }
1723
+ async function generateComposite(beforePath, afterPath, outputPath, bgColor, metadata) {
1675
1724
  const [beforeTrimmed, afterTrimmed] = await Promise.all([
1676
1725
  trimImage(beforePath),
1677
1726
  trimImage(afterPath)
1678
1727
  ]);
1728
+ const CANVAS_W = 1200;
1729
+ const PADDING = 32;
1730
+ const GAP = 24;
1731
+ const HEADER_H = 48;
1732
+ const BADGE_H = 40;
1733
+ const FOOTER_H = 24;
1734
+ const CORNER_R = 12;
1735
+ const contentW = CANVAS_W - PADDING * 2;
1736
+ const colW = Math.floor((contentW - GAP) / 2);
1679
1737
  const imgW = Math.max(beforeTrimmed.width, afterTrimmed.width);
1680
1738
  const imgH = Math.max(beforeTrimmed.height, afterTrimmed.height);
1681
- const PADDING = 40;
1682
- const GAP = 40;
1683
- const LABEL_H = 70;
1684
- const canvasW = Math.max(600, Math.min(2400, imgW * 2 + GAP + PADDING * 2));
1685
- const canvasH = Math.max(300, Math.min(2400, imgH + LABEL_H + PADDING * 2));
1686
- const maxImgH = canvasH - PADDING * 2 - LABEL_H;
1687
- const colW = Math.floor((canvasW - PADDING * 2 - GAP) / 2);
1739
+ const imgAspect = imgH / (imgW || 1);
1740
+ const scaledH = Math.min(Math.round(colW * imgAspect), 520);
1741
+ const CANVAS_H = PADDING + HEADER_H + scaledH + BADGE_H + FOOTER_H + PADDING;
1688
1742
  const [beforeBuf, afterBuf] = await Promise.all(
1689
1743
  [beforeTrimmed, afterTrimmed].map(async (trimmed) => {
1690
- return await sharp(trimmed.data).resize(colW, maxImgH, { fit: "inside" }).toBuffer({ resolveWithObject: true });
1744
+ const resized = await sharp(trimmed.data).resize(colW, scaledH, { fit: "contain", background: bgColor }).toBuffer({ resolveWithObject: true });
1745
+ return {
1746
+ data: await roundImage(resized.data, resized.info.width, resized.info.height, CORNER_R),
1747
+ width: resized.info.width,
1748
+ height: resized.info.height
1749
+ };
1691
1750
  })
1692
1751
  );
1693
- const beforeLeft = PADDING + Math.floor((colW - beforeBuf.info.width) / 2);
1694
- const beforeTop = PADDING + Math.floor((maxImgH - beforeBuf.info.height) / 2);
1695
- const afterLeft = PADDING + colW + GAP + Math.floor((colW - afterBuf.info.width) / 2);
1696
- const afterTop = PADDING + Math.floor((maxImgH - afterBuf.info.height) / 2);
1697
- const labelY = PADDING + maxImgH + 20;
1698
- const beforeLabelX = PADDING + Math.floor(colW / 2);
1699
- const afterLabelX = PADDING + colW + GAP + Math.floor(colW / 2);
1700
- const labelSvg = Buffer.from(
1701
- `<svg width="${canvasW}" height="${canvasH}">
1702
- <text x="${beforeLabelX}" y="${labelY}" text-anchor="middle"
1703
- font-family="system-ui, sans-serif" font-size="30" font-weight="500"
1704
- fill="#888">Before</text>
1705
- <text x="${afterLabelX}" y="${labelY}" text-anchor="middle"
1706
- font-family="system-ui, sans-serif" font-size="30" font-weight="500"
1752
+ const imgTop = PADDING + HEADER_H;
1753
+ const beforeLeft = PADDING;
1754
+ const afterLeft = PADDING + colW + GAP;
1755
+ const badgeY = imgTop + scaledH + 8;
1756
+ const beforeBadgeCX = PADDING + Math.floor(colW / 2);
1757
+ const afterBadgeCX = PADDING + colW + GAP + Math.floor(colW / 2);
1758
+ const diffPct = metadata.diffPercentage.toFixed(1);
1759
+ const diffBadgeW = Math.max(80, diffPct.length * 12 + 40);
1760
+ const overlaySvg = Buffer.from(
1761
+ `<svg width="${CANVAS_W}" height="${CANVAS_H}" xmlns="http://www.w3.org/2000/svg">
1762
+ <!-- Header: route name + diff badge -->
1763
+ <text x="${PADDING}" y="${PADDING + 28}"
1764
+ font-family="'SF Mono', 'Fira Code', 'Consolas', monospace" font-size="18" font-weight="600"
1765
+ fill="#e0e0e0">${escapeXml(metadata.route)}</text>
1766
+
1767
+ <rect x="${CANVAS_W - PADDING - diffBadgeW}" y="${PADDING + 6}" width="${diffBadgeW}" height="28" rx="14"
1768
+ fill="#f59e0b" fill-opacity="0.2"/>
1769
+ <text x="${CANVAS_W - PADDING - diffBadgeW / 2}" y="${PADDING + 25}"
1770
+ font-family="system-ui, sans-serif" font-size="13" font-weight="700" text-anchor="middle"
1771
+ fill="#f59e0b">${diffPct}% changed</text>
1772
+
1773
+ <!-- "Before" pill badge -->
1774
+ <rect x="${beforeBadgeCX - 44}" y="${badgeY}" width="88" height="28" rx="14"
1775
+ fill="#555" fill-opacity="0.5"/>
1776
+ <text x="${beforeBadgeCX}" y="${badgeY + 19}"
1777
+ font-family="system-ui, sans-serif" font-size="13" font-weight="600" text-anchor="middle"
1778
+ fill="#ccc">Before</text>
1779
+
1780
+ <!-- "After" pill badge -->
1781
+ <rect x="${afterBadgeCX - 40}" y="${badgeY}" width="80" height="28" rx="14"
1782
+ fill="#22c55e" fill-opacity="0.25"/>
1783
+ <text x="${afterBadgeCX}" y="${badgeY + 19}"
1784
+ font-family="system-ui, sans-serif" font-size="13" font-weight="600" text-anchor="middle"
1707
1785
  fill="#22c55e">After</text>
1786
+
1787
+ <!-- Branding -->
1788
+ <text x="${CANVAS_W - PADDING}" y="${CANVAS_H - 10}"
1789
+ font-family="system-ui, sans-serif" font-size="10" text-anchor="end"
1790
+ fill="#444">afterbefore</text>
1708
1791
  </svg>`
1709
1792
  );
1710
1793
  await sharp({
1711
1794
  create: {
1712
- width: canvasW,
1713
- height: canvasH,
1795
+ width: CANVAS_W,
1796
+ height: CANVAS_H,
1714
1797
  channels: 4,
1715
- background: bgColor
1798
+ background: "#1a1a2e"
1716
1799
  }
1717
1800
  }).composite([
1718
- { input: beforeBuf.data, left: beforeLeft, top: beforeTop },
1719
- { input: afterBuf.data, left: afterLeft, top: afterTop },
1720
- { input: labelSvg, left: 0, top: 0 }
1801
+ { input: beforeBuf.data, left: beforeLeft, top: imgTop },
1802
+ { input: afterBuf.data, left: afterLeft, top: imgTop },
1803
+ { input: overlaySvg, left: 0, top: 0 }
1721
1804
  ]).png().toFile(outputPath);
1722
1805
  }
1806
+ function escapeXml(str) {
1807
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1808
+ }
1723
1809
  async function compareOne(capture, outputDir, threshold, server, options) {
1724
1810
  const diffPath = join7(outputDir, `${capture.prefix}-diff.png`);
1725
1811
  const dir = dirname2(capture.beforePath);
@@ -1747,18 +1833,30 @@ async function compareOne(capture, outputDir, threshold, server, options) {
1747
1833
  diffPercentage = 100;
1748
1834
  changed = true;
1749
1835
  }
1750
- await generateComposite(
1751
- capture.beforePath,
1752
- capture.afterPath,
1753
- comparePath,
1754
- options.bgColor
1755
- );
1836
+ if (changed) {
1837
+ await generateComposite(
1838
+ capture.beforePath,
1839
+ capture.afterPath,
1840
+ comparePath,
1841
+ options.bgColor,
1842
+ { route: capture.route, diffPercentage }
1843
+ );
1844
+ } else {
1845
+ try {
1846
+ unlinkSync(capture.beforePath);
1847
+ } catch {
1848
+ }
1849
+ try {
1850
+ unlinkSync(capture.afterPath);
1851
+ } catch {
1852
+ }
1853
+ }
1756
1854
  return {
1757
1855
  route: capture.route,
1758
1856
  prefix: capture.prefix,
1759
1857
  beforePath: capture.beforePath,
1760
1858
  afterPath: capture.afterPath,
1761
- comparePath,
1859
+ comparePath: changed ? comparePath : "",
1762
1860
  diffPixels,
1763
1861
  totalPixels,
1764
1862
  diffPercentage,
@@ -1830,7 +1928,21 @@ function generateSummaryMd(results, gitDiff, options) {
1830
1928
 
1831
1929
  // src/stages/report.ts
1832
1930
  var COMMENT_MARKER = "<!-- afterbefore -->";
1931
+ function isGhInstalled() {
1932
+ try {
1933
+ execSync4("gh --version", { stdio: ["pipe", "pipe", "pipe"] });
1934
+ return true;
1935
+ } catch {
1936
+ return false;
1937
+ }
1938
+ }
1833
1939
  function findPrNumber() {
1940
+ if (!isGhInstalled()) {
1941
+ logger.warn(
1942
+ "GitHub CLI (gh) is not installed. Install it from https://cli.github.com"
1943
+ );
1944
+ return null;
1945
+ }
1834
1946
  try {
1835
1947
  const output = execSync4("gh pr view --json number -q .number", {
1836
1948
  encoding: "utf-8",
@@ -1882,7 +1994,7 @@ async function generateReport(results, outputDir, options) {
1882
1994
  const prNumber = findPrNumber();
1883
1995
  if (!prNumber) {
1884
1996
  logger.warn(
1885
- "Could not find PR number. Make sure you are on a branch with an open PR and `gh` is authenticated."
1997
+ "No open PR found for this branch. Push your branch and open a PR first, then run `npx afterbefore --post` again."
1886
1998
  );
1887
1999
  return;
1888
2000
  }
@@ -1892,6 +2004,317 @@ async function generateReport(results, outputDir, options) {
1892
2004
  }
1893
2005
  }
1894
2006
 
2007
+ // src/templates/report.html.ts
2008
+ function escapeHtml(str) {
2009
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2010
+ }
2011
+ function generateReportHtml(results, sessionName) {
2012
+ const changed = results.filter((r) => r.changed);
2013
+ const unchanged = results.filter((r) => !r.changed);
2014
+ const totalChecked = results.length;
2015
+ const changedCount = changed.length;
2016
+ const changedCards = changed.map((r) => {
2017
+ const diffPct = r.diffPercentage.toFixed(2);
2018
+ const beforeFile = r.beforePath.split("/").pop() || "";
2019
+ const afterFile = r.afterPath.split("/").pop() || "";
2020
+ const compareFile = r.comparePath ? r.comparePath.split("/").pop() || "" : "";
2021
+ return `
2022
+ <div class="card">
2023
+ <div class="card-header">
2024
+ <span class="route">${escapeHtml(r.route)}</span>
2025
+ <span class="badge">${diffPct}% changed</span>
2026
+ </div>
2027
+ ${compareFile ? `
2028
+ <div class="compare-wrap">
2029
+ <img src="${escapeHtml(compareFile)}" alt="Side-by-side comparison of ${escapeHtml(r.route)}" class="compare-img" loading="lazy">
2030
+ </div>
2031
+ ` : ""}
2032
+ <div class="slider-section">
2033
+ <p class="slider-label">Interactive comparison \u2014 drag the divider</p>
2034
+ <div class="slider" style="--pos: 50%">
2035
+ <img src="${escapeHtml(beforeFile)}" alt="Before" class="slider-img">
2036
+ <img src="${escapeHtml(afterFile)}" alt="After" class="slider-img slider-after" style="clip-path: inset(0 0 0 var(--pos))">
2037
+ <input type="range" min="0" max="100" value="50" class="slider-range"
2038
+ oninput="this.parentElement.style.setProperty('--pos', this.value + '%')">
2039
+ <div class="slider-labels">
2040
+ <span>Before</span>
2041
+ <span>After</span>
2042
+ </div>
2043
+ </div>
2044
+ </div>
2045
+ </div>`;
2046
+ }).join("\n");
2047
+ const unchangedSection = unchanged.length > 0 ? `
2048
+ <details class="unchanged-section">
2049
+ <summary>${unchanged.length} route${unchanged.length === 1 ? "" : "s"} unchanged</summary>
2050
+ <ul>
2051
+ ${unchanged.map((r) => `<li><code>${escapeHtml(r.route)}</code></li>`).join("\n ")}
2052
+ </ul>
2053
+ </details>` : "";
2054
+ return `<!DOCTYPE html>
2055
+ <html lang="en">
2056
+ <head>
2057
+ <meta charset="UTF-8">
2058
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
2059
+ <title>afterbefore report \u2014 ${escapeHtml(sessionName)}</title>
2060
+ <style>
2061
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
2062
+
2063
+ body {
2064
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
2065
+ background: #0f0f17;
2066
+ color: #e0e0e0;
2067
+ padding: 2rem;
2068
+ max-width: 1400px;
2069
+ margin: 0 auto;
2070
+ }
2071
+
2072
+ h1 {
2073
+ font-size: 1.5rem;
2074
+ font-weight: 600;
2075
+ margin-bottom: 0.25rem;
2076
+ }
2077
+
2078
+ .subtitle {
2079
+ color: #888;
2080
+ font-size: 0.9rem;
2081
+ margin-bottom: 2rem;
2082
+ }
2083
+
2084
+ .summary {
2085
+ display: flex;
2086
+ gap: 1.5rem;
2087
+ margin-bottom: 2rem;
2088
+ flex-wrap: wrap;
2089
+ }
2090
+
2091
+ .stat {
2092
+ background: #1a1a2e;
2093
+ border-radius: 8px;
2094
+ padding: 1rem 1.5rem;
2095
+ min-width: 140px;
2096
+ }
2097
+
2098
+ .stat-value {
2099
+ font-size: 1.8rem;
2100
+ font-weight: 700;
2101
+ }
2102
+
2103
+ .stat-value.changed { color: #f59e0b; }
2104
+ .stat-value.total { color: #3b82f6; }
2105
+ .stat-value.unchanged { color: #22c55e; }
2106
+
2107
+ .stat-label {
2108
+ color: #888;
2109
+ font-size: 0.8rem;
2110
+ margin-top: 0.25rem;
2111
+ }
2112
+
2113
+ .card {
2114
+ background: #1a1a2e;
2115
+ border-radius: 12px;
2116
+ margin-bottom: 2rem;
2117
+ overflow: hidden;
2118
+ }
2119
+
2120
+ .card-header {
2121
+ display: flex;
2122
+ align-items: center;
2123
+ justify-content: space-between;
2124
+ padding: 1rem 1.5rem;
2125
+ border-bottom: 1px solid #2a2a3e;
2126
+ }
2127
+
2128
+ .route {
2129
+ font-family: 'SF Mono', 'Fira Code', monospace;
2130
+ font-size: 1.1rem;
2131
+ font-weight: 600;
2132
+ }
2133
+
2134
+ .badge {
2135
+ background: #f59e0b20;
2136
+ color: #f59e0b;
2137
+ padding: 0.25rem 0.75rem;
2138
+ border-radius: 999px;
2139
+ font-size: 0.8rem;
2140
+ font-weight: 600;
2141
+ }
2142
+
2143
+ .compare-wrap {
2144
+ padding: 1rem;
2145
+ }
2146
+
2147
+ .compare-img {
2148
+ width: 100%;
2149
+ border-radius: 8px;
2150
+ }
2151
+
2152
+ .slider-section {
2153
+ padding: 1rem 1.5rem 1.5rem;
2154
+ }
2155
+
2156
+ .slider-label {
2157
+ color: #666;
2158
+ font-size: 0.75rem;
2159
+ margin-bottom: 0.75rem;
2160
+ }
2161
+
2162
+ .slider {
2163
+ position: relative;
2164
+ overflow: hidden;
2165
+ border-radius: 8px;
2166
+ background: #111;
2167
+ }
2168
+
2169
+ .slider-img {
2170
+ display: block;
2171
+ width: 100%;
2172
+ height: auto;
2173
+ }
2174
+
2175
+ .slider-after {
2176
+ position: absolute;
2177
+ top: 0;
2178
+ left: 0;
2179
+ }
2180
+
2181
+ .slider-range {
2182
+ position: absolute;
2183
+ top: 0;
2184
+ left: 0;
2185
+ width: 100%;
2186
+ height: 100%;
2187
+ opacity: 0;
2188
+ cursor: col-resize;
2189
+ z-index: 2;
2190
+ }
2191
+
2192
+ .slider-labels {
2193
+ display: flex;
2194
+ justify-content: space-between;
2195
+ padding: 0.5rem 0;
2196
+ color: #666;
2197
+ font-size: 0.75rem;
2198
+ }
2199
+
2200
+ .unchanged-section {
2201
+ background: #1a1a2e;
2202
+ border-radius: 12px;
2203
+ padding: 1rem 1.5rem;
2204
+ margin-bottom: 2rem;
2205
+ }
2206
+
2207
+ .unchanged-section summary {
2208
+ cursor: pointer;
2209
+ color: #888;
2210
+ font-size: 0.9rem;
2211
+ }
2212
+
2213
+ .unchanged-section ul {
2214
+ margin-top: 0.75rem;
2215
+ padding-left: 1.5rem;
2216
+ }
2217
+
2218
+ .unchanged-section li {
2219
+ color: #666;
2220
+ margin-bottom: 0.25rem;
2221
+ }
2222
+
2223
+ .unchanged-section code {
2224
+ font-family: 'SF Mono', 'Fira Code', monospace;
2225
+ font-size: 0.85rem;
2226
+ }
2227
+
2228
+ .summary-table {
2229
+ width: 100%;
2230
+ border-collapse: collapse;
2231
+ margin-bottom: 2rem;
2232
+ }
2233
+
2234
+ .summary-table th {
2235
+ text-align: left;
2236
+ padding: 0.5rem 1rem;
2237
+ border-bottom: 2px solid #2a2a3e;
2238
+ color: #888;
2239
+ font-size: 0.8rem;
2240
+ font-weight: 600;
2241
+ text-transform: uppercase;
2242
+ }
2243
+
2244
+ .summary-table td {
2245
+ padding: 0.5rem 1rem;
2246
+ border-bottom: 1px solid #1a1a2e;
2247
+ font-size: 0.9rem;
2248
+ }
2249
+
2250
+ .summary-table code {
2251
+ font-family: 'SF Mono', 'Fira Code', monospace;
2252
+ font-size: 0.85rem;
2253
+ }
2254
+
2255
+ .status-changed { color: #f59e0b; }
2256
+ .status-unchanged { color: #22c55e; }
2257
+
2258
+ footer {
2259
+ text-align: center;
2260
+ padding: 2rem 0;
2261
+ color: #444;
2262
+ font-size: 0.75rem;
2263
+ }
2264
+
2265
+ footer a {
2266
+ color: #555;
2267
+ text-decoration: none;
2268
+ }
2269
+
2270
+ footer a:hover {
2271
+ color: #888;
2272
+ }
2273
+ </style>
2274
+ </head>
2275
+ <body>
2276
+ <h1>afterbefore report</h1>
2277
+ <p class="subtitle">${escapeHtml(sessionName)}</p>
2278
+
2279
+ <div class="summary">
2280
+ <div class="stat">
2281
+ <div class="stat-value total">${totalChecked}</div>
2282
+ <div class="stat-label">Routes checked</div>
2283
+ </div>
2284
+ <div class="stat">
2285
+ <div class="stat-value changed">${changedCount}</div>
2286
+ <div class="stat-label">With visual changes</div>
2287
+ </div>
2288
+ <div class="stat">
2289
+ <div class="stat-value unchanged">${unchanged.length}</div>
2290
+ <div class="stat-label">Unchanged</div>
2291
+ </div>
2292
+ </div>
2293
+
2294
+ <table class="summary-table">
2295
+ <thead>
2296
+ <tr><th>Route</th><th>Diff %</th><th>Status</th></tr>
2297
+ </thead>
2298
+ <tbody>
2299
+ ${results.map((r) => {
2300
+ const status = r.changed ? "Changed" : "Unchanged";
2301
+ const statusClass = r.changed ? "status-changed" : "status-unchanged";
2302
+ const pct = r.changed ? `${r.diffPercentage.toFixed(2)}%` : "0%";
2303
+ return `<tr><td><code>${escapeHtml(r.route)}</code></td><td>${pct}</td><td class="${statusClass}">${status}</td></tr>`;
2304
+ }).join("\n ")}
2305
+ </tbody>
2306
+ </table>
2307
+
2308
+ ${changedCards}
2309
+ ${unchangedSection}
2310
+
2311
+ <footer>
2312
+ Generated by <a href="https://github.com/kairevicius/afterbefore">afterbefore</a>
2313
+ </footer>
2314
+ </body>
2315
+ </html>`;
2316
+ }
2317
+
1895
2318
  // src/pipeline.ts
1896
2319
  function generateSessionName(cwd) {
1897
2320
  const branch = getCurrentBranch(cwd);
@@ -1943,26 +2366,60 @@ function expandRoutes(routes, config, routeComponentMap) {
1943
2366
  }
1944
2367
  return tasks;
1945
2368
  }
2369
+ function applyConfigDefaults(options, config) {
2370
+ if (!config?.defaults) return;
2371
+ const defaults = config.defaults;
2372
+ const cliDefaults = {
2373
+ base: "main",
2374
+ output: ".afterbefore",
2375
+ post: false,
2376
+ threshold: 0.1,
2377
+ maxRoutes: 6,
2378
+ width: 1280,
2379
+ height: 720,
2380
+ delay: 0,
2381
+ autoTabs: true,
2382
+ maxTabsPerRoute: 5,
2383
+ autoSections: true,
2384
+ maxSectionsPerRoute: 10,
2385
+ maxDepth: 10,
2386
+ dryRun: false,
2387
+ verbose: false,
2388
+ open: false
2389
+ };
2390
+ const opts = options;
2391
+ for (const [key, value] of Object.entries(defaults)) {
2392
+ if (key === "cwd" || value === void 0) continue;
2393
+ if (key in cliDefaults && opts[key] === cliDefaults[key]) {
2394
+ opts[key] = value;
2395
+ }
2396
+ }
2397
+ }
1946
2398
  async function runPipeline(options) {
1947
2399
  const { base, output, post, cwd } = options;
1948
2400
  const sessionName = generateSessionName(cwd);
1949
2401
  const outputDir = resolve4(cwd, output, sessionName);
1950
2402
  const startTime = Date.now();
1951
2403
  try {
1952
- const version = true ? "0.1.17" : "dev";
2404
+ const version = true ? "0.1.18" : "dev";
2405
+ const mode = options.dryRun ? "Dry run" : "Comparing";
1953
2406
  console.log(`
1954
- afterbefore v${version} \xB7 Comparing against ${base}
2407
+ afterbefore v${version} \xB7 ${mode} against ${base}
1955
2408
  `);
1956
2409
  const config = await loadConfig(cwd);
1957
- logger.startPipeline(8);
2410
+ applyConfigDefaults(options, config);
2411
+ logger.startPipeline(options.dryRun ? 3 : 8);
2412
+ const t1 = Date.now();
1958
2413
  logger.pipeline(1, "Analyzing diff...");
1959
2414
  const diffFiles = getChangedFiles(base, cwd);
1960
- const gitDiff = getGitDiff(base, cwd);
2415
+ const gitDiff = options.dryRun ? "" : getGitDiff(base, cwd);
2416
+ logger.stageComplete("Diff", `${diffFiles.length} files changed`, Date.now() - t1);
1961
2417
  if (diffFiles.length === 0) {
1962
2418
  logger.completePipeline();
1963
2419
  logger.success("No changed files detected. Nothing to do.");
1964
2420
  return;
1965
2421
  }
2422
+ const t2 = Date.now();
1966
2423
  const classified = classifyFiles(diffFiles);
1967
2424
  const impactfulFiles = classified.filter(
1968
2425
  (f) => f.category !== "test" && f.category !== "other"
@@ -1975,11 +2432,14 @@ afterbefore v${version} \xB7 Comparing against ${base}
1975
2432
  return;
1976
2433
  }
1977
2434
  logger.pipeline(2, "Building import graph...");
1978
- const worktreePromise = createWorktree(base, cwd);
2435
+ const worktreePromise = options.dryRun ? null : createWorktree(base, cwd);
1979
2436
  const graph = await buildImportGraph(cwd);
2437
+ const graphEdges = Array.from(graph.forward.values()).reduce((sum, deps) => sum + deps.size, 0);
2438
+ logger.stageComplete("Graph", `${graph.forward.size} modules, ${graphEdges} edges`, Date.now() - t2);
2439
+ const t3 = Date.now();
1980
2440
  logger.pipeline(3, "Finding affected routes...");
1981
2441
  const changedPaths = impactfulFiles.map((f) => f.path);
1982
- let affectedRoutes = findAffectedRoutes(changedPaths, graph, cwd, options.maxRoutes);
2442
+ let affectedRoutes = findAffectedRoutes(changedPaths, graph, cwd, options.maxRoutes, options.maxDepth);
1983
2443
  const changedComponentFiles = impactfulFiles.filter((f) => f.category === "component").map((f) => f.path);
1984
2444
  const routeComponentMap = mapRouteToChangedComponents(changedComponentFiles, graph);
1985
2445
  if (affectedRoutes.length === 0) {
@@ -2006,8 +2466,21 @@ afterbefore v${version} \xB7 Comparing against ${base}
2006
2466
  }
2007
2467
  }
2008
2468
  }
2469
+ const directCount = affectedRoutes.filter((r) => r.reason === "direct").length;
2470
+ const transitiveCount = affectedRoutes.length - directCount;
2471
+ const impactDetail = directCount > 0 && transitiveCount > 0 ? `${affectedRoutes.length} routes (${directCount} direct, ${transitiveCount} transitive)` : `${affectedRoutes.length} routes`;
2472
+ logger.stageComplete("Impact", impactDetail, Date.now() - t3);
2473
+ if (options.verbose) {
2474
+ console.log("");
2475
+ for (const r of affectedRoutes) {
2476
+ const chain = r.triggerChain.map((f) => basename(f)).join(" \u2192 ");
2477
+ const depthLabel = r.depth === 0 ? "direct" : `depth ${r.depth}`;
2478
+ console.log(chalk2.dim(` ${r.route.padEnd(24)} ${depthLabel.padEnd(10)} ${chain}`));
2479
+ }
2480
+ console.log("");
2481
+ }
2009
2482
  if (affectedRoutes.length === 0) {
2010
- worktreePromise.then((w) => w.cleanup()).catch(() => {
2483
+ worktreePromise?.then((w) => w.cleanup()).catch(() => {
2011
2484
  });
2012
2485
  logger.completePipeline();
2013
2486
  logger.success(
@@ -2015,8 +2488,29 @@ afterbefore v${version} \xB7 Comparing against ${base}
2015
2488
  );
2016
2489
  return;
2017
2490
  }
2491
+ if (options.dryRun) {
2492
+ worktreePromise?.then((w) => w.cleanup()).catch(() => {
2493
+ });
2494
+ logger.completePipeline();
2495
+ console.log(`
2496
+ ${affectedRoutes.length} route(s) would be captured:
2497
+ `);
2498
+ for (const r of affectedRoutes) {
2499
+ const chain = r.triggerChain.map((f) => basename(f)).join(" \u2192 ");
2500
+ const depthLabel = r.depth === 0 ? "direct" : `depth ${r.depth}`;
2501
+ console.log(` ${r.route.padEnd(24)} ${chalk2.dim(depthLabel.padEnd(10))} ${chalk2.dim(chain)}`);
2502
+ }
2503
+ const elapsed2 = ((Date.now() - startTime) / 1e3).toFixed(1);
2504
+ console.log(chalk2.dim(`
2505
+ Completed in ${elapsed2}s (dry run \u2014 no screenshots captured)
2506
+ `));
2507
+ return;
2508
+ }
2509
+ const t4 = Date.now();
2018
2510
  logger.pipeline(4, "Setting up worktree...");
2019
2511
  const worktree = await worktreePromise;
2512
+ logger.stageComplete("Worktree", "created + dependencies installed", Date.now() - t4);
2513
+ const t5 = Date.now();
2020
2514
  logger.pipeline(5, "Starting servers...");
2021
2515
  await ensureDir(outputDir);
2022
2516
  const beforePort = await findAvailablePort();
@@ -2029,6 +2523,8 @@ afterbefore v${version} \xB7 Comparing against ${base}
2029
2523
  cleanupRegistry.register(() => stopServer(beforeServer));
2030
2524
  cleanupRegistry.register(() => stopServer(afterServer));
2031
2525
  cleanupRegistry.register(() => browser.close());
2526
+ logger.stageComplete("Servers", `ready on :${beforePort} and :${afterPort}`, Date.now() - t5);
2527
+ const t6 = Date.now();
2032
2528
  logger.pipeline(6, "Capturing screenshots...");
2033
2529
  const tasks = expandRoutes(affectedRoutes, config, routeComponentMap);
2034
2530
  const captures = await captureRoutes(
@@ -2049,38 +2545,60 @@ afterbefore v${version} \xB7 Comparing against ${base}
2049
2545
  onProgress: (i, label) => logger.pipeline(6, `Capturing ${label} (${i}/${tasks.length})...`)
2050
2546
  }
2051
2547
  );
2548
+ logger.stageComplete("Capture", `${captures.length} screenshots from ${tasks.length} routes`, Date.now() - t6);
2549
+ const t7 = Date.now();
2052
2550
  logger.pipeline(7, "Comparing screenshots...");
2053
2551
  const bgColor = detectBgColor(cwd);
2054
2552
  const allResults = await compareScreenshots(captures, outputDir, options.threshold, { bgColor });
2055
2553
  const results = allResults.filter((r) => {
2056
2554
  const isSubCapture = r.prefix.includes("~");
2057
2555
  if (isSubCapture && !r.changed) {
2058
- try {
2059
- unlinkSync2(r.beforePath);
2060
- } catch {
2061
- }
2062
- try {
2063
- unlinkSync2(r.afterPath);
2064
- } catch {
2065
- }
2066
- try {
2067
- unlinkSync2(r.comparePath);
2068
- } catch {
2556
+ if (r.comparePath) {
2557
+ try {
2558
+ unlinkSync2(r.comparePath);
2559
+ } catch {
2560
+ }
2069
2561
  }
2070
2562
  return false;
2071
2563
  }
2072
2564
  return true;
2073
2565
  });
2566
+ const changedCount = results.filter((r) => r.changed).length;
2567
+ const unchangedCount = results.length - changedCount;
2568
+ logger.stageComplete("Compare", `${changedCount} changed, ${unchangedCount} unchanged`, Date.now() - t7);
2569
+ const t8 = Date.now();
2074
2570
  logger.pipeline(8, "Generating report...");
2075
2571
  await generateReport(results, outputDir, { post });
2572
+ const reportHtml = generateReportHtml(results, sessionName);
2573
+ const reportPath = resolve4(outputDir, "report.html");
2574
+ writeFileSync2(reportPath, reportHtml, "utf-8");
2575
+ logger.stageComplete("Report", reportPath.replace(cwd + "/", ""), Date.now() - t8);
2076
2576
  const summary = generateSummaryMd(results, gitDiff);
2077
2577
  logger.completePipeline(true);
2078
2578
  console.log("\n" + summary);
2079
2579
  const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
2080
- const changedCount = results.filter((r) => r.changed).length;
2081
2580
  logger.success(
2082
- `Done in ${elapsed}s \u2014 ${results.length} route(s) captured, ${changedCount} with visual changes`
2581
+ `Done in ${elapsed}s \u2014 ${results.length} route(s) checked, ${changedCount} with visual changes`
2083
2582
  );
2583
+ const changedResults = results.filter((r) => r.changed);
2584
+ if (changedResults.length > 0) {
2585
+ const hero = changedResults.reduce((best, r) => r.diffPercentage > best.diffPercentage ? r : best);
2586
+ if (hero.comparePath) {
2587
+ console.log(chalk2.dim(`
2588
+ Biggest change: ${hero.route} (${hero.diffPercentage.toFixed(1)}%) \u2014 ${hero.comparePath.replace(cwd + "/", "")}`));
2589
+ }
2590
+ }
2591
+ if (!post) {
2592
+ console.log(chalk2.dim(` View report: open ${reportPath.replace(cwd + "/", "")}`));
2593
+ console.log(chalk2.dim(" Post to your PR: npx afterbefore --post\n"));
2594
+ }
2595
+ if (options.open) {
2596
+ try {
2597
+ const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
2598
+ execSync5(`${openCmd} "${reportPath}"`, { stdio: "ignore" });
2599
+ } catch {
2600
+ }
2601
+ }
2084
2602
  } finally {
2085
2603
  try {
2086
2604
  logger.writeLogFile(resolve4(outputDir, "debug.log"));
@@ -2094,7 +2612,7 @@ afterbefore v${version} \xB7 Comparing against ${base}
2094
2612
  var program = new Command();
2095
2613
  program.name("afterbefore").description(
2096
2614
  "Automatic before/after screenshot capture for PRs. Git diff is the config."
2097
- ).version("0.1.17").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(
2615
+ ).version("0.1.18").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(
2098
2616
  "--threshold <percent>",
2099
2617
  "Diff threshold percentage (changes below this are ignored)",
2100
2618
  "0.1"
@@ -2102,7 +2620,7 @@ program.name("afterbefore").description(
2102
2620
  "--max-routes <count>",
2103
2621
  "Maximum routes to capture (0 = unlimited)",
2104
2622
  "6"
2105
- ).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("--no-auto-sections", "Disable auto-detection of heading-labeled sections").option("--max-sections <count>", "Max auto-detected sections per page state", "10").action(async (opts) => {
2623
+ ).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("--no-auto-sections", "Disable auto-detection of heading-labeled sections").option("--max-sections <count>", "Max auto-detected sections per page state", "10").option("--max-depth <n>", "Max import graph traversal depth", "10").option("--dry-run", "Show affected routes without capturing", false).option("--verbose", "Show detailed import graph traversal", false).option("--open", "Open HTML report in browser after capture", false).action(async (opts) => {
2106
2624
  const cwd = process.cwd();
2107
2625
  if (!isGitRepo(cwd)) {
2108
2626
  logger.error("Not a git repository. Run this from inside a git repo.");
@@ -2122,6 +2640,10 @@ program.name("afterbefore").description(
2122
2640
  maxTabsPerRoute: parseInt(opts.maxTabs, 10),
2123
2641
  autoSections: opts.autoSections,
2124
2642
  maxSectionsPerRoute: parseInt(opts.maxSections, 10),
2643
+ maxDepth: parseInt(opts.maxDepth, 10),
2644
+ dryRun: opts.dryRun,
2645
+ verbose: opts.verbose,
2646
+ open: opts.open,
2125
2647
  cwd
2126
2648
  };
2127
2649
  try {
@@ -2134,6 +2656,8 @@ program.name("afterbefore").description(
2134
2656
  logger.error(
2135
2657
  err instanceof Error ? err.message : `Unexpected error: ${String(err)}`
2136
2658
  );
2659
+ console.error(chalk3.dim("\n Help us fix this: https://github.com/kairevicius/afterbefore/issues/new"));
2660
+ console.error(chalk3.dim(" Include the debug.log file from your output directory.\n"));
2137
2661
  }
2138
2662
  await cleanupRegistry.runAll();
2139
2663
  process.exit(1);