+
+
+
+
FirstWave
+
Emergency Response System
+
-
-
-
-
-
-
+
+
+
+
System:{" "}
- {online ? "Online" : "Offline"}
+ {online ? "Online" : "Offline"}
+
+
+
+
+
+
diff --git a/frontend/src/components/landing/hero.tsx b/frontend/src/components/landing/hero.tsx
new file mode 100644
index 0000000..28da3e6
--- /dev/null
+++ b/frontend/src/components/landing/hero.tsx
@@ -0,0 +1,115 @@
+import Link from "next/link"
+
+import { Button } from "@/components/ui/button"
+import { CONSOLE_HREF, DEMO_URL } from "./links"
+
+/** Thin concentric cyan rings behind the hero, top-right (~15% opacity). */
+function HeroRings() {
+ return (
+
+ {[130, 230, 330, 430].map((r, i) => (
+
+ ))}
+
+ )
+}
+
+function FactChip({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label} {value}
+
+ )
+}
+
+function SampleCallCard() {
+ return (
+
+
+
+
+ INCOMING CALL · 00:42
+
+
Sample call
+
+
+
+
+
CALLER
+
+ There was a crash. My dad is not waking up. We're on Market near Fifth.
+
+
+
+
FIRSTWAVE
+
Stay with me. Is he breathing right now?
+
+
+
+
+
+
+
+
+
P1
+
+ Life-threatening.
+
+ Recommend EMS now.
+
+
Awaiting dispatcher approval
+
+
+ )
+}
+
+export function Hero() {
+ return (
+
+
+
+
+
AI Emergency Dispatch Assistant
+
+ The first wave of help starts with the first second of the call.
+
+
+ FirstWave talks to the caller, structures what it hears, and hands the dispatcher a triaged, mapped
+ recommendation. A human approves every dispatch.
+
+
+
+ Over 240 million emergency calls are made in the US each year (NENA). Key details get missed when callers
+ panic.
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/landing/landing-nav.tsx b/frontend/src/components/landing/landing-nav.tsx
new file mode 100644
index 0000000..da705b2
--- /dev/null
+++ b/frontend/src/components/landing/landing-nav.tsx
@@ -0,0 +1,62 @@
+import Link from "next/link"
+
+import { Button } from "@/components/ui/button"
+import { CONSOLE_HREF, SETTINGS_HREF } from "./links"
+
+/** `((•))` mark. Decorative: the wordmark next to it carries the name. */
+export function WaveLogo({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+const NAV_LINKS = [
+ { href: "#pipeline", label: "Pipeline" },
+ { href: "#priority", label: "Priority" },
+ { href: "#map", label: "Map" },
+]
+
+const linkClass =
+ "rounded-full px-1 text-[15px] font-medium text-muted-foreground transition-colors hover:text-foreground"
+
+export function LandingNav() {
+ return (
+
+
+
+
+ FirstWave
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/landing/links.ts b/frontend/src/components/landing/links.ts
new file mode 100644
index 0000000..385ffbd
--- /dev/null
+++ b/frontend/src/components/landing/links.ts
@@ -0,0 +1,4 @@
+export const CONSOLE_HREF = "/console"
+export const SETTINGS_HREF = "/settings"
+export const DEMO_URL = "https://youtu.be/tViIWf1pbC8"
+export const GITHUB_URL = "https://github.com/darrensh3n/FirstWaveAI"
diff --git a/frontend/src/components/landing/map-illustration.tsx b/frontend/src/components/landing/map-illustration.tsx
new file mode 100644
index 0000000..51ceca4
--- /dev/null
+++ b/frontend/src/components/landing/map-illustration.tsx
@@ -0,0 +1,85 @@
+import { Cross, Flame, Pill, Shield, type LucideIcon } from "lucide-react"
+
+// Static illustration only. The console uses the live Leaflet map.
+const W = 614
+const H = 458
+const INCIDENT = { x: 307, y: 229 }
+
+type Marker = { label: string; legend: string; x: number; y: number; icon: LucideIcon; bg: string }
+
+const MARKERS: Marker[] = [
+ { label: "Hospital", legend: "Hospitals", x: 187, y: 142, icon: Cross, bg: "bg-resource-medical" },
+ { label: "Fire station", legend: "Fire stations", x: 438, y: 153, icon: Flame, bg: "bg-resource-fire" },
+ { label: "Police", legend: "Police", x: 384, y: 339, icon: Shield, bg: "bg-resource-police" },
+ { label: "Pharmacy", legend: "Pharmacies", x: 165, y: 317, icon: Pill, bg: "bg-resource-pharmacy" },
+]
+
+const pos = (x: number, y: number) => ({ left: `${(x / W) * 100}%`, top: `${(y / H) * 100}%` })
+
+export function MapIllustration() {
+ return (
+
+
+
+ {/* Street grid */}
+
+ {[121, 307, 492].map((x) => (
+
+ ))}
+ {[98, 229, 359].map((y) => (
+
+ ))}
+
+ {/* Main roads */}
+
+
+
+
+ {/* Search radius rings */}
+
+
+
+
+
+
+
+ {/* Incident pin: P1 color, dot + ring halo */}
+
+
+
+
+
+
+ {MARKERS.map(({ label, x, y, icon: Icon, bg }) => (
+
+
+
+ ))}
+
+
+
+
+ {MARKERS.map(({ legend, bg }) => (
+
+
+ {legend}
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/src/components/landing/sections.tsx b/frontend/src/components/landing/sections.tsx
new file mode 100644
index 0000000..840d699
--- /dev/null
+++ b/frontend/src/components/landing/sections.tsx
@@ -0,0 +1,144 @@
+import Link from "next/link"
+
+import { Button } from "@/components/ui/button"
+import { CONSOLE_HREF, GITHUB_URL } from "./links"
+import { MapIllustration } from "./map-illustration"
+
+const container = "mx-auto max-w-[1120px] px-4 sm:px-6"
+const h2Class = "mt-5 text-[32px] leading-[1.1] tracking-[-0.02em] sm:text-[40px]"
+const copyClass = "mt-6 text-[17px] leading-[1.6] text-muted-foreground"
+
+const AGENTS = [
+ { title: "Extraction", body: "Pulls location, injuries, hazards and headcount out of the live transcript." },
+ { title: "Triage", body: "Classifies the emergency and assigns a P1 to P4 priority." },
+ { title: "Next-Question", body: "Spots what is still missing and suggests the follow-up to ask." },
+ { title: "Dispatch Planner", body: "Recommends EMS, Fire or Police for the incident." },
+ { title: "Resource Locator", body: "Finds the nearest available units with ETAs." },
+ { title: "Safety Guardrail", body: "Checks every recommendation before it reaches the dispatcher." },
+]
+
+export function PipelineSection() {
+ return (
+
+
+
Multi-agent pipeline
+
+ Six agents work the call while the caller is still talking.
+
+
+ {AGENTS.map((agent, i) => (
+
+ {String(i + 1).padStart(2, "0")}
+ {agent.title}
+ {agent.body}
+
+ ))}
+
+
+
+ )
+}
+
+const PRIORITIES = [
+ { level: "P1", label: "Life-threatening, immediate response", color: "text-p1" },
+ { level: "P2", label: "Urgent, serious but stable", color: "text-p2" },
+ { level: "P3", label: "Needs a response, not time-critical", color: "text-p3" },
+ { level: "P4", label: "Low priority, can wait", color: "text-p4" },
+]
+
+export function PrioritySection() {
+ return (
+
+
+
+
Human in control
+
+ The AI recommends. The dispatcher decides.
+
+
+ Every recommendation lands with a priority, a rationale and the units to send. Nothing goes out until a
+ dispatcher approves it, and cancelling takes one click.
+
+ {/* Illustration of the console controls. Non-functional, so hidden from assistive tech and tab order. */}
+
+
+ Approve dispatch
+
+
+ Cancel
+
+
+
+
+
+ {PRIORITIES.map((p) => (
+
+
+ {p.level}
+
+ {p.label}
+
+ ))}
+
+
+
+ )
+}
+
+export function MapSection() {
+ return (
+
+
+
+
+
+
+
Resource mapping
+
+ See the nearest help before you send it.
+
+
+ An interactive map plots the incident and the closest hospitals, fire stations, police and pharmacies, each
+ with distance and travel time, built on Leaflet and OpenStreetMap.
+
+
+
+
+ )
+}
+
+export function CtaBand() {
+ return (
+
+
+
+
+ Give every dispatcher a faster first minute.
+
+
+
+
+
+ )
+}
+
+export function LandingFooter() {
+ return (
+
+ )
+}
diff --git a/frontend/src/components/nearby-resources.tsx b/frontend/src/components/nearby-resources.tsx
index a103262..be0a02e 100644
--- a/frontend/src/components/nearby-resources.tsx
+++ b/frontend/src/components/nearby-resources.tsx
@@ -1,24 +1,10 @@
"use client"
-import { useState, useEffect } from "react"
+import { useState } from "react"
import dynamic from "next/dynamic"
-import {
- MapPin,
- Phone,
- ExternalLink,
- Flame,
- Building2,
- Shield,
- Cross,
- Filter,
- Navigation,
- ChevronDown,
- Car,
- MapPinOff,
- Loader2
-} from "lucide-react"
+import { Phone, ExternalLink, MoreHorizontal, Maximize2, Minimize2, Loader2, MapPinOff } from "lucide-react"
import { Button } from "@/components/ui/button"
-import { Switch } from "@/components/ui/switch"
+import { Card, CardAction, CardHeader, CardTitle } from "@/components/ui/card"
import {
AlertDialog,
AlertDialogAction,
@@ -38,17 +24,16 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
+import { cn } from "@/lib/utils"
// Dynamically import the map to avoid SSR issues with Leaflet
-const MapComponent = dynamic(() => import("./resource-map"), {
+const MapComponent = dynamic(() => import("./resource-map"), {
ssr: false,
loading: () => (
-
-
-
Map Component Area
-
Loading map...
+
+ Loading map…
- )
+ ),
})
type ResourceType = "medical" | "police" | "fire" | "pharmacy"
@@ -134,36 +119,14 @@ const mockResources: Resource[] = [
},
]
-const resourceTypeConfig = {
- medical: {
- label: "Medical",
- icon: Cross,
- color: "text-rose-500",
- bg: "bg-rose-500/20",
- activeBg: "bg-rose-500/30"
- },
- police: {
- label: "Police",
- icon: Shield,
- color: "text-sky-500",
- bg: "bg-sky-500/20",
- activeBg: "bg-sky-500/30"
- },
- fire: {
- label: "Fire",
- icon: Flame,
- color: "text-orange-500",
- bg: "bg-orange-500/20",
- activeBg: "bg-orange-500/30"
- },
- pharmacy: {
- label: "Pharmacy",
- icon: Building2,
- color: "text-emerald-500",
- bg: "bg-emerald-500/20",
- activeBg: "bg-emerald-500/30"
- },
+// Resource type colors + labels (design.md §2.1). Order matches the legend.
+const resourceTypeConfig: Record
= {
+ medical: { label: "Hospital", dot: "bg-resource-medical" },
+ fire: { label: "Fire", dot: "bg-resource-fire" },
+ police: { label: "Police", dot: "bg-resource-police" },
+ pharmacy: { label: "Pharmacy", dot: "bg-resource-pharmacy" },
}
+const RESOURCE_TYPES = Object.keys(resourceTypeConfig) as ResourceType[]
// Demo mode: Hardcoded caller location at San Jose State University
const DEMO_CALLER_LOCATION = {
@@ -172,7 +135,12 @@ const DEMO_CALLER_LOCATION = {
name: "San Jose State University"
}
-export function NearbyResources() {
+interface NearbyResourcesProps {
+ /** Current call priority (P1–P4); tints the incident pin on the map. */
+ priority?: string | null
+}
+
+export function NearbyResources({ priority = null }: NearbyResourcesProps) {
// For demo: Start with SJSU location already set
const [userLocation, setUserLocation] = useState<{lat: number, lng: number} | null>(DEMO_CALLER_LOCATION)
const [locationError, setLocationError] = useState(null)
@@ -195,12 +163,12 @@ export function NearbyResources() {
const handleLocationToggle = (enabled: boolean) => {
setLocationEnabled(enabled)
-
+
if (enabled) {
if ("geolocation" in navigator) {
setIsLocating(true)
setLocationError(null)
-
+
navigator.geolocation.getCurrentPosition(
(position) => {
setUserLocation({
@@ -212,8 +180,8 @@ export function NearbyResources() {
},
(error) => {
setLocationError(
- error.code === 1
- ? "Location permission denied"
+ error.code === 1
+ ? "Location permission denied"
: "Unable to retrieve your location"
)
setIsLocating(false)
@@ -240,50 +208,54 @@ export function NearbyResources() {
})
}
- const filteredResources = selectedTypes.size > 0
- ? mockResources.filter(r => selectedTypes.has(r.type))
- : mockResources
+ const isTypeVisible = (type: ResourceType) => selectedTypes.size === 0 || selectedTypes.has(type)
- const resourceCounts = {
- medical: mockResources.filter(r => r.type === "medical").length,
- police: mockResources.filter(r => r.type === "police").length,
- fire: mockResources.filter(r => r.type === "fire").length,
- pharmacy: mockResources.filter(r => r.type === "pharmacy").length,
- }
+ const filteredResources = mockResources
+ .filter((r) => isTypeVisible(r.type))
+ .sort((a, b) => a.distance - b.distance)
+
+ const resourceCounts = Object.fromEntries(
+ RESOURCE_TYPES.map((type) => [type, mockResources.filter((r) => r.type === type).length])
+ ) as Record
+
+ const locationStatus = isLocating
+ ? { text: "Detecting location…", tone: "text-muted-foreground" }
+ : locationEnabled && userLocation
+ ? { text: `Caller location: ${DEMO_CALLER_LOCATION.name}`, tone: "text-muted-foreground" }
+ : locationError
+ ? { text: locationError, tone: "text-warning" }
+ : { text: "Location sharing off", tone: "text-muted-foreground" }
return (
-
- {/* Header */}
-
-
-
-
-
-
-
Nearby Resources
-
Emergency services in your area
-
-
-
+
+
+ Nearby resources
+
+ {selectedTypes.size > 0 && (
+
+ {selectedTypes.size} filter{selectedTypes.size > 1 ? "s" : ""}
+
+ )}
+ setIsExpanded(!isExpanded)}
+ aria-label={isExpanded ? "Collapse map" : "Expand map"}
+ aria-pressed={isExpanded}
+ >
+ {isExpanded ? : }
+
-
-
- Filter
- {selectedTypes.size > 0 && (
-
- {selectedTypes.size}
-
- )}
-
+
+
-
+
Resource types
- {(Object.keys(resourceTypeConfig) as ResourceType[]).map((type) => {
+ {RESOURCE_TYPES.map((type) => {
const config = resourceTypeConfig[type]
- const Icon = config.icon
return (
toggleType(type)}
onSelect={(e) => e.preventDefault()}
>
-
+
{config.label}
- {resourceCounts[type]}
+ {resourceCounts[type]}
)
})}
-
setSelectedTypes(new Set())}
>
Show all
+
+ handleLocationToggle(checked === true)}
+ onSelect={(e) => e.preventDefault()}
+ >
+ Share caller location
+
-
-
-
-
-
+
+
-
- {/* Location Toggle */}
-
-
- {isLocating ? (
-
- ) : locationEnabled && userLocation ? (
-
- ) : locationError ? (
-
- ) : (
-
+
+ {/* Map + legend */}
+
+
- {isLocating
- ? 'Detecting location...'
- : locationEnabled && userLocation
- ? `Caller location: ${DEMO_CALLER_LOCATION.name}`
- : locationError
- ? locationError
- : 'Share your location for nearby results'}
-
-
-
- Share location
-
+
-
-
- {/* Filter Badges */}
-
- {(Object.keys(resourceCounts) as ResourceType[]).map((type) => {
- const config = resourceTypeConfig[type]
- const Icon = config.icon
- const isActive = selectedTypes.has(type)
- return (
- toggleType(type)}
- className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-all duration-200
- ${isActive
- ? `${config.activeBg} ${config.color} ring-1 ring-current`
- : 'bg-secondary text-muted-foreground hover:bg-secondary/80'
- }`}
- >
-
- {resourceCounts[type]}
-
- )
- })}
-
-
- {/* Map */}
-
-
-
setIsExpanded(!isExpanded)}
+
- {isExpanded ? 'Collapse' : 'Expand'}
-
+ {RESOURCE_TYPES.map((type) => {
+ const config = resourceTypeConfig[type]
+ const active = selectedTypes.has(type)
+ const visible = isTypeVisible(type)
+ return (
+ toggleType(type)}
+ className={cn(
+ "flex h-7 items-center gap-1.5 rounded-lg border px-2 text-xs font-medium transition-colors",
+ active
+ ? "border-primary/50 bg-primary-soft text-foreground"
+ : "border-transparent hover:bg-card-raised",
+ visible ? "text-foreground" : "text-muted-foreground"
+ )}
+ >
+
+ {config.label}
+
+ )
+ })}
+
-
- {/* Resource List */}
-
- {filteredResources.map((resource) => {
- const config = resourceTypeConfig[resource.type]
- const Icon = config.icon
- return (
-
-
-
-
-
-
-
- {resource.name}
- {resource.is24Hours && (
-
- 24h
-
- )}
-
-
{resource.address}
-
-
-
- {resource.distance} mi
-
-
-
- {resource.travelTime} min
-
+ {/* Resource list */}
+
+
+ {filteredResources.map((resource) => {
+ const config = resourceTypeConfig[resource.type]
+ return (
+
+
+
+
+ {resource.name}
+ ({config.label})
+
+
{resource.address}
-
-
-
- {resource.phone && (
-
setPendingLink({ kind: "call", name: resource.name, target: resource.phone! })}
- >
-
-
- )}
- {resource.website && (
-
setPendingLink({ kind: "website", name: resource.name, target: resource.website! })}
+ {/* Actions overlay the distance on hover/focus; always inline on touch screens. */}
+ {(resource.phone || resource.website) && (
+
-
-
- )}
-
-
- )
- })}
+ {resource.phone && (
+
setPendingLink({ kind: "call", name: resource.name, target: resource.phone! })}
+ >
+
+
+ )}
+ {resource.website && (
+
setPendingLink({ kind: "website", name: resource.name, target: resource.website! })}
+ >
+
+
+ )}
+
+ )}
+
+ {resource.distance} mi · {resource.travelTime} min
+
+
+ )
+ })}
+ {filteredResources.length === 0 && (
+
No resources match these filters
+ )}
+
+
+
+ {isLocating ? (
+
+ ) : locationError ? (
+
+ ) : (
+
+ )}
+ {locationStatus.text}
+
+
!open && setPendingLink(null)}>
@@ -480,6 +428,6 @@ export function NearbyResources() {
-
+
)
}
diff --git a/frontend/src/components/resource-map.tsx b/frontend/src/components/resource-map.tsx
index 1019e07..00a2ea3 100644
--- a/frontend/src/components/resource-map.tsx
+++ b/frontend/src/components/resource-map.tsx
@@ -1,48 +1,63 @@
"use client"
-import { useEffect } from "react"
+import { useEffect, useMemo } from "react"
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet"
import L from "leaflet"
import "leaflet/dist/leaflet.css"
-// Fix for default marker icons in webpack/Next.js builds
-const createIcon = (color: string) => {
- return L.divIcon({
- className: "custom-marker",
+// Lucide glyph paths (Cross, Flame, Shield, Pill) inlined so markers can be
+// plain divIcon HTML without rendering React to a string.
+const GLYPHS: Record
= {
+ medical:
+ ' ',
+ fire: ' ',
+ police:
+ ' ',
+ pharmacy:
+ ' ',
+}
+
+const RESOURCE_TYPES = new Set(["medical", "fire", "police", "pharmacy"])
+
+// 20px filled circle in the resource-type color, 2px card stroke, white 11px glyph (design.md §5).
+const resourceIconCache = new Map()
+function getResourceIcon(type: string) {
+ const cached = resourceIconCache.get(type)
+ if (cached) return cached
+ const color = RESOURCE_TYPES.has(type) ? `var(--resource-${type})` : "var(--muted-foreground)"
+ const icon = L.divIcon({
+ className: "fw-marker",
html: `
-
-
-
-
+
+ ${GLYPHS[type] ?? ""}
+
`,
- iconSize: [32, 40],
- iconAnchor: [16, 40],
- popupAnchor: [0, -40]
+ iconSize: [20, 20],
+ iconAnchor: [10, 10],
+ popupAnchor: [0, -12],
})
+ resourceIconCache.set(type, icon)
+ return icon
}
-const userIcon = L.divIcon({
- className: "user-marker",
- html: `
-
- `,
- iconSize: [16, 16],
- iconAnchor: [8, 8],
- popupAnchor: [0, -8]
-})
-
-const resourceColors: Record = {
- medical: "#f43f5e",
- police: "#0ea5e9",
- fire: "#f97316",
- pharmacy: "#10b981"
+// Incident / caller: dot + two concentric rings in the current call's priority color
+// (foreground white before triage). Outer ring pulses; reduce-motion rules live in globals.css.
+function createIncidentIcon(priority: string | null | undefined) {
+ const key = priority?.toLowerCase()
+ const color = key && ["p1", "p2", "p3", "p4"].includes(key) ? `var(--${key})` : "var(--foreground)"
+ return L.divIcon({
+ className: "fw-marker",
+ html: `
+
+
+
+
+
+ `,
+ iconSize: [44, 44],
+ iconAnchor: [22, 22],
+ popupAnchor: [0, -14],
+ })
}
type Resource = {
@@ -51,11 +66,14 @@ type Resource = {
type: string
lat: number
lng: number
+ address?: string
}
type Props = {
resources: Resource[]
userLocation: { lat: number; lng: number } | null
+ /** Current call priority (P1–P4) used to tint the incident pin. */
+ priority?: string | null
className?: string
}
@@ -67,42 +85,54 @@ function MapUpdater({ center }: { center: [number, number] }) {
return null
}
-export default function ResourceMap({ resources, userLocation, className = "" }: Props) {
- const defaultCenter: [number, number] = userLocation
- ? [userLocation.lat, userLocation.lng]
- : [37.3352, -121.8811] // San Jose State University (demo default)
+/** Leaflet needs a nudge when its container height changes (expand/collapse). */
+function SizeWatcher() {
+ const map = useMap()
+ useEffect(() => {
+ const container = map.getContainer()
+ const observer = new ResizeObserver(() => map.invalidateSize())
+ observer.observe(container)
+ return () => observer.disconnect()
+ }, [map])
+ return null
+}
+
+export default function ResourceMap({ resources, userLocation, priority, className = "" }: Props) {
+ const lat = userLocation?.lat ?? 37.3352 // San Jose State University (demo default)
+ const lng = userLocation?.lng ?? -121.8811
+ const center = useMemo<[number, number]>(() => [lat, lng], [lat, lng])
+ const incidentIcon = useMemo(() => createIncidentIcon(priority), [priority])
return (
-
+ // fw-map: OSM tiles are darkened with a CSS filter in globals.css (design.md §5, option 2).
+
-
-
+
+
+
{userLocation && (
-
+
- Caller's Location
+ Caller location
- San Jose State University
+ San Jose State University
)}
-
+
{resources.map((resource) => (
-
+
- {resource.name}
+ {resource.name}
+ {resource.address && (
+ <>
+
+ {resource.address}
+ >
+ )}
))}
diff --git a/frontend/src/components/settings/settings-controls.tsx b/frontend/src/components/settings/settings-controls.tsx
new file mode 100644
index 0000000..7966b52
--- /dev/null
+++ b/frontend/src/components/settings/settings-controls.tsx
@@ -0,0 +1,252 @@
+"use client"
+
+import * as React from "react"
+import { ChevronDown } from "lucide-react"
+import { Card } from "@/components/ui/card"
+import { Switch } from "@/components/ui/switch"
+import { cn } from "@/lib/utils"
+
+/* Local building blocks for the settings page. The design calls for shadcn
+ `slider` / `select` (design.md §7), which are not installed yet, so the
+ slider and select here are native elements styled to §3. */
+
+export function SettingsSection({
+ id,
+ title,
+ children,
+}: {
+ id: string
+ title: string
+ children: React.ReactNode
+}) {
+ const headingId = `${id}-heading`
+ return (
+
+
+
+ {title}
+
+ {children}
+
+
+ )
+}
+
+/** One row: label + optional description left, control right. Rows are divided by a 1px border. */
+export function SettingRow({
+ label,
+ description,
+ htmlFor,
+ labelId,
+ descriptionId,
+ children,
+}: {
+ label: string
+ description?: string
+ htmlFor?: string
+ labelId?: string
+ descriptionId?: string
+ children: React.ReactNode
+}) {
+ const LabelTag = htmlFor ? "label" : "span"
+ return (
+
+
+
+ {label}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
{children}
+
+ )
+}
+
+/** Switch row. `locked` renders a checked, non-interactive switch at 55% opacity (Human approval). */
+export function SwitchRow({
+ id,
+ label,
+ description,
+ checked,
+ onCheckedChange,
+ locked = false,
+}: {
+ id: string
+ label: string
+ description?: string
+ checked: boolean
+ onCheckedChange?: (checked: boolean) => void
+ locked?: boolean
+}) {
+ const descriptionId = description ? `${id}-description` : undefined
+ return (
+
+
+
+ )
+}
+
+export function NativeSelect({
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"select">) {
+ return (
+
+
+ {children}
+
+
+
+ )
+}
+
+/** Native range input: --primary filled track, light thumb (design.md §3). */
+export function RangeSlider({
+ value,
+ min,
+ max,
+ step = 1,
+ onValueChange,
+ className,
+ ...props
+}: Omit, "type" | "value" | "onChange" | "min" | "max" | "step"> & {
+ value: number
+ min: number
+ max: number
+ step?: number
+ onValueChange: (value: number) => void
+}) {
+ const pct = ((value - min) / (max - min)) * 100
+ return (
+ onValueChange(Number(e.target.value))}
+ style={{
+ background: `linear-gradient(to right, var(--primary) ${pct}%, var(--border-strong) ${pct}%)`,
+ }}
+ className={cn(
+ "h-1.5 w-full cursor-pointer appearance-none rounded-full",
+ "[&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-card [&::-webkit-slider-thumb]:bg-foreground",
+ "[&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-card [&::-moz-range-thumb]:bg-foreground",
+ "[&::-moz-range-track]:bg-transparent",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+const chipBase =
+ "inline-flex h-9 items-center gap-2.5 rounded-[8px] border px-3 text-left text-sm transition-colors"
+const chipOn = "border-primary/50 bg-primary-soft text-foreground"
+const chipOff = "border-border bg-transparent text-muted-foreground hover:border-border-strong hover:text-foreground"
+
+/** Toggle chip (Settings → Map types). Dot + label, selected = --primary-soft fill + --primary @ 50% border. */
+export function FilterChip({
+ selected,
+ dotClassName,
+ children,
+ className,
+ ...props
+}: Omit, "type"> & {
+ selected: boolean
+ dotClassName: string
+}) {
+ return (
+
+
+ {children}
+
+ )
+}
+
+/** Single-choice chip group with roving focus and arrow-key navigation (Settings → Accent color). */
+export function SwatchGroup({
+ label,
+ labelId,
+ options,
+ value,
+ onValueChange,
+}: {
+ label: string
+ labelId: string
+ options: { value: T; label: string; color: string }[]
+ value: T
+ onValueChange: (value: T) => void
+}) {
+ const refs = React.useRef<(HTMLButtonElement | null)[]>([])
+
+ const onKeyDown = (e: React.KeyboardEvent, index: number) => {
+ const delta = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0
+ if (!delta) return
+ e.preventDefault()
+ const next = (index + delta + options.length) % options.length
+ onValueChange(options[next].value)
+ refs.current[next]?.focus()
+ }
+
+ return (
+
+
+ {label}
+
+
+ {options.map((option, i) => {
+ const selected = option.value === value
+ return (
+ {
+ refs.current[i] = el
+ }}
+ type="button"
+ role="radio"
+ aria-checked={selected}
+ tabIndex={selected ? 0 : -1}
+ onClick={() => onValueChange(option.value)}
+ onKeyDown={(e) => onKeyDown(e, i)}
+ className={cn(chipBase, selected ? chipOn : chipOff)}
+ >
+
+ {option.label}
+
+ )
+ })}
+
+
+ )
+}
diff --git a/frontend/src/components/settings/settings-form.tsx b/frontend/src/components/settings/settings-form.tsx
new file mode 100644
index 0000000..80266e7
--- /dev/null
+++ b/frontend/src/components/settings/settings-form.tsx
@@ -0,0 +1,286 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import { Input } from "@/components/ui/input"
+import { cn } from "@/lib/utils"
+import {
+ FilterChip,
+ NativeSelect,
+ RangeSlider,
+ SettingRow,
+ SettingsSection,
+ SwatchGroup,
+ SwitchRow,
+} from "./settings-controls"
+import { SettingsNav, type SettingsNavItem } from "./settings-nav"
+
+/* Visual-only settings form. All values live in local state and reset on reload.
+ TODO: replace with `useSettings()` from `src/lib/settings.tsx` (SettingsProvider,
+ localStorage `firstwave.settings`, backend URL validation) per design.md §4.3. */
+
+const DEFAULT_BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000"
+
+type ResourceType = "medical" | "fire" | "police" | "pharmacy"
+type Accent = "wave" | "indigo" | "amber" | "mint"
+
+type SettingsState = {
+ ttsEnabled: boolean
+ liveTranscription: boolean
+ recognitionLang: string
+ suggestFollowUps: boolean
+ showAgentPipeline: boolean
+ searchRadiusMi: number
+ mapTypes: Record
+ accent: Accent
+ reduceMotion: boolean
+ backendUrl: string
+}
+
+const DEFAULTS: SettingsState = {
+ ttsEnabled: true,
+ liveTranscription: true,
+ recognitionLang: "en-US",
+ suggestFollowUps: true,
+ showAgentPipeline: true,
+ searchRadiusMi: 5,
+ mapTypes: { medical: true, fire: true, police: true, pharmacy: true },
+ accent: "wave",
+ reduceMotion: false,
+ backendUrl: DEFAULT_BACKEND_URL,
+}
+
+const NAV_ITEMS: SettingsNavItem[] = [
+ { id: "voice", label: "Voice" },
+ { id: "dispatch", label: "Dispatch" },
+ { id: "map", label: "Map" },
+ { id: "appearance", label: "Appearance" },
+ { id: "connection", label: "Connection" },
+]
+
+const LANGUAGES = [
+ { value: "en-US", label: "English (US)" },
+ { value: "en-GB", label: "English (UK)" },
+ { value: "es-US", label: "Spanish (US)" },
+ { value: "es-ES", label: "Spanish (Spain)" },
+ { value: "fr-FR", label: "French" },
+ { value: "de-DE", label: "German" },
+ { value: "zh-CN", label: "Chinese (Mandarin)" },
+ { value: "vi-VN", label: "Vietnamese" },
+]
+
+const MAP_TYPES: { value: ResourceType; label: string; dot: string }[] = [
+ { value: "medical", label: "Hospitals", dot: "bg-resource-medical" },
+ { value: "fire", label: "Fire stations", dot: "bg-resource-fire" },
+ { value: "police", label: "Police", dot: "bg-resource-police" },
+ { value: "pharmacy", label: "Pharmacies", dot: "bg-resource-pharmacy" },
+]
+
+// Fixed swatch colors: `--primary` itself changes with the accent, so it can't be used here.
+const ACCENTS: { value: Accent; label: string; color: string }[] = [
+ { value: "wave", label: "Wave", color: "#38d4f2" },
+ { value: "indigo", label: "Indigo", color: "#8b8cf8" },
+ { value: "amber", label: "Amber", color: "var(--warning)" },
+ { value: "mint", label: "Mint", color: "var(--success)" },
+]
+
+type HealthStatus = "checking" | "online" | "offline"
+
+/** One-off read-only /health check of the build-time backend, so the pill isn't fake. */
+function useBackendHealth(url: string): HealthStatus {
+ const [status, setStatus] = useState("checking")
+ useEffect(() => {
+ const controller = new AbortController()
+ fetch(`${url}/health`, { cache: "no-store", signal: controller.signal })
+ .then((res) => setStatus(res.ok ? "online" : "offline"))
+ .catch(() => {
+ if (!controller.signal.aborted) setStatus("offline")
+ })
+ return () => controller.abort()
+ }, [url])
+ return status
+}
+
+const HEALTH_LABEL: Record = { checking: "Checking", online: "Online", offline: "Offline" }
+
+export function SettingsForm() {
+ const [s, setS] = useState(DEFAULTS)
+ const set = (key: K, value: SettingsState[K]) =>
+ setS((prev) => ({ ...prev, [key]: value }))
+
+ const health = useBackendHealth(DEFAULT_BACKEND_URL)
+
+ return (
+
+
+
+
+
+ set("ttsEnabled", v)}
+ />
+ set("liveTranscription", v)}
+ />
+
+ set("recognitionLang", e.target.value)}
+ >
+ {LANGUAGES.map((lang) => (
+
+ {lang.label}
+
+ ))}
+
+
+
+
+
+
+ set("suggestFollowUps", v)}
+ />
+ set("showAgentPipeline", v)}
+ />
+
+
+
+
+
+
+ Search radius
+
+
+ {s.searchRadiusMi} mi
+
+
+
set("searchRadiusMi", v)}
+ aria-valuetext={`${s.searchRadiusMi} ${s.searchRadiusMi === 1 ? "mile" : "miles"}`}
+ />
+
+
+
+ Show on map
+
+
+ {MAP_TYPES.map((type) => (
+ set("mapTypes", { ...s.mapTypes, [type.value]: !s.mapTypes[type.value] })}
+ >
+ {type.label}
+
+ ))}
+
+
+
+
+
+
+ set("accent", v)}
+ />
+
+ set("reduceMotion", v)}
+ />
+
+
+
+
+
+ Backend URL
+
+
+
set("backendUrl", e.target.value)}
+ />
+
+
+
+ Backend status:
+ {HEALTH_LABEL[health]}
+
+
+
+
+
+
+
+ setS(DEFAULTS)}
+ className="rounded-tile px-2 py-1 text-sm text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline"
+ >
+ Reset to defaults
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/settings/settings-header.tsx b/frontend/src/components/settings/settings-header.tsx
new file mode 100644
index 0000000..58eb5db
--- /dev/null
+++ b/frontend/src/components/settings/settings-header.tsx
@@ -0,0 +1,23 @@
+import Link from "next/link"
+import { Radio } from "lucide-react"
+import { Button } from "@/components/ui/button"
+
+/** Settings page header: logo left, "Back to console" pill right (design.md §4.3). */
+export function SettingsHeader() {
+ return (
+
+ )
+}
diff --git a/frontend/src/components/settings/settings-nav.tsx b/frontend/src/components/settings/settings-nav.tsx
new file mode 100644
index 0000000..cbbb83d
--- /dev/null
+++ b/frontend/src/components/settings/settings-nav.tsx
@@ -0,0 +1,78 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import { cn } from "@/lib/utils"
+
+export type SettingsNavItem = { id: string; label: string }
+
+/**
+ * Sticky section nav that scroll-spies the cards on the right (design.md §4.3).
+ * Desktop: vertical list. Mobile: horizontal scrolling tab row under the header.
+ */
+export function SettingsNav({ items }: { items: SettingsNavItem[] }) {
+ const [active, setActive] = useState(items[0]?.id)
+
+ useEffect(() => {
+ const sections = items
+ .map((item) => document.getElementById(item.id))
+ .filter((el): el is HTMLElement => el !== null)
+ if (!sections.length) return
+
+ const visible = new Map()
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) visible.set(entry.target.id, entry.isIntersecting)
+ // Topmost section inside the activation band wins.
+ const first = items.find((item) => visible.get(item.id))
+ if (first) setActive(first.id)
+ },
+ { rootMargin: "-25% 0px -60% 0px" }
+ )
+ sections.forEach((section) => observer.observe(section))
+
+ // At the very bottom the last (short) section may never reach the band.
+ const onScroll = () => {
+ const atBottom = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2
+ if (atBottom) setActive(items[items.length - 1].id)
+ }
+ window.addEventListener("scroll", onScroll, { passive: true })
+
+ return () => {
+ observer.disconnect()
+ window.removeEventListener("scroll", onScroll)
+ }
+ }, [items])
+
+ return (
+
+
+
+ )
+}
diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx
index 37a7d4b..9504793 100644
--- a/frontend/src/components/ui/button.tsx
+++ b/frontend/src/components/ui/button.tsx
@@ -5,30 +5,31 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-semibold transition-[background-color,border-color,color,filter] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary aria-invalid:border-destructive",
{
variants: {
variant: {
- default: "bg-primary text-primary-foreground hover:bg-primary/90",
- destructive:
- "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ default: "bg-primary text-primary-foreground hover:brightness-106",
+ destructive: "bg-danger text-primary-foreground hover:brightness-106",
outline:
- "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
- secondary:
- "bg-secondary text-secondary-foreground hover:bg-secondary/80",
- ghost:
- "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ "border border-border-strong bg-transparent text-foreground hover:bg-card-raised",
+ secondary: "bg-card-raised text-foreground hover:bg-border-strong",
+ ghost: "text-muted-foreground hover:bg-card-raised hover:text-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
- default: "h-9 px-4 py-2 has-[>svg]:px-3",
- sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
- lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
- icon: "size-9",
- "icon-sm": "size-8",
- "icon-lg": "size-10",
+ default: "h-11 px-5 has-[>svg]:px-4",
+ sm: "h-8 gap-1.5 px-3 text-[13px] has-[>svg]:px-2.5",
+ lg: "h-12 px-6 text-[15px] has-[>svg]:px-5",
+ icon: "size-10 rounded-tile",
+ "icon-sm": "size-8 rounded-tile",
+ "icon-lg": "size-11 rounded-tile",
},
},
+ compoundVariants: [
+ // Icon buttons sit on a raised square (mic, settings) unless they're primary (send).
+ { variant: "outline", size: ["icon", "icon-sm", "icon-lg"], class: "bg-card-raised border-border" },
+ ],
defaultVariants: {
variant: "default",
size: "default",
diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx
new file mode 100644
index 0000000..091c0c5
--- /dev/null
+++ b/frontend/src/components/ui/card.tsx
@@ -0,0 +1,69 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+/** 52px header row: title left, status (CardAction) right, divider below. */
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+/** Nested tile inside a card (summary fields, unit counts). */
+function CardTile({ className, ...props }: React.ComponentProps<"div">) {
+ return
+}
+
+export { Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter, CardTile }
diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx
new file mode 100644
index 0000000..e968592
--- /dev/null
+++ b/frontend/src/components/ui/input.tsx
@@ -0,0 +1,24 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }