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:
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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 & StatsBomb open data ·
|
||||
model-driven odds, not betting advice
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (A–L), 48 teams." />
|
||||
<Placeholder icon={<Table size={28} />}>
|
||||
Standings tables for the 12 groups arrive in Phase 1.
|
||||
</Placeholder>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
Reference in New Issue
Block a user