veza/apps/web/scripts/compare-visual.mjs
senke 39b2b642d2 feat(web): UI premium Discord/Spotify-like — tokens, shadows, focus, layout
Plan UI premium 6–8 semaines (design system, shell, Storybook, a11y):

- Design system: DESIGN_TOKENS.md, APP_SHELL.md, FULL_LAYOUT_PAGE.md. Single source
  for layout/shell (index.css), shadows (design-system.css), durations/easing.
- Tokens: shadow-cover-depth, shadow-gold-glow, shadow-fab-glow; layout max-height
  (max-h-layout-drawer, max-h-layout-panel, max-h-layout-list). All duration-200/300/500
  replaced by --duration-fast/normal/slow. Arbitrary shadows replaced by token classes.
- Shell & player: Sidebar, Header, GlobalPlayer, MiniPlayer, PlayerQueue, PlayerControls,
  AudioPlayer use tokens; focus-visible on Sidebar, PlayerQueue, DropdownMenuTrigger/Item,
  TabsTrigger. Typography: text-[10px]/[9px] → text-xs where applicable.
- ESLint: no-restricted-syntax (warn) for w-/h-/rounded-/shadow-/text-/spacing arbitrary.
- Scripts: report-arbitrary-values.mjs, capture/compare/generate visual; visual-complete.spec.ts.
- Stories full layout: Dashboard, Playlists, Library, Settings, Profile in DashboardLayout.stories.
- .cursorrules + README: DESIGN_TOKENS, APP_SHELL, visual commands, no arbitrary without justification.
- apps/web/.gitignore: e2e test artifacts (test-results-visual, playwright-report-visual).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 17:15:58 +01:00

