From 875ef820976d3e51538e226ae8a75b480f8f759b Mon Sep 17 00:00:00 2001 From: vidit-admin Date: Sun, 5 Jul 2026 00:13:32 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(frontend):=20light=20theme=20(Settings?= =?UTF-8?q?=20=E2=86=92=20Display)=20+=20factored=20preference=20plumbing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a light/dark toggle as a second display axis alongside the accent palette. Dark stays the default; a manual Switch flips it, browser-local (localStorage `vidit:theme`) like the palette, with no OS prefers-color -scheme follow. The mechanism mirrors the accent system: `data-theme="light"` on remaps the Tailwind neutral-* scale to an inverted light ramp (mirrored about the neutral-500 midpoint) in globals.css, so every neutral-* util, the scrollbar, and the MapLibre control chrome flip with no per-component change. Map.tsx swaps the CARTO dark-matter basemap for its matched light counterpart positron off useTheme (maplibre paint can't read CSS vars). The accent hue is untouched; the two axes compose. Factor the shared client-preference plumbing so theme is not a third near-duplicate store: attributePreference.ts owns the localStorage to reflection (palette + theme), useClientPreference.ts the useSyncExternalStore wiring (palette + theme + help-hidden). Keeps jscpd at 0% for the typescript set. A pre-paint inline script in the root layout applies the saved theme before first paint to avoid a dark flash. tsc / eslint / knip / jscpd / palette:check / vitest (160) green. Co-Authored-By: Claude Opus 4.8 Signed-off-by: vidit-admin --- CHANGELOG.md | 1 + docs/design.md | 12 +++-- frontend/src/app/(auth)/layout.tsx | 2 +- frontend/src/app/error.tsx | 2 +- frontend/src/app/globals.css | 61 +++++++++++++++++++---- frontend/src/app/layout.tsx | 18 ++++--- frontend/src/app/map/page.tsx | 2 +- frontend/src/app/page.tsx | 2 +- frontend/src/app/settings/page.tsx | 18 +++++++ frontend/src/components/map/Map.tsx | 16 ++++-- frontend/src/hooks/useClientPreference.ts | 31 ++++++++++++ frontend/src/hooks/useHelpHidden.ts | 20 ++------ frontend/src/hooks/usePalette.ts | 27 +++------- frontend/src/hooks/useTheme.ts | 13 +++++ frontend/src/lib/attributePreference.ts | 61 +++++++++++++++++++++++ frontend/src/lib/palette.ts | 41 +++++++-------- frontend/src/lib/theme.test.ts | 46 +++++++++++++++++ frontend/src/lib/theme.ts | 37 ++++++++++++++ planning/next.md | 2 +- 19 files changed, 322 insertions(+), 90 deletions(-) create mode 100644 frontend/src/hooks/useClientPreference.ts create mode 100644 frontend/src/hooks/useTheme.ts create mode 100644 frontend/src/lib/attributePreference.ts create mode 100644 frontend/src/lib/theme.test.ts create mode 100644 frontend/src/lib/theme.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f7020b4..7097ee1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Format roughly follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - **Submit screen reworked into one form; "bounty" retired for "request"** ([`frontend/src/app/submit/page.tsx`](frontend/src/app/submit/page.tsx), [`frontend/src/app/requests/`](frontend/src/app/requests), [`frontend/src/components/event/StatusBadge.tsx`](frontend/src/components/event/StatusBadge.tsx), [`frontend/src/components/ui/Pill.tsx`](frontend/src/components/ui/Pill.tsx), [`backend/app/routers/events/write.py`](backend/app/routers/events/write.py), [`backend/app/services/events.py`](backend/app/services/events.py), [`backend/app/routers/search.py`](backend/app/routers/search.py), [`backend/app/routers/admin.py`](backend/app/routers/admin.py), [`docs/design.md`](docs/design.md), [`planning/next.md`](planning/next.md)). The geolocation-vs-bounty mode toggle is gone: one form where you fill what you have, and two content-gated actions ("Publish geolocation", "Publish request") light up from a live requirements tick-list that escalates from the request floor to the full geolocation floor. A "Start from" chooser (Single / Bulk import) sits on top. The product term **"bounty" is retired for "request"** everywhere the user sees it: the route `/bounties` becomes `/requests` (redirect stubs kept), plus UI copy, the search filter, and internal names; the backend wire vocabulary follows (`type=request` search value, `/admin/seed-demo-requests`); the `requested` status value and the alembic / historical-changelog "bounty" are kept as they were. A new `secondary` Pill tone joins the palette, and the icon language settles as Megaphone = request, MapPin = geolocation (the old bounty target icon is retired). The Requests board drops its Open/Closed filter to show only open requests. Two backend relaxations for the real OSINT workflow: a request may now carry optional **proof images** (work started but not finished, reusing the geolocation intake), and an **event time without an event date** (an approximate hour from sun position is knowable before the day). `ruff` / `mypy` (78) / `pytest` (688) green; `tsc` / `eslint` (0 errors) / `vitest` (155) / `knip` / `palette` green; `api-types.ts` regenerated. +- **Light theme (Settings → Display)** ([`frontend/src/lib/theme.ts`](frontend/src/lib/theme.ts), [`frontend/src/lib/attributePreference.ts`](frontend/src/lib/attributePreference.ts), [`frontend/src/hooks/useTheme.ts`](frontend/src/hooks/useTheme.ts), [`frontend/src/hooks/useClientPreference.ts`](frontend/src/hooks/useClientPreference.ts), [`frontend/src/app/globals.css`](frontend/src/app/globals.css), [`frontend/src/components/map/Map.tsx`](frontend/src/components/map/Map.tsx), [`frontend/src/app/settings/page.tsx`](frontend/src/app/settings/page.tsx), [`frontend/src/app/layout.tsx`](frontend/src/app/layout.tsx), [`docs/design.md`](docs/design.md), [`planning/next.md`](planning/next.md)). A light / dark toggle joins the accent palette as a second, independent display axis. Dark stays the default; a manual switch flips it, browser-local (`localStorage` key `vidit:theme`) like the palette, with no OS `prefers-color-scheme` follow. The mechanism mirrors the accent system: `data-theme="light"` on `` remaps the Tailwind `neutral-*` scale to an inverted light ramp (mirrored about the `neutral-500` midpoint) in `globals.css`, so every `neutral-*` utility, the scrollbar, and the MapLibre control chrome flip with no per-component change; dark carries no attribute. The map basemap can't read CSS variables, so `Map.tsx` swaps CARTO Dark Matter for its matched light counterpart Positron off `useTheme`. The accent hue is untouched by the theme (the two axes compose). Both preferences (and the help-tooltip toggle) now share one plumbing: `attributePreference.ts` owns the `localStorage` ↔ `` reflection, `useClientPreference.ts` the `useSyncExternalStore` subscription, so no third near-duplicate store (kept `jscpd` at 0 % for the `typescript` set). A pre-paint inline script in the root layout applies the saved theme before first paint to avoid a dark flash. `tsc` / `eslint` / `knip` / `jscpd` / `palette:check` / `vitest` (160) green. - **`vulture` dead-code gate on the backend** ([`backend/pyproject.toml`](backend/pyproject.toml), [`backend/vulture_whitelist.py`](backend/vulture_whitelist.py), [`.github/workflows/ci.yml`](.github/workflows/ci.yml), [`Makefile`](Makefile), [`docs/engineering.md`](docs/engineering.md), [`planning/next.md`](planning/next.md)). The backend gains a dead-code gate, the analogue of the frontend's `knip`: `vulture` finds unused functions, classes, methods, and fields that ruff's `F401` (unused imports only) does not. Config under `[tool.vulture]` scans `app` + `scripts`, excludes `alembic/versions`, and runs at `min_confidence = 60` (an unused module-level function scores 60, so the default 80 would let a genuinely orphaned function slip through and only ever catch unused locals). The framework noise that a 60 floor also surfaces is handled two ways: `ignore_decorators` (`@router.*` / `@app.*` route + middleware handlers, `@field_validator` / `@model_validator` methods) and `ignore_names` (`model_config`, the validator `cls`) clear the two blanket classes, and `vulture_whitelist.py` lists the residual attribute-access false positives (SQLAlchemy `Mapped[...]` columns hydrated on load, Pydantic response-model fields set by the service layer, dataclass fields read by attribute, one test-only helper). It runs in the `backend-lint` CI job after `mypy app` and locally via `make hygiene` (or `make vulture`); `vulture_whitelist.py` is excluded from ruff since its bare-name entries are vulture's own format, not runnable Python. The baseline triage found **no genuine dead code** (108 findings at confidence 60, all framework false positives), so the whitelist silences only framework magic, not real orphans. One column, `users.claimed_at`, has no explicit Python producer or consumer yet (its `server_default` stamps it and the assembled-profile claim flow that would write it is a deferred v0.4 item); it stays as a live, documented column that the ORM hydrates on load. `ruff` / `mypy` (82) / `vulture` / `pytest` (667) green. - **Bounty test fixtures consolidated under `tests/events/`** ([`backend/tests/events/test_bounties.py`](backend/tests/events/test_bounties.py), [`backend/tests/events/conftest.py`](backend/tests/events/conftest.py), [`docs/engineering.md`](docs/engineering.md), [`planning/next.md`](planning/next.md)). `test_bounties.py` was a standalone module re-declaring the events package's `db` / `author` / tag fixtures; it moves under `tests/events/` and inherits them (the conftest's author teardown widened to a safe superset that also clears `event_claims` + `requested_by_id`, a no-op for the pure geolocations suites), and the local `_make_geolocated` helper folds into the shared `_make_geo`. Test-only, no production code. `ruff` / `mypy` (82) / `pytest` (667) green. - **Bounty detail page reuses `EventDetailBody`** ([`frontend/src/components/event/EventDetailBody.tsx`](frontend/src/components/event/EventDetailBody.tsx), [`frontend/src/app/bounties/[id]/page.tsx`](frontend/src/app/bounties/%5Bid%5D/page.tsx), [`docs/design.md`](docs/design.md), [`planning/next.md`](planning/next.md)). The bounty detail page hand-rolled ~110 lines duplicating `EventDetailBody`'s Media + Details + curated-tag + Proof rendering. `EventDetailBody` is generalized (its `geo` prop widens so a bounty, an event without location / detected-from / requested-by, flows through with those spots rendered empty or dropped) plus a `detailExtras` slot for the bounty's Working-on + Closed rows; the page collapses to one `` keeping its own actions. Follow-up to the bounty/geolocation cleanup. `tsc` / `knip` / `palette` / `vitest` (126) green. diff --git a/docs/design.md b/docs/design.md index 1d7e3303..75a754b7 100644 --- a/docs/design.md +++ b/docs/design.md @@ -11,15 +11,19 @@ ## Theme -**Dark minimal.** Uniform dark background, opaque panels, one warm accent for contrast. Dark for long-session comfort; data reads better on dark. +**Dark by default, light on demand.** Uniform background, opaque panels, one warm accent for contrast. Dark reads better for long-session, data-dense work and stays the default; light is a second axis for readers who want it. + +The theme is independent of the accent hue. The pick is browser-local (`localStorage` key `vidit:theme`), applied as `data-theme="light"` on ``, which remaps the Tailwind `neutral-*` scale to an inverted light ramp ([`globals.css`](../frontend/src/app/globals.css)); dark is the default and carries no attribute. Every `neutral-*` utility flips with no per-component change, the same mechanism as the accent palette below, so both preferences share one plumbing ([`attributePreference.ts`](../frontend/src/lib/attributePreference.ts)). The map basemap can't read CSS variables, so [`Map.tsx`](../frontend/src/components/map/Map.tsx) swaps CARTO Dark Matter for its light counterpart Positron off [`useTheme`](../frontend/src/hooks/useTheme.ts). ## Colour palette -### Foundation (dark) +### Foundation + +The dark roles below are the default. Light theme mirrors the `neutral-*` ramp about the `neutral-500` midpoint (`globals.css`), so each role holds with background and text tones swapped. | Role | Color | Tailwind | Usage | |------|-------|----------|-------| -| Background | `#0a0a0a` | `gray-950` | Global background, behind the map | +| Background | `#0a0a0a` | `neutral-950` | Global background, behind the map | | Surface | `#171717` | `neutral-900` | Panels, cards, modals | | Surface elevated | `#262626` | `neutral-800` | Inputs, interactive elements, hover | | Border | `#333333` | `neutral-700` | Separators, field outlines | @@ -97,7 +101,7 @@ Constants (the pill tones live on `` as `PILL_TONE`; these colour-only pai ## Map -- **Style:** CARTO Dark Matter (with labels), `https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json` +- **Style:** CARTO Dark Matter (with labels), `https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json`; the light theme swaps its matched counterpart Positron, `.../gl/positron-gl-style/style.json` (see [Theme](#theme)) - **Renderer:** MapLibre GL JS (vector tiles) with globe projection - Map labels (cities, regions) are discreet light-gray - Point geometry: default radius 6px, selected 7px + 2px white border; opacity 1.0 (points), 0.85 (clusters); pointer cursor on hover diff --git a/frontend/src/app/(auth)/layout.tsx b/frontend/src/app/(auth)/layout.tsx index 9e239f68..4f53a688 100644 --- a/frontend/src/app/(auth)/layout.tsx +++ b/frontend/src/app/(auth)/layout.tsx @@ -4,7 +4,7 @@ export default function AuthLayout({ children: React.ReactNode; }) { return ( -
+
{children}
); diff --git a/frontend/src/app/error.tsx b/frontend/src/app/error.tsx index a01c3056..b99d939e 100644 --- a/frontend/src/app/error.tsx +++ b/frontend/src/app/error.tsx @@ -31,7 +31,7 @@ export default function Error({ error, reset }: ErrorProps) { }, [error]); return ( -
+

Something went wrong diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index ff7a96a0..e69fc177 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -101,6 +101,38 @@ --color-orange-950: #4c0519; } +/* --------------------------------------------------------------------------- + Light theme. + + The second axis, independent of the accent palette above (which re-tints the + `orange-*` scale). The whole neutral base, backgrounds, text, borders, is a + Tailwind `neutral-*` utility compiling to `var(--color-neutral-*)`; light + mode re-points that scale to an inverted ramp on ``, + flipping the UI with no per-component change. Dark is the default and stays + inert (no attribute, or `data-theme="dark"`, both keep Tailwind's defaults). + + The ramp is Tailwind's own `neutral` values mirrored about the `500` + midpoint, so every relative pairing survives the flip: a dark surface + (`neutral-900`) becomes light, light text (`neutral-100`) becomes dark, and + in-between muted tones keep their contrast order. `500` is self-inverse. + + The map basemap can't read CSS variables, so `Map.tsx` swaps its style off + `useTheme` (dark-matter <-> positron); the same for the marker hexes below. +--------------------------------------------------------------------------- */ +[data-theme="light"] { + --color-neutral-50: #0a0a0a; + --color-neutral-100: #171717; + --color-neutral-200: #262626; + --color-neutral-300: #404040; + --color-neutral-400: #525252; + --color-neutral-500: #737373; + --color-neutral-600: #a3a3a3; + --color-neutral-700: #d4d4d4; + --color-neutral-800: #e5e5e5; + --color-neutral-900: #f5f5f5; + --color-neutral-950: #fafafa; +} + /* The default border color has changed to `currentcolor` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still @@ -145,10 +177,10 @@ outline: none; } -/* Custom scrollbar — dark theme */ +/* Custom scrollbar — follows the neutral scale, so it flips with the theme */ * { scrollbar-width: thin; - scrollbar-color: #404040 transparent; + scrollbar-color: var(--color-neutral-700) transparent; } *::-webkit-scrollbar { width: 6px; @@ -157,11 +189,11 @@ background: transparent; } *::-webkit-scrollbar-thumb { - background: #404040; + background: var(--color-neutral-700); border-radius: 3px; } *::-webkit-scrollbar-thumb:hover { - background: #525252; + background: var(--color-neutral-600); } /* Native date/time inputs (date / time / datetime-local): mute the empty @@ -190,27 +222,34 @@ input[type='datetime-local']:not(.has-value)::-webkit-datetime-edit { order: 2 !important; } -/* Dark theme for MapLibre controls */ +/* MapLibre controls — paints follow the neutral scale, so they flip with the + theme; only the glyph inversion is theme-specific (see below). */ .maplibregl-ctrl-group { - background: #171717 !important; - border: 1px solid #404040 !important; + background: var(--color-neutral-900) !important; + border: 1px solid var(--color-neutral-700) !important; } .maplibregl-ctrl-group button { - border-color: #404040 !important; + border-color: var(--color-neutral-700) !important; } .maplibregl-ctrl-group button + button { - border-top-color: #404040 !important; + border-top-color: var(--color-neutral-700) !important; } .maplibregl-ctrl-group button span { filter: invert(1); } .maplibregl-ctrl-attrib { background: transparent !important; - color: #525252 !important; + color: var(--color-neutral-600) !important; } .maplibregl-ctrl-attrib a { - color: #525252 !important; + color: var(--color-neutral-600) !important; } .maplibregl-ctrl-attrib button.maplibregl-ctrl-attrib-button { filter: invert(1); } +/* MapLibre ships black glyph icons, inverted to white for the dark UI above. + In light mode keep them black (no invert) so they read on the light chrome. */ +[data-theme="light"] .maplibregl-ctrl-group button span, +[data-theme="light"] .maplibregl-ctrl-attrib button.maplibregl-ctrl-attrib-button { + filter: none; +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index ae7b75a0..8236f249 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -29,23 +29,25 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - // `suppressHydrationWarning`: the inline script below sets `data-palette` on - // before hydration, which the server markup doesn't carry, so React - // would flag the attribute mismatch. Scoped to this one element. + // `suppressHydrationWarning`: the inline script below sets `data-palette` + // and `data-theme` on before hydration, which the server markup + // doesn't carry, so React would flag the attribute mismatch. Scoped to this + // one element. - {/* Apply the saved accent palette before first paint so themed UI - doesn't flash the default hue on load. Sets only an attribute, so an - unexpected stored value is inert (no matching override = default). */} + {/* Apply the saved accent palette + light/dark theme before first paint + so themed UI doesn't flash the default hue or a dark background on + load. Sets only attributes, so an unexpected stored value is inert + (no matching override = default). */}