afterbefore 0.1.16 → 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";
@@ -107,7 +108,6 @@ var Logger = class {
107
108
  this.log("step", `${step}/${this.pipelineTotal} ${label}`);
108
109
  const text = chalk.dim(this.renderPipeline(step, label));
109
110
  if (!this.isTTY) {
110
- console.error(text);
111
111
  return;
112
112
  }
113
113
  if (!this.spinner) {
@@ -120,6 +120,18 @@ var Logger = class {
120
120
  this.spinner.text = text;
121
121
  this.spinner.render();
122
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
+ }
123
135
  completePipeline(finished = false) {
124
136
  this.pipelineActive = false;
125
137
  this.log("info", `Pipeline ${finished ? "completed" : "stopped"}`);
@@ -218,11 +230,22 @@ var cleanupRegistry = new CleanupRegistry();
218
230
  // src/utils/git.ts
219
231
  import { execSync } from "child_process";
220
232
  function git(args, cwd) {
221
- return execSync(`git ${args}`, {
222
- cwd,
223
- encoding: "utf-8",
224
- stdio: ["pipe", "pipe", "pipe"]
225
- }).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
+ }
226
249
  }
227
250
  function isGitRepo(cwd) {
228
251
  try {
@@ -233,7 +256,14 @@ function isGitRepo(cwd) {
233
256
  }
234
257
  }
235
258
  function getMergeBase(base, cwd) {
236
- 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
+ }
237
267
  }
238
268
  function getDiffNameStatus(base, cwd) {
239
269
  const mergeBase = getMergeBase(base, cwd);
@@ -248,8 +278,10 @@ function getCurrentBranch(cwd) {
248
278
  }
249
279
 
250
280
  // src/pipeline.ts
251
- import { resolve as resolve4 } from "path";
252
- 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";
253
285
 
254
286
  // src/config.ts
255
287
  import { resolve } from "path";
@@ -737,8 +769,8 @@ function sanitizeLabel(label, maxLength = 40) {
737
769
  }
738
770
 
739
771
  // src/stages/impact.ts
740
- var MAX_DEPTH = 3;
741
- 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) {
742
774
  const routeMap = /* @__PURE__ */ new Map();
743
775
  for (const file of changedFiles) {
744
776
  const visited = /* @__PURE__ */ new Set();
@@ -760,7 +792,7 @@ function findAffectedRoutes(changedFiles, graph, projectRoot, maxRoutes = 0) {
760
792
  });
761
793
  }
762
794
  }
763
- if (depth >= MAX_DEPTH) continue;
795
+ if (depth >= maxDepth) continue;
764
796
  const importers = graph.reverse.get(path);
765
797
  if (!importers) continue;
766
798
  for (const importer of importers) {
@@ -803,7 +835,7 @@ function findAffectedRoutes(changedFiles, graph, projectRoot, maxRoutes = 0) {
803
835
  logger.dim(`Found ${routes.length} affected route(s)`);
804
836
  return routes;
805
837
  }
806
- function findRoutesForFile(file, graph) {
838
+ function findRoutesForFile(file, graph, maxDepth = DEFAULT_MAX_DEPTH) {
807
839
  const routes = /* @__PURE__ */ new Set();
808
840
  const start = normalizePath(file);
809
841
  const queue = [{ path: start, depth: 0 }];
@@ -814,7 +846,7 @@ function findRoutesForFile(file, graph) {
814
846
  const route = pagePathToRoute(path);
815
847
  if (route) routes.add(route);
816
848
  }
817
- if (depth >= MAX_DEPTH) continue;
849
+ if (depth >= maxDepth) continue;
818
850
  const importers = graph.reverse.get(path);
819
851
  if (!importers) continue;
820
852
  for (const importer of importers) {
@@ -1119,10 +1151,17 @@ async function launchBrowser() {
1119
1151
  return await chromium.launch();
1120
1152
  } catch {
1121
1153
  logger.dim("Chromium not found, installing...");
1122
- execSync3("npx playwright install chromium", {
1123
- stdio: ["pipe", "pipe", "pipe"]
1124
- });
1125
- 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
+ }
1126
1165
  }
1127
1166
  }
1128
1167
  var MAX_COMPONENT_INSTANCES_PER_SOURCE = 5;
@@ -1672,55 +1711,101 @@ async function trimImage(path) {
1672
1711
  const { data, info } = await sharp(path).trim({ threshold: 50 }).toBuffer({ resolveWithObject: true });
1673
1712
  return { data, width: info.width, height: info.height };
1674
1713
  }
1675
- 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) {
1676
1724
  const [beforeTrimmed, afterTrimmed] = await Promise.all([
1677
1725
  trimImage(beforePath),
1678
1726
  trimImage(afterPath)
1679
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);
1680
1737
  const imgW = Math.max(beforeTrimmed.width, afterTrimmed.width);
1681
1738
  const imgH = Math.max(beforeTrimmed.height, afterTrimmed.height);
1682
- const PADDING = 40;
1683
- const GAP = 40;
1684
- const LABEL_H = 70;
1685
- const canvasW = Math.max(600, Math.min(2400, imgW * 2 + GAP + PADDING * 2));
1686
- const canvasH = Math.max(300, Math.min(2400, imgH + LABEL_H + PADDING * 2));
1687
- const maxImgH = canvasH - PADDING * 2 - LABEL_H;
1688
- 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;
1689
1742
  const [beforeBuf, afterBuf] = await Promise.all(
1690
1743
  [beforeTrimmed, afterTrimmed].map(async (trimmed) => {
1691
- 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
+ };
1692
1750
  })
1693
1751
  );
1694
- const beforeLeft = PADDING + Math.floor((colW - beforeBuf.info.width) / 2);
1695
- const beforeTop = PADDING + Math.floor((maxImgH - beforeBuf.info.height) / 2);
1696
- const afterLeft = PADDING + colW + GAP + Math.floor((colW - afterBuf.info.width) / 2);
1697
- const afterTop = PADDING + Math.floor((maxImgH - afterBuf.info.height) / 2);
1698
- const labelY = PADDING + maxImgH + 20;
1699
- const beforeLabelX = PADDING + Math.floor(colW / 2);
1700
- const afterLabelX = PADDING + colW + GAP + Math.floor(colW / 2);
1701
- const labelSvg = Buffer.from(
1702
- `<svg width="${canvasW}" height="${canvasH}">
1703
- <text x="${beforeLabelX}" y="${labelY}" text-anchor="middle"
1704
- font-family="system-ui, sans-serif" font-size="30" font-weight="500"
1705
- fill="#888">Before</text>
1706
- <text x="${afterLabelX}" y="${labelY}" text-anchor="middle"
1707
- 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"
1708
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>
1709
1791
  </svg>`
1710
1792
  );
1711
1793
  await sharp({
1712
1794
  create: {
1713
- width: canvasW,
1714
- height: canvasH,
1795
+ width: CANVAS_W,
1796
+ height: CANVAS_H,
1715
1797
  channels: 4,
1716
- background: bgColor
1798
+ background: "#1a1a2e"
1717
1799
  }
1718
1800
  }).composite([
1719
- { input: beforeBuf.data, left: beforeLeft, top: beforeTop },
1720
- { input: afterBuf.data, left: afterLeft, top: afterTop },
1721
- { 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 }
1722
1804
  ]).png().toFile(outputPath);
1723
1805
  }
1806
+ function escapeXml(str) {
1807
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1808
+ }
1724
1809
  async function compareOne(capture, outputDir, threshold, server, options) {
1725
1810
  const diffPath = join7(outputDir, `${capture.prefix}-diff.png`);
1726
1811
  const dir = dirname2(capture.beforePath);
@@ -1748,18 +1833,30 @@ async function compareOne(capture, outputDir, threshold, server, options) {
1748
1833
  diffPercentage = 100;
1749
1834
  changed = true;
1750
1835
  }
1751
- await generateComposite(
1752
- capture.beforePath,
1753
- capture.afterPath,
1754
- comparePath,
1755
- options.bgColor
1756
- );
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
+ }
1757
1854
  return {
1758
1855
  route: capture.route,
1759
1856
  prefix: capture.prefix,
1760
1857
  beforePath: capture.beforePath,
1761
1858
  afterPath: capture.afterPath,
1762
- comparePath,
1859
+ comparePath: changed ? comparePath : "",
1763
1860
  diffPixels,
1764
1861
  totalPixels,
1765
1862
  diffPercentage,
@@ -1831,7 +1928,21 @@ function generateSummaryMd(results, gitDiff, options) {
1831
1928
 
1832
1929
  // src/stages/report.ts
1833
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
+ }
1834
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
+ }
1835
1946
  try {
1836
1947
  const output = execSync4("gh pr view --json number -q .number", {
1837
1948
  encoding: "utf-8",
@@ -1883,7 +1994,7 @@ async function generateReport(results, outputDir, options) {
1883
1994
  const prNumber = findPrNumber();
1884
1995
  if (!prNumber) {
1885
1996
  logger.warn(
1886
- "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."
1887
1998
  );
1888
1999
  return;
1889
2000
  }
@@ -1893,6 +2004,317 @@ async function generateReport(results, outputDir, options) {
1893
2004
  }
1894
2005
  }
1895
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
+
1896
2318
  // src/pipeline.ts
1897
2319
  function generateSessionName(cwd) {
1898
2320
  const branch = getCurrentBranch(cwd);
@@ -1944,26 +2366,60 @@ function expandRoutes(routes, config, routeComponentMap) {
1944
2366
  }
1945
2367
  return tasks;
1946
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
+ }
1947
2398
  async function runPipeline(options) {
1948
2399
  const { base, output, post, cwd } = options;
1949
2400
  const sessionName = generateSessionName(cwd);
1950
2401
  const outputDir = resolve4(cwd, output, sessionName);
1951
2402
  const startTime = Date.now();
1952
2403
  try {
1953
- const version = true ? "0.1.16" : "dev";
2404
+ const version = true ? "0.1.18" : "dev";
2405
+ const mode = options.dryRun ? "Dry run" : "Comparing";
1954
2406
  console.log(`
1955
- afterbefore v${version} \xB7 Comparing against ${base}
2407
+ afterbefore v${version} \xB7 ${mode} against ${base}
1956
2408
  `);
1957
2409
  const config = await loadConfig(cwd);
1958
- logger.startPipeline(8);
2410
+ applyConfigDefaults(options, config);
2411
+ logger.startPipeline(options.dryRun ? 3 : 8);
2412
+ const t1 = Date.now();
1959
2413
  logger.pipeline(1, "Analyzing diff...");
1960
2414
  const diffFiles = getChangedFiles(base, cwd);
1961
- const gitDiff = getGitDiff(base, cwd);
2415
+ const gitDiff = options.dryRun ? "" : getGitDiff(base, cwd);
2416
+ logger.stageComplete("Diff", `${diffFiles.length} files changed`, Date.now() - t1);
1962
2417
  if (diffFiles.length === 0) {
1963
2418
  logger.completePipeline();
1964
2419
  logger.success("No changed files detected. Nothing to do.");
1965
2420
  return;
1966
2421
  }
2422
+ const t2 = Date.now();
1967
2423
  const classified = classifyFiles(diffFiles);
1968
2424
  const impactfulFiles = classified.filter(
1969
2425
  (f) => f.category !== "test" && f.category !== "other"
@@ -1976,11 +2432,14 @@ afterbefore v${version} \xB7 Comparing against ${base}
1976
2432
  return;
1977
2433
  }
1978
2434
  logger.pipeline(2, "Building import graph...");
1979
- const worktreePromise = createWorktree(base, cwd);
2435
+ const worktreePromise = options.dryRun ? null : createWorktree(base, cwd);
1980
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();
1981
2440
  logger.pipeline(3, "Finding affected routes...");
1982
2441
  const changedPaths = impactfulFiles.map((f) => f.path);
1983
- let affectedRoutes = findAffectedRoutes(changedPaths, graph, cwd, options.maxRoutes);
2442
+ let affectedRoutes = findAffectedRoutes(changedPaths, graph, cwd, options.maxRoutes, options.maxDepth);
1984
2443
  const changedComponentFiles = impactfulFiles.filter((f) => f.category === "component").map((f) => f.path);
1985
2444
  const routeComponentMap = mapRouteToChangedComponents(changedComponentFiles, graph);
1986
2445
  if (affectedRoutes.length === 0) {
@@ -2007,8 +2466,21 @@ afterbefore v${version} \xB7 Comparing against ${base}
2007
2466
  }
2008
2467
  }
2009
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
+ }
2010
2482
  if (affectedRoutes.length === 0) {
2011
- worktreePromise.then((w) => w.cleanup()).catch(() => {
2483
+ worktreePromise?.then((w) => w.cleanup()).catch(() => {
2012
2484
  });
2013
2485
  logger.completePipeline();
2014
2486
  logger.success(
@@ -2016,8 +2488,29 @@ afterbefore v${version} \xB7 Comparing against ${base}
2016
2488
  );
2017
2489
  return;
2018
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();
2019
2510
  logger.pipeline(4, "Setting up worktree...");
2020
2511
  const worktree = await worktreePromise;
2512
+ logger.stageComplete("Worktree", "created + dependencies installed", Date.now() - t4);
2513
+ const t5 = Date.now();
2021
2514
  logger.pipeline(5, "Starting servers...");
2022
2515
  await ensureDir(outputDir);
2023
2516
  const beforePort = await findAvailablePort();
@@ -2030,6 +2523,8 @@ afterbefore v${version} \xB7 Comparing against ${base}
2030
2523
  cleanupRegistry.register(() => stopServer(beforeServer));
2031
2524
  cleanupRegistry.register(() => stopServer(afterServer));
2032
2525
  cleanupRegistry.register(() => browser.close());
2526
+ logger.stageComplete("Servers", `ready on :${beforePort} and :${afterPort}`, Date.now() - t5);
2527
+ const t6 = Date.now();
2033
2528
  logger.pipeline(6, "Capturing screenshots...");
2034
2529
  const tasks = expandRoutes(affectedRoutes, config, routeComponentMap);
2035
2530
  const captures = await captureRoutes(
@@ -2050,38 +2545,60 @@ afterbefore v${version} \xB7 Comparing against ${base}
2050
2545
  onProgress: (i, label) => logger.pipeline(6, `Capturing ${label} (${i}/${tasks.length})...`)
2051
2546
  }
2052
2547
  );
2548
+ logger.stageComplete("Capture", `${captures.length} screenshots from ${tasks.length} routes`, Date.now() - t6);
2549
+ const t7 = Date.now();
2053
2550
  logger.pipeline(7, "Comparing screenshots...");
2054
2551
  const bgColor = detectBgColor(cwd);
2055
2552
  const allResults = await compareScreenshots(captures, outputDir, options.threshold, { bgColor });
2056
2553
  const results = allResults.filter((r) => {
2057
2554
  const isSubCapture = r.prefix.includes("~");
2058
2555
  if (isSubCapture && !r.changed) {
2059
- try {
2060
- unlinkSync2(r.beforePath);
2061
- } catch {
2062
- }
2063
- try {
2064
- unlinkSync2(r.afterPath);
2065
- } catch {
2066
- }
2067
- try {
2068
- unlinkSync2(r.comparePath);
2069
- } catch {
2556
+ if (r.comparePath) {
2557
+ try {
2558
+ unlinkSync2(r.comparePath);
2559
+ } catch {
2560
+ }
2070
2561
  }
2071
2562
  return false;
2072
2563
  }
2073
2564
  return true;
2074
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();
2075
2570
  logger.pipeline(8, "Generating report...");
2076
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);
2077
2576
  const summary = generateSummaryMd(results, gitDiff);
2078
2577
  logger.completePipeline(true);
2079
2578
  console.log("\n" + summary);
2080
2579
  const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
2081
- const changedCount = results.filter((r) => r.changed).length;
2082
2580
  logger.success(
2083
- `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`
2084
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
+ }
2085
2602
  } finally {
2086
2603
  try {
2087
2604
  logger.writeLogFile(resolve4(outputDir, "debug.log"));
@@ -2095,7 +2612,7 @@ afterbefore v${version} \xB7 Comparing against ${base}
2095
2612
  var program = new Command();
2096
2613
  program.name("afterbefore").description(
2097
2614
  "Automatic before/after screenshot capture for PRs. Git diff is the config."
2098
- ).version("0.1.16").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(
2099
2616
  "--threshold <percent>",
2100
2617
  "Diff threshold percentage (changes below this are ignored)",
2101
2618
  "0.1"
@@ -2103,7 +2620,7 @@ program.name("afterbefore").description(
2103
2620
  "--max-routes <count>",
2104
2621
  "Maximum routes to capture (0 = unlimited)",
2105
2622
  "6"
2106
- ).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) => {
2107
2624
  const cwd = process.cwd();
2108
2625
  if (!isGitRepo(cwd)) {
2109
2626
  logger.error("Not a git repository. Run this from inside a git repo.");
@@ -2123,6 +2640,10 @@ program.name("afterbefore").description(
2123
2640
  maxTabsPerRoute: parseInt(opts.maxTabs, 10),
2124
2641
  autoSections: opts.autoSections,
2125
2642
  maxSectionsPerRoute: parseInt(opts.maxSections, 10),
2643
+ maxDepth: parseInt(opts.maxDepth, 10),
2644
+ dryRun: opts.dryRun,
2645
+ verbose: opts.verbose,
2646
+ open: opts.open,
2126
2647
  cwd
2127
2648
  };
2128
2649
  try {
@@ -2135,6 +2656,8 @@ program.name("afterbefore").description(
2135
2656
  logger.error(
2136
2657
  err instanceof Error ? err.message : `Unexpected error: ${String(err)}`
2137
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"));
2138
2661
  }
2139
2662
  await cleanupRegistry.runAll();
2140
2663
  process.exit(1);