# 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 3–inspired 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 `
`:
```html
```
#### 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 │ wrapped in │
│ Nav │ max-w-5xl, px-6, py-8 │
│ │ │
└──────────┴──────────────────────────────────────────┘
```
**Mobile (< 1024px):**
```
┌──────────────────────────────────────────────────┐
│ Top bar (56px): logo + page title │
├──────────────────────────────────────────────────┤
│ │
│ 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:
- `` — matches StatCard dimensions
- `` — matches the scan list card height (72px)
- `` — 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× `` in the stats grid
- 5× `` in the recent scans section
Do **not** show a spinner or "Loading…" text.
#### 3.2 Stats grid
Replace the 5 plain white boxes with `` components:
```
```
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 `
`). 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
- `` 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`:
```
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 `