test(storybook): Playwright suite for full Storybook + Spotify/Discord polish

- Add playwright.config.storybook.ts: runs against storybook-static on :6007
- Add e2e/tests/storybook/storybook-all.spec.ts: one test per story (load iframe, no errors)
- Add scripts/serve-storybook-static.cjs: serves build or stub (empty index) when no build
- npm run test:storybook:playwright (after build-storybook) for full coverage
- Storybook decorator: use bg-background / design tokens for dark (#121212)
- Preview: default dark background #121212
- Button: secondary/ghost/glass aligned to Spotify/Discord (white/5, white/10 hover)
- KodoEmptyState: softer orbs, compact copy, primary CTA without heavy glow

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
senke 2026-02-07 20:30:49 +01:00
parent 37c5acc302
commit 1efafe0cc5
8 changed files with 204 additions and 31 deletions

View file

@ -8,6 +8,7 @@
*/
import React from 'react';
import type { Decorator } from '@storybook/react';
import { cn } from '../src/lib/utils';
import { ThemeProvider } from '../src/components/theme/ThemeProvider';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
@ -36,13 +37,10 @@ export const StorybookDecorator: Decorator = (Story, context) => {
<I18nextProvider i18n={i18n}>
<ThemeProvider defaultTheme={isDark ? 'dark' : 'light'}>
<div
className={isDark ? 'dark' : ''}
style={{
minHeight: '100vh',
padding: '1rem',
background: isDark ? '#0a0a0a' : '#ffffff',
color: isDark ? '#ffffff' : '#0a0a0a',
}}
className={cn(
isDark ? 'dark' : '',
'min-h-layout-story min-h-screen p-4 bg-background text-foreground',
)}
>
<QueryClientProvider client={queryClient}>
<ToastProvider>

View file

@ -68,7 +68,7 @@ const preview: Preview = {
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0a0a0a' },
{ name: 'dark', value: '#121212' },
{ name: 'light', value: '#ffffff' },
{ name: 'steel', value: '#1a1a2e' },
],

View file

@ -0,0 +1,80 @@
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
const INDEX_PATH = path.join(process.cwd(), 'storybook-static', 'index.json');
const IFRAME_URL = (id: string) => `/iframe.html?id=${encodeURIComponent(id)}&viewMode=story`;
const NAV_TIMEOUT_MS = 20000;
const POST_LOAD_MS = 200;
/** Story IDs from built Storybook index (available at load time). */
function getStoryIds(): string[] {
if (!fs.existsSync(INDEX_PATH)) return [];
try {
const index = JSON.parse(fs.readFileSync(INDEX_PATH, 'utf8'));
const entries = index.entries ?? {};
return Object.values(entries).map((e: { id?: string }) => e.id).filter(Boolean);
} catch {
return [];
}
}
const storyIds = getStoryIds();
test.describe('Storybook all stories', () => {
if (storyIds.length === 0) {
test('run build-storybook first', async () => {
test.skip(true, 'Run npm run build-storybook first. Then start a server on port 6007 (e.g. npx serve storybook-static -l 6007) or use the Playwright webServer.');
});
return;
}
for (const storyId of storyIds) {
test(storyId, async ({ page }) => {
const consoleErrors: string[] = [];
const pageErrors: string[] = [];
page.on('console', (msg) => {
const type = msg.type();
if (type === 'error') {
const text = msg.text();
if (!isIgnoredConsoleError(text)) consoleErrors.push(text);
}
});
page.on('pageerror', (err) => {
pageErrors.push(err.message);
});
const response = await page.goto(IFRAME_URL(storyId), {
waitUntil: 'domcontentloaded',
timeout: NAV_TIMEOUT_MS,
});
expect(response?.status()).toBe(200);
await page.waitForTimeout(POST_LOAD_MS);
const errors = [...pageErrors, ...consoleErrors];
expect(
errors,
errors.length ? `Story ${storyId}: ${errors.slice(0, 3).join('; ')}` : undefined
).toHaveLength(0);
});
}
});
/** Ignore known benign Storybook/addon or runtime messages. */
function isIgnoredConsoleError(text: string): boolean {
const ignored = [
'ResizeObserver',
'Warning: ReactDOM.render',
'Download the React DevTools',
'sb-manager',
'sb-addons',
'sb-common-assets',
'mockServiceWorker',
'Failed to load resource: net::ERR_ABORTED',
'ChunkLoadError',
'Loading chunk',
'hydration',
];
return ignored.some((s) => text.includes(s));
}

View file

@ -43,7 +43,8 @@
"prepare": "husky",
"storybook": "cross-env VITE_API_URL=/api/v1 VITE_USE_MSW=true VITE_STORYBOOK=true storybook dev -p 6006",
"build-storybook": "cross-env VITE_API_URL=/api/v1 VITE_USE_MSW=true VITE_STORYBOOK=true storybook build",
"test:storybook": "node scripts/audit-storybook.js"
"test:storybook": "node scripts/audit-storybook.js",
"test:storybook:playwright": "playwright test --config=playwright.config.storybook.ts"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",

View file

@ -0,0 +1,59 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright config for testing the full Storybook (all stories).
*
* Prerequisite: build Storybook first:
* npm run build-storybook
*
* Then run:
* npm run test:storybook:playwright
*
* The config starts a static server for storybook-static on port 6007
* (reuse existing if already running). Tests load each story iframe and
* assert no console errors / page crashes.
*/
const STORYBOOK_PORT = 6007;
const STORYBOOK_BASE = `http://localhost:${STORYBOOK_PORT}`;
export default defineConfig({
testDir: './e2e/tests/storybook',
testMatch: /.*\.spec\.ts/,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 2 : 4,
timeout: 25000,
outputDir: 'e2e/test-results-storybook',
reporter: [
['html', { outputFolder: 'e2e/playwright-report-storybook', open: 'never' }],
['list'],
],
use: {
baseURL: STORYBOOK_BASE,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'off',
viewport: { width: 1280, height: 720 },
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
viewport: { width: 1280, height: 720 },
},
},
],
webServer: {
command: 'node scripts/serve-storybook-static.cjs',
url: `${STORYBOOK_BASE}/index.json`,
reuseExistingServer: true,
timeout: 60_000,
cwd: process.cwd(),
},
});