189 lines
5.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* Compare visual-tests/baselines/ vs visual-tests/current/ using pixelmatch.
* Writes diffs to visual-tests/diffs/ and report to visual-tests/reports/.
*
* Usage: node scripts/compare-visual.mjs [threshold]
* threshold Pixelmatch threshold 01 (default: VISUAL_DIFF_THRESHOLD or 0.1)
*
* Exit code: 0 if no diffs exceed maxDiffPixels (env VISUAL_MAX_DIFF_PIXELS, default 0).
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import pixelmatch from 'pixelmatch';
import { PNG } from 'pngjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const BASELINES = path.join(ROOT, 'visual-tests', 'baselines');
const CURRENT = path.join(ROOT, 'visual-tests', 'current');
const DIFFS = path.join(ROOT, 'visual-tests', 'diffs');
const REPORTS = path.join(ROOT, 'visual-tests', 'reports');
const THRESHOLD = parseFloat(process.env.VISUAL_DIFF_THRESHOLD || '0.1', 10);
const MAX_DIFF_PIXELS = process.env.VISUAL_MAX_DIFF_PIXELS
? parseInt(process.env.VISUAL_MAX_DIFF_PIXELS, 10)
: 0;
function loadPng(filePath) {
const data = fs.readFileSync(filePath);
return PNG.sync.read(data);
}
function compare(baselinePath, currentPath, diffPath, threshold) {
const imgExpected = loadPng(baselinePath);
const imgActual = loadPng(currentPath);
if (imgExpected.width !== imgActual.width || imgExpected.height !== imgActual.height) {
return {
error: `Dimensions differ: ${imgExpected.width}x${imgExpected.height} vs ${imgActual.width}x${imgActual.height}`,
diffPixels: null,
totalPixels: imgExpected.width * imgExpected.height,
};
}
const { width, height } = imgExpected;
const totalPixels = width * height;
const diff = new PNG({ width, height });
const numDiffPixels = pixelmatch(
imgExpected.data,
imgActual.data,
diff.data,
width,
height,
{ threshold }
);
fs.mkdirSync(path.dirname(diffPath), { recursive: true });
fs.writeFileSync(diffPath, PNG.sync.write(diff));
return { diffPixels: numDiffPixels, totalPixels, error: null };
}
function listPngs(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir).filter((f) => f.endsWith('.png'));
}
function generateHtmlReport(results, threshold) {
const rows = results
.map(
(r) => `
<tr class="${r.pass ? 'pass' : 'fail'}">
<td><code>${r.name}</code></td>
<td>${r.diffPixels ?? '—'}</td>
<td>${r.totalPixels ?? '—'}</td>
<td>${r.pass ? 'Pass' : 'Fail'}</td>
<td>${r.error ?? ''}</td>
<td>
${r.diffPath ? `<a href="../diffs/${path.basename(r.diffPath)}">diff</a>` : ''}
${r.baselinePath ? `<a href="../baselines/${path.basename(r.baselinePath)}">baseline</a>` : ''}
${r.currentPath ? `<a href="../current/${path.basename(r.currentPath)}">current</a>` : ''}
</td>
</tr>`
)
.join('');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Visual diff report</title>
<style>
body { font-family: system-ui, sans-serif; margin: 1rem 2rem; }
table { border-collapse: collapse; }
th, td { border: 1px solid #333; padding: 0.5rem 0.75rem; text-align: left; }
th { background: #1a1a1a; color: #eee; }
.pass { background: #0d2b0d; }
.fail { background: #2b0d0d; }
code { font-size: 0.9em; }
.meta { color: #888; font-size: 0.9rem; margin-bottom: 1rem; }
</style>
</head>
<body>
<h1>Visual diff report</h1>
<p class="meta">Threshold: ${threshold} | Max diff pixels (pass): ${MAX_DIFF_PIXELS} | Generated: ${new Date().toISOString()}</p>
<table>
<thead>
<tr>
<th>Screen</th>
<th>Diff pixels</th>
<th>Total pixels</th>
<th>Result</th>
<th>Error</th>
<th>Links</th>
</tr>
</thead>
<tbody>${rows}
</tbody>
</table>
</body>
</html>`;
}
async function main() {
const threshold = parseFloat(process.argv[2] || process.env.VISUAL_DIFF_THRESHOLD || '0.1', 10);
console.log('Comparing baselines vs current');
console.log(' Threshold:', threshold);
console.log(' Max diff pixels (pass):', MAX_DIFF_PIXELS);
const baselineFiles = listPngs(BASELINES);
if (baselineFiles.length === 0) {
console.log('No baseline PNGs in visual-tests/baselines/');
process.exit(0);
}
const results = [];
for (const name of baselineFiles) {
const baselinePath = path.join(BASELINES, name);
const currentPath = path.join(CURRENT, name);
if (!fs.existsSync(currentPath)) {
results.push({
name,
baselinePath,
currentPath: null,
diffPath: null,
diffPixels: null,
totalPixels: null,
error: 'No current file',
pass: false,
});
console.log(name, '— no current file');
continue;
}
const diffPath = path.join(DIFFS, name);
const out = compare(baselinePath, currentPath, diffPath, threshold);
const pass = out.error ? false : (out.diffPixels <= MAX_DIFF_PIXELS);
results.push({
name,
baselinePath,
currentPath,
diffPath: out.error ? null : diffPath,
diffPixels: out.diffPixels,
totalPixels: out.totalPixels,
error: out.error,
pass,
});
console.log(name, '— diff pixels:', out.diffPixels ?? out.error, pass ? '✓' : '✗');
}
fs.mkdirSync(REPORTS, { recursive: true });
fs.writeFileSync(
path.join(REPORTS, 'results.json'),
JSON.stringify({ threshold, maxDiffPixels: MAX_DIFF_PIXELS, results }, null, 2)
);
fs.writeFileSync(path.join(REPORTS, 'index.html'), generateHtmlReport(results, threshold));
console.log('Report:', path.join(REPORTS, 'index.html'));
const failed = results.filter((r) => !r.pass);
process.exit(failed.length > 0 ? 1 : 0);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});