Add PRD for design system implementation

7-phase implementation plan: foundation tokens → shared components →
all 5 pages. Defines acceptance criteria per phase, exact file list
(17 files changed, 7 new components), dependency notes (lucide-react,
clsx, tailwind-merge), and explicit non-goals. No implementation yet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 17:30:08 +01:00
parent b0a93bf824
commit 5d08d9353d

View File

@@ -0,0 +1,862 @@
# PRD: Design System Implementation
**Flight Radar Web Interface — Visual Redesign**
| | |
|---|---|
| **Version** | 1.0 |
| **Status** | Ready for Implementation |
| **Date** | February 2026 |
| **Design reference** | `docs/DESIGN_SYSTEM.md` |
| **Target** | Claude Code Implementation |
---
## 1. Overview
The Flight Radar web interface was built as a functional prototype. It works correctly but has no visual design system: navigation links run together into an unreadable string, stat cards are unstyled white boxes, forms lack any grouping or hierarchy, and the application is unusable on mobile devices.
This PRD specifies the complete implementation of the Material Design 3inspired design system defined in `docs/DESIGN_SYSTEM.md`. The result must be a production-quality UI that a non-technical user would consider polished and trustworthy.
**No new features are added in this PRD.** Every existing function is preserved. This is a pure UI/UX replacement.
---
## 2. Goals
- Implement the full design token system (colours, typography, spacing, elevation) from `docs/DESIGN_SYSTEM.md §3§6`
- Replace the broken top navigation with a proper sidebar on desktop and a bottom navigation bar on mobile
- Redesign all 5 pages using the component specifications in `docs/DESIGN_SYSTEM.md §7§8`
- Build a reusable component library that makes future pages easy to build consistently
- Achieve a fully functional, accessible mobile experience at 375px viewport width
- Pass a manual accessibility review: keyboard navigation, colour contrast (WCAG AA), and focus management
## 3. Non-Goals
- No new API endpoints or backend changes
- No dark mode (design tokens must be structured to support it later, but it need not be active)
- No animations that require a JS animation library (CSS transitions only)
- No redesign of the `AirportSearch` dropdown's behaviour — only its visual styling
- No changes to `api.ts`, `App.tsx`, routing, or any Python backend file
- No new pages beyond the existing 5
---
## 4. Success Criteria
| Criterion | Measure |
|-----------|---------|
| Navigation is readable | Nav items are separated, active state is visually distinct, works on 375px |
| Stats are scannable | Each stat card has an icon, colour accent, and label — no two look identical |
| Scan list is parseable | Destination, status chip, date range, and flight count are each in distinct visual zones |
| Form feels structured | Field groups are visually separated, labels float on focus, help text is present |
| Empty states have actions | Every zero-data view has an illustration, headline, and a CTA button |
| Loading has feedback | Every async wait shows a skeleton or spinner — never a blank area |
| Mobile is functional | All 5 pages are usable at 375px — no horizontal overflow, no overlapping elements |
| Accessibility baseline | All interactive elements ≥ 44×44px, focus rings visible, colour not sole differentiator |
---
## 5. Constraints
- **Tech stack is fixed:** React 19, TypeScript strict mode, Tailwind CSS v4, Vite 7
- **No CSS-in-JS:** all styling via Tailwind utility classes + custom CSS variables in `index.css`
- **Tailwind v4 `@theme` syntax:** token definitions use `@theme { }` block, not `tailwind.config.js`
- **Type-only imports:** `import type { X }` required for all interface/type imports (TypeScript strict)
- **No jQuery, no Bootstrap, no external UI framework** (MUI, Chakra, Ant Design etc.)
- **Three allowed new dependencies:** `lucide-react`, `clsx`, `tailwind-merge`
---
## 6. Implementation Phases
The work is divided into 5 phases that must be executed in order, because each phase depends on the previous one. A phase is complete only when all its acceptance criteria pass.
---
### Phase 1 — Foundation (tokens, fonts, layout shell)
**Objective:** Every page already looks better before a single page component is touched, because the global tokens and navigation are correct.
#### 1.1 Design tokens
**File:** `frontend/src/index.css`
Replace the existing 3-line Tailwind import with:
```css
@import "tailwindcss";
@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;
/* Semantic status colours (used via CSS variables, not Tailwind classes) */
--color-status-completed-bg: #E6F4EA;
--color-status-completed-text: #137333;
--color-status-completed-border: #A8D5B5;
--color-status-running-bg: #E8F0FE;
--color-status-running-text: #1557B0;
--color-status-running-border: #A8C7FA;
--color-status-pending-bg: #FEF7E0;
--color-status-pending-text: #7A5200;
--color-status-pending-border: #F9D659;
--color-status-failed-bg: #FDECEA;
--color-status-failed-text: #A50E0E;
--color-status-failed-border: #F5C6C6;
/* 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-1: 0 1px 2px rgba(0,0,0,.08);
--shadow-2: 0 2px 6px rgba(0,0,0,.10);
--shadow-3: 0 4px 12px rgba(0,0,0,.12);
}
/* Base resets */
body {
background-color: var(--color-bg);
color: var(--color-on-surface);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
/* 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 toast animation (keep existing) */
@keyframes slide-up {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.animate-slide-up { animation: slide-up 0.3s ease-out; }
/* Spin for loading icons */
@keyframes spin {
to { transform: rotate(360deg); }
}
.animate-spin-slow { animation: spin 0.6s linear infinite; }
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
#### 1.2 Google Fonts
**File:** `frontend/index.html`
Add inside `<head>`:
```html
<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">
```
#### 1.3 New dependencies
```bash
npm install lucide-react clsx tailwind-merge
```
#### 1.4 Layout shell redesign
**File:** `frontend/src/components/Layout.tsx`
Full replacement. The new layout has two distinct structures based on viewport:
**Desktop (≥ 1024px):**
```
┌──────────┬──────────────────────────────────────────┐
│ Sidebar │ Top bar (64px) │
│ (256px) ├──────────────────────────────────────────┤
│ │ │
│ Logo │ <Outlet /> wrapped in │
│ Nav │ max-w-5xl, px-6, py-8 │
│ │ │
└──────────┴──────────────────────────────────────────┘
```
**Mobile (< 1024px):**
```
┌──────────────────────────────────────────────────┐
│ Top bar (56px): logo + page title │
├──────────────────────────────────────────────────┤
│ │
│ <Outlet /> px-4 py-4, pb-24 (room for nav) │
│ │
├──────────────────────────────────────────────────┤
│ Bottom nav bar (56px + safe-area-bottom) │
└──────────────────────────────────────────────────┘
```
**Sidebar nav item spec:**
- Height: 48px, padding: 0 16px, border-radius: 28px (full bleed within sidebar)
- Active: `bg-primary-container text-on-primary-container font-medium`
- Inactive: `text-on-surface-variant hover:bg-surface-2`
- Icon: 20px Lucide icon, left-aligned, `mr-3`
- The active pill is the full row width minus 12px horizontal margin on each side
**Bottom nav item spec:**
- 4 items equally spaced
- Active: icon in Primary colour, 32×16px Primary Container pill behind icon, label in Primary
- Inactive: icon + label in `on-surface-variant`
- Label: 12px, 500 weight
**Nav items and routes:**
```
Icon Label Route
LayoutDashboard Dashboard /
ScanSearch Scans /scans
MapPin Airports /airports
ScrollText Logs /logs
```
**Top bar (desktop):**
- Height: 64px, `bg-surface border-b border-outline shadow-1`
- Left: empty (sidebar is present)
- Center/Left: current page title via `useLocation()` lookup
- Right: `+ New Scan` filled button (navigates to `/scans`), hidden on `/scans` page itself
**Top bar (mobile):**
- Height: 56px
- Left: ✈ `PlaneTakeoff` icon (20px, Primary) + "Flight Radar" text (title-lg)
- Right: `+ New Scan` icon button (`Plus` icon, 40×40px) on dashboard only
**Phase 1 acceptance criteria:**
- [ ] Sidebar is visible on desktop, hidden on mobile
- [ ] Bottom nav is visible on mobile, hidden on desktop
- [ ] Active nav item is visually distinct from inactive
- [ ] No horizontal scrollbar at any viewport width ≥ 320px
- [ ] Google Sans font is loading and applied
- [ ] `bg-primary` Tailwind class resolves to `#1A73E8`
---
### Phase 2 — Shared Component Library
**Objective:** Create all reusable components before touching any page. Pages are then assembled from these building blocks.
All new component files live in `frontend/src/components/`.
#### 2.1 `Button.tsx`
Props: `variant: 'filled' | 'outlined' | 'text' | 'icon'`, `size: 'sm' | 'md'` (default `md`), `loading?: boolean`, `icon?: LucideIcon`, `iconPosition?: 'left' | 'right'`, `disabled?`, `onClick?`, `type?`, `className?`, `children`
Spec per variant — see `docs/DESIGN_SYSTEM.md §7.2`.
The `loading` prop replaces children with a 16px spinner icon; button width is preserved via `min-w` matching the expected label width.
#### 2.2 `StatusChip.tsx`
Props: `status: 'completed' | 'running' | 'pending' | 'failed'`
Spec: `docs/DESIGN_SYSTEM.md §7.3`.
The chip reads its colours directly from the CSS variables `--color-status-{status}-bg` etc., applied inline or via a class map. The `running` status dot pulses using a CSS animation class.
Icon mapping:
```
completed → CheckCircle2 (16px)
running → Loader2 (16px, animate-spin-slow)
pending → Clock (16px)
failed → XCircle (16px)
```
#### 2.3 `StatCard.tsx`
Props: `label: string`, `value: number | string`, `icon: LucideIcon`, `variant: 'default' | 'primary' | 'secondary' | 'tertiary' | 'error'`, `trend?: string`
Spec: `docs/DESIGN_SYSTEM.md §7.4`.
Icon circle size: 40×40px. Colours per variant:
```
default #E8F0FE bg, Primary icon
primary #E8F0FE bg, Primary icon
secondary #E6F4EA bg, Secondary icon
tertiary #FEF7E0 bg, Tertiary icon
error #FDECEA bg, Error icon
```
Value typography: 28px (headline-md), 400 weight, `text-on-surface`.
#### 2.4 `EmptyState.tsx`
Props: `icon: LucideIcon`, `title: string`, `description?: string`, `action?: { label: string; onClick: () => void }`
Layout: centred column, icon at 64px in a 96×96px circle with `bg-surface-2`, headline-sm title, body-md description in `text-on-surface-variant`, optional filled primary button.
#### 2.5 `SkeletonCard.tsx`
Props: `rows?: number` (default 3), `showHeader?: boolean`
Renders an animated shimmer card placeholder. Uses the `.skeleton` CSS class from Phase 1.
Variants to export separately:
- `<SkeletonStatCard />` — matches StatCard dimensions
- `<SkeletonListItem />` — matches the scan list card height (72px)
- `<SkeletonTableRow />` — matches a table row (52px)
#### 2.6 `SegmentedButton.tsx`
Props: `options: Array<{ value: string; label: string; icon?: LucideIcon }>`, `value: string`, `onChange: (value: string) => void`
Spec: `docs/DESIGN_SYSTEM.md §7.7`.
The active segment shows a `Check` icon (16px) that slides in from the left using a CSS opacity+translate transition.
#### 2.7 `AirportChip.tsx`
Props: `code: string`, `onRemove: () => void`
Spec: `docs/DESIGN_SYSTEM.md §7.8`.
Shows a `PlaneTakeoff` icon (14px) before the code. The `×` button uses an `X` Lucide icon.
#### 2.8 `Toast.tsx` (update existing)
The existing `Toast.tsx` already handles dismissal logic. Update **only** the visual styling to match `docs/DESIGN_SYSTEM.md §7.12`.
Changes needed:
- New width (360px desktop / full-width mobile), border-radius 12px, shadow-3
- Position: bottom-right on desktop (`fixed bottom-6 right-6`), bottom-center on mobile
- Four variants: `success`, `error`, `info`, `warning` — each with distinct icon and colour per the design spec
- Keep the existing `animate-slide-up` class
**Phase 2 acceptance criteria:**
- [ ] All 8 components render without TypeScript errors (`npm run build` passes)
- [ ] `StatusChip` shows correct colour and icon for all 4 statuses
- [ ] `SkeletonCard` shimmers
- [ ] `SegmentedButton` check icon appears on the active segment
- [ ] `Button` loading state preserves button width
---
### Phase 3 — Dashboard page
**File:** `frontend/src/pages/Dashboard.tsx`
**Objective:** Replace the current text-dump dashboard with a stats + card-list layout using Phase 2 components.
#### 3.1 Loading state
While `loading === true`, render:
- 5× `<SkeletonStatCard />` in the stats grid
- 5× `<SkeletonListItem />` in the recent scans section
Do **not** show a spinner or "Loading…" text.
#### 3.2 Stats grid
Replace the 5 plain white boxes with `<StatCard>` components:
```
<StatCard label="Total Scans" value={stats.total} icon={ScanSearch} variant="primary" />
<StatCard label="Pending" value={stats.pending} icon={Clock} variant="tertiary" />
<StatCard label="Running" value={stats.running} icon={Loader2} variant="primary" />
<StatCard label="Completed" value={stats.completed} icon={CheckCircle2} variant="secondary" />
<StatCard label="Failed" value={stats.failed} icon={XCircle} variant="error" />
```
Grid: `grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4`
#### 3.3 Recent scans section
Header row: `"Recent Scans"` (title-lg, `text-on-surface`) + `"View all →"` text button (right-aligned, links to a future full scan list — for now it links to `/scans`).
Each scan renders as a card (not a `<tr>`). Card structure:
```
┌──────────────────────────────────────────────────────┐
│ BDS → FMM, DUS ● completed → │
│ 26 Feb 2026 27 May 2026 · 1 adult · Economy │
│ 182 routes · 50 flights found │
└──────────────────────────────────────────────────────┘
```
- Arrow `→` (`ArrowRight`, 16px, `text-on-surface-variant`) right-aligned
- On hover: card lifts (`shadow-2`, `translateY(-2px)`) via CSS transition 150ms
- `<Link>` wraps the entire card (no nested interactive elements inside except the chip)
- Date string: `formatDate` replaced with a relative label where applicable (e.g. "Today", "Yesterday", or absolute date)
#### 3.4 Empty state
When `scans.length === 0`:
```
<EmptyState
icon={ScanSearch}
title="No scans yet"
description="Create your first scan to discover flight routes and prices."
action={{ label: "+ New Scan", onClick: () => navigate('/scans') }}
/>
```
**Phase 3 acceptance criteria:**
- [ ] Skeleton shows while loading, disappears when data arrives
- [ ] Each of 5 stat cards has a distinct icon and colour accent
- [ ] Scan cards are separated, hoverable, and fully clickable
- [ ] Status chip is visually separate from destination text (no more `"FMM,DUScompleted"`)
- [ ] Empty state renders with button when no scans exist
---
### Phase 4 — Create Scan form
**File:** `frontend/src/pages/Scans.tsx`
**Objective:** Transform the flat HTML form into a structured, guided form with clear visual grouping.
#### 4.1 Page structure
```
"New Scan" (headline-md) [← Cancel — text button]
┌ Section: Origin ─────────────────────────────────┐
│ Origin Airport │
│ [AirportSearch input — full width] │
│ Help text: "3-letter IATA code (e.g. BDS)" │
└───────────────────────────────────────────────────┘
┌ Section: Destination ────────────────────────────┐
│ [ 🌍 By Country | ✈ By Airports ] ← SegmentedButton │
│ │
│ [Country input — if country mode] │
│ [AirportSearch + AirportChip list — if airports] │
└───────────────────────────────────────────────────┘
┌ Section: Parameters ─────────────────────────────┐
│ [Search Window] [Seat Class] │
│ [Adults] │
└───────────────────────────────────────────────────┘
[Create Scan — filled button]
```
Each `┌ Section ┐` is a `bg-surface rounded-lg shadow-1 p-6` card with a section label in `label-sm uppercase text-on-surface-variant tracking-wider mb-4`.
#### 4.2 Destination mode toggle
Replace the two `<button>` elements with `<SegmentedButton>`:
```tsx
<SegmentedButton
options={[
{ value: 'country', label: 'By Country', icon: Globe },
{ value: 'airports', label: 'By Airports', icon: PlaneTakeoff },
]}
value={destinationMode}
onChange={(v) => setDestinationMode(v as 'country' | 'airports')}
/>
```
#### 4.3 Input styling
All `<input>` and `<select>` elements adopt the outlined input style:
- Border: `border border-outline rounded-xs` (4px corners)
- Focus: `focus:border-primary focus:ring-2 focus:ring-primary focus:ring-offset-0 outline-none`
- Height: 48px (slightly shorter than the 56px MD3 spec to suit a dense form)
- Label: above the field, `label-md` weight, `text-on-surface-variant`
- Helper text: `body-sm text-on-surface-variant mt-1`
The `AirportSearch` component's internal input receives the same classes.
#### 4.4 Airport chips
Selected airports in airports mode use `<AirportChip>` (Phase 2 §2.7), wrapped in `flex flex-wrap gap-2 mt-3`.
#### 4.5 Parameters section layout
Desktop: `grid grid-cols-2 gap-4` for Window + Seat Class. Adults below, full width.
Mobile: single column.
#### 4.6 Number inputs
`window_months` and `adults` inputs: hide browser spin buttons via CSS (`[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none`). Add manual `` and `+` icon buttons (24px, ghost) flanking the value display.
#### 4.7 Validation feedback
Remove the inline error banner `div`. Instead:
- Field-level errors render as `body-sm text-error mt-1` below the relevant input
- The input border turns `border-error` on error
- On submission failure from the API, show a `Toast` (error variant) instead of a banner
#### 4.8 Submit / cancel
Remove the `window.location.href = '/'` navigation. Use React Router `useNavigate()` instead.
On success: show a `Toast` (success variant) then `navigate('/')` after 1500ms.
**Phase 4 acceptance criteria:**
- [ ] Three distinct visual sections (Origin, Destination, Parameters)
- [ ] Segmented button replaces mode toggle buttons
- [ ] Airport chips appear and are removable
- [ ] Number inputs have manual increment/decrement buttons
- [ ] Validation errors appear inline below the relevant field
- [ ] Success navigates via React Router (not `window.location.href`)
---
### Phase 5 — Scan Details page
**File:** `frontend/src/pages/ScanDetails.tsx`
**Objective:** Replace the plain details layout with a header card, redesigned stats, animated expandable table rows, and a proper progress indicator.
#### 5.1 Breadcrumb
Replace the bare `← Back to Dashboard` link with:
```
← Dashboard / Scan #54
```
Use `ArrowLeft` (16px) icon. Style as `body-md text-on-surface-variant hover:text-on-surface`.
#### 5.2 Header card
A single `bg-surface shadow-1 rounded-lg p-6 mb-6` card containing:
```
Row 1: "BDS → FMM, DUS" (headline-md, with PlaneTakeoff icon before text)
[right-aligned]: <StatusChip status={scan.status} />
Row 2: Calendar icon · "26 Feb 27 May 2026"
Users icon · "1 adult"
Armchair icon · "Economy"
(all body-md, text-on-surface-variant, flex row with gap-4)
Row 3 (if completed/failed):
Clock icon · Created at timestamp (body-sm, text-on-surface-variant)
```
#### 5.3 Stats row
Replace the 3 plain cards with `<StatCard>` components. Loading state uses `<SkeletonStatCard />`.
```
<StatCard label="Total Routes" value={scan.total_routes} icon={MapPin} variant="default" />
<StatCard label="Routes Scanned" value={scan.routes_scanned} icon={CheckCircle2} variant="secondary" />
<StatCard label="Total Flights" value={scan.total_flights} icon={PlaneTakeoff} variant="primary" />
```
#### 5.4 Progress card (running/pending only)
Replace the existing progress bar section with:
```
┌────────────────────────────────────────────────────────────┐
│ ↻ (Loader2, animated) Scanning in progress... │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 38% │
│ 23 of 60 routes · Auto-refreshing every 3 seconds │
└────────────────────────────────────────────────────────────┘
```
Card: `bg-surface rounded-lg p-5 mb-6 border border-running-border` (using CSS var `--color-status-running-border`) with a subtle `box-shadow: 0 0 0 3px var(--color-status-running-border)` pulse animation.
Progress bar: 4px height, Primary fill, Surface 2 track, `transition-all duration-300`.
#### 5.5 Routes table
**Header row:** `bg-surface-2`, column headers in `label-sm uppercase text-on-surface-variant`. Sortable columns (Destination, Flights, Min Price) show `ChevronUp`/`ChevronDown` icon (14px) trailing the header label. Active sort column header text is `text-primary`.
**Body rows:**
- Height: `py-4` (comfortable)
- Hover: `hover:bg-surface-2 transition-colors duration-150`
- Cursor: `cursor-pointer`
**Destination cell:**
- Expand chevron: `ChevronRight` (16px, `text-on-surface-variant`), rotates to `ChevronDown` on expand via `transition-transform duration-200`
- IATA code: `font-mono text-primary bg-primary-container px-2 py-0.5 rounded-sm text-sm font-medium` (inline chip)
- Airport name: `text-sm text-on-surface-variant ml-2`
**Min Price cell:** `text-secondary font-medium` — distinct from avg/max which are `text-on-surface-variant`
**Expanded sub-row:**
- Background: `bg-[#F8FDF9]` (very light green tint, hardcoded — not a token)
- Transition: the row must not snap in/out. Use `max-height: 0 → max-height: 600px` with `overflow-hidden transition-all duration-250 ease-in-out` applied to a wrapper div
- Sub-table headers: `label-sm uppercase text-on-surface-variant bg-[#EEF7F0]`
- Sub-table `Date` column: indent 32px to visually nest under the destination
- `Price` column: `text-secondary font-medium`, right-aligned
- Loading state inside expanded row: `<SkeletonTableRow />` × 3
#### 5.6 Empty states (no routes)
```
completed with 0 routes:
<EmptyState icon={MapPin} title="No routes found"
description="No direct flights for the selected airports and date range." />
failed:
<EmptyState icon={AlertCircle} title="Scan failed"
description={scan.error_message || 'An error occurred during the scan.'} />
running/pending with 0 routes:
(handled by progress card above — no separate empty state needed)
```
**Phase 5 acceptance criteria:**
- [ ] Header card shows origin → destination, status chip, and all metadata
- [ ] Progress card shows only when scan is running or pending
- [ ] Active sort column header is visually distinguished
- [ ] IATA codes render as inline chips in the destination cell
- [ ] Expanding a route row is animated (not instant snap)
- [ ] Sub-table flights have a distinct background from parent rows
- [ ] Min price is green; avg/max are muted
---
### Phase 6 — Airports page
**File:** `frontend/src/pages/Airports.tsx`
**Objective:** Transform the blank page into a functional search experience with a real results table.
#### 6.1 Page header
```
"Airports" (headline-md) + "Search the global airport database" (body-lg, text-on-surface-variant, mt-1)
```
#### 6.2 Search bar
Single full-width input with a leading `Search` icon (20px, `text-on-surface-variant`, left-padded inside input via `pl-12`). Input height: 48px. Remove the separate `<button>Search</button>` — search triggers on keystroke debounce (already implemented in component) and on Enter.
Replace the `bg-white rounded-lg shadow p-6 mb-6` wrapper card with just the input standing on the surface background, `mb-6`.
#### 6.3 Results — desktop (≥ 768px)
Standard data table inside a `bg-surface rounded-lg shadow-1 overflow-hidden` card.
Columns: IATA (72px fixed, mono-md, Primary) | Airport Name | City | Country | Copy
Copy cell: `<Button variant="icon">` with `Copy` icon. On click: `navigator.clipboard.writeText(airport.iata)` then show inline "Copied!" `label-sm text-secondary` tooltip for 2s (CSS opacity transition).
#### 6.4 Results — mobile (< 768px)
List view (not table). Each airport row:
```
┌──────────────────────────────────┐
│ FRA Frankfurt Airport [⎘] │
│ Frankfurt, Germany │
└──────────────────────────────────┘
```
IATA code: `font-mono text-primary font-medium` (no chip on mobile to save space).
#### 6.5 Empty and initial states
Before any search (query length < 2):
```
<EmptyState
icon={Search}
title="Search airports"
description="Enter an IATA code, city name, or airport name to search."
/>
```
After search with no results:
```
<EmptyState
icon={MapPin}
title={`No airports found for "${query}"`}
description="Try a different IATA code or city name."
/>
```
**Phase 6 acceptance criteria:**
- [ ] Search icon is visually inside the input field
- [ ] No search button — search triggers on input change (debounced)
- [ ] IATA code has monospace font and Primary colour
- [ ] Copy button works and shows transient feedback
- [ ] Initial empty state shows on page load
- [ ] Table/list switches between layouts at 768px
---
### Phase 7 — Logs page
**File:** `frontend/src/pages/Logs.tsx`
**Objective:** Make the log viewer scannable by adding log-level colour coding, monospace message text, and a cleaner filter bar.
#### 7.1 Page header
```
"System Logs" (headline-md) + auto-refresh toggle (icon button, `RefreshCw` icon, top-right)
```
The auto-refresh toggle starts as `false`. When active, it polls every 5s and shows a rotating icon.
#### 7.2 Filter bar
Replace the current two-column grid card with a single horizontal filter row at the top of the content area (no card wrapper):
```
[All Levels ▼] [Search messages... 🔍] [Clear filters ✕] ← only visible when filters active
```
- Level select: 140px wide, outlined input style, `ChevronDown` trailing icon
- Search: flex-1, outlined input with leading `Search` icon (same pattern as Airports §6.2)
- Clear button: `text` variant Button with `X` icon, only rendered when `level || searchQuery`
#### 7.3 Log row design
Each log entry is a `px-4 py-3 border-b border-outline-variant` row (no card wrapper — the list is the card).
```
┌─────────────────────────────────────────────────────────────────────────┐
│ ● INFO Flight Radar API v2.0 startup complete 22:57:33 │
│ api_server · lifespan · line 310 │
└─────────────────────────────────────────────────────────────────────────┘
```
- Level badge: fixed 64px wide, `label-md`, colours from the `getLevelColor` map (keep existing logic, update classes to use design tokens)
- Message: `font-mono text-sm text-on-surface`, `break-all` on overflow
- Metadata line: `body-sm text-on-surface-variant mt-0.5`, items separated by `·`
- Timestamp: right-aligned, `body-sm font-mono text-on-surface-variant`, fixed width
**Row background tints:**
```
ERROR → bg-[#FFF5F5] (very subtle, not the full error-container)
WARNING → bg-[#FFFBF0]
DEBUG → default
INFO → default
CRITICAL → bg-[#FFF0F0]
```
#### 7.4 Loading state
While `loading === true`: render 8× `<SkeletonTableRow />`.
**Phase 7 acceptance criteria:**
- [ ] Level badge has correct colour per level
- [ ] Message text uses monospace font
- [ ] ERROR and WARNING rows have a background tint
- [ ] Filter row is horizontal (not a two-column card grid)
- [ ] Clear filters button only appears when filters are active
- [ ] Loading state uses skeleton rows
---
## 7. Files Changed Summary
| File | Phase | Type |
|------|-------|------|
| `frontend/index.html` | 1 | Update — add Google Fonts |
| `frontend/src/index.css` | 1 | Replace — add design tokens |
| `frontend/src/components/Layout.tsx` | 1 | Replace — sidebar + bottom nav |
| `frontend/src/components/Button.tsx` | 2 | New |
| `frontend/src/components/StatusChip.tsx` | 2 | New |
| `frontend/src/components/StatCard.tsx` | 2 | New |
| `frontend/src/components/EmptyState.tsx` | 2 | New |
| `frontend/src/components/SkeletonCard.tsx` | 2 | New |
| `frontend/src/components/SegmentedButton.tsx` | 2 | New |
| `frontend/src/components/AirportChip.tsx` | 2 | New |
| `frontend/src/components/Toast.tsx` | 2 | Update — visual only |
| `frontend/src/pages/Dashboard.tsx` | 3 | Replace |
| `frontend/src/pages/Scans.tsx` | 4 | Replace |
| `frontend/src/pages/ScanDetails.tsx` | 5 | Replace |
| `frontend/src/pages/Airports.tsx` | 6 | Replace |
| `frontend/src/pages/Logs.tsx` | 7 | Replace |
| `frontend/src/components/AirportSearch.tsx` | 4, 6 | Update — visual styling only |
**Files that must NOT change:**
- `frontend/src/api.ts` — all API calls, types, and interfaces remain identical
- `frontend/src/App.tsx` — routing stays the same
- `frontend/src/main.tsx` — entry point unchanged
- All backend Python files
- `database/`, `tests/`, `docker-compose.yml`, `nginx.conf`
---
## 8. Dependency Notes
### `lucide-react`
Tree-shakeable. Import only the icons used per file:
```tsx
import { PlaneTakeoff, CheckCircle2 } from 'lucide-react';
```
Never import `import * from 'lucide-react'`.
### `clsx` + `tailwind-merge`
Use together for conditional class merging in components:
```tsx
import { clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
const cn = (...args: Parameters<typeof clsx>) => twMerge(clsx(...args));
```
Export this `cn` helper from a shared `frontend/src/lib/utils.ts` file.
---
## 9. Implementation Order
Execute phases strictly in order 1 → 7. Do not start Phase 3 before Phase 2 is complete. After each phase:
1. Run `npm run build` — must produce zero TypeScript errors
2. Run `npm run lint` — must produce zero lint errors
3. Verify the phase acceptance criteria manually in the browser
4. Commit with a descriptive message (per `CLAUDE.md` workflow rules)
5. Push to remote
---
## 10. Out of Scope for This PRD
The following items are defined in `DESIGN_SYSTEM.md` but intentionally deferred:
| Feature | Reason deferred |
|---------|-----------------|
| Dark mode | Tokens are structured to support it; implementation requires a separate pass |
| Page transition animations | Low impact, adds complexity |
| Floating label inputs (full MD3 pattern) | Requires more JS; simplified outlined labels are acceptable for v1 |
| SVG illustrations in empty states | Placeholder icon circles are sufficient for v1 |
| Sidebar collapse to 72px rail | Not needed until the app has more nav items |
| `react-router` `useNavigate` usage in `Scans.tsx` §4.8 | Should still replace `window.location.href` — this is a bug fix included in scope |
---
*— PRD v1.0 · Design System Implementation · Ready for Implementation —*