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>
131 lines
4.2 KiB
JavaScript
131 lines
4.2 KiB
JavaScript
#!/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);
|
||
});
|