Skip to content

Commit d9c1c54

Browse files
jeremyfeltclaude
andcommitted
Redesign the compare report as a single-page triage UI
Replace the flat per-comparison report pages with one static reports/index.html that embeds every result as JSON (window.REGLANCE) and renders three views client-side from the location hash, with no framework and no network access: - Overview: results grouped by page, changed-only by default, diff thumbnails, +added/-removed HTML counts, search, and full keyboard triage (worst-first queue). - Comparison: swipe, side-by-side, onion skin, diff overlay, and blink modes. - HTML diff: unified diff with line numbers, hunk headers, and collapsed unchanged regions. Generator changes: - report.mjs: buildHtmlDiff() emits changed-line counts and unified-diff hunk JSON (replacing the boolean Yes/No signal); generateReport() groups per-slug results into one entry per page and writes the single page with run metadata (compared/baseline timestamps, duration). - compare.mjs: compareSlug() returns HTML add/del/hunks inline and no longer writes per-result report or html-diff files; compare() gathers run metadata and builds the single report. Templates: new index.html shell plus assets/reglance.css (the "Quiet" direction, auto light/dark) and assets/app.js (vanilla renderer). Remove the old report.html, index.html table, diff-viewer.html, html-diff.html, and their stylesheets/script. Tests and docs updated; eslint ignores the design handoff prototypes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 59ffc6a commit d9c1c54

16 files changed

Lines changed: 2942 additions & 1356 deletions

CLAUDE.md

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,10 @@ The package is a thin CLI over a set of focused modules:
3434
`context.route()` and fulfills repeats locally instead of hitting the origin.
3535
- `src/compare.mjs` — pixelmatch comparison + HTML diffing. Exports
3636
`compare(config, options)`; opens the generated report via `file://`.
37-
- `src/report.mjs` — All HTML/report generation and asset copying, driven by the
38-
templates in `templates/`.
37+
- `src/report.mjs` — Assembles the report data and writes the single-page
38+
report. `buildHtmlDiff` turns two HTML snapshots into changed-line counts and
39+
unified-diff hunk JSON; `generateReport` groups the per-slug results into one
40+
entry per page and embeds them as `window.REGLANCE` in `templates/index.html`.
3941

4042
Command modules take the normalized `config` object and an `options` object;
4143
they never read `process.argv` or the config file themselves.
@@ -50,27 +52,32 @@ config works across developers who each run against their own local domain.
5052

5153
All artifacts are written under the configured output directory (`.reglance` by
5254
default), which gets a generated `.gitignore` so nothing is committed to the host
53-
project. Subdirectories: `captures/`, `controls/`, `compares/`, `reports/`,
54-
`assets/`, and `image-cache/` when the image cache is enabled.
55+
project. Subdirectories: `captures/`, `controls/`, `compares/` (diff PNGs only),
56+
`reports/`, `assets/`, and `image-cache/` when the image cache is enabled.
5557

5658
## Templates
5759

5860
`templates/` ships with the package (listed in `package.json` `files`):
5961

60-
- `report.html` — Per-comparison visual report with a before/after slider.
61-
- `index.html` — Report index with filtering, sorting, and a diff modal.
62-
- `diff-viewer.html` — Diff modal markup/JS injected into the index.
63-
- `html-diff.html` — HTML diff report for a single comparison.
64-
- `assets/``style.css`, `index-style.css`, `script.js`, copied into the
65-
output directory at compare time.
62+
- `index.html` — The single-page report shell. Two placeholders: `{title}` (the
63+
escaped site name) and `{data}` (the script-safe `window.REGLANCE` JSON blob),
64+
both replaced via `String.replaceAll`.
65+
- `assets/reglance.css` — The report stylesheet (the "Quiet" design direction:
66+
system font stack, indigo accent, auto light/dark via `light-dark()`).
67+
- `assets/app.js` — Vanilla (no framework, no network) renderer. Reads
68+
`window.REGLANCE`, routes on the location hash, and renders the overview,
69+
comparison (five modes), and HTML-diff views with full keyboard triage.
6670

67-
Placeholders use `{name}` style tokens replaced via `String.replaceAll`.
71+
The design source lives in `design_handoff_reglance_report/` — React/Babel
72+
prototypes kept for reference only (ignored by eslint); production is the
73+
vanilla code above.
6874

6975
## File naming
7076

71-
Captures, controls, diffs, and reports are named `{pathKey}-{viewport}` (e.g.
72-
`home-desktop`). There is no project prefix; each project has its own output
73-
directory.
77+
Captures, controls, and diffs are named `{pathKey}-{viewport}` (e.g.
78+
`home-desktop`; diffs add a `-diff` suffix). There is no project prefix; each
79+
project has its own output directory. The report itself is a single
80+
`reports/index.html`.
7481

