veza/apps/web/scripts/generate-visual-report.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

197 lines
6.2 KiB
JavaScript
Raw Permalink 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/ with pixelmatch.
* Produit un rapport JSON et HTML dans visual-tests/reports/ et les diffs dans visual-tests/diffs/.
*
* Usage: node scripts/generate-visual-report.mjs [threshold]
* threshold Sensibilité pixelmatch 01 (défaut: VISUAL_DIFF_THRESHOLD ou 0.1)
*
* Exit code: 0 si aucune diff ne dépasse maxDiffPixels (env VISUAL_MAX_DIFF_PIXELS, défaut 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.argv[2] || 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 regression 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 regression 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() {
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/');
fs.mkdirSync(REPORTS, { recursive: true });
fs.writeFileSync(
path.join(REPORTS, 'results.json'),
JSON.stringify({ threshold: THRESHOLD, maxDiffPixels: MAX_DIFF_PIXELS, results: [] }, null, 2)
);
fs.writeFileSync(path.join(REPORTS, 'index.html'), generateHtmlReport([], THRESHOLD));
process.exit(0);
return;
}
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 });
const jsonPath = path.join(REPORTS, 'results.json');
const htmlPath = path.join(REPORTS, 'index.html');
fs.writeFileSync(
jsonPath,
JSON.stringify({ threshold: THRESHOLD, maxDiffPixels: MAX_DIFF_PIXELS, results }, null, 2)
);
fs.writeFileSync(htmlPath, generateHtmlReport(results, THRESHOLD));
console.log('Report JSON:', jsonPath);
console.log('Report HTML:', htmlPath);
const failed = results.filter((r) => !r.pass);
process.exit(failed.length > 0 ? 1 : 0);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});