Phase 1: Design system foundation — tokens, fonts, sidebar layout

- index.css: @import "tailwindcss" + @theme block with full colour
  palette, shadows, radii, typography tokens, skeleton animation
- index.html: Google Sans + Roboto Mono fonts, title → Flight Radar
- src/lib/utils.ts: cn() helper (clsx + tailwind-merge)
- Layout.tsx: 256px fixed sidebar on desktop (active pill nav, logo,
  Developer section divider), sticky top bar with page title + New Scan
  CTA (hidden on /scans), bottom nav bar on mobile with pill indicator
- package.json/lock: add lucide-react, clsx, tailwind-merge
- .gitignore: unblock frontend/package*.json and frontend/src/lib/

Build: 0 TypeScript errors · 0 console errors · all 6 criteria pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 14:44:44 +01:00
parent 5d08d9353d
commit 7417d56578
7 changed files with 4664 additions and 76 deletions

View File

@@ -46,10 +46,12 @@ htmlcov/
*.csv *.csv
*.log *.log
# JSON — keep fixture and airport data, ignore everything else # JSON — keep fixture, airport data, and frontend manifests
*.json *.json
!data/airports_by_country.json !data/airports_by_country.json
!tests/confirmed_flights.json !tests/confirmed_flights.json
!frontend/package.json
!frontend/package-lock.json
# Database files # Database files
*.db *.db
@@ -57,3 +59,4 @@ htmlcov/
# Node # Node
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
!frontend/src/lib/

View File

@@ -4,7 +4,10 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>Flight Radar</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@300;400;500&family=Roboto+Mono:wght@400&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.5",
"clsx": "^2.1.1",
"lucide-react": "^0.575.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/postcss": "^4.2.1",
"@types/node": "^24.10.13",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.24",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

View File