7582
## Development Notes
7683

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,11 +155,18 @@ guards against silently baselining bad data:
155155
.reglance/
156156
captures/ Latest screenshots + HTML snapshots
157157
controls/ Baseline screenshots + HTML (+ manifest.json)
158-
compares/ Diff images and HTML diffs
158+
compares/ Diff images
159159
reports/ The report — open reports/index.html
160+
assets/ Report stylesheet + script
160161
image-cache/ Cached image responses (only with imageCache enabled)
161162
```
162163

164+
The report is a single `reports/index.html` that embeds every result as JSON
165+
and renders three views client-side from the URL hash — a triage overview
166+
(grouped by page, changed-only by default, keyboard-navigable), a comparison
167+
view (swipe, side-by-side, onion skin, diff overlay, blink), and a unified HTML
168+
diff. It opens straight from disk with no network access.
169+
163170
## Development
164171

165172
```bash

eslint.config.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import config from '@happyprime/eslint-config';
22

33
export default [
4+
{
5+
// Design handoff bundle: React/Babel/JSX prototypes kept for reference,
6+
// not production code. The real report lives in templates/ and src/.
7+
ignores: ['design_handoff_reglance_report/**'],
8+
},
49
...config,
510
{
611
// reglance is a CLI: console.log/warn ARE its user-facing output, so

src/compare.mjs

Lines changed: 100 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,7 @@ import { diffLines } from 'diff';
99
import open from 'open';
1010
import { filterTargets } from './config.mjs';
1111
import { readManifest, detectStaleControls } from './manifest.mjs';
12-
import {
13-
copyAssets,
14-
generateHtmlDiff,
15-
generateIndex,
16-
generateReport,
17-
} from './report.mjs';
12+
import { copyAssets, buildHtmlDiff, generateReport } from './report.mjs';
1813

1914
// Skip the inline HTML line-diff above this snapshot size; line-diffing a
2015
// multi-MB (often minified) document is slow and produces no useful result.
@@ -85,13 +80,6 @@ export function compareSlug(config, target, viewport) {
8580
let img1 = PNG.sync.read(fs.readFileSync(controlImage));
8681
let img2 = PNG.sync.read(fs.readFileSync(captureImage));
8782

88-
// Remember each image's natural size before padding so the report can
89-
// declare width/height and reserve the box (no layout shift on decode).
90-
const controlWidth = img1.width;
91-
const controlHeight = img1.height;
92-
const captureWidth = img2.width;
93-
const captureHeight = img2.height;
94-
9583
// Pad both onto a common canvas so width changes (a real layout
9684
// regression) surface as a large diff instead of dropping the slug from
9785
// the report. Height was already handled this way; width is too now.
@@ -116,10 +104,11 @@ export function compareSlug(config, target, viewport) {
116104
const totalPixels = maxWidth * maxHeight;
117105
const diffPercentage = (numDiffPixels / totalPixels) * 100;
118106

119-
// Compare the captured HTML snapshots when both exist.
107+
// Compare the captured HTML snapshots when both exist, emitting changed-line
108+
// counts and unified-diff hunks the report renders client-side.
120109
const controlHtml = path.join(dirs.controlsHtml, `${slug}.html`);
121110
const captureHtml = path.join(dirs.capturesHtml, `${slug}.html`);
122-
let htmlResult = { hasChanges: false, html: '' };
111+
let htmlResult = { add: 0, del: 0, hunks: [], note: '' };
123112
if (fs.existsSync(controlHtml) && fs.existsSync(captureHtml)) {
124113
// Guard pathological inputs: a multi-MB (often minified) document can
125114
// grind through the line diff for no useful result.
@@ -129,43 +118,37 @@ export function compareSlug(config, target, viewport) {
129118

130119
if (tooBig) {
131120
htmlResult = {
132-
hasChanges: false,
133-
html: `<!doctype html><meta charset="utf-8"><title>${slug}</title><p>HTML snapshot too large to diff inline (over ${Math.round(HTML_DIFF_MAX_BYTES / 1024 / 1024)}MB). Compare the captured HTML files directly.</p>`,
121+
add: 0,
122+
del: 0,
123+
hunks: [],
124+
note: `HTML snapshot too large to diff inline (over ${Math.round(HTML_DIFF_MAX_BYTES / 1024 / 1024)}MB). Compare the captured HTML files directly.`,
134125
};
135126
} else {
136-
htmlResult = generateHtmlDiff(
137-
fs.readFileSync(controlHtml, 'utf8'),
138-
fs.readFileSync(captureHtml, 'utf8'),
139-
{ name: config.name, urlKey: target.key, viewport },
140-
diffLines
141-
);
127+
htmlResult = {
128+
...buildHtmlDiff(
129+
fs.readFileSync(controlHtml, 'utf8'),
130+
fs.readFileSync(captureHtml, 'utf8'),
131+
diffLines
132+
),
133+
note: '',
134+
};
142135
}
143136
}
144137

145-
const htmlDiffPath = path.join(dirs.compares, `${slug}-html-diff.html`);
146-
writeArtifact(htmlDiffPath, htmlResult.html);
147-
148-
const report = {
138+
return {
149139
url: target.url,
150140
urlKey: target.key,
141+
path: target.path,
151142
viewport,
152143
controlImage,
153144
captureImage,
154145
diffImage,
155146
diffPercentage,
156-
htmlDiffPath,
157-
htmlHasChanges: htmlResult.hasChanges,
158-
controlWidth,
159-
controlHeight,
160-
captureWidth,
161-
captureHeight,
162-
diffWidth: maxWidth,
163-
diffHeight: maxHeight,
147+
htmlAdd: htmlResult.add,
148+
htmlDel: htmlResult.del,
149+
htmlHunks: htmlResult.hunks,
150+
htmlNote: htmlResult.note,
164151
};
165-
166-
report.reportPath = generateReport(config, report);
167-
168-
return report;
169152
}
170153

171154
/**
@@ -262,6 +245,81 @@ async function runComparisons(config, jobs, concurrency) {
262245
return reports;
263246
}
264247

248+
/**
249+
* Format a timestamp for the report, e.g. "Jun 12, 2026 · 10:41 AM".
250+
*
251+
* @param {number|string|Date|null} value A Date, epoch ms, or ISO string.
252+
* @returns {string} The formatted timestamp, or an empty string when absent.
253+
*/
254+
function formatTimestamp(value) {
255+
if (!value) {
256+
return '';
257+
}
258+
const date = new Date(value);
259+
if (Number.isNaN(date.getTime())) {
260+
return '';
261+
}
262+
const day = date.toLocaleDateString('en-US', {
263+
month: 'short',
264+
day: 'numeric',
265+
year: 'numeric',
266+
});
267+
const time = date.toLocaleTimeString('en-US', {
268+
hour: 'numeric',
269+
minute: '2-digit',
270+
});
271+
return `${day} · ${time}`;
272+
}
273+
274+
/**
275+
* Format a run duration in milliseconds as a compact string, e.g. "48s".
276+
*
277+
* @param {number} ms The elapsed milliseconds.
278+
* @returns {string} The formatted duration.
279+
*/
280+
function formatDuration(ms) {
281+
const seconds = Math.max(0, Math.round(ms / 1000));
282+
if (seconds < 60) {
283+
return `${seconds}s`;
284+
}
285+
const minutes = Math.floor(seconds / 60);
286+
const rest = seconds % 60;
287+
return rest ? `${minutes}m ${rest}s` : `${minutes}m`;
288+
}
289+
290+
/**
291+
* Derive the run metadata shown in the report header.
292+
*
293+
* The capture timestamp comes from the newest compared capture's modified
294+
* time; the baseline timestamp from the controls manifest. Both are best
295+
* effort — an empty string just hides that line in the report.
296+
*
297+
* @param {object} config The normalized config.
298+
* @param {Array} reports The comparison results.
299+
* @param {number} duration The compare run's elapsed milliseconds.
300+
* @returns {{ comparedAt: string, baselineAt: string, duration: string }} The metadata.
301+
*/
302+
function buildRunMeta(config, reports, duration) {
303+
let newestCapture = 0;
304+
for (const report of reports) {
305+
try {
306+
const { mtimeMs } = fs.statSync(report.captureImage);
307+
if (mtimeMs > newestCapture) {
308+
newestCapture = mtimeMs;
309+
}
310+
} catch {
311+
// A missing capture (already warned about elsewhere) just doesn't
312+
// contribute to the timestamp.
313+
}
314+
}
315+
316+
return {
317+
comparedAt: formatTimestamp(newestCapture || null),
318+
baselineAt: formatTimestamp(readManifest(config.dirs).updatedAt),
319+
duration: formatDuration(duration),
320+
};
321+
}
322+
265323
/**
266324
* Compare every captured target against its control and build the report.
267325
*
@@ -276,6 +334,7 @@ export async function compare(config, options = {}) {
276334
const { open: openReport = true } = options;
277335
const concurrency =
278336
options.concurrency ?? Math.max(1, availableParallelism() - 1);
337+
const startedAt = Date.now();
279338

280339
fs.mkdirSync(dirs.compares, { recursive: true });
281340
fs.mkdirSync(dirs.reports, { recursive: true });
@@ -299,7 +358,8 @@ export async function compare(config, options = {}) {
299358
return;
300359
}
301360

302-
const indexPath = generateIndex(config, reports);
361+
const meta = buildRunMeta(config, reports, Date.now() - startedAt);
362+
const indexPath = generateReport(config, reports, meta);
303363
const reportUrl = pathToFileURL(indexPath).href;
304364

305365
console.log(`\n✅ Comparison complete (${reports.length} comparisons)`);

0 commit comments

Comments
 (0)