veza/apps/web/scripts/capture-visual-baseline.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

131 lines
4.2 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
/**
* Capture visual baseline or current screenshots for pixel-accurate regression.
* Usage:
* node scripts/capture-visual-baseline.mjs [--baseline]
* --baseline Write to visual-tests/baselines/ (default: visual-tests/current/)
*
* Requires: dev server running at baseURL (default http://localhost:5173).
* Set PLAYWRIGHT_BASE_URL or VITE_FRONTEND_URL to override.
*
* Viewport: 1280×720, dark theme, reduced motion for stability.
*/
import { chromium } from 'playwright';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || process.env.VITE_FRONTEND_URL || 'http://localhost:5173';
const OUT_DIR = process.argv.includes('--baseline')
? path.join(ROOT, 'visual-tests', 'baselines')
: path.join(ROOT, 'visual-tests', 'current');
const VIEWPORT = { width: 1280, height: 720 };
const ANIMATION_SETTLE_MS = 800;
function ensureDarkTheme(page) {
return page.evaluate(() => {
document.documentElement.classList.add('dark');
document.documentElement.setAttribute('data-theme', 'dark');
});
}
async function waitSettle(page, ms = ANIMATION_SETTLE_MS) {
await page.waitForTimeout(ms);
}
async function captureFull(page, name) {
await ensureDarkTheme(page);
await waitSettle(page);
const file = path.join(OUT_DIR, `${name}-desktop.png`);
fs.mkdirSync(OUT_DIR, { recursive: true });
await page.screenshot({ path: file, fullPage: true });
console.log(' captured:', file);
}
async function captureLocator(page, locator, name) {
await ensureDarkTheme(page);
await waitSettle(page);
const file = path.join(OUT_DIR, `${name}-desktop.png`);
fs.mkdirSync(OUT_DIR, { recursive: true });
await locator.screenshot({ path: file });
console.log(' captured:', file);
}
const CRITICAL_SCREENS = [
{ name: 'login', url: '/login', auth: false, full: true },
{ name: 'register', url: '/register', auth: false, full: true },
{ name: 'dashboard', url: '/dashboard', auth: true, full: true },
{ name: 'sidebar', url: '/dashboard', auth: true, locator: 'aside.fixed' },
{ name: 'playlists', url: '/playlists', auth: true, full: true },
{ name: 'library', url: '/library', auth: true, full: true },
{ name: '404', url: '/non-existent-route-404', auth: false, full: true },
];
async function main() {
console.log('Capture target:', OUT_DIR);
console.log('Base URL:', BASE_URL);
const browser = await chromium.launch({ headless: true });
const storageStatePath = path.join(ROOT, 'e2e', '.auth', 'user.json');
const hasAuth = fs.existsSync(storageStatePath);
const contextOptions = {
viewport: VIEWPORT,
deviceScaleFactor: 1,
locale: 'en-US',
reducedMotion: 'reduce',
};
if (hasAuth) {
try {
contextOptions.storageState = storageStatePath;
} catch (e) {
console.warn('Could not set auth state:', e.message);
}
} else {
console.warn('No e2e/.auth/user.json — authenticated screens may redirect to login.');
}
const context = await browser.newContext(contextOptions);
const page = await context.newPage();
for (const screen of CRITICAL_SCREENS) {
if (screen.auth && !hasAuth) {
console.log('Skip (auth required):', screen.name);
continue;
}
if (!screen.auth) {
await context.clearCookies();
}
const fullUrl = BASE_URL.replace(/\/$/, '') + screen.url;
console.log('Open:', screen.name, fullUrl);
await page.goto(fullUrl, { waitUntil: 'networkidle', timeout: 20000 }).catch(() => {});
if (screen.locator) {
const el = page.locator(screen.locator).first();
const visible = await el.waitFor({ state: 'visible', timeout: 8000 }).then(() => true).catch(() => false);
if (visible) {
await captureLocator(page, el, screen.name);
} else {
console.log(' skipped: locator not visible');
}
} else {
await page.waitForSelector('body', { timeout: 5000 }).catch(() => {});
await captureFull(page, screen.name);
}
}
await browser.close();
console.log('Done.');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});