View file

@ -0,0 +1,41 @@
#!/usr/bin/env node
/**
* Serves storybook-static on port 6007 for Playwright/audit.
* If storybook-static is missing, serves a stub so index.json returns { entries: {} }
* and tests can skip with "Run npm run build-storybook first".
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const PORT = 6007;
const staticDir = path.join(process.cwd(), 'storybook-static');
const indexPath = path.join(staticDir, 'index.json');
if (fs.existsSync(indexPath)) {
const proc = spawn('npx', ['serve', 'storybook-static', '-l', String(PORT)], {
stdio: 'inherit',
shell: true,
cwd: process.cwd(),
});
proc.on('error', (err) => {
console.error(err);
process.exit(1);
});
proc.on('exit', (code) => process.exit(code ?? 0));
} else {
const stubIndex = JSON.stringify({ entries: {} });
const server = http.createServer((req, res) => {
if (req.url === '/index.json' || req.url === '/') {
res.setHeader('Content-Type', 'application/json');
res.end(stubIndex);
return;
}
res.statusCode = 404;
res.end('Not found. Run npm run build-storybook first.');
});
server.listen(PORT, () => {
console.log(`Stub Storybook server at http://localhost:${PORT} (no storybook-static; run build-storybook first)`);
});
}

View file

@ -28,35 +28,29 @@ export function KodoEmptyState({
className,
)}
>
{/* Subtle gradient orbs */}
<div className="absolute inset-0 opacity-20 group-hover:opacity-30 transition-opacity duration-700 pointer-events-none">
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-primary/30 rounded-full blur-[80px] animate-pulse-slow" />
<div className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-primary/20 rounded-full blur-[80px] animate-pulse-slow delay-700" />
{/* Subtle ambient orbs */}
<div className="absolute inset-0 opacity-[0.12] group-hover:opacity-20 transition-opacity duration-500 pointer-events-none">
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-primary/40 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-primary/30 rounded-full blur-3xl" />
</div>
<div className="relative mb-8 p-6">
<div className="relative bg-card/80 backdrop-blur-xl p-6 rounded-xl border border-white/10 group-hover:scale-105 transition-transform duration-[var(--duration-immersive)] ease-in-out flex items-center justify-center">
<Icon className="h-12 w-12 text-primary" />
<div className="relative mb-6 p-4">
<div className="relative bg-white/5 p-5 rounded-xl border border-white/10 flex items-center justify-center group-hover:border-white/15 transition-colors">
<Icon className="h-10 w-10 text-primary" />
</div>
</div>
<h3 className="text-2xl font-display font-bold text-foreground mb-3 tracking-tight relative z-10">
<h3 className="text-xl font-semibold text-foreground mb-2 tracking-tight relative z-10">
{title}
</h3>
<p className="text-muted-foreground max-w-sm mb-8 text-base leading-relaxed relative z-10">
<p className="text-muted-foreground max-w-sm mb-6 text-sm leading-relaxed relative z-10">
{description}
</p>
{actionLabel && onAction && (
<Button
variant="primary"
className="rounded-xl shadow-[0_0_20px_var(--color-primary)/0.25] hover:shadow-[0_0_24px_var(--color-primary)/0.35] transition-all duration-[var(--duration-immersive)] ease-in-out"
onClick={onAction}
>
<span className="flex items-center gap-2 font-bold tracking-wide">
<Button variant="primary" size="sm" onClick={onAction}>
{actionLabel}
</span>
</Button>
)}
</Card>

View file

@ -24,11 +24,11 @@ const buttonVariants = cva(
/** Outlined - secondary actions, cancel */
outline:
'border border-border bg-transparent text-foreground hover:bg-muted/50 hover:border-border',
/** Secondary - less prominent actions */
/** Secondary - less prominent (Spotify/Discord style) */
secondary:
'bg-muted text-foreground hover:bg-muted/80 border border-border',
'bg-white/5 text-foreground hover:bg-white/10 border border-white/10 hover:border-white/20',
/** Ghost - icon buttons, menu items */
ghost: 'hover:bg-muted/50 text-foreground',
ghost: 'text-muted-foreground hover:text-foreground hover:bg-white/5',
/** Gaming style - accent warning/gold */
gaming:
'bg-muted border border-warning/40 text-warning hover:bg-warning/10 hover:border-warning font-bold tracking-wider uppercase',
@ -38,9 +38,9 @@ const buttonVariants = cva(
/** Nature style - success green */
nature:
'bg-muted border border-success/30 text-success hover:bg-success/10 hover:border-success/50',
/** Glass - glassmorphism */
/** Glass - glassmorphism (Spotify/Discord) */
glass:
'bg-white/5 backdrop-blur-md border border-white/10 text-foreground hover:bg-white/10 hover:border-white/20 shadow-lg',
'bg-white/5 backdrop-blur-md border border-white/10 text-foreground hover:bg-white/10 hover:border-white/15',
},
size: {
/** Default size - standard buttons */