Skip to content

Commit 3ce98fe

Browse files
committed
feat(web): dark mode - system default + persisted toggle, warm dark tokens, dark basemap and band ramp
alidade_smooth_dark basemap; graded dark band alphas (deep amber glow over dark ground, validated monotonic legend swatches); dark category-chip variants; sun/moon toggle beside the logo chip; e2e covers system-follows + toggle-override.
1 parent a13f044 commit 3ce98fe

9 files changed

Lines changed: 166 additions & 30 deletions

File tree

apps/web/e2e/smoke.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,15 @@ test('walking isochrone smoke', async ({ page }) => {
5858
expect(draggedBody.type).toBe('FeatureCollection');
5959
});
6060

61+
test('theme: follows system dark and toggles back to light', async ({ page }) => {
62+
test.setTimeout(90_000);
63+
await page.emulateMedia({ colorScheme: 'dark' });
64+
await page.goto('/');
65+
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
66+
await page.getByRole('button', { name: 'Switch to light theme' }).click();
67+
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
68+
});
69+
6170
test('mobile: bottom sheet hosts the controls', async ({ page }) => {
6271
test.setTimeout(90_000);
6372
await page.setViewportSize({ width: 375, height: 667 });

apps/web/src/app/globals.css

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,16 @@
2222

2323
[data-theme='dark'],
2424
.dark {
25-
--background: 222 47% 11%;
26-
--foreground: 210 40% 98%;
27-
--muted: 217 33% 17%;
28-
--muted-foreground: 215 20% 65%;
29-
--border: 217 33% 17%;
30-
--accent: 217 33% 17%;
31-
--accent-foreground: 210 40% 98%;
25+
--background: 24 15% 10%;
26+
--foreground: 33 30% 92%;
27+
--muted: 24 12% 17%;
28+
--muted-foreground: 30 8% 66%;
29+
--border: 24 12% 24%;
30+
--accent: 21 45% 19%;
31+
--accent-foreground: 33 100% 88%;
32+
--primary: 21 88% 48%; /* brighter orange pops on dark */
33+
--primary-hover: 24 94% 56%;
34+
--primary-foreground: 0 0% 100%;
3235
}
3336

3437
html,

apps/web/src/app/home-inner.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@ import { TimeSelector } from '@/components/controls/TimeSelector';
2525
import { ModeSelector } from '@/components/controls/ModeSelector';
2626
import { CategoryToggles } from '@/components/controls/CategoryToggles';
2727
import { SurpriseMe } from '@/components/controls/SurpriseMe';
28+
import { ThemeToggle } from '@/components/controls/ThemeToggle';
2829
import { DestinationCard } from '@/components/destination/DestinationCard';
2930
import { BandLegend } from '@/components/legend/BandLegend';
3031
import { CoachMark } from '@/components/onboarding/CoachMark';
3132
import { tryGeolocate } from '@/lib/geolocation';
3233
import { useIsochroneBands } from '@/lib/hooks/useIsochroneBands';
34+
import { useTheme } from '@/lib/hooks/useTheme';
3335
import { useIsMobile } from '@/lib/hooks/useIsMobile';
3436
import { usePois } from '@/lib/hooks/usePois';
3537
import { bboxOf, isInsidePolygon } from '@/lib/polygon';
@@ -56,7 +58,8 @@ export function HomeInner() {
5658
() => new StadiaTileProvider({ apiKey: PUBLIC_CONFIG.stadiaApiKey || undefined }),
5759
[],
5860
);
59-
const tileStyle = useMemo(() => tileProvider.getStyle('light'), [tileProvider]);
61+
const { theme, toggle: toggleTheme } = useTheme();
62+
const tileStyle = useMemo(() => tileProvider.getStyle(theme), [tileProvider, theme]);
6063

6164
// Initial state: parse from URL on first render, fall back to public defaults.
6265
const initialState = useMemo<AppUrlState>(() => {
@@ -262,6 +265,7 @@ export function HomeInner() {
262265
onOriginDragEnd={onOriginDragEnd}
263266
bands={data}
264267
selectedMinutes={state.minutes}
268+
theme={theme}
265269
originLoading={isLoading}
266270
onPickDestination={onPickDestination}
267271
onMapBackgroundClick={onMapBackgroundClick}
@@ -300,8 +304,9 @@ export function HomeInner() {
300304
</div>
301305

302306
<header className="pointer-events-none absolute left-0 right-0 top-0 flex items-start gap-3 p-4">
303-
<div className="pointer-events-auto">
307+
<div className="pointer-events-auto flex items-start gap-2">
304308
<LogoChip />
309+
<ThemeToggle theme={theme} onToggle={toggleTheme} />
305310
</div>
306311
<ControlBar className="mx-auto hidden md:flex">
307312
<ModeSelector value={state.mode} onChange={onModeChange} />
@@ -322,7 +327,7 @@ export function HomeInner() {
322327
</header>
323328

324329
<div className="absolute bottom-4 left-4 hidden md:block">
325-
<BandLegend selectedMinutes={state.minutes} />
330+
<BandLegend selectedMinutes={state.minutes} theme={theme} />
326331
</div>
327332

328333
{isMobile && (
@@ -345,7 +350,7 @@ export function HomeInner() {
345350
: `Pick one of ${visiblePois.length} reachable places`
346351
}
347352
/>
348-
<BandLegend selectedMinutes={state.minutes} />
353+
<BandLegend selectedMinutes={state.minutes} theme={theme} />
349354
</>
350355
}
351356
/>

apps/web/src/components/controls/CategoryToggles.tsx

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,32 @@ const CATEGORIES: { id: PoiCategory; label: string; Icon: LucideIcon }[] = [
2727
{ id: 'beach', label: 'Beaches', Icon: Waves },
2828
];
2929

30-
/** Pastel chip fill + saturated icon per category (warm & playful, harmonized). */
30+
/** Pastel chip fill + saturated icon per category (warm & playful, harmonized; deep fills in dark). */
3131
const CATEGORY_STYLES: Record<PoiCategory, { chip: string; icon: string }> = {
32-
park: { chip: 'bg-emerald-100 text-emerald-900', icon: 'text-emerald-600' },
33-
cafe: { chip: 'bg-amber-100 text-amber-900', icon: 'text-amber-600' },
34-
restaurant: { chip: 'bg-rose-100 text-rose-900', icon: 'text-rose-600' },
35-
museum: { chip: 'bg-violet-100 text-violet-900', icon: 'text-violet-600' },
36-
viewpoint: { chip: 'bg-sky-100 text-sky-900', icon: 'text-sky-600' },
37-
beach: { chip: 'bg-cyan-100 text-cyan-900', icon: 'text-cyan-600' },
32+
park: {
33+
chip: 'bg-emerald-100 text-emerald-900 dark:bg-emerald-950 dark:text-emerald-200',
34+
icon: 'text-emerald-600 dark:text-emerald-400',
35+
},
36+
cafe: {
37+
chip: 'bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-200',
38+
icon: 'text-amber-600 dark:text-amber-400',
39+
},
40+
restaurant: {
41+
chip: 'bg-rose-100 text-rose-900 dark:bg-rose-950 dark:text-rose-200',
42+
icon: 'text-rose-600 dark:text-rose-400',
43+
},
44+
museum: {
45+
chip: 'bg-violet-100 text-violet-900 dark:bg-violet-950 dark:text-violet-200',
46+
icon: 'text-violet-600 dark:text-violet-400',
47+
},
48+
viewpoint: {
49+
chip: 'bg-sky-100 text-sky-900 dark:bg-sky-950 dark:text-sky-200',
50+
icon: 'text-sky-600 dark:text-sky-400',
51+
},
52+
beach: {
53+
chip: 'bg-cyan-100 text-cyan-900 dark:bg-cyan-950 dark:text-cyan-200',
54+
icon: 'text-cyan-600 dark:text-cyan-400',
55+
},
3856
};
3957

4058
export function CategoryToggles({ value, onChange, className }: Props) {
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
'use client';
2+
3+
import { Moon, Sun } from 'lucide-react';
4+
import type { Theme } from '@/lib/hooks/useTheme';
5+
6+
export function ThemeToggle({ theme, onToggle }: { theme: Theme; onToggle: () => void }) {
7+
const next = theme === 'dark' ? 'light' : 'dark';
8+
return (
9+
<button
10+
type="button"
11+
onClick={onToggle}
12+
aria-label={`Switch to ${next} theme`}
13+
title={`Switch to ${next} theme`}
14+
className="pointer-events-auto flex h-9 w-9 items-center justify-center rounded-xl bg-background/95 text-foreground shadow-md ring-1 ring-border backdrop-blur transition-colors hover:bg-accent"
15+
>
16+
{theme === 'dark' ? <Sun className="h-4 w-4" aria-hidden /> : <Moon className="h-4 w-4" aria-hidden />}
17+
</button>
18+
);
19+
}

apps/web/src/components/legend/BandLegend.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
import { TIME_BANDS_MIN } from '@ilsochrone/providers';
44

55
/**
6-
* Composite swatches of the graded #F97316 band stack over the basemap
7-
* (validated monotonic ramp; keep in sync with BAND_FILL_OPACITY in
8-
* IlsochroneMap).
6+
* Composite swatches of the graded #F97316 band stack over each basemap
7+
* (validated monotonic ramps; keep in sync with BAND_FILL_OPACITY /
8+
* DARK_BAND_FILL_OPACITY in IlsochroneMap).
99
*/
1010
const SWATCHES: Record<number, string> = {
1111
5: '#F5BE98',
@@ -15,7 +15,22 @@ const SWATCHES: Record<number, string> = {
1515
30: '#F2EAE5',
1616
};
1717

18-
export function BandLegend({ selectedMinutes }: { selectedMinutes: number }) {
18+
const DARK_SWATCHES: Record<number, string> = {
19+
5: '#874619',
20+
10: '#663919',
21+
15: '#4D2F1A',
22+
20: '#39271A',
23+
30: '#2B221B',
24+
};
25+
26+
export function BandLegend({
27+
selectedMinutes,
28+
theme = 'light',
29+
}: {
30+
selectedMinutes: number;
31+
theme?: 'light' | 'dark';
32+
}) {
33+
const swatches = theme === 'dark' ? DARK_SWATCHES : SWATCHES;
1934
const active = TIME_BANDS_MIN.filter((m) => m <= selectedMinutes);
2035
return (
2136
<div className="pointer-events-none flex items-center gap-2 rounded-xl bg-background/95 px-3 py-2 text-xs shadow-md ring-1 ring-border backdrop-blur">
@@ -26,7 +41,7 @@ export function BandLegend({ selectedMinutes }: { selectedMinutes: number }) {
2641
aria-label={`Bands from 5 to ${selectedMinutes} minutes`}
2742
>
2843
{active.map((m) => (
29-
<span key={m} className="h-3 w-6" style={{ backgroundColor: SWATCHES[m] }} title={`${m} min`} />
44+
<span key={m} className="h-3 w-6" style={{ backgroundColor: swatches[m] }} title={`${m} min`} />
3045
))}
3146
</div>
3247
<span className="tabular-nums text-muted-foreground">5–{selectedMinutes} min</span>

apps/web/src/components/map/IlsochroneMap.tsx

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ import {
2929

3030
/**
3131
* Graded per-band fill opacity (bands nest, so zones stack every band >= m):
32-
* inner zones composite to a deep amber core (~0.34) fading to a pale peach
33-
* edge (0.04). Legend swatches in BandLegend are the computed composites —
34-
* keep the two in sync.
32+
* inner zones composite to a deep amber core fading to a pale edge. Dark mode
33+
* needs higher alphas for the same perceived warmth over the dark basemap.
34+
* Legend swatches in BandLegend are the computed composites — keep in sync.
3535
*/
3636
const BAND_FILL_OPACITY: Record<number, number> = {
3737
5: 0.18,
@@ -41,6 +41,14 @@ const BAND_FILL_OPACITY: Record<number, number> = {
4141
30: 0.045,
4242
};
4343

44+
const DARK_BAND_FILL_OPACITY: Record<number, number> = {
45+
5: 0.22,
46+
10: 0.15,
47+
15: 0.1,
48+
20: 0.07,
49+
30: 0.055,
50+
};
51+
4452
interface Props {
4553
tileStyle: TileStyle;
4654
viewState: { longitude: number; latitude: number; zoom: number };
@@ -52,6 +60,8 @@ interface Props {
5260
selectedMinutes: number;
5361
/** Shows a soft pulsing halo on the origin pin while bands are loading. */
5462
originLoading?: boolean;
63+
/** Adjusts band paint (seams/ring/fill alphas) for the dark basemap. */
64+
theme?: 'light' | 'dark';
5565
/** Optional destination pin + popup content (e.g. NavigateTo card). */
5666
destination?: { lng: number; lat: number; popup: React.ReactNode };
5767
/**
@@ -84,6 +94,7 @@ export function IlsochroneMap({
8494
bands,
8595
selectedMinutes,
8696
originLoading,
97+
theme = 'light',
8798
destination,
8899
onPickDestination,
89100
onMapBackgroundClick,
@@ -156,7 +167,11 @@ export function IlsochroneMap({
156167
type="fill"
157168
filter={['==', ['get', 'minutes'], m]}
158169
layout={{ visibility: m <= selectedMinutes ? 'visible' : 'none' }}
159-
paint={{ 'fill-color': '#f97316', 'fill-opacity': BAND_FILL_OPACITY[m] ?? 0.05 }}
170+
paint={{
171+
'fill-color': '#f97316',
172+
'fill-opacity':
173+
(theme === 'dark' ? DARK_BAND_FILL_OPACITY[m] : BAND_FILL_OPACITY[m]) ?? 0.05,
174+
}}
160175
/>
161176
))}
162177
{TIME_BANDS_MIN.map((m) => (
@@ -168,8 +183,14 @@ export function IlsochroneMap({
168183
layout={{ visibility: m <= selectedMinutes ? 'visible' : 'none' }}
169184
paint={
170185
m === selectedMinutes
171-
? { 'line-color': '#c2410c', 'line-width': 3, 'line-opacity': 0.9 }
172-
: { 'line-color': '#ffffff', 'line-width': 1.2, 'line-opacity': 0.95 }
186+
? {
187+
'line-color': theme === 'dark' ? '#fb923c' : '#c2410c',
188+
'line-width': 3,
189+
'line-opacity': 0.9,
190+
}
191+
: theme === 'dark'
192+
? { 'line-color': '#1c1917', 'line-width': 1.2, 'line-opacity': 0.55 }
193+
: { 'line-color': '#ffffff', 'line-width': 1.2, 'line-opacity': 0.95 }
173194
}
174195
/>
175196
))}

apps/web/src/lib/hooks/useTheme.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
'use client';
2+
3+
import { useCallback, useEffect, useState } from 'react';
4+
5+
const STORAGE_KEY = 'ilso.theme';
6+
7+
export type Theme = 'light' | 'dark';
8+
9+
function systemTheme(): Theme {
10+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
11+
}
12+
13+
/**
14+
* System preference by default; an explicit toggle overrides it and persists.
15+
* Applies `data-theme` on <html> (Tailwind darkMode selector + globals.css).
16+
*/
17+
export function useTheme(): { theme: Theme; toggle: () => void } {
18+
const [theme, setTheme] = useState<Theme>('light');
19+
20+
useEffect(() => {
21+
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
22+
setTheme(stored ?? systemTheme());
23+
if (stored) return; // explicit choice: stop following the OS
24+
const mql = window.matchMedia('(prefers-color-scheme: dark)');
25+
const onChange = () => setTheme(systemTheme());
26+
mql.addEventListener('change', onChange);
27+
return () => mql.removeEventListener('change', onChange);
28+
}, []);
29+
30+
useEffect(() => {
31+
document.documentElement.setAttribute('data-theme', theme);
32+
}, [theme]);
33+
34+
const toggle = useCallback(() => {
35+
setTheme((t) => {
36+
const next: Theme = t === 'dark' ? 'light' : 'dark';
37+
localStorage.setItem(STORAGE_KEY, next);
38+
return next;
39+
});
40+
}, []);
41+
42+
return { theme, toggle };
43+
}

docs/superpowers/specs/2026-07-19-ui-elevation-design.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,9 @@ Testing:
136136

137137
## 8. Non-goals (this round)
138138

139-
Dark mode (tokens ready, not shipped) · POI clustering · suggested-places
139+
~~Dark mode~~ (shipped 2026-07-19-c: system-preference default + persisted
140+
sun/moon toggle; warm near-black tokens; alidade_smooth_dark basemap; graded
141+
dark band alphas 0.22/0.15/0.10/0.07/0.055 with validated monotonic legend
142+
swatches; dark category-chip variants) · POI clustering · suggested-places
140143
panel (T-17) · browser-side engine computation · custom basemap style ·
141144
cycling/transit UI enablement.

0 commit comments

Comments
 (0)