diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f7020b4..b34e5c39 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 a curated soft light ramp in `globals.css` (a soft warm grey canvas with warm off-white cards floating on it and dark-grey text, not a mechanical inversion that glares near-white), so every `neutral-*` utility, the scrollbar, and the MapLibre control chrome flip with no per-component change; dark carries no attribute. The block also mirrors the semantic `red` / `amber` scales (so danger + warning banners keep their meaning with dark text on a light tint instead of washing out) and sets `color-scheme` so native widgets track the theme. The map basemap can't read CSS variables, so `Map.tsx` swaps CARTO Dark Matter for its matched light counterpart Positron off `useTheme`, with a faint `sepia` on the canvas warming Positron's cool grey to match the surfaces, and flips the selected-point halo to a dark ring on the light basemap. 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..47230f41 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 (plus the semantic `red` / `amber` danger and warning scales, mirrored so their pale text stops go dark on the light tint) to a curated soft light ramp ([`globals.css`](../frontend/src/app/globals.css)); dark is the default and carries no attribute. The block also sets `color-scheme` so native widgets (scrollbars, date / select popups) track the theme. 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 orange accent is owned by the palette switch, so it is not theme-adjusted; accent text (links, the success banner) reads a touch lighter in light mode. 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 re-points the same `neutral-*` scale to a curated soft ramp (`globals.css`): a soft warm grey canvas (`neutral-950`) with warm off-white cards (`neutral-900`) floating on it, and dark-grey text (`neutral-100` = `#232323`, not black), so a large light surface reads as easy on the eyes rather than a flat near-white glare. The light surfaces carry a faint warmth (`R > G > B`); the text greys stay neutral. It mirrors how the dark scale avoids pure black and pure white. | 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)), with a faint `sepia` on the canvas (`globals.css`) warming Positron's cool grey to match the warm light surfaces - **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..36af949c 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -32,6 +32,12 @@ } } +/* Dark is the default theme; the light block below flips this to `light`. Keeps + native widgets (scrollbars, date / select popups, autofill) in step. */ +:root { + color-scheme: dark; +} + /* --------------------------------------------------------------------------- Accent palette switching. @@ -101,6 +107,79 @@ --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 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). + + This is a curated soft ramp, not a mechanical inversion of the dark scale: a + flat near-white expanse is harsh on the eyes, so the canvas (`neutral-950`, + the largest surface) is a soft warm grey and the raised surfaces + (`neutral-900`, cards / panels / rail) sit a step *lighter*, reading as + near-white cards floating on the grey rather than the whole viewport going + white. Text tops out at a dark grey (`neutral-100` = #232323), not near-black, + so the contrast stays comfortable. The mid greys keep their order for borders + and muted text. This mirrors how the dark theme avoids pure black (#0a0a0a, + not #000) and pure white text (#f5f5f5, not #fff). + + The map basemap can't read CSS variables, so `Map.tsx` swaps its style off + `useTheme` (dark-matter <-> positron); the marker hexes stay in `palette.ts`. +--------------------------------------------------------------------------- */ +[data-theme="light"] { + /* Tell the UA to paint native widgets (date / select popups, autofill, + scrollbar corner) light too, so they track the theme instead of the OS. */ + color-scheme: light; + --color-neutral-50: #1a1a1a; + --color-neutral-100: #232323; + --color-neutral-200: #303030; + --color-neutral-300: #454545; + --color-neutral-400: #585858; + --color-neutral-500: #767676; + --color-neutral-600: #9a9a9a; + /* The light surfaces carry a faint warmth (R > G > B) so the sidebar, cards, + and panels read as a warm off-white rather than a clinical grey; the text + greys above stay neutral. */ + --color-neutral-700: #d0cdc7; + --color-neutral-800: #e4e1db; + --color-neutral-900: #f7f5f1; + --color-neutral-950: #eae8e4; + + /* The semantic red / amber scales (danger + warning surfaces: FORM_ERROR_BANNER, + WARNING_CALLOUT, danger pills / buttons) are tuned light-on-dark, so on a + light surface the pale text stops wash out. Mirror each scale about its 500 + midpoint: the hue stays, only lightness flips, giving dark saturated text on + a light tint (the light-mode counterpart of the dark recipe). The orange + accent is owned by the palette switch above, so it is deliberately left + alone here. */ + --color-red-50: #450a0a; + --color-red-100: #7f1d1d; + --color-red-200: #991b1b; + --color-red-300: #b91c1c; + --color-red-400: #dc2626; + --color-red-500: #ef4444; + --color-red-600: #f87171; + --color-red-700: #fca5a5; + --color-red-800: #fecaca; + --color-red-900: #fee2e2; + --color-red-950: #fef2f2; + --color-amber-50: #451a03; + --color-amber-100: #78350f; + --color-amber-200: #92400e; + --color-amber-300: #b45309; + --color-amber-400: #d97706; + --color-amber-500: #f59e0b; + --color-amber-600: #fbbf24; + --color-amber-700: #fcd34d; + --color-amber-800: #fde68a; + --color-amber-900: #fef3c7; + --color-amber-950: #fffbeb; +} + /* 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 +224,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 +236,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 +269,44 @@ 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; +} +/* Warm the light basemap (Positron ships a cool grey) so it matches the warm + off-white surfaces, without hosting a custom style: a faint sepia on the map + canvas tints the greyscale land toward cream. The filter covers the whole + WebGL canvas, so the accent markers warm-shift slightly too; at 0.12 they stay + clearly on-hue (verified down to the blue palette, the coolest one). Light + theme only; the dark basemap (dark-matter) is untouched. The controls + + attribution are separate DOM, so they keep their neutral chrome above. */ +[data-theme="light"] .maplibregl-canvas { + filter: sepia(0.12) saturate(1.05); +} 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). */}