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 16:15:58 +00:00
|
|
|
|
#!/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);
|
2026-02-08 21:49:24 +00:00
|
|
|
|
}
|
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 16:15:58 +00:00
|
|
|
|
|
|
|
|
|
|
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 },
|
2026-02-08 21:49:24 +00:00
|
|
|
|
{ name: 'sidebar', url: '/dashboard', auth: true, locator: 'aside.fixed' },
|
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 16:15:58 +00:00
|
|
|
|
{ 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);
|
|
|
|
|
|
});
|