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:
862
flight-comparator/docs/PRD_DESIGN_SYSTEM.md
Normal file
862
flight-comparator/docs/PRD_DESIGN_SYSTEM.md
Normal 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 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 `<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 —*
|
||||
Reference in New Issue
Block a user