Skip to content

feat(web): event log filter chips on vessel detail#3

Merged
breaching merged 2 commits into
mainfrom
feat/web-vague-2-event-filters
Apr 28, 2026
Merged

feat(web): event log filter chips on vessel detail#3
breaching merged 2 commits into
mainfrom
feat/web-vague-2-event-filters

Conversation

@breaching
Copy link
Copy Markdown
Owner

@breaching breaching commented Apr 28, 2026

Summary

Creates the vessel-detail page (/en/vessels/<slug>) and adds client-side event-type filter chips above the event log.

  • web/app/[locale]/vessels/[slug]/page.tsx — new server-component vessel detail page. Fetches live data from the OSINT API (/api/vessels/{slug}) with graceful static fallback. Shows: vessel identity, severity badge, current zones, last position + MarineTraffic link, and the event log.
  • web/components/vessels/EventLogWithFilters.tsx — client component with a chip row above the event log. Chips filter by ZONE_ENTERED, ZONE_LEFT, AIS_GAP, AIS_SEEN, SEVERITY_CHANGED. Only chips for event types present in the fetched data are shown (no phantom chips). Uses eventTone() for chip colors.
  • web/lib/i18n.ts — adds vesselDetail.eventFilter.* keys in both EN and FR.
  • web/lib/utils.ts — adds eventTone() helper and EventType type.
  • web/lib/krill-watch.ts — adds VesselEvent, VesselDetail types and fetchVesselDetail() server-side fetcher.

Verification

  • npx tsc --noEmit → clean
  • npx next build → clean, vessel detail static pages generated for all fleet vessels
  • python -m pytest tests/ → 81 passed (no regressions)
  • Screenshot at /en/vessels/antarctic_endurance confirms: filter chips render, CRITICAL severity badge, MPA zone-entry/exit events listed, MarineTraffic link present

claude added 2 commits April 28, 2026 01:56
…cooldown bug

- scripts/seed_synthetic.py: 7-day synthetic tracks for 14 fleet vessels + 8
  ambient non-fleet vessels (Chinese reefers, Russian trawlers, Cyprus cargo).
  Includes MPA-SOISS incursion event for Antarctic Endurance. Seeds DuckDB.
- tools/screenshot.mjs: headless Playwright screenshot utility using system
  Chromium; accepts --url, --out, --width, --height flags.
- src/notifier.py: fix pre-existing cooldown bug where float('0.0') default
  sentinel would reject first alert on systems with uptime < COOLDOWN_S (600s).
  Changed to float('-inf') so first call always fires.
- web/package.json: adds playwright devDependency (used by screenshot tool).

Phase 0 verified: API /health→ok, /api/vessels→14, web /en→200.

https://claude.ai/code/session_012UuD24c13wFVc5FXW2KESR
Creates the vessel-detail page and adds client-side event-type filter chips.

Files touched:
- web/app/[locale]/vessels/[slug]/page.tsx: new server-component vessel
  detail page fetching live OSINT data (with static fallback). Shows vessel
  identity, last position, and the event timeline.
- web/components/vessels/EventLogWithFilters.tsx: new client component with
  chip row above the event log. Chips filter by ZONE_ENTERED, ZONE_LEFT,
  AIS_GAP, AIS_SEEN, SEVERITY_CHANGED. Only chips for types present in the
  fetched events are shown. Uses eventTone for chip colors.
- web/lib/i18n.ts: adds vesselDetail.eventFilter.* keys in both EN + FR.
- web/lib/utils.ts: adds eventTone() and EventType type.
- web/lib/krill-watch.ts: adds VesselEvent + VesselDetail types and
  fetchVesselDetail() server-side fetcher.

Verification:
- npx tsc --noEmit: clean
- npx next build: clean, vessel detail static pages generated
- python -m pytest tests/: 81 passed
- Screenshot: /en/vessels/antarctic_endurance shows filter chips + 3 events