@@ -1,72 +1,198 @@
import { Link, Outlet, useLocation } from 'react-router-dom'; import { Link, Outlet, useLocation } from 'react-router-dom';
import type { LucideIcon } from 'lucide-react';
import {
LayoutDashboard,
ScanSearch,
MapPin,
ScrollText,
PlaneTakeoff,
Plus,
} from 'lucide-react';
import { cn } from '../lib/utils';
type NavItem = {
icon: LucideIcon;
label: string;
path: string;
};
const PRIMARY_NAV: NavItem[] = [
{ icon: LayoutDashboard, label: 'Dashboard', path: '/' },
{ icon: ScanSearch, label: 'Scans', path: '/scans' },
{ icon: MapPin, label: 'Airports', path: '/airports' },
];
const SECONDARY_NAV: NavItem[] = [
{ icon: ScrollText, label: 'Logs', path: '/logs' },
];
const ALL_NAV = [...PRIMARY_NAV, ...SECONDARY_NAV];
function getPageTitle(pathname: string): string {
if (pathname === '/') return 'Dashboard';
if (pathname.startsWith('/scans/')) return 'Scan Details';
if (pathname === '/scans') return 'New Scan';
if (pathname === '/airports') return 'Airports';
if (pathname === '/logs') return 'Logs';
return 'Flight Radar';
}
function SidebarNavLink({ item, active }: { item: NavItem; active: boolean }) {
return (
<Link
to={item.path}
aria-current={active ? 'page' : undefined}
className={cn(
'flex items-center gap-3 mx-3 px-4 py-3 rounded-full text-sm font-medium transition-colors duration-150',
active
? 'bg-primary-container text-on-primary-container'
: 'text-on-surface-variant hover:bg-surface-2 hover:text-on-surface',
)}
>
<item.icon size={20} aria-hidden="true" />
{item.label}
</Link>
);
}
export default function Layout() { export default function Layout() {
const location = useLocation(); const location = useLocation();
const isActive = (path: string) => { const isActive = (path: string): boolean => {
return location.pathname === path; if (path === '/') return location.pathname === '/';
return location.pathname.startsWith(path);
}; };
const showNewScan = location.pathname !== '/scans';
const pageTitle = getPageTitle(location.pathname);
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-bg">
{/* Header */}
<header className="bg-white shadow-sm border-b border-gray-200"> {/* ── Sidebar (desktop ≥ lg) ───────────────────────────────── */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> <aside
<div className="flex flex-col sm:flex-row justify-between items-center gap-4"> className="hidden lg:flex lg:fixed lg:inset-y-0 lg:left-0 lg:w-64 lg:flex-col bg-surface border-r border-outline z-30"
<h1 className="text-2xl font-bold text-blue-600 flex items-center"> aria-label="Sidebar navigation"
<span className="text-3xl mr-2"></span>
Flight Radar
</h1>
<nav className="flex flex-wrap justify-center gap-2">
<Link
to="/"
className={`px-3 py-2 rounded-md text-sm font-medium ${
isActive('/')
? 'bg-blue-500 text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
> >
Dashboard {/* Logo */}
</Link> <div className="flex items-center gap-3 px-7 h-16 border-b border-outline shrink-0">
<PlaneTakeoff size={22} className="text-primary" aria-hidden="true" />
<span className="text-lg font-medium text-on-surface tracking-tight">
Flight Radar
</span>
</div>
{/* Primary nav */}
<nav className="flex-1 py-3 overflow-y-auto" aria-label="Primary">
<ul className="space-y-0.5" role="list">
{PRIMARY_NAV.map((item) => (
<li key={item.path}>
<SidebarNavLink item={item} active={isActive(item.path)} />
</li>
))}
</ul>
{/* Divider + developer section */}
<div className="mt-4 pt-4 border-t border-outline mx-4">
<p className="px-4 mb-1 text-xs font-medium uppercase tracking-wider text-on-surface-variant opacity-60">
Developer
</p>
<ul className="space-y-0.5" role="list">
{SECONDARY_NAV.map((item) => (
<li key={item.path}>
<SidebarNavLink item={item} active={isActive(item.path)} />
</li>
))}
</ul>
</div>
</nav>
</aside>
{/* ── Main content column ──────────────────────────────────── */}
<div className="lg:pl-64 flex flex-col min-h-screen">
{/* Top bar */}
<header className="sticky top-0 z-20 flex items-center justify-between h-14 lg:h-16 px-4 lg:px-6 bg-surface border-b border-outline shadow-sm shrink-0">
{/* Mobile: wordmark */}
<div className="flex items-center gap-2 lg:hidden">
<PlaneTakeoff size={20} className="text-primary" aria-hidden="true" />
<span className="text-base font-medium text-on-surface">
Flight Radar
</span>
</div>
{/* Desktop: page title */}
<h1 className="hidden lg:block text-xl font-medium text-on-surface">
{pageTitle}
</h1>
{/* Actions */}
{showNewScan && (
<Link <Link
to="/scans" to="/scans"
className={`px-3 py-2 rounded-md text-sm font-medium ${ className="flex items-center gap-1.5 h-9 px-4 rounded-full bg-primary text-on-primary text-sm font-medium hover:opacity-90 transition-opacity shadow-sm"
isActive('/scans')
? 'bg-blue-500 text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
> >
Scans <Plus size={16} aria-hidden="true" />
<span>New Scan</span>
</Link> </Link>
<Link )}
to="/airports"
className={`px-3 py-2 rounded-md text-sm font-medium ${
isActive('/airports')
? 'bg-blue-500 text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
Airports
</Link>
<Link
to="/logs"
className={`px-3 py-2 rounded-md text-sm font-medium ${
isActive('/logs')
? 'bg-blue-500 text-white'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
Logs
</Link>
</nav>
</div>
</div>
</header> </header>
{/* Main Content */} {/* Page content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <main className="flex-1 px-4 py-6 lg:px-6 lg:py-8 pb-28 lg:pb-8">
<div className="max-w-5xl mx-auto">
<Outlet /> <Outlet />
</div>
</main> </main>
</div> </div>
{/* ── Bottom nav (mobile < lg) ─────────────────────────────── */}
<nav
className="lg:hidden fixed bottom-0 left-0 right-0 z-30 bg-surface border-t border-outline"
aria-label="Main navigation"
>
<ul className="flex" role="list">
{ALL_NAV.map((item) => {
const active = isActive(item.path);
return (
<li key={item.path} className="flex-1">
<Link
to={item.path}
aria-current={active ? 'page' : undefined}
className="flex flex-col items-center gap-1 pt-2 pb-3 px-2"
>
<span
className={cn(
'flex items-center justify-center w-14 h-8 rounded-full transition-colors duration-150',
active ? 'bg-primary-container' : '',
)}
>
<item.icon
size={20}
className={cn(
active
? 'text-on-primary-container'
: 'text-on-surface-variant',
)}
aria-hidden="true"
/>
</span>
<span
className={cn(
'text-xs font-medium',
active ? 'text-primary' : 'text-on-surface-variant',
)}
>
{item.label}
</span>
</Link>
</li>
);
})}
</ul>
</nav>
</div>
); );
} }

View File

@@ -1,18 +1,81 @@
@tailwind base; @import "tailwindcss";
@tailwind components;
@tailwind utilities;
@theme {
/* Brand colours */
--color-primary: #1A73E8;
--color-primary-container: #D2E3FC;
--color-on-primary: #FFFFFF;
--color-on-primary-container: #003E8C;
--color-secondary: #0F9D58;
--color-secondary-container: #C8E6C9;
--color-on-secondary: #FFFFFF;
--color-on-secondary-container: #00391A;
--color-tertiary: #F4B400;
--color-tertiary-container: #FFF0C0;
--color-on-tertiary: #3B2A00;
--color-error: #D93025;
--color-error-container: #FDECEA;
--color-on-error: #FFFFFF;
/* Surface roles */
--color-bg: #F8F9FA;
--color-surface: #FFFFFF;
--color-surface-2: #F1F3F4;
--color-surface-variant: #E8EAED;
--color-on-surface: #202124;
--color-on-surface-variant: #5F6368;
--color-outline: #DADCE0;
--color-outline-variant: #F1F3F4;
/* Shape */
--radius-xs: 4px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-full: 9999px;
/* Typography */
--font-sans: 'Google Sans', 'Roboto', system-ui, -apple-system, sans-serif;
--font-mono: 'Roboto Mono', 'JetBrains Mono', ui-monospace, monospace;
/* Elevation shadows */
--shadow-level-1: 0 1px 2px rgba(0,0,0,.08);
--shadow-level-2: 0 2px 6px rgba(0,0,0,.10);
--shadow-level-3: 0 4px 12px rgba(0,0,0,.12);
}
/* Base */
body {
background-color: var(--color-bg);
color: var(--color-on-surface);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Skeleton shimmer */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, #E8EAED 25%, #F1F3F4 50%, #E8EAED 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
/* Slide-up (toasts) */
@keyframes slide-up { @keyframes slide-up {
from { from { transform: translateY(100%); opacity: 0; }
transform: translateY(100%); to { transform: translateY(0); opacity: 1; }
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
} }
.animate-slide-up { animation: slide-up 0.3s ease-out; }
.animate-slide-up { /* Reduced motion */
animation: slide-up 0.3s ease-out; @media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
} }

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}