feat(web): event log filter chips on vessel detail#3
Merged
Conversation
…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
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>
4 tasks
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 byZONE_ENTERED,ZONE_LEFT,AIS_GAP,AIS_SEEN,SEVERITY_CHANGED. Only chips for event types present in the fetched data are shown (no phantom chips). UseseventTone()for chip colors.web/lib/i18n.ts— addsvesselDetail.eventFilter.*keys in both EN and FR.web/lib/utils.ts— addseventTone()helper andEventTypetype.web/lib/krill-watch.ts— addsVesselEvent,VesselDetailtypes andfetchVesselDetail()server-side fetcher.Verification
npx tsc --noEmit→ cleannpx next build→ clean, vessel detail static pages generated for all fleet vesselspython -m pytest tests/→ 81 passed (no regressions)/en/vessels/antarctic_enduranceconfirms: filter chips render, CRITICAL severity badge, MPA zone-entry/exit events listed, MarineTraffic link present