Files
ciaovolo/flight-comparator/docs/PRD_DESIGN_SYSTEM.md
domverse 5d08d9353d 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>
2026-02-26 17:30:08 +01:00

34 KiB
Raw Blame History

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:

@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 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>:

<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)

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:

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:

  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 —