You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
GeoNews is currently dark-only. The UI is a situation-room OSINT workstation (plan.md §3): navy panels, cyan accent, Carto Dark map tiles. There is no theme toggle, no prefers-color-scheme handling, and several Leaflet/CSS values are hardcoded to dark hexes.
Add a first-class light / dark theme so users can switch at any time, keep the choice across reloads, and still keep the intel aesthetic in both modes. Default remains dark so existing screenshots, E2E, and the product identity do not change unless the user opts in.
This is a frontend-only change. No API, ingest, or LLM work.
Current state (what is broken / missing)
Area
Today
Problem
Tokens
:root in frontend/src/app/globals.css defines only dark values (--bg: #0b1220, --panel: #121a2b, --border: #243049, --text: #e8eefc)
No light token set
Layout
frontend/src/app/layout.tsx<html lang="en"> has no data-theme / class
Nothing to switch
Header
frontend/src/components/Header.tsx — logo + ingest status only
linear-gradient(165deg, #0b1220 …) not using CSS variables
Will not follow theme
Placeholders
#a9b6cd hardcoded in CSS and Tailwind (placeholder:text-[#a9b6cd])
Unreadable or washed out on light
Persistence
none
Choice is lost on refresh
Tests
Playwright + Vitest assume the dark chrome
Need coverage for the toggle
Most chrome already uses var(--bg), var(--panel), var(--text), var(--border), var(--accent). That is the right foundation: extend the variables, do not scatter dark: Tailwind classes everywhere.
Product rules
Default is dark. First visit with no saved preference and no explicit choice → dark. Matches plan.md (“dark situation-room aesthetic”).
Explicit user choice wins over OS prefers-color-scheme.
Optional system follow: if the user picks “System”, follow prefers-color-scheme live (listen to change on the media query). If “System” is too much UI, skip it and only offer Light / Dark — but still readprefers-color-scheme as the first-visit fallback only if we decide to honor OS. Recommended for v1: Dark / Light toggle, default Dark, persist the last explicit choice. Do not auto-flip an existing user.
No flash of wrong theme (FOUC). Apply the saved theme before first paint. Because this is a Next.js static export (output: 'export'), put a tiny inline script in layout.tsx that reads localStorage and sets document.documentElement.dataset.theme (or class) before React hydrates.
Category pin colors stay as-is (crime#f43f5e, disaster#f59e0b, etc.). Those are semantic, not theme tokens.
Light mode is still GeoNews, not a generic white dashboard. Keep Sora + IBM Plex Sans. Keep cyan accent. Light surfaces should be cool slate/ink, not pure #ffffff everywhere.
Free tiles only. Carto light/dark (no Google Maps key). Same attribution pattern.
Accessible. Toggle is a real <button>, has an accessible name, aria-pressed or aria-label that reflects the next/current mode, visible focus ring, keyboard operable. Contrast for body text on light panels must meet WCAG AA (~4.5:1).
Secrets / backend unchanged. Theme lives in the browser only.
Suggested token sets
Keep the existing dark tokens. Add a light set under html[data-theme="light"] (or html.light).
Dark (already in :root — keep as default)
Token
Value
Use
--bg
#0b1220
App chrome, map veil
--panel
#121a2b
Header, drawer, popups
--panel-2
#182338
Inputs, nested cards
--border
#243049
Dividers
--text
#e8eefc
Primary text
--text-muted
#93a0b8
Meta
--accent
#22d3ee
Links, focus, clusters
--alert / --warning / --safe
#f43f5e / #f59e0b / #34d399
Status (same in both themes)
--map-veil
cyan radial, low alpha
Atmosphere
Light (new)
Aim for cool ink-on-paper, not stark white:
Token
Suggested value
Notes
--bg
#f4f6fb
Page / overlay
--panel
#ffffff
Header, intel drawer, popups
--panel-2
#eef2f8
Inputs, nested cards
--border
#c9d3e4
Strong enough on white
--text
#0f172a
Near-slate-900
--text-muted
#475569
Must stay readable
--accent
#0891b2
Darker cyan so it contrasts on white (current #22d3ee fails AA on white)
--alert / --warning / --safe
keep
May need slightly darker variants if chips sit on white
--map-veil
very light cool wash, or none
Dark navy gradient must not leak into light
--placeholder
new token
Replace hardcoded #a9b6cd
--leaflet-bg
new token
Replace #0b1220 on .leaflet-container
--shadow
new token
Popup box-shadow is currently rgba(0,0,0,0.45) — too heavy on light
Also move the body background gradient onto variables so light mode is a flat/light wash, not the navy 165deg gradient.
Map tiles
In frontend/src/components/MapView.tsx, TileLayerurl must follow the theme:
Pass theme into MapView (or read data-theme from document.documentElement).
Changing the url prop should remount or update the Leaflet layer. If tiles stick, give TileLayer a key={theme} so it remounts.
Update .leaflet-container background to --leaflet-bg so empty-map flash matches the theme.
Cluster CSS currently uses rgba(34, 211, 238, 0.2) + rgba(18, 26, 43, 0.92). On light, cluster discs need a light fill + dark text or they look like dark blobs.
Search cancel filter: invert(1) is a dark-mode hack; gate it so light mode does not invert the × into the wrong color.
Heatmap (leaflet.heat) can stay as-is; verify it is still visible on light tiles.
UI: theme toggle
Place a compact control in Header.tsx, left of the ingest status pill (desktop-first; still tappable on tablet).
Icon + accessible name, e.g. “Switch to light mode” / “Switch to dark mode”.
data-testid="theme-toggle" (required for Playwright).
Do not bury it in a settings page (there isn’t one).
Persist immediately on click.
Persistence
localStorage key: geonews.theme
Values: "dark" | "light" (and "system" only if that mode is implemented)
Safe parse: unknown/corrupt value → dark
try/catch around localStorage (Safari private mode)
FOUC script (layout)
Inline in <html> before body paint, something equivalent to:
Implement light mode / dark mode for GeoNews
Summary
GeoNews is currently dark-only. The UI is a situation-room OSINT workstation (plan.md §3): navy panels, cyan accent, Carto Dark map tiles. There is no theme toggle, no
prefers-color-schemehandling, and several Leaflet/CSS values are hardcoded to dark hexes.Add a first-class light / dark theme so users can switch at any time, keep the choice across reloads, and still keep the intel aesthetic in both modes. Default remains dark so existing screenshots, E2E, and the product identity do not change unless the user opts in.
This is a frontend-only change. No API, ingest, or LLM work.
Current state (what is broken / missing)
:rootinfrontend/src/app/globals.cssdefines only dark values (--bg: #0b1220,--panel: #121a2b,--border: #243049,--text: #e8eefc)frontend/src/app/layout.tsx<html lang="en">has nodata-theme/classfrontend/src/components/Header.tsx— logo + ingest status onlyMapView.tsxhardcodeshttps://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png.leaflet-container { background: #0b1220 }, attribution, clusters, popup shadows, search-cancel invert filterlinear-gradient(165deg, #0b1220 …)not using CSS variables#a9b6cdhardcoded in CSS and Tailwind (placeholder:text-[#a9b6cd])Most chrome already uses
var(--bg),var(--panel),var(--text),var(--border),var(--accent). That is the right foundation: extend the variables, do not scatterdark:Tailwind classes everywhere.Product rules
prefers-color-scheme.prefers-color-schemelive (listen tochangeon the media query). If “System” is too much UI, skip it and only offer Light / Dark — but still readprefers-color-schemeas the first-visit fallback only if we decide to honor OS. Recommended for v1: Dark / Light toggle, default Dark, persist the last explicit choice. Do not auto-flip an existing user.output: 'export'), put a tiny inline script inlayout.tsxthat readslocalStorageand setsdocument.documentElement.dataset.theme(orclass) before React hydrates.crime#f43f5e,disaster#f59e0b, etc.). Those are semantic, not theme tokens.#ffffffeverywhere.<button>, has an accessible name,aria-pressedoraria-labelthat reflects the next/current mode, visible focus ring, keyboard operable. Contrast for body text on light panels must meet WCAG AA (~4.5:1).Suggested token sets
Keep the existing dark tokens. Add a light set under
html[data-theme="light"](orhtml.light).Dark (already in
:root— keep as default)--bg#0b1220--panel#121a2b--panel-2#182338--border#243049--text#e8eefc--text-muted#93a0b8--accent#22d3ee--alert/--warning/--safe#f43f5e/#f59e0b/#34d399--map-veilLight (new)
Aim for cool ink-on-paper, not stark white:
--bg#f4f6fb--panel#ffffff--panel-2#eef2f8--border#c9d3e4--text#0f172a--text-muted#475569--accent#0891b2#22d3eefails AA on white)--alert/--warning/--safe--map-veilnone--placeholder#a9b6cd--leaflet-bg#0b1220on.leaflet-container--shadowbox-shadowis currentlyrgba(0,0,0,0.45)— too heavy on lightAlso move the body background gradient onto variables so light mode is a flat/light wash, not the navy 165deg gradient.
Map tiles
In
frontend/src/components/MapView.tsx,TileLayerurlmust follow the theme:https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.pnghttps://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.pngAttribution stays
© OpenStreetMap © CARTO.Implementation notes:
themeintoMapView(or readdata-themefromdocument.documentElement).urlprop should remount or update the Leaflet layer. If tiles stick, giveTileLayerakey={theme}so it remounts..leaflet-containerbackground to--leaflet-bgso empty-map flash matches the theme.rgba(34, 211, 238, 0.2)+rgba(18, 26, 43, 0.92). On light, cluster discs need a light fill + dark text or they look like dark blobs.filter: invert(1)is a dark-mode hack; gate it so light mode does not invert the × into the wrong color.leaflet.heat) can stay as-is; verify it is still visible on light tiles.UI: theme toggle
Place a compact control in
Header.tsx, left of the ingest status pill (desktop-first; still tappable on tablet).data-testid="theme-toggle"(required for Playwright).Persistence
localStoragekey:geonews.theme"dark"|"light"(and"system"only if that mode is implemented)try/catcharoundlocalStorage(Safari private mode)FOUC script (layout)
Inline in
<html>before body paint, something equivalent to:Also set
color-scheme: dark/color-scheme: lightonhtmlso native scrollbars, form controls, and UA search decorations match.Because Next.js 15 App Router: the script must be in the root layout (or a small client
ThemeScript). Do not wait foruseEffect— that flashes.Files to touch (expected)
frontend/src/app/globals.cssfrontend/src/app/layout.tsxdata-themedefault, inline FOUC script,suppressHydrationWarningon<html>frontend/src/components/Header.tsxfrontend/src/components/MapView.tsxTileLayerurl + keyfrontend/src/components/GeoNewsApp.tsxfrontend/src/lib/theme.ts(new)Themetype,STORAGE_KEY,readTheme(),applyTheme(),subscribeTheme()frontend/src/components/ThemeProvider.tsx(new, optional)frontend/src/app/globals.css+AiPanel.tsxplaceholder:text-[#a9b6cd]withplaceholder:text-[var(--placeholder)]frontend/tailwind.config.tsdarkMode: ['selector', '[data-theme="dark"]']— prefer CSS variables so Tailwinddark:is unnecessaryplan.md§3 Visual designplanning/FRONTEND_HANDOFF.mdtheme-toggletestid.cursor/skills/leaflet-geonews-map/SKILL.mdAvoid rewriting every component. If a class still has a hardcoded dark hex after tokens exist, that is a bug in this ticket.
Implementation sketch
globals.css/ components onto CSS variables.html[data-theme="light"] { … }light overrides.frontend/src/lib/theme.ts+ FOUC script inlayout.tsx.applyTheme('light' | 'dark')which setsdata-theme,localStorage, andcolor-scheme.MapViewand swap Cartodark_all/light_all.Testing
Unit (Vitest)
theme.ts: default dark; persist light; ignore garbage values;applyThemesetsdata-theme.Header: clickingtheme-toggleflipsdocument.documentElement.dataset.themeandlocalStorage.MapViewreceives a theme prop and would render a different tile url (props contract test already exists — extend it).Playwright (
test/e2e/geonews.spec.ts)Add a focused test, e.g. “Theme toggle switches chrome and map tiles”:
gotoApp.htmldata-themeisdark(or unset treated as dark).[data-testid="theme-toggle"].html[data-theme="light"].TileLayerimgsrccontainslight_all).data-theme="light"(persistence).dark+dark_alltiles.Selector:
data-testid="theme-toggle".Manual visual QA (checklist)
Acceptance criteria
localStorage(geonews.theme) and survives refresh.dark_allin dark,light_allin light; attribution unchanged.data-testid="theme-toggle"and an accessible name.color-schemematches the active theme.plan.md§3 and frontend handoff mention light mode; leaflet skill lists both tile URLs.Out of scope
Context / pointers
GeoNews/plan.md§3 Visual designGeoNews/frontend/src/app/globals.cssGeoNews/frontend/src/components/MapView.tsx+.cursor/skills/leaflet-geonews-map/SKILL.mdGeoNews/frontend/src/components/Header.tsxplanning/FRONTEND_HANDOFF.md(FOUC script must work withoutput: 'export')GeoNews/test/e2e/geonews.spec.ts