Skip to content

GeoNews: implement light mode / dark mode #20

Description

@sshahriar

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-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 No toggle control
Map tiles MapView.tsx hardcodes https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png Light UI on a black map looks wrong
Leaflet chrome .leaflet-container { background: #0b1220 }, attribution, clusters, popup shadows, search-cancel invert filter Hardcoded dark leftovers
Body gradient 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

  1. Default is dark. First visit with no saved preference and no explicit choice → dark. Matches plan.md (“dark situation-room aesthetic”).
  2. Explicit user choice wins over OS prefers-color-scheme.
  3. 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 read prefers-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.
  4. 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.
  5. Category pin colors stay as-is (crime #f43f5e, disaster #f59e0b, etc.). Those are semantic, not theme tokens.
  6. 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.
  7. Free tiles only. Carto light/dark (no Google Maps key). Same attribution pattern.
  8. 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).
  9. 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, TileLayer url must follow the theme:

Theme URL (free Carto, no key)
Dark https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png
Light https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png

Attribution stays © OpenStreetMap © CARTO.

Implementation notes:

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

<script>
  (function () {
    try {
      var t = localStorage.getItem('geonews.theme');
      if (t === 'light' || t === 'dark') {
        document.documentElement.setAttribute('data-theme', t);
      } else {
        document.documentElement.setAttribute('data-theme', 'dark');
      }
    } catch (e) {
      document.documentElement.setAttribute('data-theme', 'dark');
    }
  })();
</script>

Also set color-scheme: dark / color-scheme: light on html so 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 for useEffect — that flashes.


Files to touch (expected)

File Change
frontend/src/app/globals.css Light token block; replace hardcoded hexes with variables; cluster/popup/placeholder/leaflet-bg
frontend/src/app/layout.tsx data-theme default, inline FOUC script, suppressHydrationWarning on <html>
frontend/src/components/Header.tsx Toggle button
frontend/src/components/MapView.tsx Theme-aware TileLayer url + key
frontend/src/components/GeoNewsApp.tsx Provide theme to map if not reading from DOM
frontend/src/lib/theme.ts (new) Theme type, STORAGE_KEY, readTheme(), applyTheme(), subscribeTheme()
frontend/src/components/ThemeProvider.tsx (new, optional) Client context if several components need it
frontend/src/app/globals.css + AiPanel.tsx Replace placeholder:text-[#a9b6cd] with placeholder:text-[var(--placeholder)]
frontend/tailwind.config.ts Only if we use darkMode: ['selector', '[data-theme="dark"]'] — prefer CSS variables so Tailwind dark: is unnecessary
plan.md §3 Visual design Note that dark is default and light is supported
planning/FRONTEND_HANDOFF.md Document tokens + theme-toggle testid
.cursor/skills/leaflet-geonews-map/SKILL.md Document light Carto URL next to dark

Avoid rewriting every component. If a class still has a hardcoded dark hex after tokens exist, that is a bug in this ticket.


Implementation sketch

  1. Extract remaining hardcoded colors in globals.css / components onto CSS variables.
  2. Add html[data-theme="light"] { … } light overrides.
  3. Add frontend/src/lib/theme.ts + FOUC script in layout.tsx.
  4. Add header toggle; wire applyTheme('light' | 'dark') which sets data-theme, localStorage, and color-scheme.
  5. Pass theme into MapView and swap Carto dark_all / light_all.
  6. Visual pass: header, filter chips, watchlist, intel drawer, AI panel, event cards, popups, clusters, search, heatmap, focus rings, scrollbars.
  7. Tests (below).
  8. Update plan/handoff/leaflet skill one-liners.

Testing

Unit (Vitest)

  • theme.ts: default dark; persist light; ignore garbage values; applyTheme sets data-theme.
  • Header: clicking theme-toggle flips document.documentElement.dataset.theme and localStorage.
  • Optional: MapView receives 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”:

  1. gotoApp.
  2. Assert html data-theme is dark (or unset treated as dark).
  3. Click [data-testid="theme-toggle"].
  4. Assert html[data-theme="light"].
  5. Assert a Carto light tile URL is requested (or the TileLayer img src contains light_all).
  6. Reload → still data-theme="light" (persistence).
  7. Toggle back → dark + dark_all tiles.
  8. Existing tests 1–N must still pass without setting a theme (dark default).

Selector: data-testid="theme-toggle".

Manual visual QA (checklist)

  • Header, status pill, place search, filter chips, watchlist, hotspots
  • Intel drawer event cards + empty state
  • AI panel, mock badge, error/retry, brief, chat input
  • Leaflet popup + close button + attribution
  • Marker clusters on a dense city (Dhaka)
  • Heatmap on/off on light tiles
  • Autofill / search cancel on Chromium
  • Keyboard: Tab to toggle, Enter/Space
  • No flash on hard reload in light mode
  • Contrast: muted text, borders, accent links on light panels

Acceptance criteria

  • User can switch light and dark from the header without reload.
  • Choice persists in localStorage (geonews.theme) and survives refresh.
  • First visit / missing key → dark (current look unchanged).
  • No FOUC: saved light theme is applied before first paint.
  • All chrome (header, filters, drawer, AI panel, inputs, popups, scrollbars) uses CSS variables — no leftover navy hexes in light mode.
  • Map tiles: Carto dark_all in dark, light_all in light; attribution unchanged.
  • Category pin colors unchanged.
  • Accent remains readable in both themes (darker cyan on light).
  • Toggle has data-testid="theme-toggle" and an accessible name.
  • color-scheme matches the active theme.
  • Vitest + Playwright coverage for toggle + persistence; existing E2E still pass on default dark.
  • plan.md §3 and frontend handoff mention light mode; leaflet skill lists both tile URLs.
  • No backend / env / secret changes.

Out of scope

  • Per-component themes, high-contrast mode, or user-uploaded palettes
  • Syncing theme to SQLite / a user account (there is no auth)
  • Changing category pin colors
  • Paid map providers
  • Mobile-only bottom-nav redesign (desktop-first still)

Context / pointers

  • Design contract: GeoNews/plan.md §3 Visual design
  • Tokens today: GeoNews/frontend/src/app/globals.css
  • Map: GeoNews/frontend/src/components/MapView.tsx + .cursor/skills/leaflet-geonews-map/SKILL.md
  • Header: GeoNews/frontend/src/components/Header.tsx
  • Static export caveat: planning/FRONTEND_HANDOFF.md (FOUC script must work with output: 'export')
  • E2E: GeoNews/test/e2e/geonews.spec.ts

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions