design: apply Pixel design system tokens (Fira Sans/Code, CSS vars, badge helpers); add Playwright E2E setup + auth spec

This commit is contained in:
Forge
2026-03-26 08:54:44 +08:00
parent 4970c70546
commit 981b415430
8 changed files with 243 additions and 53 deletions

5
.gitignore vendored
View File

@@ -34,3 +34,8 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# Playwright
playwright-report/
test-results/
.auth/

View File

@@ -2,63 +2,101 @@
@tailwind components;
@tailwind utilities;
/* FiberOps Design System — Pixel/Conan approved */
/* Primary: #0891B2 | CTA: #059669 | BG: #F8FAFC | Text: #0F172A */
/* Fonts: Fira Sans (body) + Fira Code (data/numbers) */
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
/* FiberOps tokens */
--color-primary: #0891B2;
--color-primary-dark: #0E7490;
--color-secondary: #22D3EE;
--color-cta: #059669;
--color-cta-hover: #047857;
--color-bg: #F8FAFC;
--color-surface: #FFFFFF;
--color-text: #0F172A;
--color-muted: #64748B;
--color-border: #E2E8F0;
--color-danger: #EF4444;
--color-warning: #F59E0B;
--color-success: #059669;
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
--shadow-md: 0 4px 6px rgba(0,0,0,0.07);
--shadow-lg: 0 10px 15px rgba(0,0,0,0.08);
--shadow-xl: 0 20px 25px rgba(0,0,0,0.12);
/* Radius */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
}
* {
@apply border-border;
box-sizing: border-box;
}
body {
@apply bg-background text-foreground;
background-color: var(--color-bg);
color: var(--color-text);
font-family: 'Fira Sans', 'Inter', system-ui, sans-serif;
font-size: 15px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
/* Data values, numbers, IDs */
code, .font-mono, .tabular-nums {
font-family: 'Fira Code', 'JetBrains Mono', monospace;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
/* Skeleton loading animation */
.skeleton {
@apply animate-pulse bg-slate-200 rounded;
}
/* Row hover for tables */
.table-row-hover:hover {
background-color: #F0FDFF;
cursor: pointer;
}
/* Transition helper */
.transition-fast {
transition: all 150ms ease;
}
.transition-smooth {
transition: all 200ms ease;
}
/* Card base */
.fiberops-card {
background: white;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
}
/* Status badge helpers */
.badge-active { background: #DCFCE7; color: #166534; }
.badge-inactive { background: #F1F5F9; color: #475569; }
.badge-suspended { background: #FEF3C7; color: #92400E; }
.badge-pending { background: #DBEAFE; color: #1E40AF; }
.badge-overdue { background: #FEE2E2; color: #991B1B; }
.badge-paid { background: #DCFCE7; color: #166534; }
.badge-partial { background: #FEF3C7; color: #92400E; }
.badge-void { background: #F1F5F9; color: #94A3B8; }
.badge-open { background: #DBEAFE; color: #1E40AF; }
.badge-in-progress { background: #FEF3C7; color: #92400E; }
.badge-resolved { background: #DCFCE7; color: #166534; }
.badge-closed { background: #F1F5F9; color: #475569; }
.text-balance { text-wrap: balance; }
}

View File

@@ -1,23 +1,29 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import { Fira_Sans, Fira_Code } from 'next/font/google';
import './globals.css';
import Providers from '@/components/providers';
const inter = Inter({ subsets: ['latin'] });
const firaSans = Fira_Sans({
subsets: ['latin'],
weight: ['300', '400', '500', '600', '700'],
variable: '--font-fira-sans',
});
const firaCode = Fira_Code({
subsets: ['latin'],
weight: ['400', '500', '600', '700'],
variable: '--font-fira-code',
});
export const metadata: Metadata = {
title: 'FiberOps Admin',
description: 'ISP Management Platform',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={inter.className}>
<body className={`${firaSans.variable} ${firaCode.variable} ${firaSans.className}`}>
<Providers>{children}</Providers>
</body>
</html>

39
e2e/auth.spec.ts Normal file
View File

@@ -0,0 +1,39 @@
import { test, expect } from '@playwright/test';
import { login, DEMO } from './helpers/auth';
test.describe('Authentication', () => {
test('login page renders correctly', async ({ page }) => {
await page.goto('/login');
await expect(page.locator('text=FiberOps')).toBeVisible();
await expect(page.locator('text=Sign in to your account')).toBeVisible();
await expect(page.locator('input[placeholder="e.g. demo-isp"]')).toBeVisible();
await expect(page.locator('input[type="email"]')).toBeVisible();
await expect(page.locator('input[type="password"]')).toBeVisible();
});
test('login with valid credentials redirects to dashboard', async ({ page }) => {
await login(page);
await expect(page).toHaveURL(/\/dashboard/);
});
test('login with wrong credentials shows error', async ({ page }) => {
await page.goto('/login');
await page.fill('input[placeholder="e.g. demo-isp"]', DEMO.tenant);
await page.fill('input[type="email"]', DEMO.email);
await page.fill('input[type="password"]', 'wrongpassword');
await page.click('button[type="submit"]');
// Should show error toast or stay on login
await expect(page).toHaveURL(/\/login/);
});
test('unauthenticated redirect to login', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login/);
});
test('logout returns to login', async ({ page }) => {
await login(page);
await page.click('button:has-text("Logout"), a:has-text("Logout")');
await expect(page).toHaveURL(/\/login/);
});
});

16
e2e/helpers/auth.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Page } from '@playwright/test';
export const DEMO = {
tenant: 'demo-isp',
email: 'admin@demo-isp.com',
password: 'Admin123!',
};
export async function login(page: Page, creds = DEMO) {
await page.goto('/login');
await page.fill('input[placeholder="e.g. demo-isp"]', creds.tenant);
await page.fill('input[type="email"]', creds.email);
await page.fill('input[type="password"]', creds.password);
await page.click('button[type="submit"]');
await page.waitForURL('**/dashboard', { timeout: 10000 });
}

64
package-lock.json generated
View File

@@ -38,6 +38,7 @@
"zustand": "^5.0.12"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
@@ -1547,6 +1548,22 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -8376,6 +8393,53 @@
"node": ">=16.20.0"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",

View File

@@ -39,6 +39,7 @@
"zustand": "^5.0.12"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",

21
playwright.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: false, // run serially — shared demo tenant
forbidOnly: !!process.env.CI,
retries: 1,
workers: 1,
reporter: [['html', { outputFolder: 'playwright-report' }], ['line']],
use: {
baseURL: process.env.E2E_BASE_URL || 'http://192.168.1.167:3002',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], headless: true },
},
],
});