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>
34 KiB
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
AirportSearchdropdown'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
@themesyntax: token definitions use@theme { }block, nottailwind.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:
@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>:
<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
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 Scanfilled button (navigates to/scans), hidden on/scanspage itself
Top bar (mobile):
- Height: 56px
- Left: ✈
PlaneTakeofficon (20px, Primary) + "Flight Radar" text (title-lg) - Right:
+ New Scanicon button (Plusicon, 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-primaryTailwind 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-upclass
Phase 2 acceptance criteria:
- All 8 components render without TypeScript errors (
npm run buildpasses) StatusChipshows correct colour and icon for all 4 statusesSkeletonCardshimmersSegmentedButtoncheck icon appears on the active segmentButtonloading 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:
formatDatereplaced 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>:
<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-mdweight,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-1below the relevant input - The input border turns
border-erroron 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 toChevronDownon expand viatransition-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: 600pxwithoverflow-hidden transition-all duration-250 ease-in-outapplied to a wrapper div - Sub-table headers:
label-sm uppercase text-on-surface-variant bg-[#EEF7F0] - Sub-table
Datecolumn: indent 32px to visually nest under the destination Pricecolumn: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,
ChevronDowntrailing icon - Search: flex-1, outlined input with leading
Searchicon (same pattern as Airports §6.2) - Clear button:
textvariant Button withXicon, only rendered whenlevel || 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 thegetLevelColormap (keep existing logic, update classes to use design tokens) - Message:
font-mono text-sm text-on-surface,break-allon 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 identicalfrontend/src/App.tsx— routing stays the samefrontend/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:
import { PlaneTakeoff, CheckCircle2 } from 'lucide-react';
Never import import * from 'lucide-react'.
clsx + tailwind-merge
Use together for conditional class merging in components:
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:
- Run
npm run build— must produce zero TypeScript errors - Run
npm run lint— must produce zero lint errors - Verify the phase acceptance criteria manually in the browser
- Commit with a descriptive message (per
CLAUDE.mdworkflow rules) - 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 —