Phase 0: scaffold cup26 — Vite+React+Fastify TS PWA, broadcast theme, app shell

Mirrors the TTRPG stack (strict TS, Tailwind 4, vite-plugin-pwa, esbuild server
bundle, multi-stage Dockerfile, Traefik compose). Floodlit-pitch design tokens,
UI primitives (Button/Card/Badge), TanStack Router shell with Live/Groups/
Bracket/Predict/Story routes (placeholders), dependency-free PWA icon generator.
Raw data staged: openfootball 2026 fixtures + martj42 international results CSV.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 12:49:13 +02:00
commit 4e4e75a1d8
39 changed files with 53029 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
import { Link } from '@tanstack/react-router';
export function NotFound() {
return (
<div className="grid place-items-center py-24 text-center">
<div>
<p className="font-display text-5xl font-extrabold text-accent">404</p>
<p className="mt-2 text-muted">Off the pitch that page doesn&apos;t exist.</p>
<Link to="/" className="mt-4 inline-block rounded-md bg-elevated px-4 py-2 text-sm font-medium text-ink hover:bg-line">
Back to Live
</Link>
</div>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { Link, Outlet } from '@tanstack/react-router';
import { Moon, Sun, Trophy } from 'lucide-react';
import { useUiStore } from '@/stores/uiStore';
import { cn } from '@/lib/cn';
const NAV = [
{ to: '/', label: 'Live', exact: true },
{ to: '/groups', label: 'Groups', exact: false },
{ to: '/bracket', label: 'Bracket', exact: false },
{ to: '/predict', label: 'Predict', exact: false },
{ to: '/story', label: 'Story', exact: false },
] as const;
function ThemeToggle() {
const theme = useUiStore((s) => s.theme);
const toggle = useUiStore((s) => s.toggleTheme);
return (
<button
type="button"
onClick={toggle}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
className="grid h-9 w-9 place-items-center rounded-md text-muted hover:bg-elevated hover:text-ink transition-colors"
>
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
</button>
);
}
export function RootLayout() {
return (
<div className="flex min-h-full flex-col">
<header className="sticky top-0 z-30 border-b border-line bg-surface/85 backdrop-blur">
<div className="mx-auto flex h-14 max-w-6xl items-center gap-4 px-4">
<Link to="/" className="flex items-center gap-2 font-display text-lg font-extrabold tracking-tight text-ink">
<span className="grid h-7 w-7 place-items-center rounded-md bg-gradient-to-b from-accent-soft to-accent text-accent-ink">
<Trophy size={16} />
</span>
Cup<span className="text-accent">26</span>
</Link>
<nav className="flex items-center gap-0.5 overflow-x-auto">
{NAV.map((item) => (
<Link
key={item.to}
to={item.to}
{...(item.exact ? { activeOptions: { exact: true } } : {})}
className={cn(
'rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:bg-elevated hover:text-ink',
'[&.active]:bg-accent-glow [&.active]:text-accent',
)}
>
{item.label}
</Link>
))}
</nav>
<div className="ml-auto flex items-center gap-2">
<ThemeToggle />
</div>
</div>
</header>
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-6">
<Outlet />
</main>
<footer className="border-t border-line py-6 text-center text-xs text-faint">
<p>
Cup26 · World Cup 2026 · data from openfootball, football-data.org &amp; StatsBomb open data ·
model-driven odds, not betting advice
</p>
</footer>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import type { HTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
type Tone = 'neutral' | 'accent' | 'live' | 'gold' | 'muted';
const tones: Record<Tone, string> = {
neutral: 'bg-elevated text-ink-soft border-line',
accent: 'bg-accent-glow text-accent border-accent-deep/40',
live: 'bg-live/15 text-live border-live/40',
gold: 'bg-gold/15 text-gold border-gold/40',
muted: 'bg-transparent text-muted border-line',
};
export function Badge({
className,
tone = 'neutral',
...props
}: HTMLAttributes<HTMLSpanElement> & { tone?: Tone }) {
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide',
tones[tone],
className,
)}
{...props}
/>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
type Variant = 'primary' | 'secondary' | 'subtle' | 'ghost' | 'danger';
type Size = 'sm' | 'md' | 'icon';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
}
const variants: Record<Variant, string> = {
primary: 'bg-gradient-to-b from-accent-soft to-accent text-accent-ink border border-accent-deep font-semibold shadow-[inset_0_1px_0_rgba(255,255,255,0.25)] hover:brightness-105 active:brightness-95',
secondary: 'bg-elevated text-ink hover:bg-line border border-line',
subtle: 'bg-elevated text-ink border border-transparent hover:border-line',
ghost: 'text-muted hover:text-ink hover:bg-elevated',
danger: 'bg-danger text-white hover:brightness-110 font-medium border border-danger',
};
const sizes: Record<Size, string> = {
sm: 'h-8 px-3 text-sm rounded-md',
md: 'h-10 px-4 text-sm rounded-md',
icon: 'h-9 w-9 rounded-md grid place-items-center',
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ className, variant = 'secondary', size = 'md', type, ...props },
ref,
) {
return (
<button
ref={ref}
type={type ?? 'button'}
className={cn(
'inline-flex items-center justify-center gap-2 transition-[filter,background-color,border-color,box-shadow]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60',
'disabled:opacity-50 disabled:pointer-events-none',
variants[variant],
sizes[size],
className,
)}
{...props}
/>
);
});
+20
View File
@@ -0,0 +1,20 @@
import type { HTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
/** A panel surface with a hairline border. The app's basic content container. */
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('rounded-xl border border-line bg-panel', className)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('px-4 py-3 border-b border-line', className)} {...props} />;
}
export function CardBody({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('p-4', className)} {...props} />;
}
+22
View File
@@ -0,0 +1,22 @@
import type { ReactNode } from 'react';
/** Consistent page title + subtitle + optional right-aligned actions. */
export function PageHeader({
title,
subtitle,
actions,
}: {
title: string;
subtitle?: string;
actions?: ReactNode;
}) {
return (
<div className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="font-display text-2xl font-extrabold tracking-tight text-ink">{title}</h1>
{subtitle ? <p className="mt-0.5 text-sm text-muted">{subtitle}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { ReactNode } from 'react';
import { Card, CardBody } from './Card';
/** Temporary "built in a later phase" panel so routes render cleanly meanwhile. */
export function Placeholder({ icon, children }: { icon?: ReactNode; children: ReactNode }) {
return (
<Card>
<CardBody className="grid place-items-center gap-2 py-16 text-center text-muted">
{icon ? <div className="text-accent">{icon}</div> : null}
<p className="max-w-md text-sm">{children}</p>
</CardBody>
</Card>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { GitMerge } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
import { Placeholder } from '@/components/ui/Placeholder';
export function BracketPage() {
return (
<div>
<PageHeader title="Bracket" subtitle="Round of 32 through the Final." />
<Placeholder icon={<GitMerge size={28} />}>
The knockout bracket (32 16 QF SF Final) arrives in Phase 1, with model
win-probabilities overlaid in Phase 2.
</Placeholder>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { Table } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
import { Placeholder } from '@/components/ui/Placeholder';
export function GroupsPage() {
return (
<div>
<PageHeader title="Groups" subtitle="All 12 groups (AL), 48 teams." />
<Placeholder icon={<Table size={28} />}>
Standings tables for the 12 groups arrive in Phase 1.
</Placeholder>
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { Radio } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
import { Placeholder } from '@/components/ui/Placeholder';
export function LivePage() {
return (
<div>
<PageHeader title="Live" subtitle="Today's World Cup 2026 matches, scores and what's on now." />
<Placeholder icon={<Radio size={28} />}>
Live match cards land in Phase 1 fixtures from openfootball, scores polled from
football-data.org, pushed over WebSocket.
</Placeholder>
</div>
);
}
@@ -0,0 +1,15 @@
import { TrendingUp } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
import { Placeholder } from '@/components/ui/Placeholder';
export function PredictionsPage() {
return (
<div>
<PageHeader title="Predict" subtitle="Elo + Dixon-Coles + Monte Carlo — who lifts the trophy?" />
<Placeholder icon={<TrendingUp size={28} />}>
Match win/draw/loss bars, simulated championship odds and an odds-over-time chart arrive
in Phase 2.
</Placeholder>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { Sparkles } from 'lucide-react';
import { PageHeader } from '@/components/ui/PageHeader';
import { Placeholder } from '@/components/ui/Placeholder';
export function StoryPage() {
return (
<div>
<PageHeader title="Story" subtitle="A data-driven look back, powered by StatsBomb open data." />
<Placeholder icon={<Sparkles size={28} />}>
Shot maps, pass networks and xG races arrive in Phase 3.
</Placeholder>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/** Merge conditional class names, resolving Tailwind conflicts. */
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+18
View File
@@ -0,0 +1,18 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from '@tanstack/react-router';
import { router } from './router';
import { useUiStore, applyTheme } from './stores/uiStore';
import './styles/globals.css';
// Apply persisted theme before first paint.
applyTheme(useUiStore.getState().theme);
const rootEl = document.getElementById('root');
if (!rootEl) throw new Error('Root element #root not found');
createRoot(rootEl).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);
+29
View File
@@ -0,0 +1,29 @@
import { createRootRoute, createRoute, createRouter } from '@tanstack/react-router';
import { RootLayout } from './app/RootLayout';
import { NotFound } from './app/NotFound';
import { LivePage } from './features/live/LivePage';
import { GroupsPage } from './features/groups/GroupsPage';
import { BracketPage } from './features/bracket/BracketPage';
import { PredictionsPage } from './features/predictions/PredictionsPage';
import { StoryPage } from './features/story/StoryPage';
const rootRoute = createRootRoute({
component: RootLayout,
notFoundComponent: NotFound,
});
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: LivePage });
const groupsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/groups', component: GroupsPage });
const bracketRoute = createRoute({ getParentRoute: () => rootRoute, path: '/bracket', component: BracketPage });
const predictRoute = createRoute({ getParentRoute: () => rootRoute, path: '/predict', component: PredictionsPage });
const storyRoute = createRoute({ getParentRoute: () => rootRoute, path: '/story', component: StoryPage });
const routeTree = rootRoute.addChildren([indexRoute, groupsRoute, bracketRoute, predictRoute, storyRoute]);
export const router = createRouter({ routeTree, defaultPreload: 'intent' });
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
+34
View File
@@ -0,0 +1,34 @@
import { create } from 'zustand';
export type Theme = 'light' | 'dark';
const STORAGE_KEY = 'cup26:theme';
function readTheme(): Theme {
if (typeof localStorage === 'undefined') return 'dark';
const v = localStorage.getItem(STORAGE_KEY);
return v === 'light' || v === 'dark' ? v : 'dark';
}
/** Toggle the `.dark` class on <html> and persist the choice. */
export function applyTheme(theme: Theme): void {
if (typeof document === 'undefined') return;
document.documentElement.classList.toggle('dark', theme === 'dark');
document.documentElement.classList.toggle('light', theme === 'light');
}
interface UiState {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
}
export const useUiStore = create<UiState>((set, get) => ({
theme: readTheme(),
setTheme: (theme) => {
applyTheme(theme);
try { localStorage.setItem(STORAGE_KEY, theme); } catch { /* private mode */ }
set({ theme });
},
toggleTheme: () => get().setTheme(get().theme === 'dark' ? 'light' : 'dark'),
}));
+171
View File
@@ -0,0 +1,171 @@
@import 'tailwindcss';
/* Class-based dark mode: toggling `.dark` on <html> swaps the token values below. */
@custom-variant dark (&:where(.dark, .dark *));
/* ============================================================================
CUP26 — broadcast palette. Floodlit pitch (dark) · clean telecast (light).
Accent is pitch-green; gold is the highlight. Outcome colors (win/draw/loss)
drive the model bars. Components consume --app-* tokens, never raw hex.
============================================================================ */
/* ---- Light: clean telecast ---- */
:root {
--app-surface: oklch(0.975 0.005 160);
--app-surface-2: oklch(0.945 0.008 160);
--app-panel: oklch(1 0 0);
--app-panel-2: oklch(0.975 0.006 160);
--app-elevated: oklch(0.930 0.010 160);
--app-line: oklch(0.890 0.012 160);
--app-line-strong:oklch(0.800 0.018 158);
--app-ink: oklch(0.235 0.022 158);
--app-ink-soft: oklch(0.360 0.020 158);
--app-muted: oklch(0.520 0.018 160);
--app-faint: oklch(0.640 0.014 162);
--app-accent: oklch(0.585 0.150 150);
--app-accent-deep: oklch(0.500 0.140 150);
--app-accent-soft: oklch(0.700 0.160 150);
--app-accent-ink: oklch(0.985 0.010 150);
--app-accent-glow: oklch(0.585 0.150 150 / 0.16);
--app-gold: oklch(0.760 0.130 85);
--app-pitch: oklch(0.560 0.110 150);
--app-win: oklch(0.610 0.140 150);
--app-draw: oklch(0.730 0.120 85);
--app-loss: oklch(0.580 0.160 25);
--app-danger: oklch(0.560 0.170 28);
--app-danger-glow: oklch(0.560 0.170 28 / 0.16);
--app-success: oklch(0.600 0.130 150);
--app-warning: oklch(0.720 0.130 80);
--app-info: oklch(0.560 0.120 250);
--app-live: oklch(0.600 0.200 25);
color-scheme: light;
}
/* ---- Dark: floodlit pitch at night ---- */
.dark {
--app-surface: oklch(0.165 0.022 158);
--app-surface-2: oklch(0.135 0.020 158);
--app-panel: oklch(0.205 0.024 159);
--app-panel-2: oklch(0.235 0.026 159);
--app-elevated: oklch(0.275 0.028 160);
--app-line: oklch(0.320 0.026 160);
--app-line-strong:oklch(0.420 0.030 158);
--app-ink: oklch(0.955 0.012 150);
--app-ink-soft: oklch(0.860 0.014 152);
--app-muted: oklch(0.680 0.020 156);
--app-faint: oklch(0.540 0.022 158);
--app-accent: oklch(0.800 0.175 148);
--app-accent-deep: oklch(0.700 0.160 148);
--app-accent-soft: oklch(0.870 0.165 146);
--app-accent-ink: oklch(0.165 0.030 158);
--app-accent-glow: oklch(0.800 0.175 148 / 0.20);
--app-gold: oklch(0.840 0.140 88);
--app-pitch: oklch(0.620 0.130 150);
--app-win: oklch(0.800 0.165 148);
--app-draw: oklch(0.820 0.130 88);
--app-loss: oklch(0.680 0.180 25);
--app-danger: oklch(0.680 0.185 30);
--app-danger-glow: oklch(0.680 0.185 30 / 0.22);
--app-success: oklch(0.790 0.150 150);
--app-warning: oklch(0.820 0.140 82);
--app-info: oklch(0.720 0.130 252);
--app-live: oklch(0.700 0.220 25);
color-scheme: dark;
}
/* Expose tokens as Tailwind utilities: bg-surface, text-faint, border-line, text-win… */
@theme inline {
--color-surface: var(--app-surface);
--color-surface-2: var(--app-surface-2);
--color-panel: var(--app-panel);
--color-panel-2: var(--app-panel-2);
--color-elevated: var(--app-elevated);
--color-line: var(--app-line);
--color-line-strong: var(--app-line-strong);
--color-ink: var(--app-ink);
--color-ink-soft: var(--app-ink-soft);
--color-muted: var(--app-muted);
--color-faint: var(--app-faint);
--color-accent: var(--app-accent);
--color-accent-deep: var(--app-accent-deep);
--color-accent-soft: var(--app-accent-soft);
--color-accent-ink: var(--app-accent-ink);
--color-accent-glow: var(--app-accent-glow);
--color-gold: var(--app-gold);
--color-pitch: var(--app-pitch);
--color-win: var(--app-win);
--color-draw: var(--app-draw);
--color-loss: var(--app-loss);
--color-danger: var(--app-danger);
--color-danger-glow: var(--app-danger-glow);
--color-success: var(--app-success);
--color-warning: var(--app-warning);
--color-info: var(--app-info);
--color-live: var(--app-live);
--font-display: 'Hanken Grotesk', 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
--font-mono: ui-monospace, 'SF Mono', 'Spline Sans Mono', Menlo, monospace;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background: var(--app-surface);
color: var(--app-ink);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Small-caps section label */
.smallcaps {
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 11px;
font-weight: 700;
color: var(--app-muted);
}
/* Tabular figures for scores/odds so digits don't jitter as they tick. */
.tnum { font-variant-numeric: tabular-nums; font-family: var(--font-mono); }
/* Live pulse dot */
@keyframes live-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.45; transform: scale(0.8); }
}
.live-dot { animation: live-pulse 1.4s ease-in-out infinite; }
/* Reduced-motion friendliness */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* Thin themed scrollbars */
* {
scrollbar-width: thin;
scrollbar-color: var(--app-line) transparent;
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />