Compare commits
2 Commits
cb35bf161e
...
a7ec49fcad
| Author | SHA1 | Date | |
|---|---|---|---|
| a7ec49fcad | |||
| a381633413 |
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-dpw7
|
# nuzlocke-tracker-dpw7
|
||||||
title: Modernize website design and look-and-feel
|
title: Modernize website design and look-and-feel
|
||||||
status: in-progress
|
status: completed
|
||||||
type: feature
|
type: feature
|
||||||
priority: normal
|
priority: normal
|
||||||
created_at: 2026-02-17T19:16:39Z
|
created_at: 2026-02-17T19:16:39Z
|
||||||
updated_at: 2026-02-17T21:04:45Z
|
updated_at: 2026-02-20T19:05:21Z
|
||||||
---
|
---
|
||||||
|
|
||||||
Overhaul the UI to a dark-first, techy aesthetic with a cohesive brand identity derived from the ANT steel ant logo.
|
Overhaul the UI to a dark-first, techy aesthetic with a cohesive brand identity derived from the ANT steel ant logo.
|
||||||
@@ -91,11 +91,11 @@ Self-host **Geist** (or Inter/JetBrains Mono pairing):
|
|||||||
- [x] Update all page-level backgrounds and containers
|
- [x] Update all page-level backgrounds and containers
|
||||||
- [x] Update modal styles (EncounterModal, StatusChangeModal, etc.)
|
- [x] Update modal styles (EncounterModal, StatusChangeModal, etc.)
|
||||||
- [x] Update badge/indicator styles (TypeBadge, RuleBadges, EncounterMethodBadge)
|
- [x] Update badge/indicator styles (TypeBadge, RuleBadges, EncounterMethodBadge)
|
||||||
- [ ] Add dark/light mode toggle to nav
|
- [x] Add dark/light mode toggle to nav
|
||||||
- [x] Polish hover states and transitions across all interactive elements
|
- [x] Polish hover states and transitions across all interactive elements
|
||||||
- [ ] Add automated Playwright accessibility and mobile layout tests
|
- [x] Add automated Playwright accessibility and mobile layout tests
|
||||||
- [ ] Verify accessibility (contrast ratios, focus indicators)
|
- [x] Verify accessibility (contrast ratios, focus indicators)
|
||||||
- [ ] Verify mobile layout and touch targets
|
- [x] Verify mobile layout and touch targets
|
||||||
|
|
||||||
## Automated verification approach
|
## Automated verification approach
|
||||||
|
|
||||||
|
|||||||
36
docker-compose.test.yml
Normal file
36
docker-compose.test.yml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
test-db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=postgres
|
||||||
|
- POSTGRES_PASSWORD=postgres
|
||||||
|
- POSTGRES_DB=nuzlocke_test
|
||||||
|
tmpfs:
|
||||||
|
- /var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
test-api:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@test-db:5432/nuzlocke_test
|
||||||
|
- DEBUG=true
|
||||||
|
depends_on:
|
||||||
|
test-db:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "curl -f http://localhost:8000/ || exit 1"]
|
||||||
|
interval: 3s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 15
|
||||||
|
restart: "no"
|
||||||
6
frontend/.gitignore
vendored
6
frontend/.gitignore
vendored
@@ -12,6 +12,12 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
e2e/.fixtures.json
|
||||||
|
e2e/screenshots/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
|||||||
57
frontend/e2e/accessibility.spec.ts
Normal file
57
frontend/e2e/accessibility.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import AxeBuilder from '@axe-core/playwright'
|
||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
|
||||||
|
import { loadFixtures } from './fixtures'
|
||||||
|
|
||||||
|
const fixtures = loadFixtures()
|
||||||
|
|
||||||
|
const pages = [
|
||||||
|
{ name: 'Home', path: '/' },
|
||||||
|
{ name: 'RunList', path: '/runs' },
|
||||||
|
{ name: 'NewRun', path: '/runs/new' },
|
||||||
|
{ name: 'RunEncounters', path: `/runs/${fixtures.runId}` },
|
||||||
|
{ name: 'GenlockeList', path: '/genlockes' },
|
||||||
|
{ name: 'NewGenlocke', path: '/genlockes/new' },
|
||||||
|
{ name: 'GenlockeDetail', path: `/genlockes/${fixtures.genlockeId}` },
|
||||||
|
{ name: 'Stats', path: '/stats' },
|
||||||
|
{ name: 'AdminGames', path: '/admin/games' },
|
||||||
|
{ name: 'AdminPokemon', path: '/admin/pokemon' },
|
||||||
|
{ name: 'AdminEvolutions', path: '/admin/evolutions' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const themes = ['dark', 'light'] as const
|
||||||
|
|
||||||
|
for (const theme of themes) {
|
||||||
|
test.describe(`Accessibility — ${theme} mode`, () => {
|
||||||
|
test.use({
|
||||||
|
storageState: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const { name, path } of pages) {
|
||||||
|
test(`${name} (${path}) has no WCAG violations`, async ({ page }) => {
|
||||||
|
// Set theme before navigation
|
||||||
|
await page.addInitScript((t) => {
|
||||||
|
localStorage.setItem('ant-theme', t)
|
||||||
|
}, theme)
|
||||||
|
|
||||||
|
await page.goto(path, { waitUntil: 'networkidle' })
|
||||||
|
|
||||||
|
const results = await new AxeBuilder({ page })
|
||||||
|
.withTags(['wcag2a', 'wcag2aa'])
|
||||||
|
.analyze()
|
||||||
|
|
||||||
|
const violations = results.violations.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
impact: v.impact,
|
||||||
|
description: v.description,
|
||||||
|
nodes: v.nodes.length,
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(
|
||||||
|
violations,
|
||||||
|
`${name} (${theme}): ${violations.length} accessibility violations found:\n${JSON.stringify(violations, null, 2)}`,
|
||||||
|
).toHaveLength(0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
17
frontend/e2e/fixtures.ts
Normal file
17
frontend/e2e/fixtures.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
|
||||||
|
interface Fixtures {
|
||||||
|
gameId: number
|
||||||
|
runId: number
|
||||||
|
genlockeId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached: Fixtures | null = null
|
||||||
|
|
||||||
|
export function loadFixtures(): Fixtures {
|
||||||
|
if (cached) return cached
|
||||||
|
const raw = readFileSync(resolve(__dirname, '.fixtures.json'), 'utf-8')
|
||||||
|
cached = JSON.parse(raw) as Fixtures
|
||||||
|
return cached
|
||||||
|
}
|
||||||
107
frontend/e2e/global-setup.ts
Normal file
107
frontend/e2e/global-setup.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { execSync } from 'node:child_process'
|
||||||
|
import { writeFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
|
||||||
|
const API_BASE = 'http://localhost:8000/api/v1'
|
||||||
|
const COMPOSE_FILE = resolve(__dirname, '../../docker-compose.test.yml')
|
||||||
|
const FIXTURES_PATH = resolve(__dirname, '.fixtures.json')
|
||||||
|
|
||||||
|
function run(cmd: string): string {
|
||||||
|
console.log(`[setup] ${cmd}`)
|
||||||
|
return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'inherit'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api<T>(
|
||||||
|
path: string,
|
||||||
|
options?: RequestInit,
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
...options,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text()
|
||||||
|
throw new Error(`API ${options?.method ?? 'GET'} ${path} → ${res.status}: ${body}`)
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function globalSetup() {
|
||||||
|
// 1. Start test DB + API
|
||||||
|
run(`docker compose -f ${COMPOSE_FILE} up -d --wait`)
|
||||||
|
|
||||||
|
// 2. Run migrations
|
||||||
|
run(
|
||||||
|
`docker compose -f ${COMPOSE_FILE} exec -T test-api alembic -c /app/alembic.ini upgrade head`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. Seed reference data (run from /app/src where the app package lives)
|
||||||
|
run(
|
||||||
|
`docker compose -f ${COMPOSE_FILE} exec -T -w /app/src test-api python -m app.seeds`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 4. Create test fixtures via API
|
||||||
|
const games = await api<Array<{ id: number; name: string }>>('/games')
|
||||||
|
const game = games[0]
|
||||||
|
if (!game) throw new Error('No games found after seeding')
|
||||||
|
|
||||||
|
const routes = await api<Array<{ id: number; name: string; parentRouteId: number | null }>>(
|
||||||
|
`/games/${game.id}/routes?flat=true`,
|
||||||
|
)
|
||||||
|
// Pick leaf routes (no children — a route is a leaf if no other route has it as parent)
|
||||||
|
const parentIds = new Set(routes.map((r) => r.parentRouteId).filter(Boolean))
|
||||||
|
const leafRoutes = routes.filter((r) => !parentIds.has(r.id))
|
||||||
|
if (leafRoutes.length < 3) throw new Error(`Need ≥3 leaf routes, found ${leafRoutes.length}`)
|
||||||
|
|
||||||
|
const pokemonRes = await api<{ items: Array<{ id: number; name: string }> }>(
|
||||||
|
'/pokemon?limit=10',
|
||||||
|
)
|
||||||
|
const pokemon = pokemonRes.items
|
||||||
|
if (pokemon.length < 3) throw new Error(`Need ≥3 pokemon, found ${pokemon.length}`)
|
||||||
|
|
||||||
|
// Create a test run
|
||||||
|
const testRun = await api<{ id: number }>('/runs', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
gameId: game.id,
|
||||||
|
name: 'E2E Test Run',
|
||||||
|
rules: { duplicatesClause: true, shinyClause: true },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create encounters: caught, fainted, missed
|
||||||
|
const statuses = ['caught', 'fainted', 'missed'] as const
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await api(`/runs/${testRun.id}/encounters`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
routeId: leafRoutes[i]!.id,
|
||||||
|
pokemonId: pokemon[i]!.id,
|
||||||
|
nickname: `Test ${statuses[i]}`,
|
||||||
|
status: statuses[i],
|
||||||
|
catchLevel: statuses[i] === 'missed' ? null : 5 + i * 10,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a genlocke with 2 game legs
|
||||||
|
const secondGame = games[1] ?? game
|
||||||
|
const genlocke = await api<{ id: number }>('/genlockes', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: 'E2E Test Genlocke',
|
||||||
|
gameIds: [game.id, secondGame.id],
|
||||||
|
genlockeRules: {},
|
||||||
|
nuzlockeRules: { duplicatesClause: true },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5. Write fixtures file
|
||||||
|
const fixtures = {
|
||||||
|
gameId: game.id,
|
||||||
|
runId: testRun.id,
|
||||||
|
genlockeId: genlocke.id,
|
||||||
|
}
|
||||||
|
writeFileSync(FIXTURES_PATH, JSON.stringify(fixtures, null, 2))
|
||||||
|
console.log('[setup] Fixtures written:', fixtures)
|
||||||
|
}
|
||||||
21
frontend/e2e/global-teardown.ts
Normal file
21
frontend/e2e/global-teardown.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { execSync } from 'node:child_process'
|
||||||
|
import { rmSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
|
||||||
|
const COMPOSE_FILE = resolve(__dirname, '../../docker-compose.test.yml')
|
||||||
|
const FIXTURES_PATH = resolve(__dirname, '.fixtures.json')
|
||||||
|
|
||||||
|
export default async function globalTeardown() {
|
||||||
|
console.log('[teardown] Stopping test containers...')
|
||||||
|
execSync(`docker compose -f ${COMPOSE_FILE} down -v`, {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: 'inherit',
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
rmSync(FIXTURES_PATH)
|
||||||
|
console.log('[teardown] Removed fixtures file')
|
||||||
|
} catch {
|
||||||
|
// File may not exist if setup failed
|
||||||
|
}
|
||||||
|
}
|
||||||
71
frontend/e2e/mobile.spec.ts
Normal file
71
frontend/e2e/mobile.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import AxeBuilder from '@axe-core/playwright'
|
||||||
|
import { expect, test } from '@playwright/test'
|
||||||
|
|
||||||
|
import { loadFixtures } from './fixtures'
|
||||||
|
|
||||||
|
const fixtures = loadFixtures()
|
||||||
|
|
||||||
|
const pages = [
|
||||||
|
{ name: 'Home', path: '/' },
|
||||||
|
{ name: 'RunList', path: '/runs' },
|
||||||
|
{ name: 'NewRun', path: '/runs/new' },
|
||||||
|
{ name: 'RunEncounters', path: `/runs/${fixtures.runId}` },
|
||||||
|
{ name: 'GenlockeList', path: '/genlockes' },
|
||||||
|
{ name: 'NewGenlocke', path: '/genlockes/new' },
|
||||||
|
{ name: 'GenlockeDetail', path: `/genlockes/${fixtures.genlockeId}` },
|
||||||
|
{ name: 'Stats', path: '/stats' },
|
||||||
|
{ name: 'AdminGames', path: '/admin/games' },
|
||||||
|
{ name: 'AdminPokemon', path: '/admin/pokemon' },
|
||||||
|
{ name: 'AdminEvolutions', path: '/admin/evolutions' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const viewports = [
|
||||||
|
{ name: 'mobile', width: 375, height: 667 },
|
||||||
|
{ name: 'tablet', width: 768, height: 1024 },
|
||||||
|
{ name: 'desktop', width: 1280, height: 800 },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
for (const viewport of viewports) {
|
||||||
|
test.describe(`Mobile layout — ${viewport.name} (${viewport.width}x${viewport.height})`, () => {
|
||||||
|
test.use({
|
||||||
|
viewport: { width: viewport.width, height: viewport.height },
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const { name, path } of pages) {
|
||||||
|
test(`${name} (${path}) has no overflow or touch target issues`, async ({ page }) => {
|
||||||
|
await page.goto(path, { waitUntil: 'networkidle' })
|
||||||
|
|
||||||
|
// Assert no horizontal overflow
|
||||||
|
const overflow = await page.evaluate(() => ({
|
||||||
|
scrollWidth: document.documentElement.scrollWidth,
|
||||||
|
innerWidth: window.innerWidth,
|
||||||
|
}))
|
||||||
|
expect(
|
||||||
|
overflow.scrollWidth,
|
||||||
|
`${name} at ${viewport.name}: horizontal overflow detected (scrollWidth=${overflow.scrollWidth}, innerWidth=${overflow.innerWidth})`,
|
||||||
|
).toBeLessThanOrEqual(overflow.innerWidth)
|
||||||
|
|
||||||
|
// Run axe-core target-size rule for touch target validation
|
||||||
|
const axeResults = await new AxeBuilder({ page })
|
||||||
|
.withRules(['target-size'])
|
||||||
|
.analyze()
|
||||||
|
|
||||||
|
const violations = axeResults.violations.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
impact: v.impact,
|
||||||
|
nodes: v.nodes.length,
|
||||||
|
}))
|
||||||
|
expect(
|
||||||
|
violations,
|
||||||
|
`${name} at ${viewport.name}: ${violations.length} touch target violations:\n${JSON.stringify(violations, null, 2)}`,
|
||||||
|
).toHaveLength(0)
|
||||||
|
|
||||||
|
// Capture full-page screenshot
|
||||||
|
await page.screenshot({
|
||||||
|
path: `e2e/screenshots/${viewport.name}/${name.toLowerCase()}.png`,
|
||||||
|
fullPage: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -17,6 +17,15 @@
|
|||||||
<link rel="manifest" href="/site.webmanifest" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var t = localStorage.getItem('ant-theme');
|
||||||
|
if (t === 'light' || (!t && window.matchMedia('(prefers-color-scheme: light)').matches)) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light');
|
||||||
|
document.documentElement.style.colorScheme = 'light';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
89
frontend/package-lock.json
generated
89
frontend/package-lock.json
generated
@@ -18,6 +18,8 @@
|
|||||||
"sonner": "2.0.7"
|
"sonner": "2.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "4.11.1",
|
||||||
|
"@playwright/test": "1.58.2",
|
||||||
"@tailwindcss/vite": "4.1.18",
|
"@tailwindcss/vite": "4.1.18",
|
||||||
"@types/node": "24.10.10",
|
"@types/node": "24.10.10",
|
||||||
"@types/react": "19.2.11",
|
"@types/react": "19.2.11",
|
||||||
@@ -31,6 +33,19 @@
|
|||||||
"vitest": "4.0.18"
|
"vitest": "4.0.18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@axe-core/playwright": {
|
||||||
|
"version": "4.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz",
|
||||||
|
"integrity": "sha512-mKEfoUIB1MkVTht0BGZFXtSAEKXMJoDkyV5YZ9jbBmZCcWDz71tegNsdTkIN8zc/yMi5Gm2kx7Z5YQ9PfWNAWw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"axe-core": "~4.11.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"playwright-core": ">= 1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -1506,6 +1521,22 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.58.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.2",
|
"version": "1.0.0-rc.2",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
|
||||||
@@ -2472,6 +2503,16 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/axe-core": {
|
||||||
|
"version": "4.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz",
|
||||||
|
"integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.9.19",
|
"version": "2.9.19",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
|
||||||
@@ -3234,6 +3275,54 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||||
|
"dev": 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==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
|
"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/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
"format:check": "oxfmt --check src/",
|
"format:check": "oxfmt --check src/",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest"
|
"test:watch": "vitest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "6.3.1",
|
"@dnd-kit/core": "6.3.1",
|
||||||
@@ -24,6 +26,8 @@
|
|||||||
"sonner": "2.0.7"
|
"sonner": "2.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/playwright": "4.11.1",
|
||||||
|
"@playwright/test": "1.58.2",
|
||||||
"@tailwindcss/vite": "4.1.18",
|
"@tailwindcss/vite": "4.1.18",
|
||||||
"@types/node": "24.10.10",
|
"@types/node": "24.10.10",
|
||||||
"@types/react": "19.2.11",
|
"@types/react": "19.2.11",
|
||||||
|
|||||||
27
frontend/playwright.config.ts
Normal file
27
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
globalSetup: './e2e/global-setup.ts',
|
||||||
|
globalTeardown: './e2e/global-teardown.ts',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: 'html',
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
url: 'http://localhost:5173',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,8 +1,20 @@
|
|||||||
const CONDITION_CONFIG: Record<string, { label: string; color: string }> = {
|
const CONDITION_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
'Mega Evolution': { label: 'Mega', color: 'bg-fuchsia-900/40 text-fuchsia-300' },
|
'Mega Evolution': {
|
||||||
Gigantamax: { label: 'G-Max', color: 'bg-red-900/40 text-red-300' },
|
label: 'Mega',
|
||||||
Dynamax: { label: 'D-Max', color: 'bg-rose-900/40 text-rose-300' },
|
color: 'bg-fuchsia-900/40 text-fuchsia-300 light:bg-fuchsia-100 light:text-fuchsia-700',
|
||||||
Terastallize: { label: 'Tera', color: 'bg-teal-900/40 text-teal-300' },
|
},
|
||||||
|
Gigantamax: {
|
||||||
|
label: 'G-Max',
|
||||||
|
color: 'bg-red-900/40 text-red-300 light:bg-red-100 light:text-red-700',
|
||||||
|
},
|
||||||
|
Dynamax: {
|
||||||
|
label: 'D-Max',
|
||||||
|
color: 'bg-rose-900/40 text-rose-300 light:bg-rose-100 light:text-rose-700',
|
||||||
|
},
|
||||||
|
Terastallize: {
|
||||||
|
label: 'Tera',
|
||||||
|
color: 'bg-teal-900/40 text-teal-300 light:bg-teal-100 light:text-teal-700',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConditionBadge({
|
export function ConditionBadge({
|
||||||
|
|||||||
@@ -1,17 +1,56 @@
|
|||||||
export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
|
export const METHOD_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
starter: { label: 'Starter', color: 'bg-yellow-900/40 text-yellow-300' },
|
starter: {
|
||||||
gift: { label: 'Gift', color: 'bg-pink-900/40 text-pink-300' },
|
label: 'Starter',
|
||||||
fossil: { label: 'Fossil', color: 'bg-amber-900/40 text-amber-300' },
|
color: 'bg-yellow-900/40 text-yellow-300 light:bg-yellow-100 light:text-yellow-800',
|
||||||
trade: { label: 'Trade', color: 'bg-emerald-900/40 text-emerald-300' },
|
},
|
||||||
walk: { label: 'Grass', color: 'bg-green-900/40 text-green-300' },
|
gift: {
|
||||||
headbutt: { label: 'Headbutt', color: 'bg-lime-900/40 text-lime-300' },
|
label: 'Gift',
|
||||||
surf: { label: 'Surfing', color: 'bg-blue-900/40 text-blue-300' },
|
color: 'bg-pink-900/40 text-pink-300 light:bg-pink-100 light:text-pink-700',
|
||||||
'rock-smash': { label: 'Rock Smash', color: 'bg-orange-900/40 text-orange-300' },
|
},
|
||||||
'old-rod': { label: 'Old Rod', color: 'bg-cyan-900/40 text-cyan-300' },
|
fossil: {
|
||||||
'good-rod': { label: 'Good Rod', color: 'bg-sky-900/40 text-sky-300' },
|
label: 'Fossil',
|
||||||
'super-rod': { label: 'Super Rod', color: 'bg-indigo-900/40 text-indigo-300' },
|
color: 'bg-amber-900/40 text-amber-300 light:bg-amber-100 light:text-amber-800',
|
||||||
horde: { label: 'Horde', color: 'bg-rose-900/40 text-rose-300' },
|
},
|
||||||
sos: { label: 'SOS', color: 'bg-violet-900/40 text-violet-300' },
|
trade: {
|
||||||
|
label: 'Trade',
|
||||||
|
color: 'bg-emerald-900/40 text-emerald-300 light:bg-emerald-100 light:text-emerald-700',
|
||||||
|
},
|
||||||
|
walk: {
|
||||||
|
label: 'Grass',
|
||||||
|
color: 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-700',
|
||||||
|
},
|
||||||
|
headbutt: {
|
||||||
|
label: 'Headbutt',
|
||||||
|
color: 'bg-lime-900/40 text-lime-300 light:bg-lime-100 light:text-lime-800',
|
||||||
|
},
|
||||||
|
surf: {
|
||||||
|
label: 'Surfing',
|
||||||
|
color: 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-700',
|
||||||
|
},
|
||||||
|
'rock-smash': {
|
||||||
|
label: 'Rock Smash',
|
||||||
|
color: 'bg-orange-900/40 text-orange-300 light:bg-orange-100 light:text-orange-800',
|
||||||
|
},
|
||||||
|
'old-rod': {
|
||||||
|
label: 'Old Rod',
|
||||||
|
color: 'bg-cyan-900/40 text-cyan-300 light:bg-cyan-100 light:text-cyan-700',
|
||||||
|
},
|
||||||
|
'good-rod': {
|
||||||
|
label: 'Good Rod',
|
||||||
|
color: 'bg-sky-900/40 text-sky-300 light:bg-sky-100 light:text-sky-700',
|
||||||
|
},
|
||||||
|
'super-rod': {
|
||||||
|
label: 'Super Rod',
|
||||||
|
color: 'bg-indigo-900/40 text-indigo-300 light:bg-indigo-100 light:text-indigo-700',
|
||||||
|
},
|
||||||
|
horde: {
|
||||||
|
label: 'Horde',
|
||||||
|
color: 'bg-rose-900/40 text-rose-300 light:bg-rose-100 light:text-rose-700',
|
||||||
|
},
|
||||||
|
sos: {
|
||||||
|
label: 'SOS',
|
||||||
|
color: 'bg-violet-900/40 text-violet-300 light:bg-violet-100 light:text-violet-700',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Display order for encounter method groups */
|
/** Display order for encounter method groups */
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, Outlet, useLocation } from 'react-router-dom'
|
import { Link, Outlet, useLocation } from 'react-router-dom'
|
||||||
|
import { useTheme } from '../hooks/useTheme'
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ to: '/runs/new', label: 'New Run' },
|
{ to: '/runs/new', label: 'New Run' },
|
||||||
@@ -37,6 +38,39 @@ function NavLink({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ThemeToggle() {
|
||||||
|
const { theme, toggle } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
className="p-2 rounded-md text-text-secondary hover:text-text-primary hover:bg-surface-3 transition-colors"
|
||||||
|
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||||
|
>
|
||||||
|
{theme === 'dark' ? (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 3v1m0 16v1m8.66-13.66l-.71.71M4.05 19.95l-.71.71M21 12h-1M4 12H3m16.66 7.66l-.71-.71M4.05 4.05l-.71-.71M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -68,9 +102,11 @@ export function Layout() {
|
|||||||
{link.label}
|
{link.label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
{/* Mobile hamburger */}
|
{/* Mobile hamburger */}
|
||||||
<div className="flex items-center sm:hidden">
|
<div className="flex items-center gap-1 sm:hidden">
|
||||||
|
<ThemeToggle />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMenuOpen(!menuOpen)}
|
onClick={() => setMenuOpen(!menuOpen)}
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ export function RuleBadges({ rules }: RuleBadgesProps) {
|
|||||||
title={def.description}
|
title={def.description}
|
||||||
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
def.category === 'core'
|
def.category === 'core'
|
||||||
? 'bg-blue-900/40 text-blue-300'
|
? 'bg-blue-900/40 text-blue-300 light:bg-blue-100 light:text-blue-700'
|
||||||
: def.category === 'completion'
|
: def.category === 'completion'
|
||||||
? 'bg-green-900/40 text-green-300'
|
? 'bg-green-900/40 text-green-300 light:bg-green-100 light:text-green-700'
|
||||||
: 'bg-amber-900/40 text-amber-300'
|
: 'bg-amber-900/40 text-amber-300 light:bg-amber-100 light:text-amber-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{def.name}
|
{def.name}
|
||||||
|
|||||||
63
frontend/src/hooks/useTheme.ts
Normal file
63
frontend/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useCallback, useSyncExternalStore } from 'react'
|
||||||
|
|
||||||
|
type Theme = 'dark' | 'light'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'ant-theme'
|
||||||
|
|
||||||
|
function getSystemTheme(): Theme {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredTheme(): Theme | null {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (stored === 'dark' || stored === 'light') return stored
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme: Theme) {
|
||||||
|
if (theme === 'light') {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'light')
|
||||||
|
} else {
|
||||||
|
document.documentElement.removeAttribute('data-theme')
|
||||||
|
}
|
||||||
|
document.documentElement.style.colorScheme = theme
|
||||||
|
}
|
||||||
|
|
||||||
|
const listeners = new Set<() => void>()
|
||||||
|
let currentTheme: Theme = getStoredTheme() ?? getSystemTheme()
|
||||||
|
|
||||||
|
applyTheme(currentTheme)
|
||||||
|
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: light)')
|
||||||
|
mediaQuery.addEventListener('change', () => {
|
||||||
|
if (!getStoredTheme()) {
|
||||||
|
currentTheme = getSystemTheme()
|
||||||
|
applyTheme(currentTheme)
|
||||||
|
for (const listener of listeners) listener()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function subscribe(listener: () => void) {
|
||||||
|
listeners.add(listener)
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnapshot(): Theme {
|
||||||
|
return currentTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const theme = useSyncExternalStore(subscribe, getSnapshot)
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
const next: Theme = currentTheme === 'dark' ? 'light' : 'dark'
|
||||||
|
currentTheme = next
|
||||||
|
localStorage.setItem(STORAGE_KEY, next)
|
||||||
|
applyTheme(next)
|
||||||
|
for (const listener of listeners) listener()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { theme, toggle } as const
|
||||||
|
}
|
||||||
@@ -68,6 +68,49 @@
|
|||||||
--color-status-failed-bg: rgba(248, 81, 73, 0.15);
|
--color-status-failed-bg: rgba(248, 81, 73, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@custom-variant light (&:where([data-theme="light"], [data-theme="light"] *));
|
||||||
|
|
||||||
|
/* ── Light mode overrides ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
html[data-theme='light'] {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
/* Surfaces */
|
||||||
|
--color-surface-0: #ffffff;
|
||||||
|
--color-surface-1: #f6f8fa;
|
||||||
|
--color-surface-2: #eef1f4;
|
||||||
|
--color-surface-3: #d8dee4;
|
||||||
|
--color-surface-4: #ced5dc;
|
||||||
|
|
||||||
|
/* Accent (darkened for text contrast on light surfaces) */
|
||||||
|
--color-accent-200: #245f7e;
|
||||||
|
--color-accent-300: #1a5068;
|
||||||
|
--color-accent-400: #2d6a89;
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--color-text-primary: #1f2328;
|
||||||
|
--color-text-secondary: #656d76;
|
||||||
|
--color-text-tertiary: #8b949e;
|
||||||
|
--color-text-link: #1a5068;
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--color-border-default: rgba(0, 0, 0, 0.15);
|
||||||
|
--color-border-muted: rgba(0, 0, 0, 0.08);
|
||||||
|
--color-border-accent: rgba(57, 94, 115, 0.35);
|
||||||
|
|
||||||
|
/* Status (adjusted for light surfaces) */
|
||||||
|
--color-status-alive: #1a7f37;
|
||||||
|
--color-status-alive-bg: rgba(26, 127, 55, 0.1);
|
||||||
|
--color-status-dead: #cf222e;
|
||||||
|
--color-status-dead-bg: rgba(207, 34, 46, 0.1);
|
||||||
|
--color-status-active: #1a7f37;
|
||||||
|
--color-status-active-bg: rgba(26, 127, 55, 0.1);
|
||||||
|
--color-status-completed: #0969da;
|
||||||
|
--color-status-completed-bg: rgba(9, 105, 218, 0.1);
|
||||||
|
--color-status-failed: #cf222e;
|
||||||
|
--color-status-failed-bg: rgba(207, 34, 46, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Base layer ────────────────────────────────────────────────── */
|
/* ── Base layer ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
@@ -22,5 +22,5 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts", "playwright.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user