https://claude.ai/code/session_012UuD24c13wFVc5FXW2KESR
@breaching breaching merged commit d686ade into main Apr 28, 2026
3 checks passed
@breaching breaching deleted the feat/web-vague-2-event-filters branch April 28, 2026 07:55
breaching pushed a commit that referenced this pull request Apr 28, 2026
Resolves overlap with PRs #3, #4, #6 already merged on main:
- web/app/[locale]/vessels/page.tsx: keep both Suspense + Link imports
- web/lib/krill-watch.ts: keep both fetchLivePositions + fetchVesselDetail
- web/package-lock.json: regenerated with maplibre-gl
- tests/test_api.py: ruff I001 import order fix

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
breaching added a commit that referenced this pull request Apr 28, 2026
…28 (PR #2) (#7)

PR #2 (docs/shippability-2026-04-28) became conflict-heavy after merging
#3/#4/#5/#6 from the same overnight loop session. This commit cherry-picks
the unique, non-conflicting pieces only — the rest is left in PR #2 to be
either rebased or closed by the human.

Brings in:
- INVESTIGATOR_PLAYBOOK.md — three present-tense walkthroughs for analysts
- README.md — investigator-tone rewrite (no campaign framing)
- calibration.md — formal v0.4.2 calibration record with weight justification
- config/ccamlr_iuu_vessels.json, foc_flags.json, krill_operators.json — reference datasets
- scripts/seed_for_screenshots.py — synthetic AIS seeder for first-boot screenshots
- server/decimation.py + tests/test_decimation.py — uniform-stride track decimation
- .github/dependabot.yml — monthly pip + npm + weekly actions security updates
- .github/workflows/ci.yml — adds Web (Node 20) job: tsc --noEmit + next build

Deliberately NOT included (require larger integration / conflict with main):
- src/transhipment_report.py + templates/ — depends on PR #2's 724-line detector
  variant, incompatible with main's 220-line clean version (from PR #6)
- /api/transhipments + /api/timeline endpoint changes — depend on bigger detector
- web/components/map/{EventBadge,VesselTrackMap,WindowSelector}.tsx — require
  Position, WindowKey, Dict types that don't exist on main
- web/app/[locale]/transhipments/ — same dependency issue
- Removal of militant content (about/, act/, brands/, learn/, BrandCard, StatusTag,
  brands.ts, executives.ts, messages.ts, timeline.ts) — requires rewriting Nav.tsx
  and home page.tsx, conflicts with PR #4's UX work already on main

Verification:
- python -m pytest tests/ -q → 100 passed
- python -m ruff check src server tests scripts main.py → All checks passed
- cd web && npx tsc --noEmit → exit 0

Closes the path forward for PR #2: the human can now close #2 or rebase the
remaining (deliberately-skipped) pieces against current main if they want them.

Co-authored-by: breaching <devsca@protonmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
breaching added a commit that referenced this pull request Apr 28, 2026
…-trawlers (#17)

Adds two new IUU-fishing campaigns alongside krill (mission=krill) and
whaling (mission=whaling). The detector pipeline (zones, AIS gaps,
severity, events) is fully reused — only data is added.

Galápagos campaign (mission=iuu_fishing)
- 3 representative Chinese squid jiggers / reefers in fleet config:
  Fu Yuan Yu Leng 999, Long Da 001, Hai Feng 718
- 2 zone polygons added to ccamlr_areas.geojson:
    GMR-GALAPAGOS — Galápagos Marine Reserve incl. Hermandad (2022 expansion)
    EEZ-GALAPAGOS-BUFFER — Ecuador 200-nm EEZ where the squid-jigger fleet
    historically congregates each Aug-Nov
- Headline signal: incursion into GMR (foreign-flag commercial fishing
  prohibited there) or AIS gap inside the buffer

West Africa campaign (mission=iuu_fishing)
- 3 representative DWF vessels in fleet config:
  Lurong Yuanyu 956 (CN), Lurong Yuanyu 988 (CN), Saltic Atlas (RU)
- 3 zone polygons added (simplified bounding boxes):
    EEZ-SENEGAL, EEZ-MAURITANIA, EEZ-GUINEA
- Headline signal: AIS gap inside any of these EEZs (vessel went dark
  while presumably trawling)

Web (`web/data/vessels.ts`)
- All 6 new vessels added to the static catalogue with bilingual notes
  describing the campaign context (Pingtan reefer 2017 Galápagos arrest;
  300+ vessel 2020 stand-off; EJF/Greenpeace West Africa documentation)
- They surface automatically in the existing /vessels mission-grouped UI
  (PR #15) — krill / whaling / IUU fishing sections

All vessels have IMO and MMSI marked PENDING — operator must populate
from GFW Carrier Vessel Portal / Equasis before deployment. The polygon
boundaries are simplified bounding boxes that err conservatively (over-
include slightly); operator should refine with official EEZ data
(e.g. https://www.marineregions.org/) before deployment.

Verification
- python -m pytest tests/ -q → 110 passed
- python -m ruff check → All checks passed
- cd web && npx tsc --noEmit → exit 0

What's deliberately NOT done
- No "campaign" abstraction yet. With 4 missions still hanging off
  krill_fleet.json + ccamlr_areas.geojson, the duplication cost remains
  near zero. Right time for the refactor is when the 5th mission lands.

Co-authored-by: breaching <devsca@protonmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
breaching added a commit that referenced this pull request Apr 29, 2026
…layback UX (#22)

* feat(web): fix satellite map + Windows flag rendering

- frontend/app.js: split zone-outline + penguin-buffer-outline layers (MapLibre 4.x rejects data expressions on line-dasharray); add flagImg() helper using flagcdn.com so flags render reliably on Windows
- web/components/map/FleetMap.tsx: add ESRI World Imagery satellite source + custom toggle control (🗺/🛰) with localStorage persistence + locale-aware labels
- web/components/Flag.tsx (new): SVG flag component using flag-icons CSS, with ISO-3 → ISO-2 normalization and CPWF → 🦐 special-case
- migrate 3 flagEmoji() callsites in web/ to <Flag>; mark flagEmoji deprecated
- add flag-icons dependency

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(plan): krill-watch refonte spec + 3 phases × 8 tasks (multi-agent ready)

Brainstorm output for unifying the legacy frontend/ tracker and the Next.js
web/ site into a single product, with satellite history (timeline scrubber +
colored cumulative trail), shareable atoms (incident pages + vessel dossiers
+ campaign hubs), and parallel-friendly task plans for multiple Claude Code
instances.

- 2026-04-28-krill-refonte-design.md: top-level spec (vision, architecture,
  routes, surfaces UX, phasing, risks, hors-scope)
- 2026-04-28-krill-refonte/README.md: orchestration + dependency graph
- 2026-04-28-krill-refonte/CONVENTIONS.md: multi-agent rules (file ownership,
  i18n pending pattern, branches, checkpoints)
- 24 task files (P1-T01..P3-T08) with frontmatter declaring files_owned,
  depends_on, parallelizable_with, wave — designed for 2-3 Claude Code
  instances to work simultaneously without file conflicts.
- .gitignore: ignore .superpowers/ brainstorm working directory

Phasing:
  Phase 1 · Fondation     (10d, 8 tasks) — /live in Next.js, mobile, hero hybride
  Phase 2 · Historique    (10d, 8 tasks) — trail + scrubber + 4 campaign hubs
  Phase 3 · Viralité      (12d, 8 tasks) — incident pages + OG + share + contrib

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(plan): launch prompts for 4 parallel Claude Code instances (alpha/beta/gamma/delta)

Self-contained prompts for each instance to claim its assigned phase-1 task.
Alpha takes wave-0 task P1-T01 immediately. Beta/gamma/delta wait on user
'go' signal until P1-T01 is committed completed, then claim P1-T03/T04/T02.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(plan): pick P1-T01 (alpha)

* feat(P1-T01): design system tokens & primitives [alpha]

- web/lib/tokens.ts: colors / severityHex / radius / spacing tokens
- web/components/ui/Button.tsx: primary/ghost/danger × sm/md/lg, asChild, loading
- web/components/ui/Card.tsx: Card + Card.Header/Body/Footer, polymorphic via as
- web/components/ui/Badge.tsx: 6 tones, 2 sizes, color-mix surface
- web/components/ui/Tabs.tsx: headless, ARIA tablist, arrow-key nav, underline accent
- web/components/ui/Drawer.tsx: portal, focus trap, ESC close, slide right/left
- web/components/ui/BottomSheet.tsx: portal, drag handle, snap points, dismiss-on-drag
- web/components/ui/IconButton.tsx: square variant of Button, aria-label required
- web/components/ui/index.ts: public re-exports

Verification: typecheck + next build OK (43 static pages).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(plan): pick P1-T06 [alpha]

* feat(P1-T06): hero hybride home + i18n agg target [alpha]

Replaces the static editorial hero on /[locale] with a data-driven hybrid:
phrase-choc generated from live krill-watch counts, 3 stats per severity
(linkable to /live?bucket=*), 2 CTAs, mini-map preview (desktop only).

- web/lib/headline-builder.ts: pure bilingual template builder, 3 cases
  (CRITICAL+hottest_zone, SUSPICIOUS, fallback), correct singular/plural.
- web/components/home/HeroHeadline.tsx: tag pill + h1 with accent split,
  fade-up entry animation (respects prefers-reduced-motion).
- web/components/home/HeroMiniMap.tsx: SVG fallback (T02 not shipped) —
  Antarctica silhouette + graticule + dashed CCAMLR boundary at -60° +
  severity-colored dots, 240×120, hidden < md, full tile is link to /live.
- web/components/home/HeroStats.tsx: inline stats row, each number a deep
  link to /live?bucket=critical|suspicious|watch.
- web/components/home/HeroHybrid.tsx: server component, fetches stats +
  vessels + positions in parallel, computes hottest CCAMLR subarea from
  non-NORMAL vessels, joins positions to severity by mmsi, falls back to
  CONTINUOUS WATCH phrase when API is down (no crash).
- web/i18n/locales/{fr,en}.json: seeded with heroHybrid.ctaUnderstand,
  established as the i18n aggregation target for phase 1 (other tasks
  drop into web/i18n/pending/<task-id>.json — none present yet).
- web/app/[locale]/page.tsx: replaces editorial hero block with
  <HeroHybrid /> while keeping LiveStatusBanner / ActiveAlertsStrip /
  MissionStrip / MethodologyBlock below the fold unchanged.

Verification: typecheck + next build OK (43 static pages).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(plan): pick P1-T03 [beta]

* feat(P1-T03): fleet sidebar [beta]

React rewrite of the legacy frontend/app.js left panel — isolated
components consumed in P1-T05 and wrapped in BottomSheet by P1-T07.

- web/lib/fleet-filtering.ts: pure helpers — applyFilter (text +
  bucket all|at-risk|live|in-mpa, "live" = last_seen within 24h),
  sortBySeverity (CRITICAL, SUSPICIOUS, WATCH, CPWF, NORMAL, UNKNOWN),
  countBySeverity. Case-insensitive search across name/imo/mmsi/flag/slug.
- web/components/live/FleetSearchBar.tsx: client search input, 200ms
  debounce, magnifier icon, clear button, focus-visible ring.
- web/components/live/FleetFilters.tsx: 4 chips with bucket counts,
  active = accent border + tinted bg, mobile-scrollable.
- web/components/live/FleetRow.tsx: per-vessel row with Flag, name,
  severity-colored pulsing dot (respects prefers-reduced-motion),
  position + relative timestamp (FR/EN), tag pill, active state =
  3px accent left border, ARIA listbox option, Enter/Space keyboard.
- web/components/live/SeverityLegend.tsx: 6-line legend with native
  checkboxes (toggle map visibility), counts inline, opacity change
  when unchecked.
- web/components/live/FleetSidebar.tsx: orchestrator (320px desktop,
  flex-1 mobile), collapsible sections (legend + layers + 5 layer
  toggles), search, filter chips with counts, N/M counter (aria-live),
  scrollable filtered list. Strings inlined (FR + EN dispatch) until
  the JSON i18n system is wired.
- web/i18n/pending/P1-T03.json: namespace placeholder, points T05/T06
  agg pass at the inlined STRINGS map.

Verification: typecheck + next build OK (clean .next, 43 static pages).
Smoke harness validated on web/app/dev/sidebar (build OK, page deleted
before commit per spec — 8.04 kB chunk built and prerendered fine).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(plan): pick P1-T04 [gamma]

* feat(P1-T04): vessel drawer [gamma]

React rewrite of the legacy frontend/app.js right detail panel —
6 tabs in isolated components, consumed in P1-T05 and wrapped in
BottomSheet by P1-T07.

- web/components/live/VesselDrawer.tsx: client orchestrator, fetches
  /api/vessels/<slug> with AbortController on slug change, sticky
  header (Flag + name + IMO/MMSI + severity tag + close button),
  scrollable body with Tabs.List sticky inside, footer with Copy link
  button (clipboard write, "Link copied ✓" feedback for ~1.8s).
  Skeleton during fetch, error body with Retry button when API down.
  Wraps the T01 <Drawer> primitive (right side, 420px desktop / 100vw
  mobile, focus trap + ESC + body scroll lock inherited).
- web/components/live/tabs/VesselIdentityTab.tsx: dl key/value table —
  Operator (with ownership_pending badge), Reg/Beneficial owner, Type,
  Length, GT, Built, Callsign, Flag (with <Flag/>), IMO, MMSI, OFAC,
  ITF (FOC), free-text Notes. Missing fields render "—" muted.
- web/components/live/tabs/VesselActivityTab.tsx: 4-stat grid (last
  seen + relative + abs, position with MarineTraffic deep link, SOG,
  COG), 7-day fishing-hours card with inline 24h sparkline (pure SVG,
  no lib), current zones as pill list.
- web/components/live/tabs/VesselAlertsTab.tsx: chronological list of
  the last 30 days of severe alerts, each rendered as Card or button
  (when lat/lon present + onAlertClick prop) — keyboard-actionable to
  recenter the map.
- web/components/live/tabs/VesselIncidentsTab.tsx: curated incident
  cards (vessel-specific vs fleet-wide tag, confidence pill, sources
  with hostname-only links).
- web/components/live/tabs/VesselClosuresTab.tsx: relevant CCAMLR
  closures with ACTIVE / CLOSED_PAST / PROJECTED status colour, live
  dot when active today, source links.
- web/components/live/tabs/VesselEventLogTab.tsx: mono <ol> of raw
  events (ZONE_ENTERED/LEFT, AIS_GAP_*, SEVERITY_CHANGED), severity-
  coloured event_type, hover reveals source (when in payload).
- web/i18n/pending/P1-T04.json: namespace placeholder (live.drawer.*,
  live.tabs.*) for the future JSON i18n agg pass.

Verification: typecheck + next build OK (43 static pages). Smoke
harness web/app/dev/drawer with mock data per tab built clean and
was deleted before commit per spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(plan): pick P1-T02 [delta]

* feat(P1-T02): live tracker map react component [delta]

LiveTrackerMap React component porting frontend/app.js map portion.
Sources: zones, penguin (via zones), vessels, ambient, tracks, graticule,
selected-halo. Layers: 6 zone (fill + 4 per-category outlines + label),
5 penguin (buffer fill + 2 outlines + colony dot/label), graticule-line,
track-line, 5 vessels (halo / arrow / dot / flag / label), ambient-dots,
selected-halo. SSE client maps server hello/position/event onto a typed
StreamEvent surface (fleet_update | event | connection) with exponential
backoff 1s→2s→4s→8s→max 30s and polling fallback after 5 failures.
Style toggle (CARTO dark / ESRI satellite + carto-labels) extracted with
mobile-aware placement (bottom-left on <768px). a11y: role=region +
aria-label on container, aria-pressed on style toggle.

Files:
- web/lib/sse-client.ts
- web/components/map/LiveTrackerMap.tsx
- web/components/map/style-toggle-control.ts
- web/components/map/layers/graticule.ts
- web/components/map/layers/zonesLayers.ts
- web/components/map/layers/penguinLayers.ts

Verification: typecheck OK, build OK, runtime smoke (Playwright) confirmed
canvas mounted, controls present, DARK→SATELLITE toggle flips and triggers
ESRI tile fetches, /api/zones + /api/vessels + /api/stream all hit on mount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(plan): pick P1-T05 [alpha]

* feat(P1-T05): /live cockpit assembly + URL state [alpha]

- web/lib/live-url-state.ts: pure URL <-> state helpers (v, q, bucket,
  layers params), DEFAULT_LIVE_STATE, liveStateToUrl(). Layer params
  omitted from URL when matching defaults to keep shared links clean.
- web/app/[locale]/live/page.tsx: SSR shell — fetches initial fleet,
  reads URL state, drops orphan ?v slugs (avoids drawer 404 fetch),
  hands everything to LivePageClient.
- web/app/[locale]/live/LivePageClient.tsx: client orchestrator —
  3-column desktop layout (sidebar 320px / map flex-1 / drawer 420px
  open), useSearchParams reconciliation (back/forward + deep link),
  router.replace for scroll-stable URL syncing on every state change,
  selection round-trips through ?v=<slug>, drawer close clears ?v.
- web/app/[locale]/live/v/[slug]/page.tsx: deep-link route, redirects
  to /<locale>/live?v=<slug> (force-dynamic). Lets share URLs land
  directly on a pre-opened drawer.

Verification: typecheck + next build OK — /[locale]/live (18.9 kB SSR
shell) and /[locale]/live/v/[slug] (dynamic redirect) added cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(plan): pick P1-T07 [alpha]

* feat(P1-T07): mobile responsive layouts for /live [alpha]

- web/hooks/useBreakpoint.ts: SSR-safe matchMedia hook returning
  mobile (< 768) / tablet (768-1023) / desktop (>= 1024). Defaults
  to "desktop" on first render so the server tree matches the most
  common viewport.
- web/components/live/MobileTopBar.tsx: 56px sticky top bar with
  brand dot, krill-watch wordmark, X/N counter, ⚙ filters button.
- web/components/live/MobileSatelliteFAB.tsx: floating action button
  bottom-right, toggles map/satellite, persists choice in localStorage
  under the same key as the desktop control (cross-storage event sync).
- web/components/live/MobileFiltersSheet.tsx: BottomSheet (P1-T01)
  wrapping the existing FleetSidebar, snap points 10vh / 50vh / 90vh,
  auto-collapses to 10vh when a vessel is picked so the map stays
  visible.
- web/components/live/MobileVesselDrawer.tsx: thin wrapper that bumps
  the z-index of VesselDrawer above the bottom sheet.
- web/components/live/LiveMobileLayout.tsx: composite mobile cockpit
  + LiveLayoutSwitch helper. Desktop tree renders for tablet+desktop
  per spec hybrid layout choice; mobile (<768px) gets the full
  redesigned surface.

Verification: typecheck + next build OK. Switch is exported but not
yet wired — P1-T08 integrates it into LivePageClient.tsx per spec
cross-task plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(plan): pick P1-T08 [alpha]

* feat(P1-T08): legacy cleanup + mobile switch + phase 1 checkpoint [alpha]

Wave 3 finale — close out phase 1.

Step 1 — mobile/desktop switch wired into LivePageClient.
Step 2 — git rm legacy frontend/ (app.js + index.html + style.css).
Step 3 — server/app.py: drop StaticFiles mount, /style.css, /app.js,
  /assets/* and the FRONTEND_DIR constant. The "/" handler now 302s to
  WEB_BASE_URL (or /docs when unset). Imports trimmed to drop
  StaticFiles + FileResponse + add RedirectResponse.
Step 4 — README/.env.example/.gitignore had no frontend refs to clean.
Step 5 — pending i18n files deleted: web/i18n/pending/P1-T03.json,
  P1-T04.json. Strings stay inline in components (no JSON loader wired
  yet — deferred to phase 2).
Step 6 — CHECKPOINT.md captures the phase 1 result, criteria pass list,
  and dette to carry forward (i18n, mini-map, tablet hybrid, lighthouse
  manual run before merge to main).

Verification: typecheck OK, next build OK (45 static pages, /[locale]/live
20.7 kB SSR shell). app.py syntax-checked. Lighthouse mobile + desktop
on /, /live, /vessels intentionally NOT run in this session (no display,
no prod server) — operator MUST run before merging refonte/phase-1 → main
per CHECKPOINT.md instructions.

Phase 1 closed: 8/8 tasks completed (alpha 5, beta 1, gamma 1, delta 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(P1-T02,P1-T05): drawer close + maplibre controls visibility

Two regressions caught during manual UAT on /live cockpit.

P1-T05 — drawer close + ESC were no-ops on the URL state.
LivePageClient.updateState used `patch.selectedSlug ?? prev.selectedSlug`
which can't tell `null` (caller wants to clear) from `undefined` (field
not part of the patch). handleSelect(null) silently kept the previous
slug, so closing the drawer left ?v=<slug> in the URL and React
considered the drawer still open. Switch to an `in patch` check that
preserves the explicit-null path. Same defensive change for `filter`
and `layers` so future patches that pass `undefined` don't blow them
away.

P1-T02 — maplibre-gl.css was never imported, so all .maplibregl-ctrl-*
elements fell back to position:static and stacked at the bottom of the
container (zoom +/-, scale, attribution, custom style toggle, all
unusable). Add the canonical "maplibre-gl/dist/maplibre-gl.css" import
inside LiveTrackerMap.tsx — Next.js + Turbopack splits it into the live
chunk so Home/Vessels stay unaffected. Verified: top-right control at
(1865, 57) 39x39, bottom-right zoom group, "200 nm" scale, and CARTO
attribution all render correctly after this change.

Verification (Chrome MCP UAT):
  - Click vessel "Antarctic Endurance" -> URL ?v=antarctic_endurance
  - Click X close -> URL clears, drawer transform applied (closed)
  - Press Escape -> URL clears, dialog toggles closed
  - MapLibre controls visible at the right positions, scale shows nm
  - Attribution "CARTO, OpenStreetMap" visible bottom-right

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(P1-T01,P1-T03,P1-T05): URL state for ?q + ?bucket; tab padding

Phase-1 closeout dette items #3 and #4 from manual UAT.

P1-T03 — FleetSidebar grows an optional controlled-mode for the search
text and the bucket chip (searchValue/onSearchChange + bucket/
onBucketChange). When the parent passes them, it owns the state; when
omitted, the sidebar still keeps its own local state for backward
compat (existing call sites and tests stay green).

P1-T05 — LivePageClient now wires those callbacks: handleSearchChange
and handleBucketChange call updateState({filter: {...state.filter, X}})
which round-trips through the URL via writeUrl. Hard reload of
/fr/live?q=antarctic&bucket=at-risk now restores both the input and
the active chip. The mobile branch (LiveMobileLayout ->
MobileFiltersSheet) accepts and forwards the same props so deep-linked
filters work on mobile too.

P1-T01 — Tabs.Trigger padding reduced from px-4 to px-2.5 with
whitespace-nowrap so the 6 French labels of VesselDrawer (Identité /
Activité / Alertes / Incidents / Fermetures / Journal) all sit in the
420 px drawer without truncating "Fermetures" to "Ferm".

CHECKPOINT.md updated with the post-livraison fix log and the
manual-only verification queue (mobile 360x800, SSE live, Lighthouse).

Verification (Chrome MCP):
  - Type "antarctic" -> URL becomes ?q=antarctic, list 3/23
  - Click "À risque" chip -> URL ?q=antarctic&bucket=at-risk
  - Ctrl+Shift+R -> input rehydrates, "À RISQUE 9" chip stays active,
    list still 3/23
  - Drawer tabs: Identité Activité Alertes Incidents Fermetures all
    visible at once in 420 px, Journal one tap of horizontal scroll
    away.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(plan): pick P2-T01 [alpha]

* feat(P2-T01): backend endpoint historical positions [alpha]

Phase 2 wave 0 — backend support for the timeline scrubber (P2-T03)
and vessel-page trail (P2-T04).

GET /api/vessels/{slug}/positions?days=N&downsample=M

- Resolves slug -> mmsi via vessel_status, 404 on unknown.
- Pulls AIS track over the past N days (1..90), downsampled to one
  point per `downsample` minutes (1..60) when raw points exceed 1000.
- Tags each point with an instantaneous severity tier:
    CRITICAL  in MPA polygon
    SUSPICIOUS  in buffer / penguin buffer, OR open water after a
                >= 6h AIS gap (matches GAP_SUSPICIOUS_HOURS used by
                the live worker)
    WATCH       in fishery_subarea
    NORMAL      otherwise
- Surfaces in_zones and ais_gap_open per point so the trail shader
  can color-blend on the way in/out of zones without a second query.
- Returns the same window's relevant events (ZONE_ENTERED / _LEFT,
  AIS_GAP_OPENED / _CLOSED, SEVERITY_CHANGED) for the scrubber's
  marker rendering.
- Cache-Control: public, max-age=60.

Files:
- server/services/positions_history.py: pure helper over Store +
  ZoneIndex; no FastAPI / I/O surface, easy to unit-test.
- server/services/__init__.py: package marker.
- server/app.py: new route alongside the existing /track + /timeline
  ones, lazy-imports the service to avoid circular cost on startup.

Tests (tests/test_positions_history.py, 7 passing):
- unknown_slug_returns_none           service-level 404 path
- short_window_no_downsample          1 day -> raw, downsample=null
- dense_track_triggers_downsample     2000 pts -> bucketed, label "15min"
- severity_inside_mpa_is_critical     centroid of an MPA polygon
- severity_after_long_gap_is_suspicious   8h gap in open water
- http_unknown_slug_404               TestClient HTTP path
- http_invalid_days_422               days=999 -> Pydantic 422

Smoke (isolated uvicorn on :8001): 200 OK on
/api/vessels/antarctic_endurance/positions?days=30 with the spec
shape; p95 latency over 20 calls = 4 ms (target < 300 ms).

The running uvicorn on :8000 was started without --reload (10h+
uptime, AIS worker live), so this code only takes effect on next
restart of `npm run dev:api`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(plan): pick P2-T02 [alpha]

* feat(P2-T02): vessel trail component with severity coloring [alpha]

Phase 2 wave 1 — React headless component that paints the historical
trail published by P2-T01 onto a MapLibre map, segmented by severity.

- web/lib/trail-types.ts: TS contract mirroring the P2-T01 JSON
  payload (TrailPosition, TrailEvent, VesselHistory).
- web/lib/trail-coloring.ts: pure helpers — positionsToTrailGeoJSON
  builds one LineString feature per maximal run of constant severity
  (avoids line-gradient lockin and breaks the polyline at AIS gaps so
  we never draw across a 6 h silence). positionsToEventMarkers joins
  each event back to its closest position by ts (binary search) and
  drops events with no resolved coord.
- web/components/map/layers/trailLayers.ts: addTrailLayers /
  setTrailData / setTrailVisibility / removeTrailLayers. Three layers
  on two sources: vessel-trail-line (severity-colored polylines),
  vessel-trail-current (slightly bigger dot at the head of the
  trail), vessel-trail-event-markers (white circles outlined by
  severity). All severity colors come from web/lib/tokens.severityHex
  so they stay in sync with the rest of the live UI.
- web/components/map/VesselTrail.tsx: headless React wrapper. Defers
  layer install until the parent map's style.load fires (handles the
  satellite toggle reload case from P1-T02). Cleans up sources on
  unmount only — prop changes flush data through the existing
  layers.

P2-T04 owns the orchestration: fetch the history, mount <KrillMap>
and <VesselTrail> together, hand the same `history` to the timeline
scrubber (P2-T03) which will filter `positions` by current ts.

Verification: typecheck + next build OK (45 static pages, no new
chunk size impact since the trail code only loads inside /vessels/
[slug] surfaces wired by P2-T04).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(plan): pick P2-T03 [alpha]

* feat(P2-T03): timeline scrubber + range selector + playback hook [alpha]

Phase 2 wave 1 — accessible time-cursor control consumed by P2-T04
to filter <VesselTrail> positions to a sub-range.

- web/lib/timeline-state.ts: pure types (TimelineRange, TimelineSpeed,
  TimelineState) + helpers (rangeBoundaries, clampToRange,
  timestampToRatio / ratioToTimestamp, shiftMinutes). No React.
- web/hooks/useTimelinePlayback.ts: setInterval-based playback loop.
  Advances `current` by tickMs * speedMultiplier on each tick, emits
  onComplete when the cursor catches up to "now".
- web/components/timeline/TimelineRangeSelector.tsx: 4-chip selector
  (24h / 7j / 30j / 90j). Switches to a native <select> when
  `compact` is true (mobile path from P1-T07).
- web/components/timeline/TimelineMarker.tsx: tiny glyph (▼ ▲ ◆ ◇ ●)
  positioned at a 0..1 ratio above the track. Severity-colored,
  optional onClick jumps the scrubber to that event.
- web/components/timeline/TimelineScrubber.tsx: full slider —
  pointer drag with pointerCapture, keyboard (Home/End/←/→ ±1min,
  Shift+arrow ±10min, Space play/pause), ARIA role="slider" with
  aria-valuemin/max/now/text. Renders range chips, play/pause,
  speed (1× 10× 100× 1000×), event markers above the track, and
  a human-readable "now" / "−Nj" / current-ts ribbon below.
- web/i18n/pending/P2-T03.json: namespace placeholder for the
  agg pass.

Verification: typecheck + next build OK. Component is fully headless
of map/data layers — P2-T04 will own the orchestration (fetch,
mount LiveTrackerMap + VesselTrail + this scrubber, slice the
positions array by current ts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(plan): pick P2-T05 [alpha]

* feat(P2-T05): campaign hub routes + 6 components [alpha]

Phase 2 wave 1 — /[locale]/campagnes index + /[locale]/campagnes/<slug>
hubs for the four CPWF missions (krill / whaling / galapagos /
west-africa). Layout is complete; editorial copy is loaded from
web/data/campaigns/<slug>.<locale>.md, today populated with
placeholders so build is green before P2-T06 ships real content.

- web/lib/campaigns.ts: registry of CAMPAIGNS keyed by slug, with
  per-campaign filter rules (vesselSlugs explicit OR vesselFlags),
  primaryColor + bgGradient. getCampaignVessels() filters a
  VesselSummary[].
- web/app/[locale]/campagnes/page.tsx: index, 4 large gradient
  cards with mission tag + tagline.
- web/app/[locale]/campagnes/[slug]/page.tsx: ISR (revalidate 300s),
  generateStaticParams over 4 slugs x 2 locales = 8 pre-rendered.
  Inline frontmatter parser (small enough to keep gray-matter out of
  the dep tree until P2-T06 actually needs MD body rendering).
- web/components/campaign/CampaignHero.tsx: gradient hero, tag pill,
  title + accent split, lead, optional 3-metric strip, 2 CTAs (fleet
  anchor + CPWF donate).
- web/components/campaign/CampaignLegalFrame.tsx: "Cadre légal &
  enjeu" section, prose layout for the MD-driven legal frame.
- web/components/campaign/CampaignFleetList.tsx: server component,
  fetches VesselSummary[], filters via getCampaignVessels, renders
  the campaign vessels as Link cards into /vessels/<slug>.
- web/components/campaign/CampaignTimeline.tsx: pulls /api/events?
  slug=<slug>&days=90 for each campaign vessel and merges into a
  90-day mono timeline (top 40, ISR-cached 5 min).
- web/components/campaign/CampaignSeasonCalendar.tsx: 12-month
  bar with active months highlighted in the campaign primaryColor.
- web/components/campaign/CampaignSourcesList.tsx: external sources
  with hostname-only labels.
- web/data/campaigns/<slug>.<locale>.md: 8 placeholders (krill FR has
  the realest copy as a sample), parsed by the route's inline FM
  reader. P2-T06 owns these files.
- web/i18n/pending/P2-T05.json: namespace placeholder.

Verification: typecheck + next build OK — 55 static pages (was 45),
+8 campaign hubs + 2 indexes wired in. /fr/campagnes/krill renders a
full hero from the placeholder MD. /fr/campagnes/inexistant → 404.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(plan): pick P2-T07 [alpha]

* feat(P2-T07): glossary +6 terms + tap-friendly Acronym popover [alpha]

Phase 2 wave 2.

- web/lib/glossary.ts: 12 -> 18 entries. New: dark fleet, transhipment,
  reefer, trigger closure, beneficial owner, subarea. Each entry keeps
  the existing { expansion, definition } x { fr, en } shape so the
  /about glossary page picks them up without code changes.
  GLOSSARY_ORDER now lists the 6 new keys at the end so existing
  visitors see the prior ordering above the fold and the new ones
  appear below as a "newly explained" section.
- web/components/Acronym.tsx: rewritten as a "use client" toggling
  popover. Tap (mobile) or click (desktop) opens an inline panel with
  the full expansion, the one-sentence definition, and a "See glossary
  ->" link to /about#glossary-<term>. Click outside or Escape closes.
  Existing native title= tooltip is preserved for the hover-only path.
  ARIA: button + aria-expanded + aria-controls + dialog popover with
  the term anchor as id.

Verification: typecheck + next build OK (no static pages added).
Existing call-sites — server components rendering <Acronym> in
about/methodology copy — keep working: the new component is still a
default React export, the prop signature didn't change, and the
"use client" boundary is invisible to the parent server tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(plan): pick P2-T04 [alpha]

* feat(P2-T04): vessel page history map + scrubber integration [alpha]

Phase 2 wave 2 — assembles P2-T01 (positions endpoint), P2-T02
(VesselTrail) and P2-T03 (TimelineScrubber) into a "30-day history"
block that lives just below the vessel header on
/[locale]/vessels/<slug>.

- web/lib/use-vessel-history.ts: client hook over the P2-T01 endpoint
  (/api/vessels/<slug>/positions?days=N). Per-tab in-memory cache
  keyed by (slug, days), 60 s TTL matching the server's
  Cache-Control. AbortController on slug/days change. Plus a
  filterHistoryUpTo(history, currentIso) helper used to slice the
  trail incrementally as the scrubber moves.
- web/app/[locale]/vessels/[slug]/VesselHistoryMap.tsx: client
  orchestrator. Mounts MapLibre directly (dark-matter style), hosts
  <VesselTrail> via the local map ref, drives <TimelineScrubber>
  state (range / current / isPlaying / speed). Auto-fits the map
  bounds the first time data lands and on range change, but
  intentionally not on scrubber movement so the viewport stays put
  while the user explores. Renders skeleton / empty / error states
  inline.
- web/app/[locale]/vessels/[slug]/VesselDetailClient.tsx: thin
  "use client" boundary that wraps VesselHistoryMap so the rest of
  the vessel page (header, identity, event log, incidents) keeps
  rendering on the server.
- web/app/[locale]/vessels/[slug]/page.tsx: imports apiBaseUrl,
  inserts <VesselDetailClient> right after the header, before the
  existing 3-column identity/log layout. Static params unchanged.

Verification: typecheck + next build OK. /[locale]/vessels/[slug]
now ships at 12.9 kB (was 7.95 kB) — extra chunk = maplibre-gl
+ trail layers + scrubber + playback hook. 55/55 static pages.

Phase 2: T01 + T02 + T03 + T04 + T05 + T07 completed. Remaining:
T06 (editorial content) and T08 (discoverability + checkpoint).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(plan): pick P2-T06 [alpha]

* feat(P2-T06): editorial content for 4 campaigns FR + EN [alpha]

Phase 2 wave 2 — fills the 8 placeholder MD files left by P2-T05 with
factual editorial copy. Frontmatter is exhaustive (consumed today by
the T05 hub components); markdown body is present for prose sections
that future renderers (P2-T08+) can pick up.

- web/data/campaigns/krill.{fr,en}.md: CCAMLR framing, MPA blockage,
  trigger-closure mechanism, 48.1 history, Aker BioMarine + Chinese
  operators + CPWF intervention.
- web/data/campaigns/whaling.{fr,en}.md: IWC 1946 + 1986 moratorium,
  Iceland/Norway/Japan stances, 2014 ICJ JARPA II ruling, 2019 Japan
  withdrawal, 2024 Kangei Maru launch, Hvalur 8/9 fin-whale season.
- web/data/campaigns/galapagos.{fr,en}.md: 1998 reserve + 2022
  Hermandad expansion, 2017 incident with ~6,600 sharks, AIS-off
  pattern, Pingtan, surveillance asymmetry framing.
- web/data/campaigns/west-africa.{fr,en}.md: UNCLOS EEZs, COREP
  limits, FOC laundering, EJF/Greenpeace documentation, food-security
  -> migration thread.

Editorial rules followed:
  - Journalistic, factual tone.
  - No "scandal" / "pillage" rhetoric.
  - Treaty dates and historical facts in public domain only.
  - Precise catch tonnages and percentage figures intentionally
    omitted (HTML comment in each file flags the choice).
  - EN is parallel writing, not literal translation.
  - Sources point to primary registries (CCAMLR, IWC, COREP) and
    long-running NGO documenters (ASOC, EJF, Greenpeace).

Verification: next build OK (55 static pages — 8 hub variants among
them now render with real frontmatter rather than TODO placeholders).

Caveat: this is a Claude-drafted brief. Per spec note line 143, an
OSINT-aware human review is recommended before public production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(P2-T08): campaign hubs discoverability + i18n agg + checkpoint [alpha]

Phase 2 wave 3 — final polish closing the phase.

- web/components/Nav.tsx: ajout des liens "Campagnes" et "À propos" /
  "Campaigns" + "About" dans la nav globale (sticky header). Le mobile
  pill scroller hérite automatiquement des nouveaux items.
- web/components/Footer.tsx: 4 colonnes au lieu de 3, nouvelle colonne
  "Campagnes" / "Campaigns" avec les 4 liens vers /campagnes/<slug>.
- web/components/home/MissionStrip.tsx: les 4 cards de la home
  pointent maintenant vers /campagnes/<slug> (au lieu d'un anchor sur
  /vessels). Helper anchorFor() retiré (devenu dead code), remplacé
  par campaignHrefFor() qui mappe iuuGalapagos -> 'galapagos' et
  iuuWestAfrica -> 'west-africa'. Hover bump (scale 1.01) + perte du
  modificateur card-static pour réactiver le hover-orange.
- web/i18n/pending/P2-T03.json + P2-T05.json supprimés (placeholders
  vides — strings phase 2 restent inline FR/EN dans les composants,
  même choix que la dette i18n de phase 1).
- docs/superpowers/specs/2026-04-28-krill-refonte/phase-2-historique/
  CHECKPOINT.md: bilan complet — 8/8 livrées, build 55 routes,
  Lighthouse / UAT manuelle restant à exécuter avant merge -> main.

Verification: typecheck + next build OK (55 static pages).

Phase 2 closed: 8/8 tasks. Phase 3 unblocked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(plan): pick P3-T01 [alpha]

* feat(P3-T01): incident emitter webhook + auto-incidents schema [alpha]

Plumbing for auto-generated /incident/<slug> pages:

- DuckDB v2 migration: incidents_auto table (slug PK, severity, score,
  position, details_json, sources_json, revalidated_at).
- Store: upsert_incident_auto / get_incident_auto / list_incidents_auto
  / mark_incident_revalidated.
- server/services/incident_emitter.py: deterministic slug generator
  matching the TS twin, IncidentPayload, upsert_incident,
  trigger_revalidate (POST → Next.js with shared secret), emit_incident
  (persist + revalidate + stamp on success).
- server/app.py: POST /api/internal/incident (X-Internal auth),
  GET /api/auto-incidents, GET /api/auto-incidents/{slug}.
- web/lib/incident-slug.ts: kebab + isoDate slug generator (twin).
- web/app/api/revalidate/route.ts: revalidatePath for fr+en, optional
  tag revalidation, 401 on bad secret.
- 12 new tests (slug determinism, upsert idempotency, webhook env
  short-circuit, success path, 5xx soft-fail, persistence-when-webhook-
  fails).

Env vars (added to .env.example and web/.env.example):
  NEXTJS_BASE_URL, KRILL_REVALIDATE_SECRET, KRILL_INTERNAL_SECRET.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(P3-T02): /incident/[slug] ISR page + map + context + sources [alpha]

Public per-incident pages auto-fed by the P3-T01 webhook:

- web/lib/incidents.ts: fetchIncident / fetchIncidentsList / parseIncident
  (snake_case wire → camelCase Incident type), buildIncidentHeadline
  with ZONE_ENTERED / AIS_GAP_OPENED / TRANSHIPMENT_DETECTED /
  SEVERITY_CHANGED templates in fr+en, deriveCampaignSlug
  (vesselSlugs > vesselFlags), formatDetectedAt with fixed UTC.
- web/app/[locale]/incident/[slug]/page.tsx: server component, ISR
  (revalidate=300), generateStaticParams pre-renders top 50, dynamic
  fallback for fresh slugs, OG/twitter metadata pointing at
  /api/og/incident/<slug> (P3-T03), severity badge, vessel link,
  back-to-tracker.
- IncidentMap (client island): MapLibre with 7d trail filtered to
  ±3.5d around detectedAt, severity-coloured pulsing marker via CSS
  keyframes added in globals.css, navigation+scale controls.
- IncidentContext (server): reads data/campaigns/<slug>.<locale>.md,
  extracts the "Pourquoi krill-watch suit cette campagne" /
  "Why krill-watch tracks this campaign" section, truncates to 80
  words, links to the campaign hub.
- IncidentSources (server): numbered external link list with safe
  rel attrs and a 3-source fallback when the pipeline supplied none.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(P3-T03): dynamic OG images for incidents/vessels/campaigns [alpha]

Three edge runtime endpoints powered by next/og (Satori):

- /api/og/incident/<slug>?locale=fr|en — severity chip, headline,
  flag pill + vessel name, score/position meta, abstract grid map
  with severity-coloured pulse marker.
- /api/og/vessel/<slug>?locale=fr|en — flag pill, vessel name,
  IMO/MMSI/flag triplet, severity chip, current zones.
- /api/og/campaign/<slug>?locale=fr|en — campaign tag + brand,
  title (truncated to 70 chars), lead (200 chars), bgGradient.

All three fall back to a branded krill-watch tile when data is
unreachable, and ship cache-control headers (5min/1h with SWR).

Implementation details:
- web/lib/og-image-renderer.tsx hosts the JSX trees. Each render
  function inlines its own <div> with FRAME_STYLE rather than
  delegating to a higher-order frame() helper — Satori was rendering
  Fragment-passed children as a row in column flex, this works around
  it.
- Flag emojis are rendered as ISO code monospace pills (Satori needs
  an emoji font for the real glyph; the pill is more legible at
  social-card resolution anyway).
- Abstract map: SVG grid + 3-circle pulse, severity colour, no real
  basemap (Satori can't raster MapLibre).
- Wired into the incident page metadata in P3-T02 already; vessel
  and campaign pages can adopt by adding `images:
  '/api/og/<kind>/<slug>'` to their generateMetadata.

Verified:
- typecheck clean
- /api/og/incident/<unknown> renders fallback (1200×630 PNG, ~100KB)
- /api/og/campaign/krill renders correctly
- /api/og/vessel/antarctic_endurance renders with full identity

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(P3-T04): ShareButton component + intent URLs [alpha]

Native-share-first share UI with desktop popover fallback:

- web/lib/share-urls.ts: tweet/bluesky/mastodon/linkedin intent
  builders, ogImageUrl, buildShareTitle templates per
  kind+locale (incident / vessel / campaign × fr/en).
- web/components/share/ShareButton.tsx: primary/ghost variants,
  detects navigator.share() on mobile and uses it directly,
  desktop falls through to ShareMenu popover.
- web/components/share/ShareMenu.tsx: 6-option popover (Twitter,
  Bluesky, Mastodon, LinkedIn, Download OG image, Copy link),
  full keyboard nav (↑↓ Esc), click-outside to close, autofocus
  on open. Mastodon prompts for + remembers instance domain in
  localStorage.
- web/components/share/CopyLinkButton.tsx: standalone copy-link
  for places that don't need the full menu — clipboard API with
  execCommand fallback for older browsers / private mode.
- Wired into the incident page header next to the vessel/tracker
  links, primary variant. Uses NEXT_PUBLIC_SITE_URL when present
  (prod canonical), falls back to https://krill-watch.org.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(P3-T05): public contribution form + DuckDB storage [alpha]

End-to-end submission pipeline for /contribuer:

- Schema v3: new `contributions` DuckDB table (UUID PK, category,
  vessel identity, observed_at + lat/lon, free description, optional
  on-disk photo path, status + reviewer_notes for the eventual
  moderation UI). Indexed on submitted_at and status.
- Store API: insert_contribution, get_contribution, list_contributions
  (status filter).
- server/services/contributions_store.py: ContributionInput +
  PhotoBlob dataclasses, validate (50-char min description,
  whitelisted MIME, 5 MB photo cap, email shape check), photo
  persistence under data/contributions/<uuid>/, optional Discord
  webhook notification (KRILL_ADMIN_DISCORD_WEBHOOK) — webhook payload
  excludes contact_email and contact_name to keep PII out of the
  notification channel.
- POST /api/contributions on FastAPI accepts multipart/form-data,
  surfaces ContributionError as 422.
- Next.js /api/contribute proxy re-streams multipart upstream so the
  browser never sees the OSINT origin and CORS is sidestepped.
- web/app/[locale]/contribuer/page.tsx: server-rendered intro, privacy
  + limits notes, generateMetadata with hreflang.
- ContributionForm: 4 categories (vessel-sighting / transhipment /
  ais-anomaly / other) with adaptive fields, MIN_DESC=50 live
  countdown, consent checkbox required, locale-aware labels.
- PhotoUpload: drag-drop or file picker, client-side validation,
  preview with size + remove.
- ContributionSuccess: thank-you with short ID, conditional contact
  follow-up note.

Coverage: 10 new tests in test_contributions.py — round trip,
validation rejections (description / category / email / photo
size / mime), photo persistence with filename slugification, webhook
PII guarantee (email + name absent from body), webhook skip when
unset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(P3-T06): /digest index + /digest/[isoweek] route [alpha]

Exposes the existing transhipment-review HTML files (one per ISO
week, generated by the Python pipeline under
output/transhipments_<YYYY>-W<WW>.html) as Next.js routes:

- FastAPI: GET /api/digests (newest-first list with size +
  generated_at) and GET /api/digests/<isoweek> (full HTML body).
  Strict YYYY-Www regex on the path so traversal attempts get
  400'd before we touch the filesystem.
- web/lib/digests.ts: typed wrappers, isoWeekRange helper that
  computes the Mon→Sun calendar range from the ISO week, defensive
  scrubDigestHtml that strips <script>/<iframe>/<object>/<embed>
  and inline event handlers as defence-in-depth (the source HTML
  is our own Jinja2 template, but layered defence costs nothing).
- /[locale]/digest: card list with isoweek, range, generated date,
  size. Empty-state message when the pipeline hasn't published any
  digest yet.
- /[locale]/digest/[isoweek]: ISR (revalidate=3600), generateStatic-
  Params pre-renders all known weeks fr+en, dangerouslySetInnerHTML
  on the scrubbed body inside a prose container.

Tests: 5 new tests in test_digests.py — listing newest-first,
404 on unknown week, 400 on malformed isoweek, traversal rejected,
HTML body returned. Decoy files (krill_fleet.geojson, malformed
filenames) are correctly skipped by the regex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(P3-T07): pipeline → incident emitter wiring [alpha]

Hooks the ZONE_ENTERED detection in server/workers and the
transhipment scorer into P3-T01 so each detection auto-generates a
shareable /incident/<slug> page.

- server/workers._process_position: after the existing event-log
  insert and SSE broadcast, fire-and-forget asyncio.create_task into
  a new _emit_incident_safe wrapper. Filters: severity must be
  CRITICAL/SUSPICIOUS/WATCH AND zone.category in {mpa, buffer_zone,
  penguin_buffer}. Fishery subareas (CCAMLR 48.x) and CPWF friendly
  vessels are excluded — they belong in the live event log, not on
  a shareable page.
- server/services/incident_emitter.transhipment_incident_payload:
  helper that translates a ScoredEncounter dict into an
  IncidentPayload (severity = CRITICAL when score ≥ 0.75 else
  SUSPICIOUS, default sources = GFW + AISStream, partner + reefer
  + duration + signals carried in details). Returns None below
  the 0.50 FLAGGED tier.
- src/notifier._fmt_live_event: appends NEXTJS_BASE_URL/<locale>/
  incident/<slug> to the message when the event is ZONE_ENTERED or
  TRANSHIPMENT_DETECTED, locale picked from WEB_DEFAULT_LOCALE
  (en fallback). Slug derivation reuses make_slug() so it matches
  the page that was just generated.

Tests: 4 zone-monitor wiring tests (MPA → emit, fishery_subarea →
skip, CPWF → skip, idempotent on repeat ticks) and 6 transhipment
helper tests (below-threshold None, FLAGGED→SUSPICIOUS,
CRITICAL≥0.75, missing vessel None, ISO ts parsing, default sources
attached). 12 notifier regressions still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(P3-T08): SupportCTA partout + ShareButton coverage + phase-3 checkpoint [alpha]

Closes phase 3 by:

- web/components/SupportCTA.tsx: 3 variants (inline, banner,
  minimal) all linking to paulwatsonfoundation.org/donate with
  utm_source=krill-watch + utm_medium=<variant> tracking. Banner
  variant is the educational card; minimal is the muted footer
  link; inline is the primary button used in CTAs.
- Home page: <SupportCTA variant="banner"> after MethodologyBlock.
- Incident page: <SupportCTA variant="banner"> below sources.
- Vessel page: ShareButton in the header (kind=vessel) +
  <SupportCTA variant="minimal"> at the bottom of the article.
- Campaign page: ShareButton just below the hero (kind=campaign) +
  <SupportCTA variant="banner"> above the footer.
- Footer: <SupportCTA variant="minimal"> alongside HealthPill so
  every page in the site has at least one path to donate.

Verification (CHECKPOINT.md):
- 37 / 37 phase-3 Python tests pass.
- web typecheck clean.
- web build clean — all phase-3 routes prerendered or marked
  dynamic, edge runtime correctly bundled for OG endpoints.
- OG images 1200×630 PNGs verified for incident (fallback +
  populated), vessel, and campaign — screenshots/og-*.png.
- Phase 3 README dashboard updated to all-green.
- Top-level README "What it does" section grew with the 4 new
  phase-3 features (campaign hubs, incident pages, OG images,
  contribution form, digest route, alert deep links).

Phase 3 limits documented in CHECKPOINT.md (modération UI,
chiffrement E2EE, Twitter Card validation, backfill script — all
explicitly out-of-scope for this phase).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(plan): sync P3-T01..T08 frontmatter to completed [alpha]

* fix(P3-T05): add python-multipart to requirements-core

FastAPI's form() parser requires python-multipart at runtime —
without it, POST /api/contributions returns 400 with the message
"The python-multipart library must be installed to use form
parsing." The contribution form pipeline (P3-T05) is the first
caller of form() in this codebase, so the dependency wasn't
previously surfaced.

Verified by an E2E submission against the running backend after
installing the package: contribute → 200 with {id, short_id,
status:"received"}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(refonte): visual verification screenshots from phase 3 UAT

16 captures from local UAT of phase 3 surfaces:
- home (FR/EN/mobile)
- campaign hub krill (FR/EN/mobile)
- incident page (real data + 404 case)
- vessel dossier
- contribute form (FR/EN/mobile)
- digest index
- ShareButton open menu

Used as evidence in P3-T08 CHECKPOINT.md. Not part of any spec
deliverable, kept here for repository historical record.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(refonte): manual test checklist (12 sections, 60-75 min)

Comprehensive manual test plan to walk through before pushing to
prod. Organized by audience persona (activist / random / journalist
/ whistleblower) and severity (🔴 critical / 🟡 important / 🟢 nice-to-have).

Includes step-by-step instructions for: webhook→revalidate E2E with
copy-paste curl, Lighthouse commands for the 5 key pages, mobile
DevTools emulation, and edge case 404 verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(audit): P0-P3 batch — hydration, keys, i18n, OG, mission split, playback UX

Comprehensive audit-driven fixes across 4 priority tiers, persona = activist /
journalist preparing maritime IUU/krill investigations.

P0 — React stability on vessel detail page
* TimelineScrubber: hydration mismatch (Date.now() at SSR vs client)
  fixed by deferring `now` state to useEffect (web/components/timeline/TimelineScrubber.tsx)
* VesselHistoryMap: `current` initial value now empty string, set client-side
  to keep SSR/first-paint identical (web/app/[locale]/vessels/[slug]/VesselHistoryMap.tsx)
* CampaignTimeline duplicate React keys — root cause was wrong query param
  (`?slug=` ignored by FastAPI, every fan-out returned the same global event
  list). Fixed by switching to `?vessel=` and adding Set-based dedup
  (web/components/campaign/CampaignTimeline.tsx)

P1 — i18n / SEO / discoverability
* `<html lang>` now derives from URL pathname via middleware-injected
  x-pathname header — was hardcoded "fr" on every page (web/middleware.ts,
  web/app/layout.tsx)
* Locale-aware `<title>`, `<meta description>`, openGraph, twitter:card,
  canonical, hreflang fr/en/x-default in [locale]/layout.tsx
* og:image now set on home (was missing) — defaults to /api/og/campaign/krill
* "30 j" leak removed on /en — TimelineScrubber bottom label now locale-aware
* Footer exposes Live tracker, Transhipments, Weekly digests, Contribute
  (previously invisible without URL guessing)

P2 — Editorial / UX
* Markdown leak on incident page IncidentContext — CRLF line endings made
  the regex `m`-flag match `$` before every \n, stopping the lazy quantifier
  at the heading. Fixed via 2-step extraction (header match + boundary
  match). Page now renders 530 chars of body instead of raw "## Why..."
* Vessel detail page now has its own generateMetadata pointing to
  /api/og/vessel/{slug} — was inheriting the campaign krill OG fallback
* Privacy + limits notice on /contribuer moved ABOVE the form so a
  whistleblower-adjacent contributor sees the guarantees before filling
* New /transhipments page (was 404 despite README claim) — fetches
  /api/transhipments?days=30 and renders summary + 6 weighted signals +
  thresholds + links to live/digest/calibration source
* Playback UX on vessel timeline: clicking ▶ from "now" was effectively
  a no-op (cursor already at right edge). Fixed: rewind to range start if
  at end, auto-pause on completion. Resume from middle keeps position.

P3 — Mission grouping + polish
* Galápagos and West Africa now distinct mission tags in VESSELS data
  (was lumped into "iuu_fishing" — broke the editorial framing the README
  promises). /vessels page now shows 4 sections instead of 3:
  KRILL (7) · WHALING (3) · GALÁPAGOS (3) · WEST AFRICA (3)
* MissionBadge + MissionStrip + ActiveAlertsStrip handle the new tags
* Domain canonicalization: 4 SHARE-button fallbacks unified on
  https://krill-free.org (was mixed with krill-watch.org)

Verifications
* SSR via curl: html lang correct fr/en, hreflang triples, og:image set
* DOM via Claude Preview: 0 hydration errors, 0 duplicate keys after fixes
* Server logs clean across all reloaded pages
* TypeScript: tsc --noEmit passes

Two non-bugs identified during audit (kept here for the record):
* "Mojibake em-dash" in API JSON output was a Windows console encoding
  artefact in `python -c` — raw bytes are clean UTF-8 (e2 80 94)
* "ON WATCH untranslated" was already translated as "SURVEILLÉES"
  (HeroStats) and "à surveiller" (LiveStatusBanner) — initial regex missed it

See docs/AUDIT_NOTES.md for the full per-page audit and verdicts, and
docs/API_INTEGRATIONS_PLAN.md for the next-step plan to integrate
OpenSanctions, Marine Regions and Skylight (3 high-leverage APIs that
unblock the documented stubs and the Galápagos / West Africa campaigns).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: breaching <devsca@protonmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
breaching pushed a commit that referenced this pull request May 5, 2026
Adds the 6 specs called out in PROD_READINESS #3 + #15:

- web/e2e/live-loads.spec.ts: open /en/live, assert >=1 vessel dot,
  banner shows fleet count.
- web/e2e/vessels-loads.spec.ts: /en/vessels mini-map renders with
  dots, click first dot -> popup with severity + dossier link.
- web/e2e/vessel-detail.spec.ts: /en/vessels/antarctic_endurance,
  history map height >100px (regression for the inset-0 collapse),
  trail SVG present, scrubber controls visible.
- web/e2e/firms-toggle.spec.ts: toggle FIRMS layer, assert
  /api/firms/recent fires + label updates.
- web/e2e/operator-background.spec.ts: vessel detail page, assert
  "Aker BioMarine" + "Norway" appear (Wikidata cascade integration).
- web/e2e/a11y.spec.ts: @axe-core/playwright across 7 routes
  (home, about, vessels list, campaigns hub, privacy, terms, legal),
  WCAG 2.1 AA tags. Fails on serious/critical only — minor/moderate
  tolerated so a single tag-soup edge case doesn't block deploys.

- web/playwright.config.ts: chromium project, fullyParallel,
  CI-aware retries/workers/reporter. We deliberately don't use
  Playwright's webServer option — orchestration is owned by the root
  `npm run dev`.

- .gitignore: drop web/test-results/, web/playwright-report/,
  web/playwright/.cache/.

Closes prod-P0 #3 (5 specs) and the axe-core half of P2 #15.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
breaching added a commit that referenced this pull request May 8, 2026
…layback UX (#22)


* feat(web): fix satellite map + Windows flag rendering

- frontend/app.js: split zone-outline + penguin-buffer-outline layers (MapLibre 4.x rejects data expressions on line-dasharray); add flagImg() helper using flagcdn.com so flags render reliably on Windows
- web/components/map/FleetMap.tsx: add ESRI World Imagery satellite source + custom toggle control (🗺/🛰) with localStorage persistence + locale-aware labels
- web/components/Flag.tsx (new): SVG flag component using flag-icons CSS, with ISO-3 → ISO-2 normalization and CPWF → 🦐 special-case
- migrate 3 flagEmoji() callsites in web/ to <Flag>; mark flagEmoji deprecated
- add flag-icons dependency
* docs(plan): krill-watch refonte spec + 3 phases × 8 tasks (multi-agent ready)

Brainstorm output for unifying the legacy frontend/ tracker and the Next.js
web/ site into a single product, with satellite history (timeline scrubber +
colored cumulative trail), shareable atoms (incident pages + vessel dossiers
+ campaign hubs), and parallel-friendly task plans for multiple Claude Code
instances.

- 2026-04-28-krill-refonte-design.md: top-level spec (vision, architecture,
  routes, surfaces UX, phasing, risks, hors-scope)
- 2026-04-28-krill-refonte/README.md: orchestration + dependency graph
- 2026-04-28-krill-refonte/CONVENTIONS.md: multi-agent rules (file ownership,
  i18n pending pattern, branches, checkpoints)
- 24 task files (P1-T01..P3-T08) with frontmatter declaring files_owned,
  depends_on, parallelizable_with, wave — designed for 2-3 Claude Code
  instances to work simultaneously without file conflicts.
- .gitignore: ignore .superpowers/ brainstorm working directory

Phasing:
  Phase 1 · Fondation     (10d, 8 tasks) — /live in Next.js, mobile, hero hybride
  Phase 2 · Historique    (10d, 8 tasks) — trail + scrubber + 4 campaign hubs
  Phase 3 · Viralité      (12d, 8 tasks) — incident pages + OG + share + contrib
* chore(plan): launch prompts for 4 parallel Claude Code instances (alpha/beta/gamma/delta)

Self-contained prompts for each instance to claim its assigned phase-1 task.
Alpha takes wave-0 task P1-T01 immediately. Beta/gamma/delta wait on user
'go' signal until P1-T01 is committed completed, then claim P1-T03/T04/T02.
* chore(plan): pick P1-T01 (alpha)

* feat(P1-T01): design system tokens & primitives [alpha]

- web/lib/tokens.ts: colors / severityHex / radius / spacing tokens
- web/components/ui/Button.tsx: primary/ghost/danger × sm/md/lg, asChild, loading
- web/components/ui/Card.tsx: Card + Card.Header/Body/Footer, polymorphic via as
- web/components/ui/Badge.tsx: 6 tones, 2 sizes, color-mix surface
- web/components/ui/Tabs.tsx: headless, ARIA tablist, arrow-key nav, underline accent
- web/components/ui/Drawer.tsx: portal, focus trap, ESC close, slide right/left
- web/components/ui/BottomSheet.tsx: portal, drag handle, snap points, dismiss-on-drag
- web/components/ui/IconButton.tsx: square variant of Button, aria-label required
- web/components/ui/index.ts: public re-exports

Verification: typecheck + next build OK (43 static pages).
* chore(plan): pick P1-T06 [alpha]

* feat(P1-T06): hero hybride home + i18n agg target [alpha]

Replaces the static editorial hero on /[locale] with a data-driven hybrid:
phrase-choc generated from live krill-watch counts, 3 stats per severity
(linkable to /live?bucket=*), 2 CTAs, mini-map preview (desktop only).

- web/lib/headline-builder.ts: pure bilingual template builder, 3 cases
  (CRITICAL+hottest_zone, SUSPICIOUS, fallback), correct singular/plural.
- web/components/home/HeroHeadline.tsx: tag pill + h1 with accent split,
  fade-up entry animation (respects prefers-reduced-motion).
- web/components/home/HeroMiniMap.tsx: SVG fallback (T02 not shipped) —
  Antarctica silhouette + graticule + dashed CCAMLR boundary at -60° +
  severity-colored dots, 240×120, hidden < md, full tile is link to /live.
- web/components/home/HeroStats.tsx: inline stats row, each number a deep
  link to /live?bucket=critical|suspicious|watch.
- web/components/home/HeroHybrid.tsx: server component, fetches stats +
  vessels + positions in parallel, computes hottest CCAMLR subarea from
  non-NORMAL vessels, joins positions to severity by mmsi, falls back to
  CONTINUOUS WATCH phrase when API is down (no crash).
- web/i18n/locales/{fr,en}.json: seeded with heroHybrid.ctaUnderstand,
  established as the i18n aggregation target for phase 1 (other tasks
  drop into web/i18n/pending/<task-id>.json — none present yet).
- web/app/[locale]/page.tsx: replaces editorial hero block with
  <HeroHybrid /> while keeping LiveStatusBanner / ActiveAlertsStrip /
  MissionStrip / MethodologyBlock below the fold unchanged.

Verification: typecheck + next build OK (43 static pages).
* chore(plan): pick P1-T03 [beta]

* feat(P1-T03): fleet sidebar [beta]

React rewrite of the legacy frontend/app.js left panel — isolated
components consumed in P1-T05 and wrapped in BottomSheet by P1-T07.

- web/lib/fleet-filtering.ts: pure helpers — applyFilter (text +
  bucket all|at-risk|live|in-mpa, "live" = last_seen within 24h),
  sortBySeverity (CRITICAL, SUSPICIOUS, WATCH, CPWF, NORMAL, UNKNOWN),
  countBySeverity. Case-insensitive search across name/imo/mmsi/flag/slug.
- web/components/live/FleetSearchBar.tsx: client search input, 200ms
  debounce, magnifier icon, clear button, focus-visible ring.
- web/components/live/FleetFilters.tsx: 4 chips with bucket counts,
  active = accent border + tinted bg, mobile-scrollable.
- web/components/live/FleetRow.tsx: per-vessel row with Flag, name,
  severity-colored pulsing dot (respects prefers-reduced-motion),
  position + relative timestamp (FR/EN), tag pill, active state =
  3px accent left border, ARIA listbox option, Enter/Space keyboard.
- web/components/live/SeverityLegend.tsx: 6-line legend with native
  checkboxes (toggle map visibility), counts inline, opacity change
  when unchecked.
- web/components/live/FleetSidebar.tsx: orchestrator (320px desktop,
  flex-1 mobile), collapsible sections (legend + layers + 5 layer
  toggles), search, filter chips with counts, N/M counter (aria-live),
  scrollable filtered list. Strings inlined (FR + EN dispatch) until
  the JSON i18n system is wired.
- web/i18n/pending/P1-T03.json: namespace placeholder, points T05/T06
  agg pass at the inlined STRINGS map.

Verification: typecheck + next build OK (clean .next, 43 static pages).
Smoke harness validated on web/app/dev/sidebar (build OK, page deleted
before commit per spec — 8.04 kB chunk built and prerendered fine).
* chore(plan): pick P1-T04 [gamma]

* feat(P1-T04): vessel drawer [gamma]

React rewrite of the legacy frontend/app.js right detail panel —
6 tabs in isolated components, consumed in P1-T05 and wrapped in
BottomSheet by P1-T07.

- web/components/live/VesselDrawer.tsx: client orchestrator, fetches
  /api/vessels/<slug> with AbortController on slug change, sticky
  header (Flag + name + IMO/MMSI + severity tag + close button),
  scrollable body with Tabs.List sticky inside, footer with Copy link
  button (clipboard write, "Link copied ✓" feedback for ~1.8s).
  Skeleton during fetch, error body with Retry button when API down.
  Wraps the T01 <Drawer> primitive (right side, 420px desktop / 100vw
  mobile, focus trap + ESC + body scroll lock inherited).
- web/components/live/tabs/VesselIdentityTab.tsx: dl key/value table —
  Operator (with ownership_pending badge), Reg/Beneficial owner, Type,
  Length, GT, Built, Callsign, Flag (with <Flag/>), IMO, MMSI, OFAC,
  ITF (FOC), free-text Notes. Missing fields render "—" muted.
- web/components/live/tabs/VesselActivityTab.tsx: 4-stat grid (last
  seen + relative + abs, position with MarineTraffic deep link, SOG,
  COG), 7-day fishing-hours card with inline 24h sparkline (pure SVG,
  no lib), current zones as pill list.
- web/components/live/tabs/VesselAlertsTab.tsx: chronological list of
  the last 30 days of severe alerts, each rendered as Card or button
  (when lat/lon present + onAlertClick prop) — keyboard-actionable to
  recenter the map.
- web/components/live/tabs/VesselIncidentsTab.tsx: curated incident
  cards (vessel-specific vs fleet-wide tag, confidence pill, sources
  with hostname-only links).
- web/components/live/tabs/VesselClosuresTab.tsx: relevant CCAMLR
  closures with ACTIVE / CLOSED_PAST / PROJECTED status colour, live
  dot when active today, source links.
- web/components/live/tabs/VesselEventLogTab.tsx: mono <ol> of raw
  events (ZONE_ENTERED/LEFT, AIS_GAP_*, SEVERITY_CHANGED), severity-
  coloured event_type, hover reveals source (when in payload).
- web/i18n/pending/P1-T04.json: namespace placeholder (live.drawer.*,
  live.tabs.*) for the future JSON i18n agg pass.

Verification: typecheck + next build OK (43 static pages). Smoke
harness web/app/dev/drawer with mock data per tab built clean and
was deleted before commit per spec.
* chore(plan): pick P1-T02 [delta]

* feat(P1-T02): live tracker map react component [delta]

LiveTrackerMap React component porting frontend/app.js map portion.
Sources: zones, penguin (via zones), vessels, ambient, tracks, graticule,
selected-halo. Layers: 6 zone (fill + 4 per-category outlines + label),
5 penguin (buffer fill + 2 outlines + colony dot/label), graticule-line,
track-line, 5 vessels (halo / arrow / dot / flag / label), ambient-dots,
selected-halo. SSE client maps server hello/position/event onto a typed
StreamEvent surface (fleet_update | event | connection) with exponential
backoff 1s→2s→4s→8s→max 30s and polling fallback after 5 failures.
Style toggle (CARTO dark / ESRI satellite + carto-labels) extracted with
mobile-aware placement (bottom-left on <768px). a11y: role=region +
aria-label on container, aria-pressed on style toggle.

Files:
- web/lib/sse-client.ts
- web/components/map/LiveTrackerMap.tsx
- web/components/map/style-toggle-control.ts
- web/components/map/layers/graticule.ts
- web/components/map/layers/zonesLayers.ts
- web/components/map/layers/penguinLayers.ts

Verification: typecheck OK, build OK, runtime smoke (Playwright) confirmed
canvas mounted, controls present, DARK→SATELLITE toggle flips and triggers
ESRI tile fetches, /api/zones + /api/vessels + /api/stream all hit on mount.
* chore(plan): pick P1-T05 [alpha]

* feat(P1-T05): /live cockpit assembly + URL state [alpha]

- web/lib/live-url-state.ts: pure URL <-> state helpers (v, q, bucket,
  layers params), DEFAULT_LIVE_STATE, liveStateToUrl(). Layer params
  omitted from URL when matching defaults to keep shared links clean.
- web/app/[locale]/live/page.tsx: SSR shell — fetches initial fleet,
  reads URL state, drops orphan ?v slugs (avoids drawer 404 fetch),
  hands everything to LivePageClient.
- web/app/[locale]/live/LivePageClient.tsx: client orchestrator —
  3-column desktop layout (sidebar 320px / map flex-1 / drawer 420px
  open), useSearchParams reconciliation (back/forward + deep link),
  router.replace for scroll-stable URL syncing on every state change,
  selection round-trips through ?v=<slug>, drawer close clears ?v.
- web/app/[locale]/live/v/[slug]/page.tsx: deep-link route, redirects
  to /<locale>/live?v=<slug> (force-dynamic). Lets share URLs land
  directly on a pre-opened drawer.

Verification: typecheck + next build OK — /[locale]/live (18.9 kB SSR
shell) and /[locale]/live/v/[slug] (dynamic redirect) added cleanly.
* chore(plan): pick P1-T07 [alpha]

* feat(P1-T07): mobile responsive layouts for /live [alpha]

- web/hooks/useBreakpoint.ts: SSR-safe matchMedia hook returning
  mobile (< 768) / tablet (768-1023) / desktop (>= 1024). Defaults
  to "desktop" on first render so the server tree matches the most
  common viewport.
- web/components/live/MobileTopBar.tsx: 56px sticky top bar with
  brand dot, krill-watch wordmark, X/N counter, ⚙ filters button.
- web/components/live/MobileSatelliteFAB.tsx: floating action button
  bottom-right, toggles map/satellite, persists choice in localStorage
  under the same key as the desktop control (cross-storage event sync).
- web/components/live/MobileFiltersSheet.tsx: BottomSheet (P1-T01)
  wrapping the existing FleetSidebar, snap points 10vh / 50vh / 90vh,
  auto-collapses to 10vh when a vessel is picked so the map stays
  visible.
- web/components/live/MobileVesselDrawer.tsx: thin wrapper that bumps
  the z-index of VesselDrawer above the bottom sheet.
- web/components/live/LiveMobileLayout.tsx: composite mobile cockpit
  + LiveLayoutSwitch helper. Desktop tree renders for tablet+desktop
  per spec hybrid layout choice; mobile (<768px) gets the full
  redesigned surface.

Verification: typecheck + next build OK. Switch is exported but not
yet wired — P1-T08 integrates it into LivePageClient.tsx per spec
cross-task plan.
* chore(plan): pick P1-T08 [alpha]

* feat(P1-T08): legacy cleanup + mobile switch + phase 1 checkpoint [alpha]

Wave 3 finale — close out phase 1.

Step 1 — mobile/desktop switch wired into LivePageClient.
Step 2 — git rm legacy frontend/ (app.js + index.html + style.css).
Step 3 — server/app.py: drop StaticFiles mount, /style.css, /app.js,
  /assets/* and the FRONTEND_DIR constant. The "/" handler now 302s to
  WEB_BASE_URL (or /docs when unset). Imports trimmed to drop
  StaticFiles + FileResponse + add RedirectResponse.
Step 4 — README/.env.example/.gitignore had no frontend refs to clean.
Step 5 — pending i18n files deleted: web/i18n/pending/P1-T03.json,
  P1-T04.json. Strings stay inline in components (no JSON loader wired
  yet — deferred to phase 2).
Step 6 — CHECKPOINT.md captures the phase 1 result, criteria pass list,
  and dette to carry forward (i18n, mini-map, tablet hybrid, lighthouse
  manual run before merge to main).

Verification: typecheck OK, next build OK (45 static pages, /[locale]/live
20.7 kB SSR shell). app.py syntax-checked. Lighthouse mobile + desktop
on /, /live, /vessels intentionally NOT run in this session (no display,
no prod server) — operator MUST run before merging refonte/phase-1 → main
per CHECKPOINT.md instructions.

Phase 1 closed: 8/8 tasks completed (alpha 5, beta 1, gamma 1, delta 1).
* fix(P1-T02,P1-T05): drawer close + maplibre controls visibility

Two regressions caught during manual UAT on /live cockpit.

P1-T05 — drawer close + ESC were no-ops on the URL state.
LivePageClient.updateState used `patch.selectedSlug ?? prev.selectedSlug`
which can't tell `null` (caller wants to clear) from `undefined` (field
not part of the patch). handleSelect(null) silently kept the previous
slug, so closing the drawer left ?v=<slug> in the URL and React
considered the drawer still open. Switch to an `in patch` check that
preserves the explicit-null path. Same defensive change for `filter`
and `layers` so future patches that pass `undefined` don't blow them
away.

P1-T02 — maplibre-gl.css was never imported, so all .maplibregl-ctrl-*
elements fell back to position:static and stacked at the bottom of the
container (zoom +/-, scale, attribution, custom style toggle, all
unusable). Add the canonical "maplibre-gl/dist/maplibre-gl.css" import
inside LiveTrackerMap.tsx — Next.js + Turbopack splits it into the live
chunk so Home/Vessels stay unaffected. Verified: top-right control at
(1865, 57) 39x39, bottom-right zoom group, "200 nm" scale, and CARTO
attribution all render correctly after this change.

Verification (Chrome MCP UAT):
  - Click vessel "Antarctic Endurance" -> URL ?v=antarctic_endurance
  - Click X close -> URL clears, drawer transform applied (closed)
  - Press Escape -> URL clears, dialog toggles closed
  - MapLibre controls visible at the right positions, scale shows nm
  - Attribution "CARTO, OpenStreetMap" visible bottom-right
* fix(P1-T01,P1-T03,P1-T05): URL state for ?q + ?bucket; tab padding

Phase-1 closeout dette items #3 and #4 from manual UAT.

P1-T03 — FleetSidebar grows an optional controlled-mode for the search
text and the bucket chip (searchValue/onSearchChange + bucket/
onBucketChange). When the parent passes them, it owns the state; when
omitted, the sidebar still keeps its own local state for backward
compat (existing call sites and tests stay green).

P1-T05 — LivePageClient now wires those callbacks: handleSearchChange
and handleBucketChange call updateState({filter: {...state.filter, X}})
which round-trips through the URL via writeUrl. Hard reload of
/fr/live?q=antarctic&bucket=at-risk now restores both the input and
the active chip. The mobile branch (LiveMobileLayout ->
MobileFiltersSheet) accepts and forwards the same props so deep-linked
filters work on mobile too.

P1-T01 — Tabs.Trigger padding reduced from px-4 to px-2.5 with
whitespace-nowrap so the 6 French labels of VesselDrawer (Identité /
Activité / Alertes / Incidents / Fermetures / Journal) all sit in the
420 px drawer without truncating "Fermetures" to "Ferm".

CHECKPOINT.md updated with the post-livraison fix log and the
manual-only verification queue (mobile 360x800, SSE live, Lighthouse).

Verification (Chrome MCP):
  - Type "antarctic" -> URL becomes ?q=antarctic, list 3/23
  - Click "À risque" chip -> URL ?q=antarctic&bucket=at-risk
  - Ctrl+Shift+R -> input rehydrates, "À RISQUE 9" chip stays active,
    list still 3/23
  - Drawer tabs: Identité Activité Alertes Incidents Fermetures all
    visible at once in 420 px, Journal one tap of horizontal scroll
    away.
* chore(plan): pick P2-T01 [alpha]

* feat(P2-T01): backend endpoint historical positions [alpha]

Phase 2 wave 0 — backend support for the timeline scrubber (P2-T03)
and vessel-page trail (P2-T04).

GET /api/vessels/{slug}/positions?days=N&downsample=M

- Resolves slug -> mmsi via vessel_status, 404 on unknown.
- Pulls AIS track over the past N days (1..90), downsampled to one
  point per `downsample` minutes (1..60) when raw points exceed 1000.
- Tags each point with an instantaneous severity tier:
    CRITICAL  in MPA polygon
    SUSPICIOUS  in buffer / penguin buffer, OR open water after a
                >= 6h AIS gap (matches GAP_SUSPICIOUS_HOURS used by
                the live worker)
    WATCH       in fishery_subarea
    NORMAL      otherwise
- Surfaces in_zones and ais_gap_open per point so the trail shader
  can color-blend on the way in/out of zones without a second query.
- Returns the same window's relevant events (ZONE_ENTERED / _LEFT,
  AIS_GAP_OPENED / _CLOSED, SEVERITY_CHANGED) for the scrubber's
  marker rendering.
- Cache-Control: public, max-age=60.

Files:
- server/services/positions_history.py: pure helper over Store +
  ZoneIndex; no FastAPI / I/O surface, easy to unit-test.
- server/services/__init__.py: package marker.
- server/app.py: new route alongside the existing /track + /timeline
  ones, lazy-imports the service to avoid circular cost on startup.

Tests (tests/test_positions_history.py, 7 passing):
- unknown_slug_returns_none           service-level 404 path
- short_window_no_downsample          1 day -> raw, downsample=null
- dense_track_triggers_downsample     2000 pts -> bucketed, label "15min"
- severity_inside_mpa_is_critical     centroid of an MPA polygon
- severity_after_long_gap_is_suspicious   8h gap in open water
- http_unknown_slug_404               TestClient HTTP path
- http_invalid_days_422               days=999 -> Pydantic 422

Smoke (isolated uvicorn on :8001): 200 OK on
/api/vessels/antarctic_endurance/positions?days=30 with the spec
shape; p95 latency over 20 calls = 4 ms (target < 300 ms).

The running uvicorn on :8000 was started without --reload (10h+
uptime, AIS worker live), so this code only takes effect on next
restart of `npm run dev:api`.
* chore(plan): pick P2-T02 [alpha]

* feat(P2-T02): vessel trail component with severity coloring [alpha]

Phase 2 wave 1 — React headless component that paints the historical
trail published by P2-T01 onto a MapLibre map, segmented by severity.

- web/lib/trail-types.ts: TS contract mirroring the P2-T01 JSON
  payload (TrailPosition, TrailEvent, VesselHistory).
- web/lib/trail-coloring.ts: pure helpers — positionsToTrailGeoJSON
  builds one LineString feature per maximal run of constant severity
  (avoids line-gradient lockin and breaks the polyline at AIS gaps so
  we never draw across a 6 h silence). positionsToEventMarkers joins
  each event back to its closest position by ts (binary search) and
  drops events with no resolved coord.
- web/components/map/layers/trailLayers.ts: addTrailLayers /
  setTrailData / setTrailVisibility / removeTrailLayers. Three layers
  on two sources: vessel-trail-line (severity-colored polylines),
  vessel-trail-current (slightly bigger dot at the head of the
  trail), vessel-trail-event-markers (white circles outlined by
  severity). All severity colors come from web/lib/tokens.severityHex
  so they stay in sync with the rest of the live UI.
- web/components/map/VesselTrail.tsx: headless React wrapper. Defers
  layer install until the parent map's style.load fires (handles the
  satellite toggle reload case from P1-T02). Cleans up sources on
  unmount only — prop changes flush data through the existing
  layers.

P2-T04 owns the orchestration: fetch the history, mount <KrillMap>
and <VesselTrail> together, hand the same `history` to the timeline
scrubber (P2-T03) which will filter `positions` by current ts.

Verification: typecheck + next build OK (45 static pages, no new
chunk size impact since the trail code only loads inside /vessels/
[slug] surfaces wired by P2-T04).
* chore(plan): pick P2-T03 [alpha]

* feat(P2-T03): timeline scrubber + range selector + playback hook [alpha]

Phase 2 wave 1 — accessible time-cursor control consumed by P2-T04
to filter <VesselTrail> positions to a sub-range.

- web/lib/timeline-state.ts: pure types (TimelineRange, TimelineSpeed,
  TimelineState) + helpers (rangeBoundaries, clampToRange,
  timestampToRatio / ratioToTimestamp, shiftMinutes). No React.
- web/hooks/useTimelinePlayback.ts: setInterval-based playback loop.
  Advances `current` by tickMs * speedMultiplier on each tick, emits
  onComplete when the cursor catches up to "now".
- web/components/timeline/TimelineRangeSelector.tsx: 4-chip selector
  (24h / 7j / 30j / 90j). Switches to a native <select> when
  `compact` is true (mobile path from P1-T07).
- web/components/timeline/TimelineMarker.tsx: tiny glyph (▼ ▲ ◆ ◇ ●)
  positioned at a 0..1 ratio above the track. Severity-colored,
  optional onClick jumps the scrubber to that event.
- web/components/timeline/TimelineScrubber.tsx: full slider —
  pointer drag with pointerCapture, keyboard (Home/End/←/→ ±1min,
  Shift+arrow ±10min, Space play/pause), ARIA role="slider" with
  aria-valuemin/max/now/text. Renders range chips, play/pause,
  speed (1× 10× 100× 1000×), event markers above the track, and
  a human-readable "now" / "−Nj" / current-ts ribbon below.
- web/i18n/pending/P2-T03.json: namespace placeholder for the
  agg pass.

Verification: typecheck + next build OK. Component is fully headless
of map/data layers — P2-T04 will own the orchestration (fetch,
mount LiveTrackerMap + VesselTrail + this scrubber, slice the
positions array by current ts).
* chore(plan): pick P2-T05 [alpha]

* feat(P2-T05): campaign hub routes + 6 components [alpha]

Phase 2 wave 1 — /[locale]/campagnes index + /[locale]/campagnes/<slug>
hubs for the four CPWF missions (krill / whaling / galapagos /
west-africa). Layout is complete; editorial copy is loaded from
web/data/campaigns/<slug>.<locale>.md, today populated with
placeholders so build is green before P2-T06 ships real content.

- web/lib/campaigns.ts: registry of CAMPAIGNS keyed by slug, with
  per-campaign filter rules (vesselSlugs explicit OR vesselFlags),
  primaryColor + bgGradient. getCampaignVessels() filters a
  VesselSummary[].
- web/app/[locale]/campagnes/page.tsx: index, 4 large gradient
  cards with mission tag + tagline.
- web/app/[locale]/campagnes/[slug]/page.tsx: ISR (revalidate 300s),
  generateStaticParams over 4 slugs x 2 locales = 8 pre-rendered.
  Inline frontmatter parser (small enough to keep gray-matter out of
  the dep tree until P2-T06 actually needs MD body rendering).
- web/components/campaign/CampaignHero.tsx: gradient hero, tag pill,
  title + accent split, lead, optional 3-metric strip, 2 CTAs (fleet
  anchor + CPWF donate).
- web/components/campaign/CampaignLegalFrame.tsx: "Cadre légal &
  enjeu" section, prose layout for the MD-driven legal frame.
- web/components/campaign/CampaignFleetList.tsx: server component,
  fetches VesselSummary[], filters via getCampaignVessels, renders
  the campaign vessels as Link cards into /vessels/<slug>.
- web/components/campaign/CampaignTimeline.tsx: pulls /api/events?
  slug=<slug>&days=90 for each campaign vessel and merges into a
  90-day mono timeline (top 40, ISR-cached 5 min).
- web/components/campaign/CampaignSeasonCalendar.tsx: 12-month
  bar with active months highlighted in the campaign primaryColor.
- web/components/campaign/CampaignSourcesList.tsx: external sources
  with hostname-only labels.
- web/data/campaigns/<slug>.<locale>.md: 8 placeholders (krill FR has
  the realest copy as a sample), parsed by the route's inline FM
  reader. P2-T06 owns these files.
- web/i18n/pending/P2-T05.json: namespace placeholder.

Verification: typecheck + next build OK — 55 static pages (was 45),
+8 campaign hubs + 2 indexes wired in. /fr/campagnes/krill renders a
full hero from the placeholder MD. /fr/campagnes/inexistant → 404.
* chore(plan): pick P2-T07 [alpha]

* feat(P2-T07): glossary +6 terms + tap-friendly Acronym popover [alpha]

Phase 2 wave 2.

- web/lib/glossary.ts: 12 -> 18 entries. New: dark fleet, transhipment,
  reefer, trigger closure, beneficial owner, subarea. Each entry keeps
  the existing { expansion, definition } x { fr, en } shape so the
  /about glossary page picks them up without code changes.
  GLOSSARY_ORDER now lists the 6 new keys at the end so existing
  visitors see the prior ordering above the fold and the new ones
  appear below as a "newly explained" section.
- web/components/Acronym.tsx: rewritten as a "use client" toggling
  popover. Tap (mobile) or click (desktop) opens an inline panel with
  the full expansion, the one-sentence definition, and a "See glossary
  ->" link to /about#glossary-<term>. Click outside or Escape closes.
  Existing native title= tooltip is preserved for the hover-only path.
  ARIA: button + aria-expanded + aria-controls + dialog popover with
  the term anchor as id.

Verification: typecheck + next build OK (no static pages added).
Existing call-sites — server components rendering <Acronym> in
about/methodology copy — keep working: the new component is still a
default React export, the prop signature didn't change, and the
"use client" boundary is invisible to the parent server tree.
* chore(plan): pick P2-T04 [alpha]

* feat(P2-T04): vessel page history map + scrubber integration [alpha]

Phase 2 wave 2 — assembles P2-T01 (positions endpoint), P2-T02
(VesselTrail) and P2-T03 (TimelineScrubber) into a "30-day history"
block that lives just below the vessel header on
/[locale]/vessels/<slug>.

- web/lib/use-vessel-history.ts: client hook over the P2-T01 endpoint
  (/api/vessels/<slug>/positions?days=N). Per-tab in-memory cache
  keyed by (slug, days), 60 s TTL matching the server's
  Cache-Control. AbortController on slug/days change. Plus a
  filterHistoryUpTo(history, currentIso) helper used to slice the
  trail incrementally as the scrubber moves.
- web/app/[locale]/vessels/[slug]/VesselHistoryMap.tsx: client
  orchestrator. Mounts MapLibre directly (dark-matter style), hosts
  <VesselTrail> via the local map ref, drives <TimelineScrubber>
  state (range / current / isPlaying / speed). Auto-fits the map
  bounds the first time data lands and on range change, but
  intentionally not on scrubber movement so the viewport stays put
  while the user explores. Renders skeleton / empty / error states
  inline.
- web/app/[locale]/vessels/[slug]/VesselDetailClient.tsx: thin
  "use client" boundary that wraps VesselHistoryMap so the rest of
  the vessel page (header, identity, event log, incidents) keeps
  rendering on the server.
- web/app/[locale]/vessels/[slug]/page.tsx: imports apiBaseUrl,
  inserts <VesselDetailClient> right after the header, before the
  existing 3-column identity/log layout. Static params unchanged.

Verification: typecheck + next build OK. /[locale]/vessels/[slug]
now ships at 12.9 kB (was 7.95 kB) — extra chunk = maplibre-gl
+ trail layers + scrubber + playback hook. 55/55 static pages.

Phase 2: T01 + T02 + T03 + T04 + T05 + T07 completed. Remaining:
T06 (editorial content) and T08 (discoverability + checkpoint).
* chore(plan): pick P2-T06 [alpha]

* feat(P2-T06): editorial content for 4 campaigns FR + EN [alpha]

Phase 2 wave 2 — fills the 8 placeholder MD files left by P2-T05 with
factual editorial copy. Frontmatter is exhaustive (consumed today by
the T05 hub components); markdown body is present for prose sections
that future renderers (P2-T08+) can pick up.

- web/data/campaigns/krill.{fr,en}.md: CCAMLR framing, MPA blockage,
  trigger-closure mechanism, 48.1 history, Aker BioMarine + Chinese
  operators + CPWF intervention.
- web/data/campaigns/whaling.{fr,en}.md: IWC 1946 + 1986 moratorium,
  Iceland/Norway/Japan stances, 2014 ICJ JARPA II ruling, 2019 Japan
  withdrawal, 2024 Kangei Maru launch, Hvalur 8/9 fin-whale season.
- web/data/campaigns/galapagos.{fr,en}.md: 1998 reserve + 2022
  Hermandad expansion, 2017 incident with ~6,600 sharks, AIS-off
  pattern, Pingtan, surveillance asymmetry framing.
- web/data/campaigns/west-africa.{fr,en}.md: UNCLOS EEZs, COREP
  limits, FOC laundering, EJF/Greenpeace documentation, food-security
  -> migration thread.

Editorial rules followed:
  - Journalistic, factual tone.
  - No "scandal" / "pillage" rhetoric.
  - Treaty dates and historical facts in public domain only.
  - Precise catch tonnages and percentage figures intentionally
    omitted (HTML comment in each file flags the choice).
  - EN is parallel writing, not literal translation.
  - Sources point to primary registries (CCAMLR, IWC, COREP) and
    long-running NGO documenters (ASOC, EJF, Greenpeace).

Verification: next build OK (55 static pages — 8 hub variants among
them now render with real frontmatter rather than TODO placeholders).

Caveat: this is a Claude-drafted brief. Per spec note line 143, an
OSINT-aware human review is recommended before public production.
* feat(P2-T08): campaign hubs discoverability + i18n agg + checkpoint [alpha]

Phase 2 wave 3 — final polish closing the phase.

- web/components/Nav.tsx: ajout des liens "Campagnes" et "À propos" /
  "Campaigns" + "About" dans la nav globale (sticky header). Le mobile
  pill scroller hérite automatiquement des nouveaux items.
- web/components/Footer.tsx: 4 colonnes au lieu de 3, nouvelle colonne
  "Campagnes" / "Campaigns" avec les 4 liens vers /campagnes/<slug>.
- web/components/home/MissionStrip.tsx: les 4 cards de la home
  pointent maintenant vers /campagnes/<slug> (au lieu d'un anchor sur
  /vessels). Helper anchorFor() retiré (devenu dead code), remplacé
  par campaignHrefFor() qui mappe iuuGalapagos -> 'galapagos' et
  iuuWestAfrica -> 'west-africa'. Hover bump (scale 1.01) + perte du
  modificateur card-static pour réactiver le hover-orange.
- web/i18n/pending/P2-T03.json + P2-T05.json supprimés (placeholders
  vides — strings phase 2 restent inline FR/EN dans les composants,
  même choix que la dette i18n de phase 1).
- docs/superpowers/specs/2026-04-28-krill-refonte/phase-2-historique/
  CHECKPOINT.md: bilan complet — 8/8 livrées, build 55 routes,
  Lighthouse / UAT manuelle restant à exécuter avant merge -> main.

Verification: typecheck + next build OK (55 static pages).

Phase 2 closed: 8/8 tasks. Phase 3 unblocked.
* chore(plan): pick P3-T01 [alpha]

* feat(P3-T01): incident emitter webhook + auto-incidents schema [alpha]

Plumbing for auto-generated /incident/<slug> pages:

- DuckDB v2 migration: incidents_auto table (slug PK, severity, score,
  position, details_json, sources_json, revalidated_at).
- Store: upsert_incident_auto / get_incident_auto / list_incidents_auto
  / mark_incident_revalidated.
- server/services/incident_emitter.py: deterministic slug generator
  matching the TS twin, IncidentPayload, upsert_incident,
  trigger_revalidate (POST → Next.js with shared secret), emit_incident
  (persist + revalidate + stamp on success).
- server/app.py: POST /api/internal/incident (X-Internal auth),
  GET /api/auto-incidents, GET /api/auto-incidents/{slug}.
- web/lib/incident-slug.ts: kebab + isoDate slug generator (twin).
- web/app/api/revalidate/route.ts: revalidatePath for fr+en, optional
  tag revalidation, 401 on bad secret.
- 12 new tests (slug determinism, upsert idempotency, webhook env
  short-circuit, success path, 5xx soft-fail, persistence-when-webhook-
  fails).

Env vars (added to .env.example and web/.env.example):
  NEXTJS_BASE_URL, KRILL_REVALIDATE_SECRET, KRILL_INTERNAL_SECRET.
* feat(P3-T02): /incident/[slug] ISR page + map + context + sources [alpha]

Public per-incident pages auto-fed by the P3-T01 webhook:

- web/lib/incidents.ts: fetchIncident / fetchIncidentsList / parseIncident
  (snake_case wire → camelCase Incident type), buildIncidentHeadline
  with ZONE_ENTERED / AIS_GAP_OPENED / TRANSHIPMENT_DETECTED /
  SEVERITY_CHANGED templates in fr+en, deriveCampaignSlug
  (vesselSlugs > vesselFlags), formatDetectedAt with fixed UTC.
- web/app/[locale]/incident/[slug]/page.tsx: server component, ISR
  (revalidate=300), generateStaticParams pre-renders top 50, dynamic
  fallback for fresh slugs, OG/twitter metadata pointing at
  /api/og/incident/<slug> (P3-T03), severity badge, vessel link,
  back-to-tracker.
- IncidentMap (client island): MapLibre with 7d trail filtered to
  ±3.5d around detectedAt, severity-coloured pulsing marker via CSS
  keyframes added in globals.css, navigation+scale controls.
- IncidentContext (server): reads data/campaigns/<slug>.<locale>.md,
  extracts the "Pourquoi krill-watch suit cette campagne" /
  "Why krill-watch tracks this campaign" section, truncates to 80
  words, links to the campaign hub.
- IncidentSources (server): numbered external link list with safe
  rel attrs and a 3-source fallback when the pipeline supplied none.
* feat(P3-T03): dynamic OG images for incidents/vessels/campaigns [alpha]

Three edge runtime endpoints powered by next/og (Satori):

- /api/og/incident/<slug>?locale=fr|en — severity chip, headline,
  flag pill + vessel name, score/position meta, abstract grid map
  with severity-coloured pulse marker.
- /api/og/vessel/<slug>?locale=fr|en — flag pill, vessel name,
  IMO/MMSI/flag triplet, severity chip, current zones.
- /api/og/campaign/<slug>?locale=fr|en — campaign tag + brand,
  title (truncated to 70 chars), lead (200 chars), bgGradient.

All three fall back to a branded krill-watch tile when data is
unreachable, and ship cache-control headers (5min/1h with SWR).

Implementation details:
- web/lib/og-image-renderer.tsx hosts the JSX trees. Each render
  function inlines its own <div> with FRAME_STYLE rather than
  delegating to a higher-order frame() helper — Satori was rendering
  Fragment-passed children as a row in column flex, this works around
  it.
- Flag emojis are rendered as ISO code monospace pills (Satori needs
  an emoji font for the real glyph; the pill is more legible at
  social-card resolution anyway).
- Abstract map: SVG grid + 3-circle pulse, severity colour, no real
  basemap (Satori can't raster MapLibre).
- Wired into the incident page metadata in P3-T02 already; vessel
  and campaign pages can adopt by adding `images:
  '/api/og/<kind>/<slug>'` to their generateMetadata.

Verified:
- typecheck clean
- /api/og/incident/<unknown> renders fallback (1200×630 PNG, ~100KB)
- /api/og/campaign/krill renders correctly
- /api/og/vessel/antarctic_endurance renders with full identity
* feat(P3-T04): ShareButton component + intent URLs [alpha]

Native-share-first share UI with desktop popover fallback:

- web/lib/share-urls.ts: tweet/bluesky/mastodon/linkedin intent
  builders, ogImageUrl, buildShareTitle templates per
  kind+locale (incident / vessel / campaign × fr/en).
- web/components/share/ShareButton.tsx: primary/ghost variants,
  detects navigator.share() on mobile and uses it directly,
  desktop falls through to ShareMenu popover.
- web/components/share/ShareMenu.tsx: 6-option popover (Twitter,
  Bluesky, Mastodon, LinkedIn, Download OG image, Copy link),
  full keyboard nav (↑↓ Esc), click-outside to close, autofocus
  on open. Mastodon prompts for + remembers instance domain in
  localStorage.
- web/components/share/CopyLinkButton.tsx: standalone copy-link
  for places that don't need the full menu — clipboard API with
  execCommand fallback for older browsers / private mode.
- Wired into the incident page header next to the vessel/tracker
  links, primary variant. Uses NEXT_PUBLIC_SITE_URL when present
  (prod canonical), falls back to https://krill-watch.org.
* feat(P3-T05): public contribution form + DuckDB storage [alpha]

End-to-end submission pipeline for /contribuer:

- Schema v3: new `contributions` DuckDB table (UUID PK, category,
  vessel identity, observed_at + lat/lon, free description, optional
  on-disk photo path, status + reviewer_notes for the eventual
  moderation UI). Indexed on submitted_at and status.
- Store API: insert_contribution, get_contribution, list_contributions
  (status filter).
- server/services/contributions_store.py: ContributionInput +
  PhotoBlob dataclasses, validate (50-char min description,
  whitelisted MIME, 5 MB photo cap, email shape check), photo
  persistence under data/contributions/<uuid>/, optional Discord
  webhook notification (KRILL_ADMIN_DISCORD_WEBHOOK) — webhook payload
  excludes contact_email and contact_name to keep PII out of the
  notification channel.
- POST /api/contributions on FastAPI accepts multipart/form-data,
  surfaces ContributionError as 422.
- Next.js /api/contribute proxy re-streams multipart upstream so the
  browser never sees the OSINT origin and CORS is sidestepped.
- web/app/[locale]/contribuer/page.tsx: server-rendered intro, privacy
  + limits notes, generateMetadata with hreflang.
- ContributionForm: 4 categories (vessel-sighting / transhipment /
  ais-anomaly / other) with adaptive fields, MIN_DESC=50 live
  countdown, consent checkbox required, locale-aware labels.
- PhotoUpload: drag-drop or file picker, client-side validation,
  preview with size + remove.
- ContributionSuccess: thank-you with short ID, conditional contact
  follow-up note.

Coverage: 10 new tests in test_contributions.py — round trip,
validation rejections (description / category / email / photo
size / mime), photo persistence with filename slugification, webhook
PII guarantee (email + name absent from body), webhook skip when
unset.
* feat(P3-T06): /digest index + /digest/[isoweek] route [alpha]

Exposes the existing transhipment-review HTML files (one per ISO
week, generated by the Python pipeline under
output/transhipments_<YYYY>-W<WW>.html) as Next.js routes:

- FastAPI: GET /api/digests (newest-first list with size +
  generated_at) and GET /api/digests/<isoweek> (full HTML body).
  Strict YYYY-Www regex on the path so traversal attempts get
  400'd before we touch the filesystem.
- web/lib/digests.ts: typed wrappers, isoWeekRange helper that
  computes the Mon→Sun calendar range from the ISO week, defensive
  scrubDigestHtml that strips <script>/<iframe>/<object>/<embed>
  and inline event handlers as defence-in-depth (the source HTML
  is our own Jinja2 template, but layered defence costs nothing).
- /[locale]/digest: card list with isoweek, range, generated date,
  size. Empty-state message when the pipeline hasn't published any
  digest yet.
- /[locale]/digest/[isoweek]: ISR (revalidate=3600), generateStatic-
  Params pre-renders all known weeks fr+en, dangerouslySetInnerHTML
  on the scrubbed body inside a prose container.

Tests: 5 new tests in test_digests.py — listing newest-first,
404 on unknown week, 400 on malformed isoweek, traversal rejected,
HTML body returned. Decoy files (krill_fleet.geojson, malformed
filenames) are correctly skipped by the regex.
* feat(P3-T07): pipeline → incident emitter wiring [alpha]

Hooks the ZONE_ENTERED detection in server/workers and the
transhipment scorer into P3-T01 so each detection auto-generates a
shareable /incident/<slug> page.

- server/workers._process_position: after the existing event-log
  insert and SSE broadcast, fire-and-forget asyncio.create_task into
  a new _emit_incident_safe wrapper. Filters: severity must be
  CRITICAL/SUSPICIOUS/WATCH AND zone.category in {mpa, buffer_zone,
  penguin_buffer}. Fishery subareas (CCAMLR 48.x) and CPWF friendly
  vessels are excluded — they belong in the live event log, not on
  a shareable page.
- server/services/incident_emitter.transhipment_incident_payload:
  helper that translates a ScoredEncounter dict into an
  IncidentPayload (severity = CRITICAL when score ≥ 0.75 else
  SUSPICIOUS, default sources = GFW + AISStream, partner + reefer
  + duration + signals carried in details). Returns None below
  the 0.50 FLAGGED tier.
- src/notifier._fmt_live_event: appends NEXTJS_BASE_URL/<locale>/
  incident/<slug> to the message when the event is ZONE_ENTERED or
  TRANSHIPMENT_DETECTED, locale picked from WEB_DEFAULT_LOCALE
  (en fallback). Slug derivation reuses make_slug() so it matches
  the page that was just generated.

Tests: 4 zone-monitor wiring tests (MPA → emit, fishery_subarea →
skip, CPWF → skip, idempotent on repeat ticks) and 6 transhipment
helper tests (below-threshold None, FLAGGED→SUSPICIOUS,
CRITICAL≥0.75, missing vessel None, ISO ts parsing, default sources
attached). 12 notifier regressions still pass.
* feat(P3-T08): SupportCTA partout + ShareButton coverage + phase-3 checkpoint [alpha]

Closes phase 3 by:

- web/components/SupportCTA.tsx: 3 variants (inline, banner,
  minimal) all linking to paulwatsonfoundation.org/donate with
  utm_source=krill-watch + utm_medium=<variant> tracking. Banner
  variant is the educational card; minimal is the muted footer
  link; inline is the primary button used in CTAs.
- Home page: <SupportCTA variant="banner"> after MethodologyBlock.
- Incident page: <SupportCTA variant="banner"> below sources.
- Vessel page: ShareButton in the header (kind=vessel) +
  <SupportCTA variant="minimal"> at the bottom of the article.
- Campaign page: ShareButton just below the hero (kind=campaign) +
  <SupportCTA variant="banner"> above the footer.
- Footer: <SupportCTA variant="minimal"> alongside HealthPill so
  every page in the site has at least one path to donate.

Verification (CHECKPOINT.md):
- 37 / 37 phase-3 Python tests pass.
- web typecheck clean.
- web build clean — all phase-3 routes prerendered or marked
  dynamic, edge runtime correctly bundled for OG endpoints.
- OG images 1200×630 PNGs verified for incident (fallback +
  populated), vessel, and campaign — screenshots/og-*.png.
- Phase 3 README dashboard updated to all-green.
- Top-level README "What it does" section grew with the 4 new
  phase-3 features (campaign hubs, incident pages, OG images,
  contribution form, digest route, alert deep links).

Phase 3 limits documented in CHECKPOINT.md (modération UI,
chiffrement E2EE, Twitter Card validation, backfill script — all
explicitly out-of-scope for this phase).
* chore(plan): sync P3-T01..T08 frontmatter to completed [alpha]

* fix(P3-T05): add python-multipart to requirements-core

FastAPI's form() parser requires python-multipart at runtime —
without it, POST /api/contributions returns 400 with the message
"The python-multipart library must be installed to use form
parsing." The contribution form pipeline (P3-T05) is the first
caller of form() in this codebase, so the dependency wasn't
previously surfaced.

Verified by an E2E submission against the running backend after
installing the package: contribute → 200 with {id, short_id,
status:"received"}.
* docs(refonte): visual verification screenshots from phase 3 UAT

16 captures from local UAT of phase 3 surfaces:
- home (FR/EN/mobile)
- campaign hub krill (FR/EN/mobile)
- incident page (real data + 404 case)
- vessel dossier
- contribute form (FR/EN/mobile)
- digest index
- ShareButton open menu

Used as evidence in P3-T08 CHECKPOINT.md. Not part of any spec
deliverable, kept here for repository historical record.
* docs(refonte): manual test checklist (12 sections, 60-75 min)

Comprehensive manual test plan to walk through before pushing to
prod. Organized by audience persona (activist / random / journalist
/ whistleblower) and severity (🔴 critical / 🟡 important / 🟢 nice-to-have).

Includes step-by-step instructions for: webhook→revalidate E2E with
copy-paste curl, Lighthouse commands for the 5 key pages, mobile
DevTools emulation, and edge case 404 verification.
* fix(audit): P0-P3 batch — hydration, keys, i18n, OG, mission split, playback UX

Comprehensive audit-driven fixes across 4 priority tiers, persona = activist /
journalist preparing maritime IUU/krill investigations.

P0 — React stability on vessel detail page
* TimelineScrubber: hydration mismatch (Date.now() at SSR vs client)
  fixed by deferring `now` state to useEffect (web/components/timeline/TimelineScrubber.tsx)
* VesselHistoryMap: `current` initial value now empty string, set client-side
  to keep SSR/first-paint identical (web/app/[locale]/vessels/[slug]/VesselHistoryMap.tsx)
* CampaignTimeline duplicate React keys — root cause was wrong query param
  (`?slug=` ignored by FastAPI, every fan-out returned the same global event
  list). Fixed by switching to `?vessel=` and adding Set-based dedup
  (web/components/campaign/CampaignTimeline.tsx)

P1 — i18n / SEO / discoverability
* `<html lang>` now derives from URL pathname via middleware-injected
  x-pathname header — was hardcoded "fr" on every page (web/middleware.ts,
  web/app/layout.tsx)
* Locale-aware `<title>`, `<meta description>`, openGraph, twitter:card,
  canonical, hreflang fr/en/x-default in [locale]/layout.tsx
* og:image now set on home (was missing) — defaults to /api/og/campaign/krill
* "30 j" leak removed on /en — TimelineScrubber bottom label now locale-aware
* Footer exposes Live tracker, Transhipments, Weekly digests, Contribute
  (previously invisible without URL guessing)

P2 — Editorial / UX
* Markdown leak on incident page IncidentContext — CRLF line endings made
  the regex `m`-flag match `$` before every \n, stopping the lazy quantifier
  at the heading. Fixed via 2-step extraction (header match + boundary
  match). Page now renders 530 chars of body instead of raw "## Why..."
* Vessel detail page now has its own generateMetadata pointing to
  /api/og/vessel/{slug} — was inheriting the campaign krill OG fallback
* Privacy + limits notice on /contribuer moved ABOVE the form so a
  whistleblower-adjacent contributor sees the guarantees before filling
* New /transhipments page (was 404 despite README claim) — fetches
  /api/transhipments?days=30 and renders summary + 6 weighted signals +
  thresholds + links to live/digest/calibration source
* Playback UX on vessel timeline: clicking ▶ from "now" was effectively
  a no-op (cursor already at right edge). Fixed: rewind to range start if
  at end, auto-pause on completion. Resume from middle keeps position.

P3 — Mission grouping + polish
* Galápagos and West Africa now distinct mission tags in VESSELS data
  (was lumped into "iuu_fishing" — broke the editorial framing the README
  promises). /vessels page now shows 4 sections instead of 3:
  KRILL (7) · WHALING (3) · GALÁPAGOS (3) · WEST AFRICA (3)
* MissionBadge + MissionStrip + ActiveAlertsStrip handle the new tags
* Domain canonicalization: 4 SHARE-button fallbacks unified on
  https://krill-free.org (was mixed with krill-watch.org)

Verifications
* SSR via curl: html lang correct fr/en, hreflang triples, og:image set
* DOM via Claude Preview: 0 hydration errors, 0 duplicate keys after fixes
* Server logs clean across all reloaded pages
* TypeScript: tsc --noEmit passes

Two non-bugs identified during audit (kept here for the record):
* "Mojibake em-dash" in API JSON output was a Windows console encoding
  artefact in `python -c` — raw bytes are clean UTF-8 (e2 80 94)
* "ON WATCH untranslated" was already translated as "SURVEILLÉES"
  (HeroStats) and "à surveiller" (LiveStatusBanner) — initial regex missed it

See docs/AUDIT_NOTES.md for the full per-page audit and verdicts, and
docs/API_INTEGRATIONS_PLAN.md for the next-step plan to integrate
OpenSanctions, Marine Regions and Skylight (3 high-leverage APIs that
unblock the documented stubs and the Galápagos / West Africa campaigns).
---------

Co-authored-by: breaching <devsca@protonmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
breaching pushed a commit that referenced this pull request May 8, 2026
Adds the 6 specs called out in PROD_READINESS #3 + #15:

- web/e2e/live-loads.spec.ts: open /en/live, assert >=1 vessel dot,
  banner shows fleet count.
- web/e2e/vessels-loads.spec.ts: /en/vessels mini-map renders with
  dots, click first dot -> popup with severity + dossier link.
- web/e2e/vessel-detail.spec.ts: /en/vessels/antarctic_endurance,
  history map height >100px (regression for the inset-0 collapse),
  trail SVG present, scrubber controls visible.
- web/e2e/firms-toggle.spec.ts: toggle FIRMS layer, assert
  /api/firms/recent fires + label updates.
- web/e2e/operator-background.spec.ts: vessel detail page, assert
  "Aker BioMarine" + "Norway" appear (Wikidata cascade integration).
- web/e2e/a11y.spec.ts: @axe-core/playwright across 7 routes
  (home, about, vessels list, campaigns hub, privacy, terms, legal),
  WCAG 2.1 AA tags. Fails on serious/critical only — minor/moderate
  tolerated so a single tag-soup edge case doesn't block deploys.

- web/playwright.config.ts: chromium project, fullyParallel,
  CI-aware retries/workers/reporter. We deliberately don't use
  Playwright's webServer option — orchestration is owned by the root
  `npm run dev`.

- .gitignore: drop web/test-results/, web/playwright-report/,
  web/playwright/.cache/.

Closes prod-P0 #3 (5 specs) and the axe-core half of P2 #15.
breaching added a commit that referenced this pull request May 11, 2026
* chore(setup): seed synthetic AIS data, screenshot tool, fix notifier cooldown bug

- scripts/seed_synthetic.py: 7-day synthetic tracks for 14 fleet vessels + 8
  ambient non-fleet vessels (Chinese reefers, Russian trawlers, Cyprus cargo).
  Includes MPA-SOISS incursion event for Antarctic Endurance. Seeds DuckDB.
- tools/screenshot.mjs: headless Playwright screenshot utility using system
  Chromium; accepts --url, --out, --width, --height flags.
- src/notifier.py: fix pre-existing cooldown bug where float('0.0') default
  sentinel would reject first alert on systems with uptime < COOLDOWN_S (600s).
  Changed to float('-inf') so first call always fires.
- web/package.json: adds playwright devDependency (used by screenshot tool).

Phase 0 verified: API /health→ok, /api/vessels→14, web /en→200.

* feat(web): event log filter chips on vessel detail page

Creates the vessel-detail page and adds client-side event-type filter chips.

Files touched:
- web/app/[locale]/vessels/[slug]/page.tsx: new server-component vessel
  detail page fetching live OSINT data (with static fallback). Shows vessel
  identity, last position, and the event timeline.
- web/components/vessels/EventLogWithFilters.tsx: new client component with
  chip row above the event log. Chips filter by ZONE_ENTERED, ZONE_LEFT,
  AIS_GAP, AIS_SEEN, SEVERITY_CHANGED. Only chips for types present in the
  fetched events are shown. Uses eventTone for chip colors.
- web/lib/i18n.ts: adds vesselDetail.eventFilter.* keys in both EN + FR.
- web/lib/utils.ts: adds eventTone() and EventType type.
- web/lib/krill-watch.ts: adds VesselEvent + VesselDetail types and
  fetchVesselDetail() server-side fetcher.

Verification:
- npx tsc --noEmit: clean
- npx next build: clean, vessel detail static pages generated
- python -m pytest tests/: 81 passed
- Screenshot: /en/vessels/antarctic_endurance shows filter chips + 3 events

---------
breaching added a commit that referenced this pull request May 11, 2026
…28 (PR #2) (#7)

PR #2 (docs/shippability-2026-04-28) became conflict-heavy after merging
#3/#4/#5/#6 from the same overnight loop session. This commit cherry-picks
the unique, non-conflicting pieces only — the rest is left in PR #2 to be
either rebased or closed by the human.

Brings in:
- INVESTIGATOR_PLAYBOOK.md — three present-tense walkthroughs for analysts
- README.md — investigator-tone rewrite (no campaign framing)
- calibration.md — formal v0.4.2 calibration record with weight justification
- config/ccamlr_iuu_vessels.json, foc_flags.json, krill_operators.json — reference datasets
- scripts/seed_for_screenshots.py — synthetic AIS seeder for first-boot screenshots
- server/decimation.py + tests/test_decimation.py — uniform-stride track decimation
- .github/dependabot.yml — monthly pip + npm + weekly actions security updates
- .github/workflows/ci.yml — adds Web (Node 20) job: tsc --noEmit + next build

Deliberately NOT included (require larger integration / conflict with main):
- src/transhipment_report.py + templates/ — depends on PR #2's 724-line detector
  variant, incompatible with main's 220-line clean version (from PR #6)
- /api/transhipments + /api/timeline endpoint changes — depend on bigger detector
- web/components/map/{EventBadge,VesselTrackMap,WindowSelector}.tsx — require
  Position, WindowKey, Dict types that don't exist on main
- web/app/[locale]/transhipments/ — same dependency issue
- Removal of militant content (about/, act/, brands/, learn/, BrandCard, StatusTag,
  brands.ts, executives.ts, messages.ts, timeline.ts) — requires rewriting Nav.tsx
  and home page.tsx, conflicts with PR #4's UX work already on main

Verification:
- python -m pytest tests/ -q → 100 passed
- python -m ruff check src server tests scripts main.py → All checks passed
- cd web && npx tsc --noEmit → exit 0

Closes the path forward for PR #2: the human can now close #2 or rebase the
remaining (deliberately-skipped) pieces against current main if they want them.

Co-authored-by: breaching <devsca@protonmail.com>
breaching added a commit that referenced this pull request May 11, 2026
…-trawlers (#17)

Adds two new IUU-fishing campaigns alongside krill (mission=krill) and
whaling (mission=whaling). The detector pipeline (zones, AIS gaps,
severity, events) is fully reused — only data is added.

Galápagos campaign (mission=iuu_fishing)
- 3 representative Chinese squid jiggers / reefers in fleet config:
  Fu Yuan Yu Leng 999, Long Da 001, Hai Feng 718
- 2 zone polygons added to ccamlr_areas.geojson:
    GMR-GALAPAGOS — Galápagos Marine Reserve incl. Hermandad (2022 expansion)
    EEZ-GALAPAGOS-BUFFER — Ecuador 200-nm EEZ where the squid-jigger fleet
    historically congregates each Aug-Nov
- Headline signal: incursion into GMR (foreign-flag commercial fishing
  prohibited there) or AIS gap inside the buffer

West Africa campaign (mission=iuu_fishing)
- 3 representative DWF vessels in fleet config:
  Lurong Yuanyu 956 (CN), Lurong Yuanyu 988 (CN), Saltic Atlas (RU)
- 3 zone polygons added (simplified bounding boxes):
    EEZ-SENEGAL, EEZ-MAURITANIA, EEZ-GUINEA
- Headline signal: AIS gap inside any of these EEZs (vessel went dark
  while presumably trawling)

Web (`web/data/vessels.ts`)
- All 6 new vessels added to the static catalogue with bilingual notes
  describing the campaign context (Pingtan reefer 2017 Galápagos arrest;
  300+ vessel 2020 stand-off; EJF/Greenpeace West Africa documentation)
- They surface automatically in the existing /vessels mission-grouped UI
  (PR #15) — krill / whaling / IUU fishing sections

All vessels have IMO and MMSI marked PENDING — operator must populate
from GFW Carrier Vessel Portal / Equasis before deployment. The polygon
boundaries are simplified bounding boxes that err conservatively (over-
include slightly); operator should refine with official EEZ data
(e.g. https://www.marineregions.org/) before deployment.

Verification
- python -m pytest tests/ -q → 110 passed
- python -m ruff check → All checks passed
- cd web && npx tsc --noEmit → exit 0

What's deliberately NOT done
- No "campaign" abstraction yet. With 4 missions still hanging off
  krill_fleet.json + ccamlr_areas.geojson, the duplication cost remains
  near zero. Right time for the refactor is when the 5th mission lands.

Co-authored-by: breaching <devsca@protonmail.com>
breaching added a commit that referenced this pull request May 11, 2026
…layback UX (#22)


* feat(web): fix satellite map + Windows flag rendering

- frontend/app.js: split zone-outline + penguin-buffer-outline layers (MapLibre 4.x rejects data expressions on line-dasharray); add flagImg() helper using flagcdn.com so flags render reliably on Windows
- web/components/map/FleetMap.tsx: add ESRI World Imagery satellite source + custom toggle control (🗺/🛰) with localStorage persistence + locale-aware labels
- web/components/Flag.tsx (new): SVG flag component using flag-icons CSS, with ISO-3 → ISO-2 normalization and CPWF → 🦐 special-case
- migrate 3 flagEmoji() callsites in web/ to <Flag>; mark flagEmoji deprecated
- add flag-icons dependency
* docs(plan): krill-watch refonte spec + 3 phases × 8 tasks (multi-agent ready)

Brainstorm output for unifying the legacy frontend/ tracker and the Next.js
web/ site into a single product, with satellite history (timeline scrubber +
colored cumulative trail), shareable atoms (incident pages + vessel dossiers
+ campaign hubs), and parallel-friendly task plans for multiple parallel sessions
instances.

- 2026-04-28-krill-refonte-design.md: top-level spec (vision, architecture,
  routes, surfaces UX, phasing, risks, hors-scope)
- 2026-04-28-krill-refonte/README.md: orchestration + dependency graph
- 2026-04-28-krill-refonte/CONVENTIONS.md: multi-agent rules (file ownership,
  i18n pending pattern, branches, checkpoints)
- 24 task files (P1-T01..P3-T08) with frontmatter declaring files_owned,
  depends_on, parallelizable_with, wave — designed for 2-3 parallel branches
  instances to work simultaneously without file conflicts.
- .gitignore: ignore .superpowers/ brainstorm working directory

Phasing:
  Phase 1 · Fondation     (10d, 8 tasks) — /live in Next.js, mobile, hero hybride
  Phase 2 · Historique    (10d, 8 tasks) — trail + scrubber + 4 campaign hubs
  Phase 3 · Viralité      (12d, 8 tasks) — incident pages + OG + share + contrib
* chore(plan): launch prompts for 4 parallel sessions (alpha/beta/gamma/delta)

Self-contained prompts for each instance to claim its assigned phase-1 task.
Alpha takes wave-0 task P1-T01 immediately. Beta/gamma/delta wait on user
'go' signal until P1-T01 is committed completed, then claim P1-T03/T04/T02.
* chore(plan): pick P1-T01 (alpha)

* feat(P1-T01): design system tokens & primitives [alpha]

- web/lib/tokens.ts: colors / severityHex / radius / spacing tokens
- web/components/ui/Button.tsx: primary/ghost/danger × sm/md/lg, asChild, loading
- web/components/ui/Card.tsx: Card + Card.Header/Body/Footer, polymorphic via as
- web/components/ui/Badge.tsx: 6 tones, 2 sizes, color-mix surface
- web/components/ui/Tabs.tsx: headless, ARIA tablist, arrow-key nav, underline accent
- web/components/ui/Drawer.tsx: portal, focus trap, ESC close, slide right/left
- web/components/ui/BottomSheet.tsx: portal, drag handle, snap points, dismiss-on-drag
- web/components/ui/IconButton.tsx: square variant of Button, aria-label required
- web/components/ui/index.ts: public re-exports

Verification: typecheck + next build OK (43 static pages).
* chore(plan): pick P1-T06 [alpha]

* feat(P1-T06): hero hybride home + i18n agg target [alpha]

Replaces the static editorial hero on /[locale] with a data-driven hybrid:
phrase-choc generated from live krill-watch counts, 3 stats per severity
(linkable to /live?bucket=*), 2 CTAs, mini-map preview (desktop only).

- web/lib/headline-builder.ts: pure bilingual template builder, 3 cases
  (CRITICAL+hottest_zone, SUSPICIOUS, fallback), correct singular/plural.
- web/components/home/HeroHeadline.tsx: tag pill + h1 with accent split,
  fade-up entry animation (respects prefers-reduced-motion).
- web/components/home/HeroMiniMap.tsx: SVG fallback (T02 not shipped) —
  Antarctica silhouette + graticule + dashed CCAMLR boundary at -60° +
  severity-colored dots, 240×120, hidden < md, full tile is link to /live.
- web/components/home/HeroStats.tsx: inline stats row, each number a deep
  link to /live?bucket=critical|suspicious|watch.
- web/components/home/HeroHybrid.tsx: server component, fetches stats +
  vessels + positions in parallel, computes hottest CCAMLR subarea from
  non-NORMAL vessels, joins positions to severity by mmsi, falls back to
  CONTINUOUS WATCH phrase when API is down (no crash).
- web/i18n/locales/{fr,en}.json: seeded with heroHybrid.ctaUnderstand,
  established as the i18n aggregation target for phase 1 (other tasks
  drop into web/i18n/pending/<task-id>.json — none present yet).
- web/app/[locale]/page.tsx: replaces editorial hero block with
  <HeroHybrid /> while keeping LiveStatusBanner / ActiveAlertsStrip /
  MissionStrip / MethodologyBlock below the fold unchanged.

Verification: typecheck + next build OK (43 static pages).
* chore(plan): pick P1-T03 [beta]

* feat(P1-T03): fleet sidebar [beta]

React rewrite of the legacy frontend/app.js left panel — isolated
components consumed in P1-T05 and wrapped in BottomSheet by P1-T07.

- web/lib/fleet-filtering.ts: pure helpers — applyFilter (text +
  bucket all|at-risk|live|in-mpa, "live" = last_seen within 24h),
  sortBySeverity (CRITICAL, SUSPICIOUS, WATCH, CPWF, NORMAL, UNKNOWN),
  countBySeverity. Case-insensitive search across name/imo/mmsi/flag/slug.
- web/components/live/FleetSearchBar.tsx: client search input, 200ms
  debounce, magnifier icon, clear button, focus-visible ring.
- web/components/live/FleetFilters.tsx: 4 chips with bucket counts,
  active = accent border + tinted bg, mobile-scrollable.
- web/components/live/FleetRow.tsx: per-vessel row with Flag, name,
  severity-colored pulsing dot (respects prefers-reduced-motion),
  position + relative timestamp (FR/EN), tag pill, active state =
  3px accent left border, ARIA listbox option, Enter/Space keyboard.
- web/components/live/SeverityLegend.tsx: 6-line legend with native
  checkboxes (toggle map visibility), counts inline, opacity change
  when unchecked.
- web/components/live/FleetSidebar.tsx: orchestrator (320px desktop,
  flex-1 mobile), collapsible sections (legend + layers + 5 layer
  toggles), search, filter chips with counts, N/M counter (aria-live),
  scrollable filtered list. Strings inlined (FR + EN dispatch) until
  the JSON i18n system is wired.
- web/i18n/pending/P1-T03.json: namespace placeholder, points T05/T06
  agg pass at the inlined STRINGS map.

Verification: typecheck + next build OK (clean .next, 43 static pages).
Smoke harness validated on web/app/dev/sidebar (build OK, page deleted
before commit per spec — 8.04 kB chunk built and prerendered fine).
* chore(plan): pick P1-T04 [gamma]

* feat(P1-T04): vessel drawer [gamma]

React rewrite of the legacy frontend/app.js right detail panel —
6 tabs in isolated components, consumed in P1-T05 and wrapped in
BottomSheet by P1-T07.

- web/components/live/VesselDrawer.tsx: client orchestrator, fetches
  /api/vessels/<slug> with AbortController on slug change, sticky
  header (Flag + name + IMO/MMSI + severity tag + close button),
  scrollable body with Tabs.List sticky inside, footer with Copy link
  button (clipboard write, "Link copied ✓" feedback for ~1.8s).
  Skeleton during fetch, error body with Retry button when API down.
  Wraps the T01 <Drawer> primitive (right side, 420px desktop / 100vw
  mobile, focus trap + ESC + body scroll lock inherited).
- web/components/live/tabs/VesselIdentityTab.tsx: dl key/value table —
  Operator (with ownership_pending badge), Reg/Beneficial owner, Type,
  Length, GT, Built, Callsign, Flag (with <Flag/>), IMO, MMSI, OFAC,
  ITF (FOC), free-text Notes. Missing fields render "—" muted.
- web/components/live/tabs/VesselActivityTab.tsx: 4-stat grid (last
  seen + relative + abs, position with MarineTraffic deep link, SOG,
  COG), 7-day fishing-hours card with inline 24h sparkline (pure SVG,
  no lib), current zones as pill list.
- web/components/live/tabs/VesselAlertsTab.tsx: chronological list of
  the last 30 days of severe alerts, each rendered as Card or button
  (when lat/lon present + onAlertClick prop) — keyboard-actionable to
  recenter the map.
- web/components/live/tabs/VesselIncidentsTab.tsx: curated incident
  cards (vessel-specific vs fleet-wide tag, confidence pill, sources
  with hostname-only links).
- web/components/live/tabs/VesselClosuresTab.tsx: relevant CCAMLR
  closures with ACTIVE / CLOSED_PAST / PROJECTED status colour, live
  dot when active today, source links.
- web/components/live/tabs/VesselEventLogTab.tsx: mono <ol> of raw
  events (ZONE_ENTERED/LEFT, AIS_GAP_*, SEVERITY_CHANGED), severity-
  coloured event_type, hover reveals source (when in payload).
- web/i18n/pending/P1-T04.json: namespace placeholder (live.drawer.*,
  live.tabs.*) for the future JSON i18n agg pass.

Verification: typecheck + next build OK (43 static pages). Smoke
harness web/app/dev/drawer with mock data per tab built clean and
was deleted before commit per spec.
* chore(plan): pick P1-T02 [delta]

* feat(P1-T02): live tracker map react component [delta]

LiveTrackerMap React component porting frontend/app.js map portion.
Sources: zones, penguin (via zones), vessels, ambient, tracks, graticule,
selected-halo. Layers: 6 zone (fill + 4 per-category outlines + label),
5 penguin (buffer fill + 2 outlines + colony dot/label), graticule-line,
track-line, 5 vessels (halo / arrow / dot / flag / label), ambient-dots,
selected-halo. SSE client maps server hello/position/event onto a typed
StreamEvent surface (fleet_update | event | connection) with exponential
backoff 1s→2s→4s→8s→max 30s and polling fallback after 5 failures.
Style toggle (CARTO dark / ESRI satellite + carto-labels) extracted with
mobile-aware placement (bottom-left on <768px). a11y: role=region +
aria-label on container, aria-pressed on style toggle.

Files:
- web/lib/sse-client.ts
- web/components/map/LiveTrackerMap.tsx
- web/components/map/style-toggle-control.ts
- web/components/map/layers/graticule.ts
- web/components/map/layers/zonesLayers.ts
- web/components/map/layers/penguinLayers.ts

Verification: typecheck OK, build OK, runtime smoke (Playwright) confirmed
canvas mounted, controls present, DARK→SATELLITE toggle flips and triggers
ESRI tile fetches, /api/zones + /api/vessels + /api/stream all hit on mount.
* chore(plan): pick P1-T05 [alpha]

* feat(P1-T05): /live cockpit assembly + URL state [alpha]

- web/lib/live-url-state.ts: pure URL <-> state helpers (v, q, bucket,
  layers params), DEFAULT_LIVE_STATE, liveStateToUrl(). Layer params
  omitted from URL when matching defaults to keep shared links clean.
- web/app/[locale]/live/page.tsx: SSR shell — fetches initial fleet,
  reads URL state, drops orphan ?v slugs (avoids drawer 404 fetch),
  hands everything to LivePageClient.
- web/app/[locale]/live/LivePageClient.tsx: client orchestrator —
  3-column desktop layout (sidebar 320px / map flex-1 / drawer 420px
  open), useSearchParams reconciliation (back/forward + deep link),
  router.replace for scroll-stable URL syncing on every state change,
  selection round-trips through ?v=<slug>, drawer close clears ?v.
- web/app/[locale]/live/v/[slug]/page.tsx: deep-link route, redirects
  to /<locale>/live?v=<slug> (force-dynamic). Lets share URLs land
  directly on a pre-opened drawer.

Verification: typecheck + next build OK — /[locale]/live (18.9 kB SSR
shell) and /[locale]/live/v/[slug] (dynamic redirect) added cleanly.
* chore(plan): pick P1-T07 [alpha]

* feat(P1-T07): mobile responsive layouts for /live [alpha]

- web/hooks/useBreakpoint.ts: SSR-safe matchMedia hook returning
  mobile (< 768) / tablet (768-1023) / desktop (>= 1024). Defaults
  to "desktop" on first render so the server tree matches the most
  common viewport.
- web/components/live/MobileTopBar.tsx: 56px sticky top bar with
  brand dot, krill-watch wordmark, X/N counter, ⚙ filters button.
- web/components/live/MobileSatelliteFAB.tsx: floating action button
  bottom-right, toggles map/satellite, persists choice in localStorage
  under the same key as the desktop control (cross-storage event sync).
- web/components/live/MobileFiltersSheet.tsx: BottomSheet (P1-T01)
  wrapping the existing FleetSidebar, snap points 10vh / 50vh / 90vh,
  auto-collapses to 10vh when a vessel is picked so the map stays
  visible.
- web/components/live/MobileVesselDrawer.tsx: thin wrapper that bumps
  the z-index of VesselDrawer above the bottom sheet.
- web/components/live/LiveMobileLayout.tsx: composite mobile cockpit
  + LiveLayoutSwitch helper. Desktop tree renders for tablet+desktop
  per spec hybrid layout choice; mobile (<768px) gets the full
  redesigned surface.

Verification: typecheck + next build OK. Switch is exported but not
yet wired — P1-T08 integrates it into LivePageClient.tsx per spec
cross-task plan.
* chore(plan): pick P1-T08 [alpha]

* feat(P1-T08): legacy cleanup + mobile switch + phase 1 checkpoint [alpha]

Wave 3 finale — close out phase 1.

Step 1 — mobile/desktop switch wired into LivePageClient.
Step 2 — git rm legacy frontend/ (app.js + index.html + style.css).
Step 3 — server/app.py: drop StaticFiles mount, /style.css, /app.js,
  /assets/* and the FRONTEND_DIR constant. The "/" handler now 302s to
  WEB_BASE_URL (or /docs when unset). Imports trimmed to drop
  StaticFiles + FileResponse + add RedirectResponse.
Step 4 — README/.env.example/.gitignore had no frontend refs to clean.
Step 5 — pending i18n files deleted: web/i18n/pending/P1-T03.json,
  P1-T04.json. Strings stay inline in components (no JSON loader wired
  yet — deferred to phase 2).
Step 6 — CHECKPOINT.md captures the phase 1 result, criteria pass list,
  and dette to carry forward (i18n, mini-map, tablet hybrid, lighthouse
  manual run before merge to main).

Verification: typecheck OK, next build OK (45 static pages, /[locale]/live
20.7 kB SSR shell). app.py syntax-checked. Lighthouse mobile + desktop
on /, /live, /vessels intentionally NOT run in this session (no display,
no prod server) — operator MUST run before merging refonte/phase-1 → main
per CHECKPOINT.md instructions.

Phase 1 closed: 8/8 tasks completed (alpha 5, beta 1, gamma 1, delta 1).
* fix(P1-T02,P1-T05): drawer close + maplibre controls visibility

Two regressions caught during manual UAT on /live cockpit.

P1-T05 — drawer close + ESC were no-ops on the URL state.
LivePageClient.updateState used `patch.selectedSlug ?? prev.selectedSlug`
which can't tell `null` (caller wants to clear) from `undefined` (field
not part of the patch). handleSelect(null) silently kept the previous
slug, so closing the drawer left ?v=<slug> in the URL and React
considered the drawer still open. Switch to an `in patch` check that
preserves the explicit-null path. Same defensive change for `filter`
and `layers` so future patches that pass `undefined` don't blow them
away.

P1-T02 — maplibre-gl.css was never imported, so all .maplibregl-ctrl-*
elements fell back to position:static and stacked at the bottom of the
container (zoom +/-, scale, attribution, custom style toggle, all
unusable). Add the canonical "maplibre-gl/dist/maplibre-gl.css" import
inside LiveTrackerMap.tsx — Next.js + Turbopack splits it into the live
chunk so Home/Vessels stay unaffected. Verified: top-right control at
(1865, 57) 39x39, bottom-right zoom group, "200 nm" scale, and CARTO
attribution all render correctly after this change.

Verification (Chrome MCP UAT):
  - Click vessel "Antarctic Endurance" -> URL ?v=antarctic_endurance
  - Click X close -> URL clears, drawer transform applied (closed)
  - Press Escape -> URL clears, dialog toggles closed
  - MapLibre controls visible at the right positions, scale shows nm
  - Attribution "CARTO, OpenStreetMap" visible bottom-right
* fix(P1-T01,P1-T03,P1-T05): URL state for ?q + ?bucket; tab padding

Phase-1 closeout dette items #3 and #4 from manual UAT.

P1-T03 — FleetSidebar grows an optional controlled-mode for the search
text and the bucket chip (searchValue/onSearchChange + bucket/
onBucketChange). When the parent passes them, it owns the state; when
omitted, the sidebar still keeps its own local state for backward
compat (existing call sites and tests stay green).

P1-T05 — LivePageClient now wires those callbacks: handleSearchChange
and handleBucketChange call updateState({filter: {...state.filter, X}})
which round-trips through the URL via writeUrl. Hard reload of
/fr/live?q=antarctic&bucket=at-risk now restores both the input and
the active chip. The mobile branch (LiveMobileLayout ->
MobileFiltersSheet) accepts and forwards the same props so deep-linked
filters work on mobile too.

P1-T01 — Tabs.Trigger padding reduced from px-4 to px-2.5 with
whitespace-nowrap so the 6 French labels of VesselDrawer (Identité /
Activité / Alertes / Incidents / Fermetures / Journal) all sit in the
420 px drawer without truncating "Fermetures" to "Ferm".

CHECKPOINT.md updated with the post-livraison fix log and the
manual-only verification queue (mobile 360x800, SSE live, Lighthouse).

Verification (Chrome MCP):
  - Type "antarctic" -> URL becomes ?q=antarctic, list 3/23
  - Click "À risque" chip -> URL ?q=antarctic&bucket=at-risk
  - Ctrl+Shift+R -> input rehydrates, "À RISQUE 9" chip stays active,
    list still 3/23
  - Drawer tabs: Identité Activité Alertes Incidents Fermetures all
    visible at once in 420 px, Journal one tap of horizontal scroll
    away.
* chore(plan): pick P2-T01 [alpha]

* feat(P2-T01): backend endpoint historical positions [alpha]

Phase 2 wave 0 — backend support for the timeline scrubber (P2-T03)
and vessel-page trail (P2-T04).

GET /api/vessels/{slug}/positions?days=N&downsample=M

- Resolves slug -> mmsi via vessel_status, 404 on unknown.
- Pulls AIS track over the past N days (1..90), downsampled to one
  point per `downsample` minutes (1..60) when raw points exceed 1000.
- Tags each point with an instantaneous severity tier:
    CRITICAL  in MPA polygon
    SUSPICIOUS  in buffer / penguin buffer, OR open water after a
                >= 6h AIS gap (matches GAP_SUSPICIOUS_HOURS used by
                the live worker)
    WATCH       in fishery_subarea
    NORMAL      otherwise
- Surfaces in_zones and ais_gap_open per point so the trail shader
  can color-blend on the way in/out of zones without a second query.
- Returns the same window's relevant events (ZONE_ENTERED / _LEFT,
  AIS_GAP_OPENED / _CLOSED, SEVERITY_CHANGED) for the scrubber's
  marker rendering.
- Cache-Control: public, max-age=60.

Files:
- server/services/positions_history.py: pure helper over Store +
  ZoneIndex; no FastAPI / I/O surface, easy to unit-test.
- server/services/__init__.py: package marker.
- server/app.py: new route alongside the existing /track + /timeline
  ones, lazy-imports the service to avoid circular cost on startup.

Tests (tests/test_positions_history.py, 7 passing):
- unknown_slug_returns_none           service-level 404 path
- short_window_no_downsample          1 day -> raw, downsample=null
- dense_track_triggers_downsample     2000 pts -> bucketed, label "15min"
- severity_inside_mpa_is_critical     centroid of an MPA polygon
- severity_after_long_gap_is_suspicious   8h gap in open water
- http_unknown_slug_404               TestClient HTTP path
- http_invalid_days_422               days=999 -> Pydantic 422

Smoke (isolated uvicorn on :8001): 200 OK on
/api/vessels/antarctic_endurance/positions?days=30 with the spec
shape; p95 latency over 20 calls = 4 ms (target < 300 ms).

The running uvicorn on :8000 was started without --reload (10h+
uptime, AIS worker live), so this code only takes effect on next
restart of `npm run dev:api`.
* chore(plan): pick P2-T02 [alpha]

* feat(P2-T02): vessel trail component with severity coloring [alpha]

Phase 2 wave 1 — React headless component that paints the historical
trail published by P2-T01 onto a MapLibre map, segmented by severity.

- web/lib/trail-types.ts: TS contract mirroring the P2-T01 JSON
  payload (TrailPosition, TrailEvent, VesselHistory).
- web/lib/trail-coloring.ts: pure helpers — positionsToTrailGeoJSON
  builds one LineString feature per maximal run of constant severity
  (avoids line-gradient lockin and breaks the polyline at AIS gaps so
  we never draw across a 6 h silence). positionsToEventMarkers joins
  each event back to its closest position by ts (binary search) and
  drops events with no resolved coord.
- web/components/map/layers/trailLayers.ts: addTrailLayers /
  setTrailData / setTrailVisibility / removeTrailLayers. Three layers
  on two sources: vessel-trail-line (severity-colored polylines),
  vessel-trail-current (slightly bigger dot at the head of the
  trail), vessel-trail-event-markers (white circles outlined by
  severity). All severity colors come from web/lib/tokens.severityHex
  so they stay in sync with the rest of the live UI.
- web/components/map/VesselTrail.tsx: headless React wrapper. Defers
  layer install until the parent map's style.load fires (handles the
  satellite toggle reload case from P1-T02). Cleans up sources on
  unmount only — prop changes flush data through the existing
  layers.

P2-T04 owns the orchestration: fetch the history, mount <KrillMap>
and <VesselTrail> together, hand the same `history` to the timeline
scrubber (P2-T03) which will filter `positions` by current ts.

Verification: typecheck + next build OK (45 static pages, no new
chunk size impact since the trail code only loads inside /vessels/
[slug] surfaces wired by P2-T04).
* chore(plan): pick P2-T03 [alpha]

* feat(P2-T03): timeline scrubber + range selector + playback hook [alpha]

Phase 2 wave 1 — accessible time-cursor control consumed by P2-T04
to filter <VesselTrail> positions to a sub-range.

- web/lib/timeline-state.ts: pure types (TimelineRange, TimelineSpeed,
  TimelineState) + helpers (rangeBoundaries, clampToRange,
  timestampToRatio / ratioToTimestamp, shiftMinutes). No React.
- web/hooks/useTimelinePlayback.ts: setInterval-based playback loop.
  Advances `current` by tickMs * speedMultiplier on each tick, emits
  onComplete when the cursor catches up to "now".
- web/components/timeline/TimelineRangeSelector.tsx: 4-chip selector
  (24h / 7j / 30j / 90j). Switches to a native <select> when
  `compact` is true (mobile path from P1-T07).
- web/components/timeline/TimelineMarker.tsx: tiny glyph (▼ ▲ ◆ ◇ ●)
  positioned at a 0..1 ratio above the track. Severity-colored,
  optional onClick jumps the scrubber to that event.
- web/components/timeline/TimelineScrubber.tsx: full slider —
  pointer drag with pointerCapture, keyboard (Home/End/←/→ ±1min,
  Shift+arrow ±10min, Space play/pause), ARIA role="slider" with
  aria-valuemin/max/now/text. Renders range chips, play/pause,
  speed (1× 10× 100× 1000×), event markers above the track, and
  a human-readable "now" / "−Nj" / current-ts ribbon below.
- web/i18n/pending/P2-T03.json: namespace placeholder for the
  agg pass.

Verification: typecheck + next build OK. Component is fully headless
of map/data layers — P2-T04 will own the orchestration (fetch,
mount LiveTrackerMap + VesselTrail + this scrubber, slice the
positions array by current ts).
* chore(plan): pick P2-T05 [alpha]

* feat(P2-T05): campaign hub routes + 6 components [alpha]

Phase 2 wave 1 — /[locale]/campagnes index + /[locale]/campagnes/<slug>
hubs for the four CPWF missions (krill / whaling / galapagos /
west-africa). Layout is complete; editorial copy is loaded from
web/data/campaigns/<slug>.<locale>.md, today populated with
placeholders so build is green before P2-T06 ships real content.

- web/lib/campaigns.ts: registry of CAMPAIGNS keyed by slug, with
  per-campaign filter rules (vesselSlugs explicit OR vesselFlags),
  primaryColor + bgGradient. getCampaignVessels() filters a
  VesselSummary[].
- web/app/[locale]/campagnes/page.tsx: index, 4 large gradient
  cards with mission tag + tagline.
- web/app/[locale]/campagnes/[slug]/page.tsx: ISR (revalidate 300s),
  generateStaticParams over 4 slugs x 2 locales = 8 pre-rendered.
  Inline frontmatter parser (small enough to keep gray-matter out of
  the dep tree until P2-T06 actually needs MD body rendering).
- web/components/campaign/CampaignHero.tsx: gradient hero, tag pill,
  title + accent split, lead, optional 3-metric strip, 2 CTAs (fleet
  anchor + CPWF donate).
- web/components/campaign/CampaignLegalFrame.tsx: "Cadre légal &
  enjeu" section, prose layout for the MD-driven legal frame.
- web/components/campaign/CampaignFleetList.tsx: server component,
  fetches VesselSummary[], filters via getCampaignVessels, renders
  the campaign vessels as Link cards into /vessels/<slug>.
- web/components/campaign/CampaignTimeline.tsx: pulls /api/events?
  slug=<slug>&days=90 for each campaign vessel and merges into a
  90-day mono timeline (top 40, ISR-cached 5 min).
- web/components/campaign/CampaignSeasonCalendar.tsx: 12-month
  bar with active months highlighted in the campaign primaryColor.
- web/components/campaign/CampaignSourcesList.tsx: external sources
  with hostname-only labels.
- web/data/campaigns/<slug>.<locale>.md: 8 placeholders (krill FR has
  the realest copy as a sample), parsed by the route's inline FM
  reader. P2-T06 owns these files.
- web/i18n/pending/P2-T05.json: namespace placeholder.

Verification: typecheck + next build OK — 55 static pages (was 45),
+8 campaign hubs + 2 indexes wired in. /fr/campagnes/krill renders a
full hero from the placeholder MD. /fr/campagnes/inexistant → 404.
* chore(plan): pick P2-T07 [alpha]

* feat(P2-T07): glossary +6 terms + tap-friendly Acronym popover [alpha]

Phase 2 wave 2.

- web/lib/glossary.ts: 12 -> 18 entries. New: dark fleet, transhipment,
  reefer, trigger closure, beneficial owner, subarea. Each entry keeps
  the existing { expansion, definition } x { fr, en } shape so the
  /about glossary page picks them up without code changes.
  GLOSSARY_ORDER now lists the 6 new keys at the end so existing
  visitors see the prior ordering above the fold and the new ones
  appear below as a "newly explained" section.
- web/components/Acronym.tsx: rewritten as a "use client" toggling
  popover. Tap (mobile) or click (desktop) opens an inline panel with
  the full expansion, the one-sentence definition, and a "See glossary
  ->" link to /about#glossary-<term>. Click outside or Escape closes.
  Existing native title= tooltip is preserved for the hover-only path.
  ARIA: button + aria-expanded + aria-controls + dialog popover with
  the term anchor as id.

Verification: typecheck + next build OK (no static pages added).
Existing call-sites — server components rendering <Acronym> in
about/methodology copy — keep working: the new component is still a
default React export, the prop signature didn't change, and the
"use client" boundary is invisible to the parent server tree.
* chore(plan): pick P2-T04 [alpha]

* feat(P2-T04): vessel page history map + scrubber integration [alpha]

Phase 2 wave 2 — assembles P2-T01 (positions endpoint), P2-T02
(VesselTrail) and P2-T03 (TimelineScrubber) into a "30-day history"
block that lives just below the vessel header on
/[locale]/vessels/<slug>.

- web/lib/use-vessel-history.ts: client hook over the P2-T01 endpoint
  (/api/vessels/<slug>/positions?days=N). Per-tab in-memory cache
  keyed by (slug, days), 60 s TTL matching the server's
  Cache-Control. AbortController on slug/days change. Plus a
  filterHistoryUpTo(history, currentIso) helper used to slice the
  trail incrementally as the scrubber moves.
- web/app/[locale]/vessels/[slug]/VesselHistoryMap.tsx: client
  orchestrator. Mounts MapLibre directly (dark-matter style), hosts
  <VesselTrail> via the local map ref, drives <TimelineScrubber>
  state (range / current / isPlaying / speed). Auto-fits the map
  bounds the first time data lands and on range change, but
  intentionally not on scrubber movement so the viewport stays put
  while the user explores. Renders skeleton / empty / error states
  inline.
- web/app/[locale]/vessels/[slug]/VesselDetailClient.tsx: thin
  "use client" boundary that wraps VesselHistoryMap so the rest of
  the vessel page (header, identity, event log, incidents) keeps
  rendering on the server.
- web/app/[locale]/vessels/[slug]/page.tsx: imports apiBaseUrl,
  inserts <VesselDetailClient> right after the header, before the
  existing 3-column identity/log layout. Static params unchanged.

Verification: typecheck + next build OK. /[locale]/vessels/[slug]
now ships at 12.9 kB (was 7.95 kB) — extra chunk = maplibre-gl
+ trail layers + scrubber + playback hook. 55/55 static pages.

Phase 2: T01 + T02 + T03 + T04 + T05 + T07 completed. Remaining:
T06 (editorial content) and T08 (discoverability + checkpoint).
* chore(plan): pick P2-T06 [alpha]

* feat(P2-T06): editorial content for 4 campaigns FR + EN [alpha]

Phase 2 wave 2 — fills the 8 placeholder MD files left by P2-T05 with
factual editorial copy. Frontmatter is exhaustive (consumed today by
the T05 hub components); markdown body is present for prose sections
that future renderers (P2-T08+) can pick up.

- web/data/campaigns/krill.{fr,en}.md: CCAMLR framing, MPA blockage,
  trigger-closure mechanism, 48.1 history, Aker BioMarine + Chinese
  operators + CPWF intervention.
- web/data/campaigns/whaling.{fr,en}.md: IWC 1946 + 1986 moratorium,
  Iceland/Norway/Japan stances, 2014 ICJ JARPA II ruling, 2019 Japan
  withdrawal, 2024 Kangei Maru launch, Hvalur 8/9 fin-whale season.
- web/data/campaigns/galapagos.{fr,en}.md: 1998 reserve + 2022
  Hermandad expansion, 2017 incident with ~6,600 sharks, AIS-off
  pattern, Pingtan, surveillance asymmetry framing.
- web/data/campaigns/west-africa.{fr,en}.md: UNCLOS EEZs, COREP
  limits, FOC laundering, EJF/Greenpeace documentation, food-security
  -> migration thread.

Editorial rules followed:
  - Journalistic, factual tone.
  - No "scandal" / "pillage" rhetoric.
  - Treaty dates and historical facts in public domain only.
  - Precise catch tonnages and percentage figures intentionally
    omitted (HTML comment in each file flags the choice).
  - EN is parallel writing, not literal translation.
  - Sources point to primary registries (CCAMLR, IWC, COREP) and
    long-running NGO documenters (ASOC, EJF, Greenpeace).

Verification: next build OK (55 static pages — 8 hub variants among
them now render with real frontmatter rather than TODO placeholders).

Caveat: this is a -drafted brief. Per spec note line 143, an
OSINT-aware human review is recommended before public production.
* feat(P2-T08): campaign hubs discoverability + i18n agg + checkpoint [alpha]

Phase 2 wave 3 — final polish closing the phase.

- web/components/Nav.tsx: ajout des liens "Campagnes" et "À propos" /
  "Campaigns" + "About" dans la nav globale (sticky header). Le mobile
  pill scroller hérite automatiquement des nouveaux items.
- web/components/Footer.tsx: 4 colonnes au lieu de 3, nouvelle colonne
  "Campagnes" / "Campaigns" avec les 4 liens vers /campagnes/<slug>.
- web/components/home/MissionStrip.tsx: les 4 cards de la home
  pointent maintenant vers /campagnes/<slug> (au lieu d'un anchor sur
  /vessels). Helper anchorFor() retiré (devenu dead code), remplacé
  par campaignHrefFor() qui mappe iuuGalapagos -> 'galapagos' et
  iuuWestAfrica -> 'west-africa'. Hover bump (scale 1.01) + perte du
  modificateur card-static pour réactiver le hover-orange.
- web/i18n/pending/P2-T03.json + P2-T05.json supprimés (placeholders
  vides — strings phase 2 restent inline FR/EN dans les composants,
  même choix que la dette i18n de phase 1).
- docs/superpowers/specs/2026-04-28-krill-refonte/phase-2-historique/
  CHECKPOINT.md: bilan complet — 8/8 livrées, build 55 routes,
  Lighthouse / UAT manuelle restant à exécuter avant merge -> main.

Verification: typecheck + next build OK (55 static pages).

Phase 2 closed: 8/8 tasks. Phase 3 unblocked.
* chore(plan): pick P3-T01 [alpha]

* feat(P3-T01): incident emitter webhook + auto-incidents schema [alpha]

Plumbing for auto-generated /incident/<slug> pages:

- DuckDB v2 migration: incidents_auto table (slug PK, severity, score,
  position, details_json, sources_json, revalidated_at).
- Store: upsert_incident_auto / get_incident_auto / list_incidents_auto
  / mark_incident_revalidated.
- server/services/incident_emitter.py: deterministic slug generator
  matching the TS twin, IncidentPayload, upsert_incident,
  trigger_revalidate (POST → Next.js with shared secret), emit_incident
  (persist + revalidate + stamp on success).
- server/app.py: POST /api/internal/incident (X-Internal auth),
  GET /api/auto-incidents, GET /api/auto-incidents/{slug}.
- web/lib/incident-slug.ts: kebab + isoDate slug generator (twin).
- web/app/api/revalidate/route.ts: revalidatePath for fr+en, optional
  tag revalidation, 401 on bad secret.
- 12 new tests (slug determinism, upsert idempotency, webhook env
  short-circuit, success path, 5xx soft-fail, persistence-when-webhook-
  fails).

Env vars (added to .env.example and web/.env.example):
  NEXTJS_BASE_URL, KRILL_REVALIDATE_SECRET, KRILL_INTERNAL_SECRET.
* feat(P3-T02): /incident/[slug] ISR page + map + context + sources [alpha]

Public per-incident pages auto-fed by the P3-T01 webhook:

- web/lib/incidents.ts: fetchIncident / fetchIncidentsList / parseIncident
  (snake_case wire → camelCase Incident type), buildIncidentHeadline
  with ZONE_ENTERED / AIS_GAP_OPENED / TRANSHIPMENT_DETECTED /
  SEVERITY_CHANGED templates in fr+en, deriveCampaignSlug
  (vesselSlugs > vesselFlags), formatDetectedAt with fixed UTC.
- web/app/[locale]/incident/[slug]/page.tsx: server component, ISR
  (revalidate=300), generateStaticParams pre-renders top 50, dynamic
  fallback for fresh slugs, OG/twitter metadata pointing at
  /api/og/incident/<slug> (P3-T03), severity badge, vessel link,
  back-to-tracker.
- IncidentMap (client island): MapLibre with 7d trail filtered to
  ±3.5d around detectedAt, severity-coloured pulsing marker via CSS
  keyframes added in globals.css, navigation+scale controls.
- IncidentContext (server): reads data/campaigns/<slug>.<locale>.md,
  extracts the "Pourquoi krill-watch suit cette campagne" /
  "Why krill-watch tracks this campaign" section, truncates to 80
  words, links to the campaign hub.
- IncidentSources (server): numbered external link list with safe
  rel attrs and a 3-source fallback when the pipeline supplied none.
* feat(P3-T03): dynamic OG images for incidents/vessels/campaigns [alpha]

Three edge runtime endpoints powered by next/og (Satori):

- /api/og/incident/<slug>?locale=fr|en — severity chip, headline,
  flag pill + vessel name, score/position meta, abstract grid map
  with severity-coloured pulse marker.
- /api/og/vessel/<slug>?locale=fr|en — flag pill, vessel name,
  IMO/MMSI/flag triplet, severity chip, current zones.
- /api/og/campaign/<slug>?locale=fr|en — campaign tag + brand,
  title (truncated to 70 chars), lead (200 chars), bgGradient.

All three fall back to a branded krill-watch tile when data is
unreachable, and ship cache-control headers (5min/1h with SWR).

Implementation details:
- web/lib/og-image-renderer.tsx hosts the JSX trees. Each render
  function inlines its own <div> with FRAME_STYLE rather than
  delegating to a higher-order frame() helper — Satori was rendering
  Fragment-passed children as a row in column flex, this works around
  it.
- Flag emojis are rendered as ISO code monospace pills (Satori needs
  an emoji font for the real glyph; the pill is more legible at
  social-card resolution anyway).
- Abstract map: SVG grid + 3-circle pulse, severity colour, no real
  basemap (Satori can't raster MapLibre).
- Wired into the incident page metadata in P3-T02 already; vessel
  and campaign pages can adopt by adding `images:
  '/api/og/<kind>/<slug>'` to their generateMetadata.

Verified:
- typecheck clean
- /api/og/incident/<unknown> renders fallback (1200×630 PNG, ~100KB)
- /api/og/campaign/krill renders correctly
- /api/og/vessel/antarctic_endurance renders with full identity
* feat(P3-T04): ShareButton component + intent URLs [alpha]

Native-share-first share UI with desktop popover fallback:

- web/lib/share-urls.ts: tweet/bluesky/mastodon/linkedin intent
  builders, ogImageUrl, buildShareTitle templates per
  kind+locale (incident / vessel / campaign × fr/en).
- web/components/share/ShareButton.tsx: primary/ghost variants,
  detects navigator.share() on mobile and uses it directly,
  desktop falls through to ShareMenu popover.
- web/components/share/ShareMenu.tsx: 6-option popover (Twitter,
  Bluesky, Mastodon, LinkedIn, Download OG image, Copy link),
  full keyboard nav (↑↓ Esc), click-outside to close, autofocus
  on open. Mastodon prompts for + remembers instance domain in
  localStorage.
- web/components/share/CopyLinkButton.tsx: standalone copy-link
  for places that don't need the full menu — clipboard API with
  execCommand fallback for older browsers / private mode.
- Wired into the incident page header next to the vessel/tracker
  links, primary variant. Uses NEXT_PUBLIC_SITE_URL when present
  (prod canonical), falls back to https://krill-watch.org.
* feat(P3-T05): public contribution form + DuckDB storage [alpha]

End-to-end submission pipeline for /contribuer:

- Schema v3: new `contributions` DuckDB table (UUID PK, category,
  vessel identity, observed_at + lat/lon, free description, optional
  on-disk photo path, status + reviewer_notes for the eventual
  moderation UI). Indexed on submitted_at and status.
- Store API: insert_contribution, get_contribution, list_contributions
  (status filter).
- server/services/contributions_store.py: ContributionInput +
  PhotoBlob dataclasses, validate (50-char min description,
  whitelisted MIME, 5 MB photo cap, email shape check), photo
  persistence under data/contributions/<uuid>/, optional Discord
  webhook notification (KRILL_ADMIN_DISCORD_WEBHOOK) — webhook payload
  excludes contact_email and contact_name to keep PII out of the
  notification channel.
- POST /api/contributions on FastAPI accepts multipart/form-data,
  surfaces ContributionError as 422.
- Next.js /api/contribute proxy re-streams multipart upstream so the
  browser never sees the OSINT origin and CORS is sidestepped.
- web/app/[locale]/contribuer/page.tsx: server-rendered intro, privacy
  + limits notes, generateMetadata with hreflang.
- ContributionForm: 4 categories (vessel-sighting / transhipment /
  ais-anomaly / other) with adaptive fields, MIN_DESC=50 live
  countdown, consent checkbox required, locale-aware labels.
- PhotoUpload: drag-drop or file picker, client-side validation,
  preview with size + remove.
- ContributionSuccess: thank-you with short ID, conditional contact
  follow-up note.

Coverage: 10 new tests in test_contributions.py — round trip,
validation rejections (description / category / email / photo
size / mime), photo persistence with filename slugification, webhook
PII guarantee (email + name absent from body), webhook skip when
unset.
* feat(P3-T06): /digest index + /digest/[isoweek] route [alpha]

Exposes the existing transhipment-review HTML files (one per ISO
week, generated by the Python pipeline under
output/transhipments_<YYYY>-W<WW>.html) as Next.js routes:

- FastAPI: GET /api/digests (newest-first list with size +
  generated_at) and GET /api/digests/<isoweek> (full HTML body).
  Strict YYYY-Www regex on the path so traversal attempts get
  400'd before we touch the filesystem.
- web/lib/digests.ts: typed wrappers, isoWeekRange helper that
  computes the Mon→Sun calendar range from the ISO week, defensive
  scrubDigestHtml that strips <script>/<iframe>/<object>/<embed>
  and inline event handlers as defence-in-depth (the source HTML
  is our own Jinja2 template, but layered defence costs nothing).
- /[locale]/digest: card list with isoweek, range, generated date,
  size. Empty-state message when the pipeline hasn't published any
  digest yet.
- /[locale]/digest/[isoweek]: ISR (revalidate=3600), generateStatic-
  Params pre-renders all known weeks fr+en, dangerouslySetInnerHTML
  on the scrubbed body inside a prose container.

Tests: 5 new tests in test_digests.py — listing newest-first,
404 on unknown week, 400 on malformed isoweek, traversal rejected,
HTML body returned. Decoy files (krill_fleet.geojson, malformed
filenames) are correctly skipped by the regex.
* feat(P3-T07): pipeline → incident emitter wiring [alpha]

Hooks the ZONE_ENTERED detection in server/workers and the
transhipment scorer into P3-T01 so each detection auto-generates a
shareable /incident/<slug> page.

- server/workers._process_position: after the existing event-log
  insert and SSE broadcast, fire-and-forget asyncio.create_task into
  a new _emit_incident_safe wrapper. Filters: severity must be
  CRITICAL/SUSPICIOUS/WATCH AND zone.category in {mpa, buffer_zone,
  penguin_buffer}. Fishery subareas (CCAMLR 48.x) and CPWF friendly
  vessels are excluded — they belong in the live event log, not on
  a shareable page.
- server/services/incident_emitter.transhipment_incident_payload:
  helper that translates a ScoredEncounter dict into an
  IncidentPayload (severity = CRITICAL when score ≥ 0.75 else
  SUSPICIOUS, default sources = GFW + AISStream, partner + reefer
  + duration + signals carried in details). Returns None below
  the 0.50 FLAGGED tier.
- src/notifier._fmt_live_event: appends NEXTJS_BASE_URL/<locale>/
  incident/<slug> to the message when the event is ZONE_ENTERED or
  TRANSHIPMENT_DETECTED, locale picked from WEB_DEFAULT_LOCALE
  (en fallback). Slug derivation reuses make_slug() so it matches
  the page that was just generated.

Tests: 4 zone-monitor wiring tests (MPA → emit, fishery_subarea →
skip, CPWF → skip, idempotent on repeat ticks) and 6 transhipment
helper tests (below-threshold None, FLAGGED→SUSPICIOUS,
CRITICAL≥0.75, missing vessel None, ISO ts parsing, default sources
attached). 12 notifier regressions still pass.
* feat(P3-T08): SupportCTA partout + ShareButton coverage + phase-3 checkpoint [alpha]

Closes phase 3 by:

- web/components/SupportCTA.tsx: 3 variants (inline, banner,
  minimal) all linking to paulwatsonfoundation.org/donate with
  utm_source=krill-watch + utm_medium=<variant> tracking. Banner
  variant is the educational card; minimal is the muted footer
  link; inline is the primary button used in CTAs.
- Home page: <SupportCTA variant="banner"> after MethodologyBlock.
- Incident page: <SupportCTA variant="banner"> below sources.
- Vessel page: ShareButton in the header (kind=vessel) +
  <SupportCTA variant="minimal"> at the bottom of the article.
- Campaign page: ShareButton just below the hero (kind=campaign) +
  <SupportCTA variant="banner"> above the footer.
- Footer: <SupportCTA variant="minimal"> alongside HealthPill so
  every page in the site has at least one path to donate.

Verification (CHECKPOINT.md):
- 37 / 37 phase-3 Python tests pass.
- web typecheck clean.
- web build clean — all phase-3 routes prerendered or marked
  dynamic, edge runtime correctly bundled for OG endpoints.
- OG images 1200×630 PNGs verified for incident (fallback +
  populated), vessel, and campaign — screenshots/og-*.png.
- Phase 3 README dashboard updated to all-green.
- Top-level README "What it does" section grew with the 4 new
  phase-3 features (campaign hubs, incident pages, OG images,
  contribution form, digest route, alert deep links).

Phase 3 limits documented in CHECKPOINT.md (modération UI,
chiffrement E2EE, Twitter Card validation, backfill script — all
explicitly out-of-scope for this phase).
* chore(plan): sync P3-T01..T08 frontmatter to completed [alpha]

* fix(P3-T05): add python-multipart to requirements-core

FastAPI's form() parser requires python-multipart at runtime —
without it, POST /api/contributions returns 400 with the message
"The python-multipart library must be installed to use form
parsing." The contribution form pipeline (P3-T05) is the first
caller of form() in this codebase, so the dependency wasn't
previously surfaced.

Verified by an E2E submission against the running backend after
installing the package: contribute → 200 with {id, short_id,
status:"received"}.
* docs(refonte): visual verification screenshots from phase 3 UAT

16 captures from local UAT of phase 3 surfaces:
- home (FR/EN/mobile)
- campaign hub krill (FR/EN/mobile)
- incident page (real data + 404 case)
- vessel dossier
- contribute form (FR/EN/mobile)
- digest index
- ShareButton open menu

Used as evidence in P3-T08 CHECKPOINT.md. Not part of any spec
deliverable, kept here for repository historical record.
* docs(refonte): manual test checklist (12 sections, 60-75 min)

Comprehensive manual test plan to walk through before pushing to
prod. Organized by audience persona (activist / random / journalist
/ whistleblower) and severity (🔴 critical / 🟡 important / 🟢 nice-to-have).

Includes step-by-step instructions for: webhook→revalidate E2E with
copy-paste curl, Lighthouse commands for the 5 key pages, mobile
DevTools emulation, and edge case 404 verification.
* fix(audit): P0-P3 batch — hydration, keys, i18n, OG, mission split, playback UX

Comprehensive audit-driven fixes across 4 priority tiers, persona = activist /
journalist preparing maritime IUU/krill investigations.

P0 — React stability on vessel detail page
* TimelineScrubber: hydration mismatch (Date.now() at SSR vs client)
  fixed by deferring `now` state to useEffect (web/components/timeline/TimelineScrubber.tsx)
* VesselHistoryMap: `current` initial value now empty string, set client-side
  to keep SSR/first-paint identical (web/app/[locale]/vessels/[slug]/VesselHistoryMap.tsx)
* CampaignTimeline duplicate React keys — root cause was wrong query param
  (`?slug=` ignored by FastAPI, every fan-out returned the same global event
  list). Fixed by switching to `?vessel=` and adding Set-based dedup
  (web/components/campaign/CampaignTimeline.tsx)

P1 — i18n / SEO / discoverability
* `<html lang>` now derives from URL pathname via middleware-injected
  x-pathname header — was hardcoded "fr" on every page (web/middleware.ts,
  web/app/layout.tsx)
* Locale-aware `<title>`, `<meta description>`, openGraph, twitter:card,
  canonical, hreflang fr/en/x-default in [locale]/layout.tsx
* og:image now set on home (was missing) — defaults to /api/og/campaign/krill
* "30 j" leak removed on /en — TimelineScrubber bottom label now locale-aware
* Footer exposes Live tracker, Transhipments, Weekly digests, Contribute
  (previously invisible without URL guessing)

P2 — Editorial / UX
* Markdown leak on incident page IncidentContext — CRLF line endings made
  the regex `m`-flag match `$` before every \n, stopping the lazy quantifier
  at the heading. Fixed via 2-step extraction (header match + boundary
  match). Page now renders 530 chars of body instead of raw "## Why..."
* Vessel detail page now has its own generateMetadata pointing to
  /api/og/vessel/{slug} — was inheriting the campaign krill OG fallback
* Privacy + limits notice on /contribuer moved ABOVE the form so a
  whistleblower-adjacent contributor sees the guarantees before filling
* New /transhipments page (was 404 despite README claim) — fetches
  /api/transhipments?days=30 and renders summary + 6 weighted signals +
  thresholds + links to live/digest/calibration source
* Playback UX on vessel timeline: clicking ▶ from "now" was effectively
  a no-op (cursor already at right edge). Fixed: rewind to range start if
  at end, auto-pause on completion. Resume from middle keeps position.

P3 — Mission grouping + polish
* Galápagos and West Africa now distinct mission tags in VESSELS data
  (was lumped into "iuu_fishing" — broke the editorial framing the README
  promises). /vessels page now shows 4 sections instead of 3:
  KRILL (7) · WHALING (3) · GALÁPAGOS (3) · WEST AFRICA (3)
* MissionBadge + MissionStrip + ActiveAlertsStrip handle the new tags
* Domain canonicalization: 4 SHARE-button fallbacks unified on
  https://krill-free.org (was mixed with krill-watch.org)

Verifications
* SSR via curl: html lang correct fr/en, hreflang triples, og:image set
* DOM via  Preview: 0 hydration errors, 0 duplicate keys after fixes
* Server logs clean across all reloaded pages
* TypeScript: tsc --noEmit passes

Two non-bugs identified during audit (kept here for the record):
* "Mojibake em-dash" in API JSON output was a Windows console encoding
  artefact in `python -c` — raw bytes are clean UTF-8 (e2 80 94)
* "ON WATCH untranslated" was already translated as "SURVEILLÉES"
  (HeroStats) and "à surveiller" (LiveStatusBanner) — initial regex missed it

See docs/AUDIT_NOTES.md for the full per-page audit and verdicts, and
docs/API_INTEGRATIONS_PLAN.md for the next-step plan to integrate
OpenSanctions, Marine Regions and Skylight (3 high-leverage APIs that
unblock the documented stubs and the Galápagos / West Africa campaigns).
---------

Co-authored-by: breaching <devsca@protonmail.com>
breaching pushed a commit that referenced this pull request May 11, 2026
Adds the 6 specs called out in PROD_READINESS #3 + #15:

- web/e2e/live-loads.spec.ts: open /en/live, assert >=1 vessel dot,
  banner shows fleet count.
- web/e2e/vessels-loads.spec.ts: /en/vessels mini-map renders with
  dots, click first dot -> popup with severity + dossier link.
- web/e2e/vessel-detail.spec.ts: /en/vessels/antarctic_endurance,
  history map height >100px (regression for the inset-0 collapse),
  trail SVG present, scrubber controls visible.
- web/e2e/firms-toggle.spec.ts: toggle FIRMS layer, assert
  /api/firms/recent fires + label updates.
- web/e2e/operator-background.spec.ts: vessel detail page, assert
  "Aker BioMarine" + "Norway" appear (Wikidata cascade integration).
- web/e2e/a11y.spec.ts: @axe-core/playwright across 7 routes
  (home, about, vessels list, campaigns hub, privacy, terms, legal),
  WCAG 2.1 AA tags. Fails on serious/critical only — minor/moderate
  tolerated so a single tag-soup edge case doesn't block deploys.

- web/playwright.config.ts: chromium project, fullyParallel,
  CI-aware retries/workers/reporter. We deliberately don't use
  Playwright's webServer option — orchestration is owned by the root
  `npm run dev`.

- .gitignore: drop web/test-results/, web/playwright-report/,
  web/playwright/.cache/.

Closes prod-P0 #3 (5 specs) and the axe-core half of P2 #15.
breaching pushed a commit that referenced this pull request May 11, 2026
Phase-1 closeout dette items #3 and #4 from manual UAT.

P1-T03 — FleetSidebar grows an optional controlled-mode for the search
text and the bucket chip (searchValue/onSearchChange + bucket/
onBucketChange). When the parent passes them, it owns the state; when
omitted, the sidebar still keeps its own local state for backward
compat (existing call sites and tests stay green).

P1-T05 — LivePageClient now wires those callbacks: handleSearchChange
and handleBucketChange call updateState({filter: {...state.filter, X}})
which round-trips through the URL via writeUrl. Hard reload of
/fr/live?q=antarctic&bucket=at-risk now restores both the input and
the active chip. The mobile branch (LiveMobileLayout ->
MobileFiltersSheet) accepts and forwards the same props so deep-linked
filters work on mobile too.

P1-T01 — Tabs.Trigger padding reduced from px-4 to px-2.5 with
whitespace-nowrap so the 6 French labels of VesselDrawer (Identité /
Activité / Alertes / Incidents / Fermetures / Journal) all sit in the
420 px drawer without truncating "Fermetures" to "Ferm".

CHECKPOINT.md updated with the post-livraison fix log and the
manual-only verification queue (mobile 360x800, SSE live, Lighthouse).

Verification (Chrome MCP):
  - Type "antarctic" -> URL becomes ?q=antarctic, list 3/23
  - Click "À risque" chip -> URL ?q=antarctic&bucket=at-risk
  - Ctrl+Shift+R -> input rehydrates, "À RISQUE 9" chip stays active,
    list still 3/23
  - Drawer tabs: Identité Activité Alertes Incidents Fermetures all
    visible at once in 420 px, Journal one tap of horizontal scroll
    away.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants