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>
189 lines
5.8 KiB
JavaScript
189 lines
5.8 KiB
JavaScript
#!/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 0–1 (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);
|
||
});
|