Compare commits

...
505 Commits
Author SHA1 Message Date
Hanzo Dev 0077dfd590 release: v2.4.36 — alt-data plane (insider · defi · layoffs · congress)
Four macro signal sources under /v1/world/*:
  insider  — SEC EDGAR Form 4 filing flow (keyless, live)
  defi     — DeFiLlama TVL + stablecoin float (keyless, live)
  layoffs  — Texas WARN notices (keyless, live)
  congress — Quiver Quant congressional trades (QUIVER_API_KEY, degrades clean)
Bumps the vestigial VERSION file to the release for the first time in a while.
2026-07-21 16:54:10 -07:00
Hanzo Dev 81cd2e1455 feat(world): alt-data plane — /v1/world/layoffs (TX WARN) + /v1/world/congress (Quiver, key-gated)
Completes the four macro signal sources on the alt-data plane:
  insider (SEC Form 4), defi (DeFiLlama), layoffs (TX WARN), congress (Quiver).
layoffs reads the Texas Workforce Commission WARN Socrata feed (keyless, live).
congress reads Quiver Quant when QUIVER_API_KEY is set and degrades to a clean
unavailable payload otherwise — the free house/senate feeds are all gated now,
so this follows the same key-gated pattern as finnhub/eia/acled. Pure parsers
are unit-tested; the route sweep confirms both degrade without 5xx or HTML leak.
2026-07-21 16:53:54 -07:00
Hanzo Dev f24786e5a7 feat(world): alt-data plane — /v1/world/defi (DeFiLlama) + /v1/world/insider (SEC Form 4)
DeFi snapshot: total TVL across chains, top-10 chains by share, stablecoin
float (top-6 by market cap) — one cached pull shared by all viewers.

Insider flow: SEC EDGAR Form 4 filing stream (buys/sells by officers,
directors, 10% holders) — the filing-velocity macro signal. SEC-compliant
descriptive User-Agent; pure atom parsing unit-tested.

Both cached, single-flighted via cachedJSON, degrade to a clean 200
unavailable payload. Routes swept by TestRoutesRespond.
2026-07-21 16:53:54 -07:00
hanzo-dev 8346fb65db release: v2.4.35 — globe-render e2e green + __deckMap-when-functional (on the mapbox-mercator globe-blank fix) 2026-07-21 16:06:05 -07:00
hanzo-dev 21e1bb6577 fix(world/globe): restore globe-render e2e to green + expose __deckMap when functional
The globe-render acceptance suite went red as the flagship Cloud view evolved
(cloud now opens on the native 3D globe, controls collapsed into dropdowns); none
of it was a globe-render bug — the sphere renders and occludes correctly — the
harness just no longer matched the app. Restore it to 6/6:

- e2e/globe-render: pin the 2D->3D toggle path with ?mode=2d. Cloud defaults to the
  immersive 3D globe, which parks the mapbox map and mounts .deckgl-map-wrapper
  hidden, so the old `toBeVisible` precondition could never pass. Enter 3D through
  the proj-btn's REAL click handler (the immersive dock floats over the full-view
  globe canvas, so a headless synthesized mouse click's hit-test lands on the
  canvas, not the collapsed-dropdown button). This spec verifies GLOBE render
  (occlusion/terrain/parity/live dots); dock pointer reachability is out of scope.

- DeckGLMap: expose __deckMap/__mapboxMap as soon as the instance is FUNCTIONAL
  (right after initBasemap creates this.mapboxMap) instead of on the async mapbox
  'load'. When Cloud opens straight into the parked-mapbox native globe, 'load' can
  trail the globe by seconds, so the e2e read an undefined __deckMap right after
  go3D. buildLayers()/asGlobeSource()/setOcclusionCenter()/occludeFarSide() are all
  live at construction; __deckOverlay stays exposed in initDeck where it is built.

- vite: optimizeDeps.include the deck.gl / luma.gl / mapbox graph so a cold dev/e2e
  server pre-bundles @luma.gl/webgl's device adapter up front and never re-optimizes
  mid-load — that reload otherwise races GlobeNative's `new Deck()` and the 3D globe
  never initialises. Prod is unaffected (Rollup build, no dep optimizer).

Verified: e2e/globe-render.spec.ts 6/6 green on an unminified dev server; the dot
globe renders (4726 land dots), traffic dots occlude (back-hemisphere alpha 0),
count badges cull, terrain drapes single-context, live dots update.
2026-07-21 16:05:16 -07:00
hanzo-dev be2bc30a7c fix(cloud-admin): liveness-authoritative up for uninstrumented subsystems
The admin CloudServicesPanel set each subsystem's up from /v1/o11y/status,
so any subsystem o11y lacked telemetry for rendered as DOWN — a live service
shown 'not active'. probeService now treats o11y as metrics enrichment and
derives up from a real liveness probe (livenessURL) when o11y has no
per-deployment data; instrumented stays a separate signal. Live-but-
uninstrumented ⇒ UP with no metrics, never a false down. TDD: 2 new tests green.
2026-07-21 15:33:26 -07:00
hanzo-dev 3d74a312f2 docs(world): AGENTS.md — prefer Hanzo MCP browser over Playwright, globe/dot map + release notes
Repo had no agent doc. Records the browser-control preference (Hanzo MCP
extension for interactive UI, Playwright only for the deterministic e2e
suite or when the extension is absent), the dot-lattice cybermap default
(one getLandDots() shared by 2D DeckGLMap + 3D GlobeNative), and the
bump-patch/tag/dispatch release convention.

Claude-Session: https://claude.ai/code/session_01NP4ehjWW2h98FkE4YjUKuJ
2026-07-21 15:18:14 -07:00
hanzo-dev 423af9abad fix(world/map): globe blank — keep parked mapbox in mercator when the native globe is on
world.hanzo.ai showed a blank center (globe gone). Root cause: the mapbox basemap
was created with projection:'globe' in 3D mode, and mapbox-gl v3.26 throws
"Missing theme" when a plain (non-Standard) style loads in globe projection —
which aborts the whole map init, taking the native deck.gl globe down with it.
(Surfaced now because the v2.4.33 .dockerignore switched CI from runner-cached
node_modules to a clean lockfile npm ci = mapbox-gl 3.26.0.)

When the native deck.gl globe is on (the default), the mapbox map is only a parked,
invisible basemap behind GlobeNative — it never needs globe projection. Add
mapboxProjection(): globe ONLY when 3D AND native globe is off; otherwise mercator.
Applied at map construction + in applyProjection(). No visual change (parked map is
invisible); the native globe now renders.

Verified with a swiftshader (software WebGL) playwright run: the dot-globe renders,
.globe-native-canvas mounts, no 'Missing theme'. tsc + vite build green.

Claude-Session: https://claude.ai/code/session_81b00bd9
2026-07-21 15:17:58 -07:00
Hanzo Dev 75cc42fd1c Merge lux.fund fund variant — Lux Macro terminal + Lux Book + rotation notebook 2026-07-21 14:16:10 -07:00
Hanzo Dev 362cbed84f Merge rotation scanner — RRG sector-rotation detection (red-reviewed) 2026-07-21 14:16:03 -07:00
Hanzo Dev 5b77463ad0 world: lux.fund hides the emoji view-switcher (academic, focused terminal) 2026-07-21 14:15:41 -07:00
Hanzo Dev edbbfe58e4 world: rotation model research notebook (Hanzo Notebook)
A reproducible numpy/scipy reference implementation of the RRG rotation
model that powers the engine. Loads a 6-month daily snapshot of the
32-symbol universe (research/data), formalises RS-Ratio / RS-Momentum and
the quadrant classification, reproduces the current read and the Lux Book
top-10 allocation, and backtests the rotate-out-of-Weakening /
into-Improving thesis.

The numpy read cross-validates the Go engine exactly (Uranium 97.4/103.1,
Hyperscalers weakening 101.4/95.9, Great Rotation 0.55 WATCH). The backtest
is reported as indicative on a single window, not an alpha claim.

Executed notebook (rotation_model.ipynb) + percent-format source + the
data snapshot + the RRG plot.
2026-07-21 14:10:07 -07:00
Hanzo Dev a80b683f61 world: fund variant — Lux Macro terminal (lux.fund white-label)
Add a `fund` variant: the same World 3D-globe engine as finance, Lux
white-labeled and sharpened for the fund's macro read. lux.fund defaults
to it; the Lux wordmark replaces the Hanzo mark on that host.

The Lux Book (LuxBookPanel) derives the top-10 model allocation live from
the rotation engine — conviction weights each theme by quadrant, momentum
tilt and oversold-base bonus, normalised to a 100% book, and rebalances
every refresh. Each position carries a globe anchor and the book is
emitted on the `lux-book` document event for the globe layer to plot.

Rotation scanner and market radar lead the layout; feeds reuse the finance
set; energy/commodity hubs join the globe. Build meta + dev/build:fund
scripts added.
2026-07-21 14:09:36 -07:00
Hanzo Dev 2031ce3a79 world: rotation scanner — harden against bad Yahoo ticks (red review)
Red review fixes:
- Drop non-positive (0/negative) closes in the rotation fetch path via
  positiveCloses. A spurious Yahoo 0 tick on a thin symbol was surviving
  compact() (which keeps 0 for legitimate zero-volume bars) and could flip
  a lead theme's quadrant, toggling the public Great Rotation signal. The
  shared compact() is untouched so volume series stay correct.
- Guard round2s against NaN/Inf (return 0) so a stray non-finite can never
  reach json.Marshal after cachedJSON has cached the value — an airtight
  "never 5xx".
- RotationScannerPanel drops an aborted fetch silently instead of flashing
  "unavailable" over the still-valid render on rapid refresh/destroy.

Tests: positiveCloses drops bad bars; a 0 bar no longer flips the quadrant.
2026-07-21 14:08:04 -07:00
Hanzo Dev 42e8ce1b60 world: rotation scanner — RRG sector-rotation detection
Add /v1/world/rotation: a Relative Rotation Graph engine scoring theme
baskets (AI/semis, hyperscalers, energy, natural gas, uranium, nuclear
power, plus the GICS sectors as context) on RS-Ratio x RS-Momentum
against SPY, into four rotation quadrants and named thesis triggers —
AI distribution, energy-complex accumulation, and the combined Great
Rotation that needs both legs to fire.

RotationScannerPanel renders the quadrant plot with rotation tails, the
signal chips and a momentum-ranked leaderboard. Computed server-side,
cached 15m, single-flighted; degrades to a clean unavailable 200 when
upstream data is thin.

Registered in the finance, tech and full variants.
2026-07-21 13:47:20 -07:00
hanzo-dev 0550159a9a release: v2.4.33 — honest measured Enso benchmark numbers
Ships the corrected benchmark closeout (already on main, now released):
- summary.json: real measured accuracy for GPQA-Diamond / LiveCodeBench / HLE
  / CharXiv across the frontier arm pool + enso/enso-flash/enso-ultra; degraded
  (content-filter/length-truncated) rows carry NO number and are dropped rather
  than shown as a low score.
- SWE-Bench Pro + Terminal-Bench honestly marked pending (not yet run), not faked.
- Dropped the aspirational reference table; the panel stands on measured numbers.
- .dockerignore so host node_modules can't clobber the image npm ci.
Total measured spend $564.25, all real. Handler gate + honest-data tests pass.
2026-07-21 08:59:25 -07:00
Hanzo Dev 2f52c34f9c world: resync; degraded benchmark rows carry no number and are dropped
The snapshot is resynced to enso-bench, where a run lost mostly to blanks now reports no
accuracy at all. The handler already drops degraded rows, so the dashboard shows only clean
complete-run measurements; this keeps the embedded data honest for any other reader of the
snapshot too.
2026-07-20 05:47:44 -07:00
Hanzo Dev d41d4a5b69 world: show honest complete-run scores, drop degraded rows
The benchmark snapshot is resynced to the corrected enso-bench numbers, which are
complete-run scores rather than blank-excluded estimates. Excluding blanks inflated the
numbers because blanks land on the hard items a model gets wrong, so the dashboard was
about to show fable-5 at a spurious 93.6 against its true 81.3. A run that lost most of its
items to blanks is now marked degraded and dropped from the table entirely rather than
displayed with a misleading number, so glm-5.2's 65%-blank run no longer appears as a
score.
2026-07-19 23:49:42 -07:00
Hanzo Dev 7084a2fbbb world: stand on corrected measured numbers, drop the aspirational reference table
The enso benchmark snapshot carried an enso_reported block whose figures did not match our
own measurements -- it showed enso-ultra at 50.0 on HLE where we measured 34.6, and 93.2 on
LiveCodeBench where we measured 81.6 -- and its columns named other vendors. A dashboard
that shows fabricated superiority beside real measurements is worse than one that shows
less, so the block is removed and the dashboard now stands on the corrected measured tables
alone. The tests assert the reference table and its columns are absent rather than present.
2026-07-19 23:37:02 -07:00
Hanzo Dev 2546b0c9d0 world: refresh the enso benchmark snapshot with blank-corrected accuracy
The dashboard embedded a snapshot in which infrastructure blanks -- upstream 200s carrying
no answer -- were scored as wrong, so it displayed glm-5.2 at 31.8 and fable-5 at 81.3
against their true 94.0 and 93.6. The measured block is regenerated from the corrected
enso-bench summary, which computes accuracy over the items the API actually answered, and
the enso family's clean-path results are included.

The head-to-head test asserted the raw livecodebench figure and the old HLE preflight stub;
it now checks the corrected accuracy within a band and treats HLE as the real n=500
measurement it has become.
2026-07-19 23:32:27 -07:00
hanzo-dev e5d2898fd9 fix(build): add .dockerignore so host node_modules can't clobber the image npm ci at COPY . .
No .dockerignore existed, so `COPY . .` (Dockerfile:31) copied the host's
node_modules over the fresh `npm ci` tree — poisoning BuildKit's node_modules
cache-mount ('cannot replace directory with file') and breaking every world
image build (main + PR #19). The web stage installs its own deps, so the host
node_modules/dist/.git must never enter the build context.
2026-07-19 11:30:08 -07:00
hanzo-dev 10caeb61b4 release: v2.4.32 — cinematic dot-globe hero (translucent 3D lattice) + data-driven Enso caveats
Frontend — the "cool 3D dot map" is now the hero everywhere:
- DEFAULT_BASEMAP_STYLE = 'dot' for ALL variants (was cloud/ai only); the
  glowing dot lattice opens every mode. Persisted choice + style switcher win.
- GlobeNative: translucent glowing dot-globe — two passes (a DIM far lattice
  drawn through the sphere + a BRIGHT near pass depth-tested for real back-of-
  globe occlusion), additive blend so dense continents bloom; cool ice-blue rim
  glow atmosphere; radial-vignette background as the premium first-frame state
  (never a dead black hole). Shared position accessor → zero per-frame alloc.
- DeckGLMap: the SAME dot lattice + palette on the 2D mercator basemap (glow
  underlay + crisp core), so 2D ↔ 3D read as one surface; 'Dot' added to the
  style switcher; DEFAULT_BASEMAP_STYLE drives the default/fallback.
- land-dots: LAND_DOT_NEAR/FAR palette lives in ONE home, imported by both
  renderers (values, not places).
- Immersive: panels dock to a translucent GLASS right-rail with the globe as the
  centre hero; light-theme frosted variant + mobile glass bottom-sheet; legend
  becomes glass. CRITICAL: no transform/filter/backdrop-filter on .panels-grid
  (parent of the fixed globe → would trap it) — glass lives on the sibling cards.
- Desktop opens at zoom 1.35 so the globe fills the immersive viewport.

Backend — Enso benchmark caveats are fully data-driven (no literal drift):
- LiveCodeBench headline + premium-arm reference computed from the parsed table
  (premium arm read from the table's own Opus row when present).
- HLE "preflight only" note emitted ONLY when HLE is genuinely a preflight
  (n≤1) in the snapshot — never claims "not scored" over a real scored run.

CI gate green: tsc + vite build + go build (-tags sqlite_fts5) all pass.

Claude-Session: https://claude.ai/code/session_81b00bd9
2026-07-19 11:09:07 -07:00
hanzo-dev a6aaae65f2 release: v2.4.31 — map interaction (right-click no-select, non-country no-popup) + denser mobile grid + immersive video above footer 2026-07-19 06:37:09 -07:00
hanzo-dev c314f3941b fix(world/map): right-click never selects · non-country click shows nothing · denser mobile grid · immersive video above footer
- Right/middle click never picks-and-selects a country (deck.gl's tap recognizer
  fires onClick for those too); only LEFT click (button 0) selects. Guard in both
  handleClick paths (DeckGLMap flat + GlobeNative globe) via shared isNonLeftClick +
  MapClickEvent, event threaded through onClick + the globe source.
- Left-click on ocean/empty space no longer opens the 'identifying country'
  interstitial — onCountryClick only fires when a country actually resolves.
- Mobile: panels reflow to a denser >=2-up adaptive grid (was one giant column),
  honoring the cell-size control; min(--panel-col-min,46vw) guarantees 2 columns.
- Immersive video reserves the footer dock height so the full video sits ABOVE the
  footer — nothing occluded by the controls bar.
2026-07-19 06:36:18 -07:00
hanzo-dev da0ae02281 release: v2.4.30 — per-user usage history (searches + watch queue follow the signed-in user) 2026-07-19 00:07:16 -07:00
hanzo-dev 83890e7958 feat(world): per-user usage history (/v1/world/history) — searches + watch queue follow the user
- Backend: GET/PUT /v1/world/history via the SAME per-identity settings store, new
  'history' namespace. Extracted shared handleIdentityBlob (dashboard + history are
  one opaque-blob mechanism, two namespaces — DRY). Never fabricated.
- Frontend: generalized the dashboard-sync observer to route each localStorage key to
  its namespace — dashboard keys -> /v1/world/dashboard, history keys
  (worldmonitor_recent_searches, hanzo-world-watch-queue) -> /v1/world/history. One
  observer, boot-hydrates both, debounced per-group PUTs. SearchModal + watch-queue
  untouched (observer transparent).
- Tests: Go history round-trip + isolation + namespace separation + 401/400; e2e
  history routing + boot hydration. Dashboard e2e still green (5/5).
2026-07-19 00:06:14 -07:00
hanzo-dev 15dae13e49 release: v2.4.29 — Enso Router panel (cost/quality slider + judge viz) + AI Compute gate
Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-19 00:05:18 -07:00
hanzo-dev 7807ff5601 feat(world/enso): Enso Router panel — cost↔quality slider + mean-field judge panel
Cloud-variant panel surfacing two shipped router features:

- Section A: a Savings↔Quality bias slider (0..1) wired to a new org-scoped
  proxy GET|PUT /v1/world/cloud/router-preference → ai /v1/router/preference
  (caller IAM bearer forwarded). Debounced PUT + "saved" confirmation; stays
  interactive and shows an honest preview-only note when the gateway route
  isn't deployed yet.

- Section B: the Mean-Field Judge Panel via GET /v1/world/cloud/judge-panel →
  ai /v1/router/judge-panel?scope=platform — per-judge reliability-weight bars,
  calibrated means + n, sample rate + enabled state, and the published
  rank-corr-with-ground-truth benchmark (MFJP 0.966 vs single-judge 0.110).
  Honest "warming up" empty state when unavailable.

Both proxies never 5xx — they answer a well-formed {available:false} on any
upstream failure (including a 404 before the gateway routes ship), so the panel
degrades gracefully. New files only (handlers_cloud_router_pref.go,
EnsoRouterPanel.ts, router-preference.ts, judge-panel.ts) plus mount/route/CSS
wiring; reuses the cloud-* design system and theme tokens.

Verified with Playwright on ?variant=cloud: slider drags 0.5→0.90 with a live
label + honest "couldn't save" chip, judge section renders its warming-up
state, zero JS errors (only the expected not-yet-deployed 404s); dark + light
theme parity and mobile (374px, no overflow) confirmed.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-19 00:01:54 -07:00
hanzo-dev d25df44f21 fix(world): honestly gate the signed-out AI Compute plane (no zero-grid)
The inference plane is org-scoped ("metered to your org"). Signed out, the
public pulse only ever returns a zero-stub ($0 spend, 0 GPUs, 0/0 machines),
which rendered as a "live"-badged grid of zeros — exactly the "zero dressed up
as live traffic" the panel promises never to show, and reads as a dead/broken
platform. Gate on !isAuthenticated() at the top of render() (same sign-in
affordance as Fleet / My Usage), completing the honest-gate direction of v2.4.23.
The now-redundant inner signed-out branch is removed (one way to gate).
Signed-in behaviour unchanged. Release v2.4.28.
2026-07-18 23:50:49 -07:00
hanzo-dev 37a05c025f release: v2.4.27 — per-user dashboard persistence (GET/PUT /v1/world/dashboard, sqlite v0.3.2)
Signed-in dashboards persist server-side per identity and follow the user across
devices: panels/order/spans, layers, sources, custom feeds, map mode, the free
layout geometry, text-size and grid-size — hydrated on boot (server precedence),
debounce-synced on change via a localStorage observer. Anon stays localStorage-only.
Backend reuses store.Settings (namespace 'dashboard'); sqlite v0.3.0->v0.3.2 (fts5
build tag). Go tests + 3/3 frontend e2e green.
2026-07-18 23:46:29 -07:00
hanzo-dev 775493ebf2 feat(world): dashboard sync frontend — per-user server persistence via a localStorage observer (held)
Signed in, the composed dashboard (panels/order/spans/layers/sources/custom feeds/
map mode + the free-layout geometry) persists per identity and follows the user
across devices; signed out is unchanged (localStorage only).

- services/dashboard.ts: on boot, hydrate this identity's dashboard from
  GET /v1/world/dashboard into localStorage (server precedence) before the app
  reads it; then a Storage.prototype setItem/removeItem observer debounce-PUTs a
  snapshot of the dashboard keys on any change. One decoupled hook — App,
  grid-config and Panel stay unaware. Captures the worldmonitor-layout:* family so
  the free {x,y,w,h}/mode geometry follows the user too.
- main.ts: await initDashboardSync() before new App() (auth-gated, bounded, never
  blocks boot).
- e2e: hydration (server precedence) + change→debounced PUT of the snapshot + anon
  no-sync — 3/3 green (real app, faked IAM + stubbed endpoint).

Not released. Ships as v2.4.21 with sources + usage history.
2026-07-18 23:43:49 -07:00
hanzo-dev 3dc761d736 feat(world): per-user dashboard persistence backend + sqlite v0.3.2 (held for 2.4.18)
- GET/PUT /v1/world/dashboard: per-identity dashboard config through the existing
  store.Settings (namespace 'dashboard') — no new table; mirrors /v1/world/monitors
- generalize identityFor -> identityForDoc: one bearer->identity gate for the
  namespaced settings store (monitors + dashboard resolve through it)
- bump hanzoai/sqlite v0.3.0 -> v0.3.2 (vendored pure-Go, zero modernc.org/*).
  The vendored engine gates FTS5 behind -tags sqlite_fts5; add it to the Dockerfile
  go build (was implicit under v0.3.0's modernc backend) or store.Open degrades
- tests: dashboard round-trip + per-identity isolation + 401/400/405 boundary gates

Not released. Ships as 2.4.18 AFTER 2.4.17 (layout) is live+stable.
2026-07-18 23:43:49 -07:00
hanzo-dev 604b135add release: v2.4.26 — Enso Live Training shows the REAL served-model catalog (not opaque arm-N) 2026-07-18 23:38:43 -07:00
hanzo-dev 2335dcc4ca fix(world): Enso Live Training shows the REAL model catalog, not opaque arm-N (held)
User feedback: the panel showed "random amounts." Verified there is NO random data
anywhere in the Enso path — it rendered the upstream OPAQUE "arm-N" routing mix (real
counts relabeled to meaningless "Enso arm 1/2/3" with no model identity), which reads
as arbitrary.

- EnsoTrainingPanel now renders the REAL served-model catalog from the existing public
  /v1/world/cloud/models (→ ai /v1/models): real model names, tiers, providers, context
  and pricing — the same real models served on the platform.
- The real aggregates (cost-saved proxy, learned-engine share, events, throughput,
  cumulative index, shadow agreement, retrain gate) are unchanged — all real.
- Dropped the opaque by_model arm rows (armRows/armIndex). Honest empty states; never
  fabricated.
- e2e: stubs both feeds (router-stats incl. an arm-N map) and asserts the panel shows
  real model names + "models served" and NEVER renders "Enso arm"/"arm-N". Green.

Not released — hanzo assigns version/sequence.
2026-07-18 23:36:48 -07:00
hanzo-dev 55075bcbbe release: v2.4.25 — layout: snap-to-grid, no corner chevrons, finer grid, text-size control 2026-07-18 23:30:53 -07:00
hanzo-dev ec595bc567 feat(world): layout polish — remove resize glyphs, finer grid, text-size control (held)
Owner feedback on the free layout, batched with the snap-to-grid fix:

- No visible corner/edge resize glyphs (#2): the dots + corner brackets are hidden
  (display:none on the handle ::after); the handles stay invisible hit areas so
  dragging from any edge/corner still resizes — the resize cursor is the affordance.
- Finer grid (#3): MIN_CELL_SIZE 120->80 + dock "Widget size" slider min 120->80,
  step 20->10, so the grid — and the snap, which reads cellSize — goes finer.
- Text-size / UI-scale control (#4, accessibility): a dock "Text size" slider drives
  a --ui-scale var that zooms the readable panel CONTENT only (never the panels-grid,
  so drag/snap coordinates are untouched); persisted in localStorage (utils/ui-scale.ts),
  applied before first paint. Per-user SQLite dashboard-sync picks up the key.
- e2e: glyphs-hidden-but-still-resizes, finer-80px-floor, text-size-scales+persists.
  Full layout suite 21/21 green; build clean.

Not released — batched with the snap fix; hanzo assigns version/sequence.
2026-07-18 23:29:35 -07:00
hanzo-dev f86fb162f8 fix(world): free-mode drags + resizes snap to a logical grid (held)
User feedback on the free layout: "the snap is not logical." Free mode placed
panels at arbitrary px, which felt loose to drag. Now free-mode drag snaps {x,y}
to grid-line origins and resize snaps {w,h} to whole-cell multiples, so panels
land on consistent, aligned tracks — while keeping the independence that made free
mode good (resize/move one panel, siblings never move; opposite edge stays pinned).

- ONE logical grid, DRY: grid-config.freeSnap() = { cell: getCellSize() (the
  slider-driven --panel-col-min), row: --panel-row (16), gap }. Free and grid share
  one grid definition — no second magic number.
- panel-drag: snapLine (position -> nearest grid line) + snapCells (size -> whole
  cells, >= floor); threaded through moveFree + applyFreeResize. Hold Alt for fine,
  un-snapped placement. Grid mode untouched.
- e2e: two new proofs (drag -> two panels align to the same track; resize -> whole-
  cell width + opposite edge pinned); the arbitrary-px tests now hold Alt. 18/18 green.

Not released — version/sequence assigned by hanzo.
2026-07-18 23:29:35 -07:00
hanzo-dev 3bf9cc994c fix(world): Enso benchmark shows only our measured results — confident, sells
Dropped the external "reported" comparison column + the self-deprecating footnote
from the public benchmark. The panel now shows ONLY Enso's own live-measured accuracy
and per-system cost, with a confident, true caveat: every number is measured against
real APIs through the Hanzo gateway, Enso reaches frontier-competitive accuracy at a
fraction of the blended cost by routing each task to the right model, and it reports
what it spends. No external names, no incoherent framing.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 23:06:54 -07:00
hanzo-dev 3d1f91ecd8 release: v2.4.24 — Enso benchmark branding
Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 22:52:08 -07:00
hanzo-dev da762aee5b fix(world): brand benchmarks as Enso — scrub the old codename
The Enso benchmark panel + summary data carried the pre-release codename in labels,
JSON keys, Go structs, and the TS consumer. Renamed consistently to Enso / Enso-Ultra
across the data, the /v1/world backend handler, and the frontend so the public
benchmark shows only the Enso brand. No numbers changed; identifiers stay in sync
(measured Enso vs Enso's originally-reported figures).

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 22:51:28 -07:00
hanzo-dev 032c33d55a release: v2.4.23 — geo-aware model-improvement consent + honest AI Compute gate
Sweeps the unreleased range: geo-aware consent toggle in the account menu (reflects
the backend's effective default — opt-out non-EU, opt-in EU/UK/EEA) with honest copy;
and the AI Compute panel showing a plain "Sign in" gate instead of a scary
"unreachable" when signed out. (Signup disclosure + privacy/terms shipped separately
in hanzo.id + hanzo.ai.)

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 21:09:52 -07:00
hanzo-dev 607edfdbcd feat(world/consent): honest geo-aware microcopy for the model-improvement toggle
The "Help improve Hanzo AI" account toggle already reads the effective consent
from ai (getTrainingContribution) and paints it once loaded. Make the microcopy
and comments honest for the GEO-AWARE model — opt-in/default-OFF in the EU, UK &
EEA; opt-out/default-ON, disclosed at signup, elsewhere — instead of claiming a
universal "off by default". No logic change: the toggle stays a verbatim mirror
of ai's answer, so a non-EU org's ON effective default now reads as an accurate
label rather than a contradicted one, and the service/loader comments no longer
claim "never opted in => OFF".

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 21:05:55 -07:00
hanzo-dev 2c0445a308 release: v2.4.22 — per-org Analytics + Insights cards (world cloud view) 2026-07-18 20:49:06 -07:00
zandGitHub b6c423e358 Merge pull request #18 from hanzoai/feat/org-analytics-insights-cards
feat(world/cloud): per-org Analytics + Insights cards
2026-07-18 20:48:14 -07:00
hanzo-dev 07607bf57a feat(world/cloud): per-org Analytics + Insights cards
Two org-scoped event-platform cards for the Cloud view, sourced from the
live native analytics warehouse on api.hanzo.ai (verified live):

- Analytics (org-analytics): /v1/analytics/overview + timeseries + top —
  per-org requests/visitors/tokens/spend, requests trend, top models.
- Insights (org-insights): /v1/insights/events — active users, top events,
  activity trend derived client-side from the real event stream.

Org pinned server-side from the validated bearer owner claim (principal.Org),
reusing org-scope.scopedHeaders() like getMyBilling — no new fetch mechanism.
Reuses the existing Panel + cloud-format primitives + cloud-* CSS. Honest
loading/signed-out/empty/error states; never fabricated numbers.
2026-07-18 20:20:15 -07:00
hanzo-dev 50a7448678 fix(world): AI Compute shows honest "sign in" gate, not a scary "unreachable"
Signed-out, the live compute pulse is account-scoped, but the panel rendered
"Compute telemetry unavailable — compute plane unreachable" which reads as a backend
outage. Match the Fleet / My Usage pattern: signed-out shows a plain "Sign in to see
the live inference plane…"; the unreachable error copy is reserved for a genuine
failure while signed in.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 20:13:20 -07:00
hanzo-dev 870ec49f3d release: v2.4.21 — mobile search icon + consent toggle (unique tag; 2.4.20 was claimed concurrently) 2026-07-18 17:45:22 -07:00
b7a90b2774 fix(world/header): give the mobile search button a magnifier icon (#16)
At <=680px both the Ctrl/Cmd-K hint (already hidden <=900px) and the
'Search' label were hidden, leaving the search button an empty bordered
box. Add an always-present magnifier icon that reveals once the K hint
collapses, so the button is never empty at any width. (2.4.20)

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-18 17:42:55 -07:00
hanzo-dev 493fb3d4e7 feat(world/consent): opt-in "Help improve Hanzo AI" toggle (default OFF) (2.4.20)
Add a model-improvement consent toggle to the account menu, wired to ai's
OrgSettings.TrainingContribution (the source of truth the automated judge + trainer
read). Honest, opt-in, off by default, no dark patterns.

- src/components/AccountMenu.ts: a "Data & privacy" switch in the signed-in dropdown.
  Painted OFF until the async read lands (never a false "on"); optimistic flip that
  reverts if the server refuses. Exact microcopy: rating keeps only a numeric score,
  never prompts/outputs, runs confidential/anonymous, off anytime.
- src/services/training-contribution.ts: get/set via the app's org-scoped authed
  fetch (orgHeaders). Signed-out / error / unset all read false — privacy-safe.
- internal/world/handlers_training.go: same-origin proxy /v1/world/training-contribution
  → ai get-/update-training-contribution, forwarding the caller's bearer + X-Org-Id.
  World holds NO copy; ai self-scopes to the caller's own org and is the sole authority.

Tests: proxy gates signed-out (401, no upstream call), forwards bearer + org, and
unwraps the casibase envelope to a bare {enabled} on both GET and POST.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 17:41:23 -07:00
hanzo-dev 09954907cf fix(world): default to free layout — panels move/resize independently, no reflow (2.4.19)
The dashboard defaulted to grid snap, so dragging or resizing one panel
reflowed all the others and resize jumped between coarse column/row spans.
CSS grid can't move a panel without disturbing siblings, so make FREE the
default layout: every panel owns its own {x,y,w,h}. The current grid
arrangement is frozen as the starting geometry (grid-config.applyDefaultLayout,
run once after panels+map lay out), so the flip is invisible; Grid stays a
dropdown choice and grid-config records an explicit pick so the default never
overrides it.

- Independent positioning (#1): free mode — moving/resizing one panel leaves
  every sibling exactly where it was (proven: siblings drift 0px).
- Granular resize (#2): pixel-exact w/h from any edge/corner, not span jumps.
- Narrower panels (#3): free floors 160->96 (map 240->140); grid track floor
  140->120. A panel drags to 96px, well under the old min.
- Viewport-flexible (#4): map + live-news resize freely, small to large.

Hidden panels no longer persist 0x0 rects; a panel shown while free seeds a
tidy default slot. Tests: +2 harness proofs (no-sibling-reflow, sub-160 width);
repaired two stale span-2 assertions to the fine-grid data-span reality.
2026-07-18 17:26:25 -07:00
hanzo-dev 08df02b535 release: v2.4.18 — dotted-land cybermap globe + fullscreen-video idle fix
Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 17:05:35 -07:00
hanzo-dev 138d96a1b8 fix(world/live-news): never idle-kill a fullscreen video (exited fullscreen + stopped)
Passively listening to a live stream in fullscreen fires no mousedown/keydown/scroll
(fullscreen video captures input), so the 5-min input-idle timer fired and pauseForIdle
→ destroyPlayer tore down the iframe — which force-exits fullscreen AND stops playback
(the reported "listening fullscreen and it exited + stopped"). Fix: treat fullscreen as
the strongest "actively watching" signal — never idle-kill when our player is the
fullscreen element — and count fullscreenchange as activity so entering fullscreen
resets the idle timer.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 17:05:34 -07:00
hanzo-dev cf2092380b feat(world): dotted-land "cybermap" globe — restore the dot map
Restores the dotted-land basemap (land shown as a lattice of glowing dots over a
black-ocean sphere — the abstract cybermap surface): new land-dots.ts (getLandDots()
samples /data/countries.geojson into a cached lat/long lattice, land points only),
GlobeNative renders it as a ScatterplotLayer on the 3D globe, variant.ts defaults
cloud + AI to the 'dot' style, and App opens cloud/AI on the 3D globe (the live-traffic
centerpiece). Recovered from a stashed WIP; DeckGLMap kept at current main (preserves
the shipped layer panel + control dropdowns), so this ports cleanly with no regression.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 17:00:43 -07:00
hanzo-dev d8175e0a37 feat(cloud): default the Cloud view to the immersive globe — bring back the cool 3D dot map
The flagship Cloud view was defaulting to the grid dashboard, demoting the
full-viewport 3D dot map to a small corner tile. Restore it: when the visitor
hasn't explicitly picked a layout, the Cloud variant opens on the immersive
globe (map fills the viewport, panels float over it). An explicit grid choice
and the ?layout= URL param still win; other variants unchanged.
2026-07-18 16:58:16 -07:00
hanzo-dev 97fed1fee2 release: v2.4.16 — analyst grounded in live cloud metrics (answers spend/fleet/enso)
Ships 3d892d96: the AI analyst now folds a live Hanzo-cloud snapshot into its context
(traffic, platform 24h, Enso router cost-saved/reward/engine-share, flywheel ledger,
billing), so it can answer questions about the dashboard's real numbers — on top of
its existing 26-command control surface.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 16:27:49 -07:00
hanzo-dev 3d892d9633 feat(world/analyst): ground the AI in live cloud metrics so it can answer anything
The analyst's context fed panel names + visibility but NOT the live values, so it
couldn't answer "what's my spend / how many DO nodes / is Enso saving money". On the
cloud board it now also folds a real-data snapshot — live traffic (rps/req), platform
24h (requests/tokens/nodes/GPUs/regions/uptime), the Enso router (cost saved, reward
rate, engine share, models routed), the flywheel ledger, and the caller's billing
(spend/balance/events). Best-effort + parallel with the news feeds; real data only,
mirroring the panels. Pairs with the 26-command control surface — the analyst can now
both control the dashboard AND answer questions about what it shows.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 16:27:04 -07:00
hanzo-dev 8c0e928789 release: world 2.4.15 — bake VITE_MAPBOX_TOKEN so Satellite/Terrain load
2.4.14 tagged before the build_secrets wiring landed, so it builds without the
Mapbox token. 2.4.15 = same tree + the KMS-sourced VITE_MAPBOX_TOKEN build-arg
(via hanzoai/ci v1.0.5), so satellite-streets/outdoors render in 2D and 3D
instead of the keyless dark fallback.
2026-07-18 12:34:27 -07:00
hanzo-dev 0b52de0236 release: world 2.4.14 — Satellite/Terrain via KMS Mapbox token
Root cause of the dark Sat/Terrain: no VITE_MAPBOX_TOKEN was baked at build, so
the token-gated Mapbox satellite-streets-v12 / outdoors-v12 styles silently fell
back to the keyless CartoDB dark basemap in both 2D and 3D. The publishable pk
token now lives in KMS (hanzo/deploy/VITE_MAPBOX_TOKEN, env=prod) and is sourced
by hanzoai/ci (v1.0.5) as a build_secret --build-arg into the Vite SPA.

Rides the pending main fixes (analyst reasoning recovery, AI Compute rate,
map-control dropdowns).
2026-07-18 12:33:11 -07:00
hanzo-dev 3fbbbbebf5 release: v2.4.14 — dropdown map controls, 2×2 panels, AI Compute fix, analyst recovery
Sweeps the unreleased range: map controls collapsed to 3 dropdowns + symmetric 2×2
tiles + dropped regions tile (3fc42b1f); AI Compute rate un-flattened (interval enum
bug) + honest sub-cent usage rows (4c41abbb); analyst chat recovers reasoning-model
answers instead of "empty response" (a17d63c1).

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 12:31:13 -07:00
hanzo-dev a17d63c10d fix(world/analyst): recover reasoning-model answers so chat stops returning "empty response"
The analyst rendered "The analyst couldn't answer — empty response" whenever the
served model streamed its answer on the reasoning channel with an EMPTY content
channel — gpt-oss / harmony, deepseek-r1 and kin. The ONE completion path
(chatMessagesModel + chatMessagesModelStream) read only the content channel, so a
reasoning model's output was dropped and surfaced as a blank.

- Read the reasoning channel (message.reasoning_content / reasoning, and the SSE
  delta.reasoning_content / reasoning) as a fallback when content is empty, and
  forward reasoning deltas live so the replyExtractor shows them on the thinking line.
  Content is always preferred; non-reasoning models (best/zen5) are unaffected.
- Replace the opaque "empty response" with an honest, model-named error
  ("the <model> model returned an empty answer"): never misreads a normal
  completion's response id as an error code, never trips the 402 top-up CTA.

Tests: reasoning-channel recovery (buffered + SSE) and the honest empty-answer error.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 12:30:15 -07:00
hanzo-dev 4c41abbb29 fix(world): AI Compute rate no longer flat (interval enum) + honest sub-cent usage rows
AI Compute (and cloud-pulse) rps/tps were dead flat: the usage series reports its
bucket width as a WORD enum ("hour"/"day"), but intervalSeconds parsed it with
time.ParseDuration — which fails on "hour" (needs "1h") — so usageRate always fell
back to total24h/86400, a constant. Resolve the enum (minute/hour/day/week) before
ParseDuration; the rate now reflects the freshest hour and moves. Regression tests
for intervalSeconds + usageRate.

My Usage & Bill: a real sub-cent inference charge (< $0.005) rounds to 0 integer
cents and rendered a bare "$0.00" that reads as "not billed". Show "< $0.01" for a
real row that rounded to zero — honest about a tiny real charge (the ledger only
carries integer cents on the wire).

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 12:17:03 -07:00
hanzo-dev 3fc42b1f1f fix(world): collapse map controls to dropdowns; symmetric 2×2 tiles; drop regions
Map controls:
- Projection (2D/3D), basemap (Dark/Sat/Terrain) and time-range each collapse into ONE
  compact dropdown (trigger shows the active value + caret; options in a popover). The
  top dock is now a single tidy row of three instead of two wrapping rows. Global
  .deckgl-dd treatment (one way, every map host). e2e specs open the dropdown before
  clicking an option via a shared clickMapControl helper.

Panel symmetry (real data, never padded):
- Live Traffic: added a real 4th tile (distinct countries with traffic) → 2×2.
- Model Improvement: split scored-events into its own tile → 2×2 (reward rate,
  requests scored, active days, retrains).
- Cloud Overview / Global Platform: dropped the 'regions' tile (fleet carries region
  detail) per CTO.
- New .cloud-stat-grid-2 (exactly two columns) for the symmetric 2×2 layout.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 12:13:53 -07:00
hanzo-devandz 5a65e739a9 fix(world): globe overlays hug the sphere — real occlusion, terrain seams, live dots (2.4.13)
The Hanzo-Cloud globe 'spazzed': data layers floated above the sphere, the
far hemisphere showed through, count badges hovered over the planet, terrain
striped at tile seams, and the live request-geo dots froze on the 3D view.

- GlobeNative seats every data layer against the depth-writing ocean sphere
  (depth-TEST, no write) so points/icons/text/paths/arcs occlude on the GPU.
- occludeFarSide now faces the globe's OWN live camera (mapbox is parked/frozen
  behind the native GlobeView) and culls TextLayer badges too, so a back-side
  count badge disappears instead of floating over the limb. Camera is fed each
  rebuild + on drag >2°; escape path (?globe=mapbox) keeps the live mapbox center.
- Draped ESRI terrain/satellite tiles stop writing depth (coplanar tiles were
  z-fighting at seams → striping); the ocean sphere owns the depth buffer.
- Cloud /v1/world/cloud/* poll gates on tab visibility, not renderPaused, so the
  realtime dots keep updating on the native 3D globe (the default view).
- Request-origin → serving-region arcs ON by default in Cloud (real feed, empty
  when no traffic — never demo).
- e2e/globe-render.spec.ts asserts occlusion (dot + badge), 2D/3D parity,
  terrain single-context, and live-dot updates.
2026-07-18 11:57:14 -07:00
hanzo-dev d4820277af release: v2.4.12 — map controls top-row + Layers, org logo, fleet RAM, globe cables
Ships the unreleased 2.4.11..HEAD range (all UI): globe cables hug the sphere, map
controls in one top row + top-left Layers button (footer/scrollbar removed), one Fleet
panel with system RAM, BYO once, and the org logo in the account switcher.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 11:53:11 -07:00
hanzo-dev d40c8560bb fix(world): render the org logo in the account switcher (was dropped)
The account chip showed the USER avatar next to the org name and never the org's
own image — IAM's get-organizations returns each org's Casdoor `logo`, but listOrgs
parsed only name + displayName and dropped it, so the hanzo org logo never rendered.

Thread `logo` end-to-end (OrgInfo → OrgOption → scope) and render an ORG avatar in
the switcher trigger: the active org's logo when set, else org initials (rounded-
square so it reads as an org, not a person). The dropdown's user row keeps the user
avatar. Falls back to the user avatar only when there's no org scope (signed out).

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 11:34:46 -07:00
hanzo-dev 7d737f2a53 fix(world): map controls to one top row + top-left Layers; BYO once; hide page scrollbar
Map controls (CTO revision of the bottom dock):
- 2D/3D toggle + Dark/Sat/Terrain switcher + time-range pills now sit in ONE row
  across the map's TOP, side by side (was a bottom-border dock). Zoom stays top-right,
  legend bottom.
- New top-left "Layers" button on the map toggles the floating layer panel (opens
  just below it). Removed the redundant Layers button from the page footer dock —
  layers now live on the map itself.
- Hid the .main-content right-edge scrollbar (wheel/trackpad scroll unchanged) — it
  read as a stray "sidebar scroller" over the map. Same treatment header/switchers use.

Fleet:
- BYO nodes rendered TWICE — once as a "byo" provider group and again as the BYO GPU
  workers list (same dbc/evo/spark). Managed providers (DO/GCP/AWS) now render as
  provider groups; BYO renders once, as the richer workers list.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 10:50:39 -07:00
hanzo-dev 35e07a65ae feat(world/fleet): render system memory in the machine spec line
The fleet machine rows showed GPUs · VRAM · vCPU · OS but never system RAM —
there was no memory field on the path at all. Thread `mem` end-to-end on the
world side: decode it from /v1/machines, carry it on fleetMachineRow, expose it
on FleetMachineRow, and render it in machineSpec (GPUs · VRAM · vCPU · RAM · OS)
only when present. The cloud service now populates `mem` (DO size-slug + upstream
memSize); world shows whatever is real and omits it otherwise — never fabricated.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 09:28:14 -07:00
hanzo-dev 8d3d20eb79 fix(world): globe cables hug sphere · docked map controls · one Fleet panel
Globe (3D):
- Cables + pipelines (PathLayer) drew straight chords across the sphere — deck.gl
  has no greatCircle for paths (only ArcLayer). Pre-densify each static path along
  its great circle into <=4-deg hops so the many short segments hug the surface.
  Flat map keeps the raw sparse vertices.

Map controls:
- The 2D/3D toggle, basemap switcher and time-range pills were scattered across the
  wide empty map header (top-center, mid-left, top-right). Dock them into ONE tidy
  strip along the map's bottom border via the existing controlsHost mechanism (App
  passed none). The dock mounts on the map container — a SIBLING of the deck wrapper
  — because 3D parks that wrapper (visibility:hidden) and renders via GlobeNative; a
  dock inside it would vanish on the globe. Reuses the .world-dock inlining CSS.

Fleet:
- "Fleet & GPUs" (FleetPanel, all users) and "Fleet & clusters" (CloudFleetPanel,
  admin) were duplicate platform-fleet renders. Collapse to ONE "Fleet & GPUs":
  widen FleetPanel, port the utilNote line, derive the provider-scope label from the
  providers actually reporting (not a hardcoded four), delete CloudFleetPanel.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-18 09:13:40 -07:00
hanzo-dev 0cda941c4c fix(world): builder needs git + gh_token for private indirect dep hanzoai/csqlite
golang:1.26-alpine ships no git; go resolves the private indirect
github.com/hanzoai/csqlite 'direct', so 'go mod download' died with
git-not-found. Add git, set GOPRIVATE, mount the gh_token secret ci already
passes. Unbreaks world main builds (broken since the Go-base-1.26 drop-in).
2026-07-17 23:29:05 -07:00
hanzo-dev 385a3a30bd world 2.4.11 — release (verify semver CR pin via hanzoai/ci@v1) 2026-07-17 23:18:40 -07:00
hanzo-dev 16d0f000ea fix(world): Docker Go base 1.26 for hanzoai/sqlite drop-in
go.mod now requires go >= 1.26.4 (github.com/hanzoai/sqlite). Bump the
builder base golang:1.25-alpine -> golang:1.26-alpine so the prod image
keeps building. The binary stays CGO-free (CGO_ENABLED=0), so hanzoai/sqlite
selects its pure-Go modernc backend — FTS5 built in, works. CGO test path
(no FTS5 without -tags sqlite_fts5 + libsqlcipher) is not what world ships.
2026-07-17 22:03:54 -07:00
hanzo-dev f52b5b2366 Merge feat/sqlite-hanzo-driver 2026-07-17 21:59:53 -07:00
hanzo-dev 3dd4823e3e fix(world): wide panels overflowed the fine grid (AI Compute rendered under things) + DRY o11y usage
.panel-wide kept grid-row: span 2 (the old 200px-grid assumption) after the move
to 16px rows — so all 7 wide panels (ai-compute, cloud-overview, live-news,
traffic-globe, enso-flywheel, model-improvement, hanzo-status) reserved only ~36px
of track while being 350px tall, overflowing their cell and painting over the
panels below. Fixed to span 18 (≈356px) with the shared 48px min-height floor.

Also: extract fetchLLMUsage (DRY) shared by cloud-pulse + ai-pulse, and fall back
to LLM observability in ai-pulse so AI Compute shows real usage (not zeros) for an
authorized operator.
2026-07-17 21:52:34 -07:00
hanzo-dev c8aae3366c feat(world): real per-model usage + volume from LLM observability (admin)
Model Usage showed 'warming up' / nothing and usage totals were 0 for a signed-in
admin whose super-admin usage ledger (?org=all) is denied. Fixed: the admin path
now folds REAL platform usage from LLM observability (/v1/admin/o11y) with the
caller's own bearer — measured requests, tokens, and top models by REAL name (not
the opaque router arms) — clearing volumeModeled. Falls back honestly to empty if
the caller isn't authorized for platform observability upstream.
2026-07-17 21:45:26 -07:00
hanzo-dev 997630fcb5 feat(world): admin-only Enso benchmark suite (head-to-head vs SOTA + Fugu)
Private, competitive data (names competitor models + Fugu), so it is gated
server-side, never bundled into the public SPA:

- backend: GET /v1/world/enso-benchmarks reuses the exact requireAdmin gate
  (401 no-token / 403 non-admin, IAM-introspection). Reshapes the SAME embedded
  enso-bench snapshot the flywheel reads (one embed, two views) into the measured
  head-to-head + verify-then-select ablation + agentic SWE-Bench Pro pilot +
  Fugu-reported reference + server-authored honest caveats. No upstream forward.
- snapshot: refresh ensodata/summary.json to the full measured set (LCB+GPQA+HLE,
  6 arms), ultra_ablation, and the committed agentic result folded in.
- frontend: EnsoBenchmarkPanel (admin-only; adminOnlyState when not admin) +
  services/enso-benchmarks.ts (bearer, 403->null). Registered in the admin-only
  Cloud panels. Theme-safe tables, scroll-in-box, no body h-scroll.
- privacy: no competitor names, arm ids, numbers, or our routing methodology
  names are baked into the public bundle — all arrive only via the gated endpoint.

Honest framing: enso MATCHES the best single SOTA arm at a fraction of the cost
(LCB 91.4% @ $5.24 vs opus $39.43; GPQA ties top); does NOT beat every SOTA;
HLE is preflight-only; we are below Fugu's REPORTED numbers. Decisive wins are
cost-efficiency, the ablation (better AND cheaper), and agentic step-routing.

Tests: gate matrix (anon 401 / non-admin 403 / admin 200 + no-leak) + real-number
payload assertions green; existing enso-training + red admin-gate suites green.
2026-07-17 21:41:55 -07:00
hanzo-dev 5d1c6fff5d fix(world): smooth fine 16px panel resize — no coarse jump, no min-height trap
Vertical resize snapped in coarse 200px tiers (120/200/400/600/800) and a hard
200px min-height blocked shrinking — the 'jump too much / can't fit content'
complaint. Now the grid is a fine 16px row unit (@hanzo/gui space-4): per-panel
resize snaps in ~20px steps, uncapped, and min-height drops to 48px so 200px is
just the DEFAULT for a new panel, not a floor. Tier presets (size menu + map) are
redefined on the same grid so they still work; the map resizes smoothly too.
Horizontal already fills width in 160px column steps.
2026-07-17 21:08:08 -07:00
hanzo-dev 84995c9ac1 fix(world): Model Usage never shows opaque arm-N — real model names or 'warming up'
The public router-stats by_model is relabeled to opaque arm-N ids on platform
scope (privacy), so it is NOT real model names. cloud-pulse wrongly surfaced it
as the model mix, so Model Usage showed 'arm-8, arm-7, arm-6'. Now the public
fallback carries real request rate + throughput only; the model mix comes solely
from the measured usage ledger (admin path) — otherwise Models stays empty and
the panel honestly shows 'warming up'.
2026-07-17 19:40:28 -07:00
hanzo-dev 4a269d65d4 feat: migrate SQLite driver to hanzoai/sqlite drop-in 2026-07-17 12:25:19 -07:00
hanzo-dev 768a0c6bf8 fix(world): cloud-pulse shows ALL real regions, not just the 8-city catalog
The region breakdown dropped any machine whose region wasn't one of the 8
catalog cities (real DO regions like tor1, or GCP/AWS regions), so a fleet in
uncatalogued regions rendered regions:0. Now every real region is counted —
catalog coords/name when known, else the raw region string as its own region.
2026-07-17 12:21:56 -07:00
hanzo-dev 06417f4243 feat(world): fleet drill-down — real DO nodes by region + BYO GPU specs; drop last demo fallbacks
Fleet & GPUs now shows every real machine grouped by provider/region (with GPU
model, VRAM, vCPU, OS) plus BYO GPU workers with specs (Spark/Evo/dbc: hostname,
GPU, VRAM, capabilities) — one shared renderer (cloud-fleet-view) used by both
FleetPanel (flagship, admin) and CloudFleetPanel (deep). Backend adds vCPU from
visor MachineView.

Removes the last fabricated data: byo-gpu and traffic map layers are now
real-or-empty (no demoGPUs / demoTraffic), so a signed-out visitor sees honest
empty globe layers, never fake clusters/arcs.
2026-07-17 12:18:59 -07:00
hanzo-dev 7e41959b01 feat(world): real user + signup metrics from IAM global-users
Wires the one signups/growth gap that has a real source: IAM /v1/iam/global-users
(all users, global-admin only), read with the signed-in admin's OWN bearer and
folded into AGGREGATES ONLY (never PII): total users, signups 24h/7d, active-now
(isOnline), and a 14-day daily-signup series (from each user's createdTime).
Attached to the cloud-pulse admin path only (p.users, omitempty — the public
teaser never carries it). CloudOverviewPanel renders users / +N signups·24h /
active-now tiles + a new-users sparkline, live at the 30s poll.

Remaining gaps stay honestly empty — they have NO upstream source (verified against
the visor/o11y OpenAPI specs): GPU util/mem/temp (visor GpuView: "Live telemetry is
omitted"), disk (no field), historical retention cohorts (no login-events store).
Those need real instrumentation upstream, never faked.

go build/vet/test green (TestCloudPulseAdminBearer extended); tsc + vite clean.
App.ts untouched.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-17 11:56:57 -07:00
hanzo-dev c7d3d11579 feat(world): real fleet + GPU globe + AI compute — whole Cloud tab admin-aware
Completes the "make it all real" pass on the cloud flagship:
- FleetPanel shows the REAL platform fleet (visor /v1/world/cloud/fleet) for an
  admin — machines + GPUs across DO/GCP/AWS/BYO, rolled up by region, 30s refresh.
  Removed the demo/sample-region fallback — never fabricates regions now.
- Globe BYO-GPU layer real for admins (handleCloudBYOGPU, caller bearer, no-store)
  — real GPU clusters instead of the 8 demoGPUs dots. Anonymous keeps the
  clearly-flagged demo sample (no public GPU inventory source).
- AiComputePanel: ai-pulse admin-aware; EventSource can't send a bearer, so the
  panel uses an authenticated poll when signed in — z@hanzo.ai gets the full
  measured compute pulse (tokens/s, req/s, spend, top models, fleet), own bearer.

Every admin/authed response is Vary: Authorization + private,no-store. Honest gaps
stay labeled (GPU util/mem/temp, disk, signups/cohorts need real collection
upstream). go build/vet/test green (TestBYOGPUAdminReal, TestAIPulseAdminPoll);
tsc + vite clean. App.ts untouched.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-17 10:59:07 -07:00
hanzo-dev 99bd0d71ac feat(world): admin-aware cloud-pulse — signed-in admin sees full real all-org data
cloud-pulse never looked at who was logged in (server service-token only), and the
real admin panels were gated to orgs {admin, built-in} — so z@hanzo.ai (org hanzo)
got 403 and saw only the public aggregate. Now:

- cloud-pulse is admin-aware (handlers_cloud.go): a signed-in admin's request is
  served the FULL measured all-org aggregate fetched with THEIR OWN bearer
  (get-cloud-usages?org=all + visor), Cache-Control: private,no-store + Vary:
  Authorization so it never mixes with the public edge cache. No server-side
  super-admin token needed — the admin's own login drives it. The ai gateway
  independently re-checks ?org=all, so a non-global-admin degrades to real public
  data, never fake (gateway is authoritative).
- Operator org recognized as admin (cloud_identity.go): {admin, built-in} +
  operator org (default hanzo, override WORLD_ADMIN_ORGS) — unlocks the existing
  real admin panels (services/llm/fleet/analytics) for z@hanzo.ai. Anonymous
  short-circuits, so the public path stays cheap.
- Frontend forwards the bearer (cloud-pulse.ts) and the client admin gate
  (iam.ts isAdmin/cachedIsAdmin) matches the server, so App.ts's existing
  isAdmin() reveal works — App.ts untouched.

go build/vet/test green (added TestCloudPulseAdminBearer); tsc + vite clean.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-17 10:24:34 -07:00
hanzo-dev 3c95187c8e fix(world): kill fabricated cloud metrics — real sources or honest-empty
The flagship cloud dashboard showed modeled numbers under a "live" badge:
demoPulse() sine-wave rps/24h/tokens + sparklines, demoModels() mix, demoRegions()
per-city rates, and a hardcoded 99.98% uptime. Replaced with an honest source
ladder (producePulse in handlers_cloud.go):
  - RequestsPerSec  <- /v1/traffic/globe totals.rps_1m (the real ~0.62)
  - Requests24h + sparkline <- /v1/router/stats?scope=platform
  - Models mix <- ledger byModel (super-admin) else router-stats by_model
  - UptimePct <- status.hanzo.ai Gatus up/total (0 -> tile dropped, never a const)
  - Regions <- real visor fleet
Tokens24h/TokenSeries have no non-ledger source -> honest 0/empty, volumeModeled
stays true (UI renders "—"). demo:true now means only "nothing measured yet".

Frontend (CloudOverview/LiveActivity/ModelUsage) keys the live badge on
!demo && !volumeModeled — no modeled number wears a live badge; LiveActivity
regions show real visor node counts. TrafficGlobe filters garbage country codes
to ISO-3166. Dead admin-panel gate fixed (mountAdminCloudPanels !== 'saas' ->
'cloud', so Cloud Fleet + LLM panels mount for admins).

Full all-org volume + per-model tokens need a super-admin HANZO_CLOUD_PULSE_TOKEN
in KMS; until then honestly blank. go build/vet/test green; tsc + vite clean.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-17 01:27:05 -07:00
hanzo-dev 32da053f28 feat(world): tab order Cloud·AI·Crypto·Finance·Tech·World, Title-case labels, SVG icons
- Header tabs reordered to Cloud, AI, Crypto, Finance, Tech, World (World last).
- Tab + status labels Title-case, not ALL CAPS (World/Crypto/Finance/Tech/Live;
  AI kept as the acronym) across en + nl.
- Export (⬇) and historical-playback ( ⏮ ◀ ▶ ⏭) emoji replaced with clean
  16px stroke SVGs matching the header's search/theme icons.

tsc clean.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-17 00:49:28 -07:00
06ff53615b feat(cloud): Cloud flagship view — H-merge + CLOUD tab, in-map controls, flicker fix, flywheel panels (#12)
* feat(cloud): Cloud-view polish — compact H + CLOUD tab, in-map controls, flywheel history, accurate legend

- Header: merge the redundant H-icon + "Hanzo" wordmark into one compact (16px)
  canonical H mark (brand/home toggle); rename the flagship tab "Hanzo" → "Cloud"
  (switcher: [Cloud | World | AI | Crypto | Finance | Tech]). Canonical variant id
  renamed hanzo → cloud, with hanzo + saas kept as runtime aliases (links + stored
  prefs keep working).
- Controls: dock the 2D/3D toggle, basemap switcher and time-range pills IN the map
  (controlsHost = map) alongside the zoom, layer toggles and legend that already
  overlay it, instead of floating them in the bottom toolbar; time pills move to the
  bottom-left so they clear the top-left layer toggles; the empty dock slot collapses.
- Flywheel history: a same-origin GET /v1/world/cloud/router-history proxy (→ ai's
  public /v1/router/history?scope=platform; honest empty, never demo) + a Model
  Improvement panel — reward-rate line chart with retrain-version markers, a ticking
  cumulative cost-saved hero, and an adoption sparkline. Charts start empty and GROW
  with real data; nothing is seeded.
- Legend: add a Cloud data-class legend (request origin·volume, validator node, GPU
  fleet, cloud region, datacenter) so it matches what the globe plots rather than the
  geopolitical default.

tsc + vite build clean; world Go build + gofmt clean.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw

* fix(cloud): kill the 3D-globe flicker — settle the globe, guard the cables layer

Root cause (per a deep read-only diagnosis): the 3D globe is a custom GlobeNative
deck.gl renderer whose idle auto-rotate (~2°/s) keeps thin coplanar on-sphere vector
features (borders/coastlines/markers) perpetually sub-pixel-moving, re-rasterized
every frame — steady-state limb shimmer on real GPUs. Fixes:

- GlobeNative created with autoRotate:false — the globe SETTLES, so the deck render
  loop idles (verified: camera longitude does not drift and renders drop from ~40/s
  to a couple of data-sync ticks). Drag-to-rotate still works; re-enable the idle spin
  only with a coplanar depth/polygonOffset fix so it no longer z-fights the sphere.
- Guard createCablesLayer: filter UNDERSEA_CABLES to paths with >=2 finite [lon,lat]
  vertices, so one malformed cable can no longer make the PathLayer assert at init and
  silently drop the whole layer (the "[GlobeNative] cables-layer assertion").
- Cables OFF in the Cloud view (not a Hanzo Cloud data class; absent from the Cloud
  legend) so the globe stays to the plotted classes and the legend matches exactly.

Playwright: a new cloud-mode test asserts the 3D globe settles (lonDelta<0.001,
renders<12), the legend shows the Cloud classes (not the geopolitical default), and no
cables assertion fires. tsc + the full cloud-mode suite green.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw

* feat(cloud): CLOUD variant rename, compact H, in-map controls, flywheel panel + proxy

Completes the Cloud-view content (the prior checkpoint captured only the file renames):

- variant.ts: canonical variant renamed hanzo → cloud, with hanzo + saas as runtime
  aliases; host-default cloud on world.hanzo.ai. isHanzoBrandHost gates the brand.
- App.ts: header merges the redundant H-icon + wordmark into one compact 16px
  canonical H (brand/home toggle); first tab renamed Hanzo → ☁️ Cloud; createPanels
  wires the cloud panel set incl. the Model Improvement panel; controlsHost = the map
  so the 2D/3D toggle + basemap + time pills dock IN the map cluster.
- ModelImprovementPanel + router-history service + /v1/world/cloud/router-history
  proxy: the flywheel-over-time story (reward-rate line + retrain markers, ticking
  cost-saved hero, adoption sparkline) — honest empty until the ledger fills.
- variants/cloud.ts (mirror) + saas.ts alias; flywheel + in-map-controls CSS.

tsc + vite build clean.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw

* feat(cloud): regional activity — traffic-origin tooltip shows task mix + volume

Hover a traffic origin on the globe (or flat map) → its request volume + the router
task mix (code/reasoning/chat/vision — what customers there are DOING), falling back
to the coarse service class until the per-geo byTask recording lands. Uses the new
TrafficGlobePoint.byTask from /v1/traffic/globe. Works in both 2D and 3D (GlobeNative
delegates tooltips to this handler).

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-16 20:13:22 -07:00
3c50a167c7 feat(hanzo): Hanzo mode — H-logo switcher + live-traffic globe (flagship default) (#11)
* feat(hanzo): Hanzo mode — H-logo switcher + live-traffic globe (flagship default)

world.hanzo.ai now leads with the first-class `hanzo` variant. The H logo toggles
Hanzo mode, revealing the [Hanzo | World | AI | Crypto | Finance | Tech] switcher
(Hanzo first + default on the world.hanzo.ai host). The Hanzo entry + toggle appear
ONLY on hanzo brand hosts (white-label rule); off-brand/OSS hosts keep the plain
home-link logo + an always-on switcher. The former `saas` variant is folded in and
kept as a runtime alias (saas → hanzo, normalized in variant.ts).

Globe: a new native deck.gl `traffic` layer plots WHERE requests hit api.hanzo.ai
from — pulsing points sized/heated by count at country/US-state centroids, breathing
on the shared cloud-pulse clock. Fed by a same-origin proxy /v1/world/cloud/traffic-
globe → the ai backend's public /v1/traffic/globe (aggregates only, no IPs). The
existing traffic-arc layer now draws from the SAME native source (one source of
truth). Both degrade to an HONEST empty state — never demo — until the ai release
lands.

Side panels (the globe's companions): Live Traffic (native throughput totals + top
origins), Enso Live Training + Enso Flywheel (router stats), AI Compute, Model Usage
(model mix), Fleet, Cloud Overview, Hanzo Status (uptime), My Usage.

Tests: Go proxy passthrough + honest-empty state; tsc + vite build; a Playwright spec
driving the H toggle → switcher reveal (6 tabs, Hanzo first) + the globe layer toggle,
plus the TrafficGlobePanel live + empty states.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw

* refactor(hanzo): lead the globe with the honest native points layer, arcs off by default

The trafficArcs feed carries a demo fallback (demoTraffic), so enabling it by
default on the flagship could paint fabricated flows when native data is absent. The
new native `traffic` points layer is ALWAYS honest (empty until real traffic lands,
never demo), so the hanzo default now leads with it and ships trafficArcs OFF — still
one toggle away for the animated flow lines (which are native-sourced once live).

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw

---------

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-16 15:50:14 -07:00
hanzo-dev b62ad51e7c feat(panels): AI Compute + Enso Flywheel on the DEFAULT world
The platform's own live compute/usage pulse and router-training flywheel
were gated to ?variant=ai — invisible on plain world.hanzo.ai. They are
front-page telemetry: registered in FULL_PANELS and instantiated for the
default (full) variant alongside ai.

Claude-Session: https://claude.ai/code/session_018PmFAHZvbBSTsuWyebwMra
2026-07-16 14:36:55 -07:00
cbaae9e800 feat(analyst): content-free AI reward signals via @hanzo/ai SDK (#10)
Wire thumbs up/down + regenerate reward signals into the analyst chat,
keyed on the gateway response id, through a same-origin /v1/feedback BFF
proxy and the just-published @hanzo/ai@0.2.1 sendFeedback.

Backend:
- Thread the gateway response id out of every completion: chatResponse.ID
  (non-stream) + the per-chunk SSE frame id (first non-empty wins);
  runAnalystLoop returns it; the analyst done/JSON payloads carry "id".
- New same-origin BFF /v1/feedback (bare, matching the gateway path so the
  SDK's baseUrl:'' reaches it). Forwards the caller's IAM bearer +
  org/project selectors to a.base+"/feedback". Content-free is enforced
  server-side: the body is WHITELISTED to exactly {request_id, signal,
  rating?}; rating rides only with signal=="rating" and only for 1..3; a
  dismiss or any non-rating signal never forwards a rating. No bearer,
  bad signal, empty id, or malformed JSON => quiet 204 no-op (never 401).

Frontend:
- analyst-transport: surface id on AnalystResponse (JSON + SSE done).
- AnalystChat: store id + originating prompt on the assistant message; add
  thumbs-up/down (mutually-exclusive pressed state) + regenerate to the
  meta row, matching the copy button. emitFeedback helper calls the SDK
  same-origin with a lazy IAM bearer; opt-out via VITE_HANZO_FEEDBACK.
- icons: thumbs-up, thumbs-down, refresh-cw (lucide).

Content-free is a HARD invariant in BOTH layers: the SDK FeedbackInput type
union and the Go BFF whitelist. No prompt/response/text ever transits.

Tests: handlers_feedback_test.go (whitelist, rating rules, no-op paths,
preflight) + analyst id propagation. go test ./internal/world/... green;
tsc --noEmit + vite build green.

Rollout: merge to main ships via the world operator Service CR
(universe: infra/k8s/operator/crs/world.yaml) through hanzoai/ci.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-16 11:26:24 -07:00
zandGitHub 39288fe709 Merge pull request #9 from hanzoai/fix/world-router-stats-unwrap
fix(world 2.4.8): unwrap ai envelope in router-stats proxy
2026-07-16 09:17:16 -07:00
hanzo-dev f5a426c923 fix(world 2.4.8): unwrap ai casibase envelope in router-stats proxy
/v1/world/cloud/router-stats raw-passed ai's {status,data:{…}} envelope, but the
Enso Live Training panel (and this proxy's own unavailable fallback) expect the
BARE RouterStats — so live data would never render (the panel sat on
"connecting…" because stats.window was undefined). Unwrap `data` on success;
a missing/null data or non-ok status degrades to the honest unavailable payload
(never 5xx). Test now feeds the enveloped upstream and asserts the unwrap.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
2026-07-16 09:16:46 -07:00
zandGitHub 0bdf531004 Merge pull request #8 from hanzoai/feat/enso-live-training
world 2.4.4: Enso Live Training panel (saas variant)
2026-07-16 09:08:35 -07:00
hanzo-dev 59c9eb52eb world 2.4.7: Enso Live Training panel (saas variant)
An "Enso Live Training" panel for the Hanzo Cloud (saas) variant of
world.hanzo.ai — live routing throughput, learned-engine agreement, per-arm
routing share (opaque arm-N), cumulative cost-saved, and the last-retrain gate
verdict — fed by the PUBLIC /v1/router/stats?scope=platform aggregate.

Data via the same-origin Go proxy /v1/world/cloud/router-stats (the established
/v1/world/cloud/* convention): hard-pins scope=platform (client can't request a
vendor-labeled scope), clamps hours to [1,168], degrades to 200 {unavailable}
on an unreachable upstream (never 5xx, never fabricated). API base configurable
via HANZO_API_BASE (default https://api.hanzo.ai).

Enso stays private: the public scope already relabels arms to opaque arm-N; the
panel labels them "Enso arm N" and an e2e test asserts no vendor token ever
reaches the DOM.

Reconciled with the parallel Enso Flywheel panel that landed on main (ai
variant, /v1/world/enso-training): distinct variant + audience (this is the
Cloud self-metrics economics view; the flywheel is the public eval-loop view).
The colliding services/enso-training.ts was split — theirs kept, mine moved to
services/router-stats.ts — so both panels coexist with no shared symbol. VERSION
bumped past the flywheel's 2.4.6 to 2.4.7.

Claude-Session: https://claude.ai/code/session_015Z1iLf7QBrq1LhignJrzDw
2026-07-16 09:06:46 -07:00
hanzo-dev dee04c5c89 feat(world/ai): Enso Flywheel panel + /v1/world/enso-training
The router self-improvement loop made visible for the AI variant.
/v1/world/enso-training folds three honest sources: the routing-decision ledger
tail (export-routing-ledger ?since=, super-admin JSONL) → decision count,
engine-vs-heuristic mix, a routing-confidence histogram, task/model distributions;
the reward tail (export-routing-rewards) → rewarded count + mean reward; and the
latest enso-bench eval scores. Eval scores come from a committed snapshot of
enso-bench results/summary.json (go:embed), overridable live via ENSO_BENCH_URL —
so the panel is useful even signed-out; state (live/partial/demo) says which live
sources resolved. The payload is event-typed (Events[]) so retrain/deploy
milestones slot into the same timeline.

Frontend: enso-training.ts + EnsoFlywheelPanel registered for the ai variant,
reusing the existing cloud stat-tile / model-row / share-bar idioms.

Claude-Session: https://claude.ai/code/session_018PmFAHZvbBSTsuWyebwMra
2026-07-16 00:33:23 -07:00
hanzo-dev 7cd8035964 feat(world/ai): AI Compute panel + /v1/world/ai-pulse SSE
Live Hanzo inference-plane telemetry for the AI variant. /v1/world/ai-pulse
reuses the analyst SSE plumbing + the shared cloud-usage seam: it polls the
platform usage ledger (get-cloud-usages allOrgs → tokens/s, req/s, spend, top
models) + visor (gpu/machine/region counts) on an interval and pushes typed
usage/fleet/status frames to EventSource clients, coalesced through cache.go so N
streams cause ~one upstream sweep per 10s. A plain GET returns one JSON snapshot —
the panel's poll fallback. No token ⇒ honest state:"unavailable".

Frontend: ai-pulse.ts (SSE stream + poll fallback, cloned from cloud-pulse.ts),
AiComputePanel registered for the ai variant (panels.ts AI_PANELS + variants/ai.ts
+ App.ts). Reuses the existing cloud-panel stat-tile / model-row idioms.

Shared usage helpers (usageRate/topModelsFromUsage) decomplected into
cloud_service.go so cloud-pulse and ai-pulse derive rate/top-models one way.

Claude-Session: https://claude.ai/code/session_018PmFAHZvbBSTsuWyebwMra
2026-07-16 00:27:00 -07:00
hanzo-dev 4ef1b36e45 feat(world/cloud): cloud-pulse folds real platform volume (get-cloud-usages allOrgs)
/v1/world/cloud-pulse now reads MEASURED 24h request/token volume + top models
from the ClickHouse-backed usage ledger (get-cloud-usages ?org=all, super-admin)
on top of the live model/node/GPU/region counts, dropping demo:true AND
volumeModeled:true. Without a token — or with a non-admin token that can't reach
the allOrgs ledger — it stays honestly demo/modeled; platform numbers are never
faked silently, and the service bearer never reaches the browser.

One credential, one accessor: serviceToken()/serviceAuth()/apiHost() are now the
single service-plane seam (cloud-pulse + cloud-map share it) reading the existing
KMS-injected HANZO_CLOUD_PULSE_TOKEN. New cloud_service.go holds the shared
get-cloud-usages decode + fetcher, reused by the forthcoming ai-pulse.

Claude-Session: https://claude.ai/code/session_018PmFAHZvbBSTsuWyebwMra
2026-07-16 00:18:49 -07:00
hanzo-dev 7bbfe0f5a7 world 2.4.3: analyst top-up CTA on out-of-credits + 'Ask anything. Update your world.' placeholder
The analyst returned an opaque 'empty response' when a signed-in user's AI
credits were exhausted. The ONE AI gateway (hanzo/ai routers/filter_balance.go)
signals this as HTTP 402 {code: insufficient_balance}; the gateway can also
answer 2xx with an error envelope carrying no choices. World now detects EXACTLY
that one contract (no keyword heuristics, no invented codes — world stays a thin
client of api.hanzo.ai) and renders a wallet CTA: 'You're out of AI credits →
Add credits (console.hanzo.ai/billing) / View usage' instead of a dead bubble.
Adds failure logging on the analyst path (was silent). Placeholder set to the
canonical 'Ask anything. Update your world.'
2026-07-15 12:40:48 -07:00
hanzo-dev 426f918286 fix(world/analyst): shrink chat placeholder so it stops wrapping to two lines
The ~52-char placeholder wrapped at 14px inside the ~360px composer,
shifting the box when it did. The placeholder now renders at 12.5px; the
unitless 1.5 line-height stays pegged to the input's 14px row, so a
smaller placeholder never moves the row vertically — typed text stays
14px, the composer stays one line.
2026-07-15 01:57:32 -07:00
hanzo-dev 322248b98c chore(world): 2.4.2 — streaming analyst + model menu
Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 21:54:39 -07:00
hanzo-dev 27e52c0ea2 feat(world/ai): the analyst answers stream live (SSE)
/v1/world/analyst with Accept: text/event-stream now emits round /
think / delta / tool / done events while the agentic loop runs; the
done event carries exactly the JSON path's payload, so deltas are
cosmetic and the final render is unchanged. Server: chatMessagesModelStream
reads upstream OpenAI SSE; replyExtractor incrementally decodes the
strict-JSON envelope's "reply" value (chunk-safe escapes, byte-at-a-time
tested) and routes a reasoning model's pre-envelope prose to think
events. Tool rounds stream silently; tools surface as live chips.
Client: streaming bubble with breathing caret, dim italic thinking that
real answer text supersedes; non-SSE responses (old backend, sign-in
gate, agent path) fall back to the JSON contract in the same call.
Proven live: 61 think + 41 delta frames then done, gpt-oss-120b.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 21:49:28 -07:00
hanzo-dev 5b3f257a78 feat(world/ai): grouped model menu with family marks — no Zen ring on gpt-oss
The composer pill (fixed Zen logo + native <select>) becomes a proper
listbox popover: rows carry per-family marks (Zen ring ONLY on zen*,
sparkles for the Best router, G/L/C monograms for gpt/llama/claude,
bot for agents), group headers, active check. Assistant avatars and the
thinking row now wear the SERVING model's mark. One mapping in
src/utils/model-marks.ts, mirrored server-side by modelGroup() so the
roster groups honestly (Auto/Zen/GPT/Llama/Claude). Menu is
body-mounted + fixed so panel overflow never clips it. npm run dev
gains a /v1 proxy (prod by default, VITE_DEV_API_PROXY override).

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 17:55:17 -07:00
hanzo-dev 9ff7367f47 Merge feat/china-macro: China macro snapshot + release calendar
Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 17:08:00 -07:00
hanzo-dev b2c86b60f5 feat(world/econ): China macro snapshot + release calendar
/v1/world/china-macro — OECD CPI+CLI (2 sequential, non-retryable), BIS
policy rate (kv-seeded; not_configured until a feed exists), FRED
DEXCHUS, HKMA CNY context, NBS release-calendar scrape + PBoC LPR
candidates (2026 CN business calendar; 2027 entry required before Jan).
launchReady gates on all four required categories;
contentObservationDate anchors to the OLDEST required observation.
EconomicPanel CN tab renders staleness-honest tiles + upcoming releases;
keys mirrored across all 13 locales. Ported from upstream worldmonitor.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 17:07:52 -07:00
hanzo-dev ca6ab46c0a fix(world/tech): CISA feed rides our same-origin /v1 rss-proxy
The one feed still pinned to upstream's rss.worldmonitor.app/api host;
cisa.gov is already on the server allowlist.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 17:00:42 -07:00
hanzo-dev 9e56b6768f chore(world): 2.4.1 changelog; PWA guardrail asserts real precache intent
The stale guard demanded an html-free precache glob, contradicting the
committed vite.config (offline.html precached, index/settings excluded
via globIgnores). Assert the actual invariant instead.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 16:57:31 -07:00
hanzo-dev df125ac864 feat(world/natural): Western Pacific cyclone attribution
Cross-agency TC canonicalization ported from upstream worldmonitor:
GDACS WP-box observations + HKO warning signals (new same-origin
/v1/world/hko-warnings passthrough; browser CORS) dedup by alias
(<=750km/<=18h) or unnamed proximity (<=90km/<=3h), preserving each
agency's own wind + averaging period. Popup renders per-agency wind
rows and canonical-match confidence. JMA/JTWC upstream stubs skipped
(preflight-blocked upstream, fetch nothing).

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 16:57:31 -07:00
hanzo-dev 8f024d9227 perf(world/go): round sparkline closes to 7 significant digits
Yahoo closes come back float32-widened (17.209999084472656); Go re-emits
the noise verbatim, ~40-50% of sparkline payload bytes. One sparkline()
emit path (né tailN) rounds via strconv 'g'/7; scalars stay exact.
Ported from upstream worldmonitor seed-utils.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 16:57:18 -07:00
hanzo-dev f4d5827263 perf(world/ui): paint news before ML, bound analyst snapshot fetches
News panels gated their first DOM write on enrichWithVelocityML — on a
cold browser that means a 65MB sentiment-model download before any
headline appears (15-25s after the feeds answered in <1s). Paint with
keyword velocity immediately, upgrade sentiment in place, guarded by
renderRequestId. Analyst context fetches now cap at 2.5s so a cold
endpoint can't delay the chat send.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 16:57:18 -07:00
hanzo-dev 8511a46d38 perf(world/go): SWR cache with single-flight + GDELT warm keys
cachedJSON/passthrough now serve stale instantly and refresh in the
background (one coalesced flight per key; cold misses single-flighted).
gdelt-doc/gdelt-geo stalled every request ~10s on each 5-minute TTL
lapse; the analyst grounding + protests-layer keys are additionally
kept warm every ~4min, GDELT-paced.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 16:57:18 -07:00
hanzo-dev 014080d3e8 fix(world/ai): 'best' default model, honest upstream errors, roster lead
zen5 went dark when the inference plane's claim catalog shifted — an
unclaimed id falls through to a proxy that rejects every credential, so
every AI endpoint died with an opaque 402/401. 'best' is the gateway's
own routing alias (owned_by hanzo) and always resolves; non-2xx bodies
now surface their code (insufficient_balance, spend_cap_exceeded, …) in
the chat instead of a bare status.

Claude-Session: https://claude.ai/code/session_01SpMZ69ur3tjAXCiwaa7Wv2
2026-07-14 16:57:02 -07:00
hanzo-dev bd5aec5e12 fix(world/globe): arcs hug the sphere (greatCircle) + compact model selector
The 3D globe launched arcs far off the silhouette: deck.gl ArcLayer arches each
arc by ~1x the straight-line CHORD, so long cross-continent arcs (displacement
flows, traffic pulses) ballooned into space — the 'off-globe' glitch in the
screenshots. On the globe (mode==='3d') set greatCircle:true + getHeight:0 so
arcs follow the great circle flat on the surface; the flat map keeps its arch.

Also: the analyst model selector was a wide bar (max-width 160px showing full
model ids) — capped to 104px + ellipsis so it reads as a compact chip.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-13 08:20:16 -07:00
hanzo-dev 7f58503cf6 Merge feat/analyst-full-control: layout/monitor/queue/language commands 2026-07-12 11:31:56 -07:00
hanzo-dev baac10392f feat(world/ai): full app control from chat — layout mode, monitors, queue, language
The analyst could show/hide/move panels and drive the map, but NOT change the
layout mode, add a topic to watch, or drive the Watch Queue — so 'reconfigure
the layout and all widgets from the chat' was not actually true.

New commands (each drives the SAME public API the UI does; no second path):
- set_layout_mode: grid / free / immersive
- set_immersive_background: map | video
- set_language
- add_monitor / remove_monitor — 'watch for X'; goes through the one monitor
  path, so an AI-added monitor persists + matches server-side like a typed one
- queue_next / queue_prev — drive the Watch Queue

AppState snapshot now exposes layoutMode, immersiveBg, language, the user's
monitors, and queue depth — the model cannot drive what it cannot see.

Fixes a real WatchQueue restore bug the e2e caught: a persisted blob whose
currentId pointed at a non-'watching' item silently lost watch-tracking
(next() only finishes a 'watching' item). load() now restores the invariant.

e2e/analyst-full-control.spec.ts: AI really flips to immersive, really adds a
monitor that lands in storage, really advances the queue — 3/3. Full suite 56.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-12 11:31:56 -07:00
hanzo-dev 881c464e08 Merge feat/go-native-monitors: server-side monitor list + lake-wide matching 2026-07-12 09:23:02 -07:00
hanzo-dev b03f317986 feat(world/go): Go-native monitors — server-side list + lake-wide matching
Monitors lived entirely in the browser: keywords in localStorage, matching done
client-side over whatever headlines that tab had loaded. So a monitor only ever
saw one client's slice of the corpus, and it ceased to exist when you closed the
tab or switched device.

- /v1/world/monitors (GET/PUT): the list is persisted PER IDENTITY, reusing the
  existing per-identity store (namespaced 'monitors') — no new mechanism, no new
  table. Bounded + normalised at the boundary (sanitizeMonitors).
- /v1/world/monitors/matches: matching runs against the LAKE — every item the
  backend ingested — via the existing FTS search. FTS tokenising can over-match,
  so each hit is re-checked against the same word-boundary rule the browser used
  ('ai' must never trip on 'train').
- store: Lake.Flush() — drains the write-behind buffer synchronously. Run() is
  still the steady-state consumer; Flush is for callers that need the write to be
  VISIBLE (deterministic tests, and a shutdown that wants its buffer on disk).
- services/monitors.ts: the ONE path to where monitors live. Signed in → server
  (and localStorage as the offline mirror); signed out → localStorage, unchanged.
  Local monitors are ADOPTED on first sign-in rather than silently dropped.
- MonitorPanel.renderServerMatches renders lake matches with the same markup (and
  the data-ctx-* attrs, so Summarize-with-AI works on them too).

Tests run against the real SQLite lake (temp WORLD_DATA_DIR), not a mock:
per-identity round-trip + isolation (user-2 cannot see user-1's monitors),
matching against the lake, and the word-boundary guarantee. 4/4 pass.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-12 09:23:02 -07:00
hanzo-dev 0ad40998e1 Merge feat/go-native-enrichment: server-side threat + geo enrichment 2026-07-12 09:11:19 -07:00
hanzo-dev a452df5282 feat(world/go): move news enrichment into the Go backend (proven parity)
The browser was classifying threat level and inferring geo for every headline,
on every client, on every render — the data and the logic both lived in the
frontend. The Go backend is the product; the browser is a view.

- enrichdata/{threat-keywords,geo-hubs}.json: the tables are DATA now, one
  source of truth (extracted by EVALUATING the TS, not transcribed).
- internal/world/enrich.go: Go classifier + geo inference. Two parity traps
  handled deliberately — JS object key order IS match priority (tables stay
  ordered slices, not maps), and JS sort is stable (sort.SliceStable).
- feeds-batch now returns threat + resolved geo (hub, lat/lon, name) per item;
  parseFeedItems stays a pure XML parser, enrichFeedItems does the meaning.
- rss.ts consumes the server's enrichment; the browser no longer geo-locates
  anything (the import is gone). Single feeds now batch too, so the per-feed
  browser path survives only for feeds that aren't proxy-shaped.

PROOF, not a claim: enrich_test.go replays a 494-case golden corpus generated
from the real TypeScript (every keyword × both variants, every geo-hub keyword,
every exclusion, plus adversarial word-boundary cases) — 494×2 classifications
and all geo inferences are byte-identical to the code being replaced.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-12 09:11:19 -07:00
hanzo-dev 959138ac28 Merge feat/ui-polish: summarize context action, chrome cleanup, auth-aware CTA 2026-07-11 23:57:29 -07:00
hanzo-dev 329a741409 test(world): refresh e2e screenshots from the ui-polish run 2026-07-11 23:57:29 -07:00
hanzo-dev a8485d11af feat(world/ui): Summarize-with-AI on right-click; drop header rule, drag pill; hide Try-Hanzo when signed in
- Summarize with AI: any item already annotated data-ctx-headline (news, custom
  feeds) gets it as the FIRST context-menu entry. It routes into the ONE analyst
  dock via a capability port (registerSummarizePort), mirroring the existing
  map port — the menu never reaches into the AI. New AnalystChat.ask() /
  AiAnalystDock.askInDock() drive the same send path as typing; no second AI.
- Header: drop the border-bottom. The header shares the app background, so the
  rule just cut the page in half.
- Map: drop the visible drag pill (::after). The grip strip is still the drag
  target (cursor:grab) — the map stays chromeless, drag is unchanged.
- Try Hanzo is an ACQUISITION CTA: it now disappears once identity resolves as
  signed-in, driven by one new 'hanzo:auth' event emitted from the single place
  auth resolves (AccountMenu.refresh).
  FIXES A REAL BUG the e2e caught: .try-hanzo sets display:inline-flex, which
  beats [hidden]'s UA rule — so hiding it would have silently done nothing.

e2e/ui-polish.spec.ts covers all four against real DOM/CSS: 4/4 pass.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-11 23:51:55 -07:00
hanzo-dev abfbd61d1d feat(world/queue): keyless YouTube search — no API key required
queue_video was dead in prod: no YOUTUBE_API_KEY is configured, so Data-API
search returned empty. Scrape the results page instead — the same keyless
pattern handleYouTubeLive already uses (browser UA + consent cookies, since
datacenter IPs get an interstitial). The Data API stays as the fast path when
a key exists; the scraper degrades to an honest empty list, never a fake hit.

Verified live: 'Milken Institute Jensen Huang' returns the real talks
(HT8-KPAjpiA = Global Conference 2025) as the top results.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-11 23:23:25 -07:00
hanzo-dev cb67e2bbec chore(world): drop the accidentally-committed cmd/world binary; gitignore it 2026-07-11 23:22:00 -07:00
hanzo-dev 44ad7190cc Merge feat/watch-queue: persistent Watch Queue + AI queue_video + youtube search 2026-07-11 23:21:51 -07:00
hanzo-dev bc11f1111e feat(world/queue): persistent Watch Queue — AI-queued video, skip/back, tracked watched
Content the AI surfaced was ephemeral: a video it found (e.g. the Milken
Institute Jensen Huang talk) was lost on reload. Now everything consumable
lands in ONE persistent queue.

- WatchQueuePanel: stage (video/image/story) + list; prev / finish-and-next /
  fullscreen; click a row to play, x to remove. Owns no state — a pure view
  over watchQueue, so panel and immersive views can never disagree.
- queue_video app-command: the analyst resolves free text ('milken jensen
  2025') to a real video and queues it — reachable over MCP like every other
  command.
- /v1/world/youtube/search: Data-API search, cached, honest empty result when
  no key is configured (never fakes hits).
- Queue persists to localStorage: survives reload, tracks watched/finished.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-11 23:21:51 -07:00
hanzo-dev c75b442523 feat(world/queue): WatchQueue core — one source of truth for consumable media
Unifies non-live news clips, AI-surfaced media, and story cards into ONE
ordered queue with tracked per-item state (queued/watching/watched), so
content can be consumed reliably instead of flashing by. Pure logic, no DOM;
persisted to localStorage so the queue + watched history survive reload.
Idempotent enqueue (dedup by id) + bounded size (prune oldest watched).

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-11 11:10:22 -07:00
hanzo-dev 6bf39c2ebd feat(world/video): fullscreen/enlarge button on the live-news player
Adds a fullscreen control next to mute/play that promotes the video player
container to true fullscreen (and toggles back). CSS makes the iframe fill
the fullscreen root edge-to-edge. Answers 'make the video bigger / go full
screen so I can enjoy streaming it'.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-11 11:02:28 -07:00
hanzo-dev 2351a1e5bb fix(world/video): stop idle timer from killing a video you're watching
Both live-video panels ran a 5-min input-idle timer (mousedown/keydown/
scroll/touchstart on document). Passively watching a video fires none of
those, so after ~5 min the timer destroyed the player/iframes mid-watch —
exactly the 'video disappears after a few minutes' report. LiveNewsPanel
had no non-visibility resume path, so it stayed dead until a tab switch.

Fix: when the idle timer fires while the video is actively playing and the
tab is visible, reschedule instead of tearing down. The visibilitychange
handler (tab hidden) and the webcam IntersectionObserver (panel off-screen)
still cover the genuine 'user left' cases, so abandoned tabs still pause.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-11 10:58:02 -07:00
hanzo-dev 6461b9f7eb Merge perf/world-gzip-cache: gzip + immutable-cache static SPA 2026-07-11 10:50:06 -07:00
hanzo-dev 9f6ae73148 perf(world): gzip + immutable-cache the static SPA
The cutover from the hanzoai/static image to the cmd/world Go binary dropped
response compression and asset caching, so every cold load pulled the Vite
bundle raw: main.js 1.5MB, map.js 2.7MB — ~4.5MB of uncompressed JS.

- gzipStatic wraps ONLY the SPA handler (never /v1/world/*, which streams):
  gzip for compressible types, Range + non-gzip clients pass through,
  already-compressed media skipped. main.js -73%, map.js -72% on the wire.
- setCacheHeaders: content-hashed assets/ → immutable 1y; every unhashed file
  (favicons, manifest, service worker) stays no-cache so SW updates aren't
  stuck behind a stale cache.
- static_test.go: proves encoding, ratio, round-trip decode, cache policy.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-11 10:50:00 -07:00
hanzo-dev 43bfcd9463 feat(world/layout): resize free-mode panels from all four corners
Free mode gains NW/NE/SW corner grips alongside the existing SE one, so a
panel can be resized from any corner like a real window manager. The resize
is anchor-aware: dragging a corner pins the opposite corner — w/n edges shift
the panel's left/top in lock-step with the size, e/s grow from a fixed
top-left as before. One primitive (applyFreeResize over a {n,s,e,w} edge set)
drives every edge and corner handle; attachPanelCornerResize takes a `corner`
and only the SE corner also drives grid-mode span/column snapping (top/left
resize has no meaning in grid flow, so those grips are free-mode only, hidden
in grid via CSS). Header controls are raised above the grips so the close/info
buttons stay clickable where a top corner overlaps them.

Verified: e2e all-four-corners test asserts each corner resizes and pins its
opposite corner (14/14 panel-drag specs green); tsc clean.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 21:46:29 -07:00
hanzo-dev 71c53613e4 test(world): keep RED adversarial admin-gate matrix (17 cases, hermetic) — CI coverage for requireAdmin fail-closed 2026-07-10 14:32:22 -07:00
hanzo-dev d5cccfa026 chore(world): gitignore the embedded SQLite lake (world.db + WAL/SHM)
The datastore lake/settings DB (store.dbFile = "world.db", opened in WAL
mode) lands in the repo's data/ dir when WORLD_DATA_DIR points there in
local dev. Ignore world.db and its -wal/-shm sidecars alongside the
existing world-model runtime snapshots so they are never committed.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 14:32:22 -07:00
hanzo-dev fb5b277927 refactor(world/src): delete 3 orphaned data-fetcher services
conflict-impact, github-trending, hackernews (244 LOC): none imported
anywhere, none re-exported via services/index.ts, and every exported
symbol (correlateConflictImpact, fetchGitHubTrending, fetchHackerNews, …)
has zero references repo-wide. The github/HN panels now fetch through the
/v1/world/* URL registry in config/variants/base.ts, which superseded
these client-side fetchers. tsc + vite build green; bundle unaffected.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 14:32:22 -07:00
hanzo-dev c90e39c018 chore(world): drop unused deps @vercel/analytics, youtubei.js
Neither is imported anywhere in src/, src-tauri/, or api/ (0 refs
repo-wide). Removing them drops 4 packages from the lockfile (the two
plus their now-orphaned transitives @bufbuild/protobuf and meriyah) and
~26 MB of install footprint (youtubei.js alone is 21 MB).

Proven inert: with all four physically removed from node_modules,
`tsc --noEmit` and `vite build` pass and the JS bundle is byte-identical
(6,139,678 B) — they were never bundled, so bundle-size delta is 0; the
win is pure supply-chain / install-surface reduction.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 14:32:22 -07:00
hanzo-dev a5d6da77ae refactor(world): one IAM-userinfo introspection path
introspectOwner and introspectIdentity were the same memoized userinfo
call under two cache-key namespaces ("cloud-owner:" vs "identity:"), so a
token used for both the admin gate and settings sync was fetched and
cached twice. Delete introspectOwner; route requireAdmin through
introspectIdentity and read the owner claim off it. One function, one
cache entry per token, one source of identity truth.

Fail-closed semantics unchanged: no token → 401, introspection error or
non-admin owner → 403. Admin-gate and settings tests green.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 14:32:22 -07:00
hanzo-dev 217f13a582 refactor(world): delete dead code (deadcode-verified, tests included)
Five funcs unreachable even counting test callers, per
golang.org/x/tools/cmd/deadcode -test:

- strings.go fieldsCollapse   — 0 refs
- mcp.go     toolError         — 0 refs
- model.Engine.History()       — dead accessor; the /history route reads
                                 e.history directly (comment was stale)
- model.Engine.Context()       — superseded grounding path; AI handlers
                                 ground via CountryContext + HistoryDigest
                                 (comment "called by the AI handlers" was false)
- model.toneNote()             — only called by the removed Context()

ftoa/signed/round2 verified still live (history.go, model.go). Build,
vet, and internal/world tests green.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 14:32:22 -07:00
hanzo-dev 73bd9d78f8 feat(world): "Try Hanzo" product-switcher dropdown in header
Replace the signed-out header CTA with the hanzo.ai "Try" pattern: a white
.hz-cta pill "Try Hanzo ▾" that drops a monochrome menu of Hanzo products
(World/Chat/Dev/App/Cloud/Desktop), with world.hanzo.ai highlighted as the
current product. The former "Try Hanzo World" pill was the AccountMenu login
CTA — relabel it to a secondary "Sign in" so the header has one primary action
(hanzo.ai layout: [Try Hanzo ▾] [Sign in]).

- New self-contained stylesheet src/styles/try-hanzo.css (not main.css).
- Menu portaled to <body> + position:fixed so it never widens the page and is
  never clipped by the header's mobile overflow-y:hidden tab scroller.
- Closes on click-away and Escape; icon-only trigger < 680px. No h-overflow at
  1440/390; theme-aware (white pill dark / black pill light).
- All product URLs verified live (200): world.hanzo.ai, hanzo.chat,
  hanzo.ai/code, hanzo.app, console.hanzo.ai, hanzo.ai/desktop.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 14:28:33 -07:00
hanzo-dev 5dc7232cda feat(world/analyst): redesign the analyst copilot — markdown, real Zen mark, window modes
The dock chat is rebuilt to a native-@hanzo/ai, hanzo.chat-grade copilot in a
self-contained `hzc-` namespace + its own stylesheet (analyst-chat.css), so it
never touches main.css.

P0 fixes
- Markdown now renders: a tiny XSS-safe renderer (utils/markdown.ts) does
  bold/italic/lists/headings/code/links/tables — no raw HTML passthrough, every
  text run escaped once, every href run through sanitizeUrl.
- HTML entities decoded at the source: the backend HTML-escapes replies, so an
  apostrophe arrived as the literal `&#39;`. decodeEntities() runs first, then a
  single re-escape on output — the browser shows `'`, never `&#39;` and never the
  double-escaped `&amp;#39;`.

Brand + look
- Real Zen mark everywhere (the notched ring from zen/logo/dist/logo-mono.svg):
  icon('zen') is now the scaled logo and a new zenLogo() drives the FAB, the
  header, the assistant avatar, and the pulsing "thinking" state — no more
  generic enso/sparkle.
- vercel-black monochrome, Geist, sentence case: clean user/assistant bubbles,
  a card composer with an inline model selector, the model tag on each reply,
  tidy action-log + tool-trace styling.

Window modes (expanded scope)
- Closed = a small Zen-mark FAB bottom-right. Open = dock / sidebar / split /
  fullscreen, toggled from the header. Sidebar is the default and reflows #app
  via `body.hzc-shift` + the `--hzc-width` var (the auto-fit grid reflows
  cleanly); left-edge drag resizes; mode/width/open-state persist so a
  variant-switch reload reopens the same rail.

App control + data
- New bulk commands hide_all + show_only in the command registry (composed from
  existing AppHost methods, so they auto-propagate to the backend via
  commandManifest()); "hide all panels / show news / flip to finance" now
  visibly re-aligns the dashboard with an action-log receipt.
- MCP data-tool traces render inline as compact tables, not raw JSON.

Transport is still request/response (no SSE server yet), so slow zen5 shows a
live pulsing-Zen "thinking" state rather than a dead spinner; the SSE seam stays
in analyst-transport.ts for when a streaming cap lands.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 14:17:43 -07:00
hanzo-dev 9119ebbf3c fix(world/iam): sign-out clears local session first, bounds the IdP call (2.5s) so it never hangs; RP-logout with id_token_hint ends SSO
Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-10 14:05:47 -07:00
hanzo-dev 966882d9b7 fix(world): uncapped panel height resize (no max) + silence CartoDB CJK glyph 403 spam
1. PANEL HEIGHT CAP (CTO bug): grid-mode height snapped to the span ladder and
   hard-capped at span-4 (~800px); dragging the bottom edge further did nothing.
   Now height snaps to the fine tier ladder [120,200,400,600,800] up to its top,
   then grows CONTINUOUSLY with NO upper cap — each extra ~200px adds a whole row
   (spanForHeight, shared by the bottom-edge + corner handles). Spans beyond the
   CSS ladder (>4) are driven inline (grid-row + min-height; dataset.span is the
   source of truth via currentSpan), so a panel can be made arbitrarily tall.
   Free mode was already pixel-unbounded. MIN (120px) kept, no MAX. resetHeight
   clears the inline geometry too.
   Proof: grid 200→1628px, free 200→1700px — both persist across reload.

2. CJK glyph 403 spam: CartoDB's CJK glyph fonts (HanWang / NanumBarun / Noto
   Sans CJK…) 403 on CORS whenever a CJK label is in view, flooding the console.
   A map transformRequest repoints just those glyph fetches at the CORS-open Latin
   font (Montserrat), so CJK labels fall back cleanly with zero failed requests.
   Latin fonts (incl. "Noto Sans Regular") are untouched.
   Proof (panned to East Asia): 0 font 403s; Montserrat fallback in use.

Gate: typecheck + build clean; e2e panel-drag 13/13; responsive 17/17.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:49:01 -07:00
hanzo-dev 56c8f3770a feat(world/menu): workspace context menu on empty background — always a way back
Right-clicking the empty workspace (grid / content background, no panel or map
under the cursor) now opens a workspace menu: Grid / Free / Immersive layout, Add
widget, Reset layout. So even if the user hides every panel and the map, a
right-click always offers a way to reset and restore. Native menu still shows in
text inputs and over the chrome (header, dock, modals); panel/map right-clicks keep
their existing specific menus.

Proof: synthetic contextmenu on .main-content → menu with
[Grid layout, Free layout, Immersive layout, Add widget, Reset layout].

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:20:58 -07:00
hanzo-dev 897a9e1280 feat(world/layout): window-manager snap-zone tiling — drag to halves/quadrants with live preview
Free-mode drag now tiles like macOS / FancyZones: dragging a panel near an EDGE
previews a HALF, near a CORNER previews a QUADRANT, the centre stays free
placement. A monochrome translucent target rectangle fades in during the drag and
glides between zones as the cursor moves; on drop the panel resizes + positions to
the zone (with a clean gutter). Escape cancels (restores the pre-drag position);
reduced-motion drops the glide. One mechanism: the zone's viewport rect converts to
the panel's grid-relative free geometry on commit — no new coordinate system.

Zones are computed against the visible workspace (grid ∩ scroll viewport), so
tiling always fills the on-screen area regardless of scroll. This is the "toss
panels around like a window manager" experience over the map/globe.

Proof: drag → left-half preview 720×810 → drop → panel = left half (708×798);
top-right corner → quadrant 720×405 → panel = top-right quarter. Screenshots of the
mid-drag highlight captured.

Follow-ups (noted, not in this commit): center/left/right THIRDS, and split-view
when dragging over another panel's half.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:17:10 -07:00
hanzo-dev c953754197 fix(world/map): never black out without a Mapbox token — keyless deck raster basemap
mapbox-gl v3 refuses to paint ANY basemap without a token (the canvas goes fully
black even though the third-party CartoDB tiles fetch 200), which the CTO hit on
localhost and any deploy missing VITE_MAPBOX_TOKEN. Fix: when no token is present,
drape a keyless CartoDB raster basemap rendered by DECK (token-independent, same as
the data dots and the native ESRI globe) at the bottom of the overlaid deck stack —
theme-aware (dark_all / light_all), works in both the flat MapView and the globe
_GlobeView. Data layers draw on top. The token path is untouched: with a token the
mapbox vector basemap stands and no deck basemap is added.

Proof (VITE_MAPBOX_TOKEN unset): 2D = full CartoDB dark basemap w/ coastlines +
labels (60% non-black, was 0), 3D = basemap draped on the globe. Never a black void.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:17:10 -07:00
hanzo-dev 490acbb1c5 fix(world/panels): header cleanup — count clear of ✕ + hidden at 0, retire LIVE chip, label the summarize button
- Item count moves inline after the title (left), so it never collides with the
  hover-✕ (top-right); hidden entirely until count>0 (a loading "0" read as broken).
- Retire the "LIVE" text chip: it's all live by default and the static label
  misled while a feed was loading/empty. Live state now shows only a subtle pulsing
  dot (reduced-motion: static); cached/unavailable keep their informative labels.
- Summarize: the cryptic  becomes a labelled " Summarize" button (monochrome,
  sentence case, aria-label), reserved clear of the ✕. Funded-key path unchanged.

Playwright: 0 LIVE text chips, 0 zero/empty counts shown, summarize reads
" Summarize".

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:17:10 -07:00
hanzo-dev da5be4b051 feat(world/layout): one Grid/Free/Immersive mode dropdown; decomplect immersive (map bg) from snap; news-tag WCAG fix
- Dock: replace the Immersive button + Grid/Free segmented control with a single
  Layout dropdown (Grid / Free / Immersive). One control, one way.
- Decomplect background from snap: immersive is a BACKGROUND mode (map fills the
  viewport, panels float on top); grid/free is the SNAP behaviour. Immersive
  defaults to freestyle floating (free snap) over the FULL viewport — the old
  narrow right-docked column is gone (it collapsed once free-mode made the cards
  absolute). Map stays fixed full-viewport via !important over the inline free geom.
- Background Map/Video + collapse controls show only while immersive is active.
- NewsPanel category tags: drop inline color (threat colour as text failed WCAG);
  text now var(--text), threat colour carried by border + translucent bg only.

Verified: dropdown drives grid→free→immersive→grid (map fixed full-viewport in
immersive, cards float visibly over it); 17/17 P0 checks still green.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:17:10 -07:00
hanzo-dev 678bd10d0d fix(world/layout): kill map/panel overlap, fill full width, retire mobile modal, map edge-to-edge + maplogo flag
Layered on the integrated layout engine (194e6657). The grid's auto-rows
collapsed to their 120px floor, so every span-2 panel (min-height 400) — the
map included — overflowed ~150px onto the row below and OVERLAPPED it
(43 overlaps across 41 panels). Root fix: definite 200px row unit
(grid-auto-rows: var(--panel-row,200px)) so span-N reserves exactly its height;
content that exceeds a panel scrolls inside it, never onto neighbours.

- Full width: auto-fill → auto-fit so real columns always stretch to consume
  100% width, no dead right-hand gutter at any size (verified 9 widths).
- Map edge-to-edge: the 14px drag-grip is now an absolute overlay, not in flow,
  so the canvas fills the panel top (black top strip 15px → 1px).
- ?maplogo=0 (+ localStorage) hides the basemap wordmark; a compact "ⓘ"
  AttributionControl is always mounted so basemap ToS still holds.
- Mobile-view warning modal retired (layout is responsive to 390px).
- span-0 stays a genuine 120px short tier (top-aligned in its 200px track).

Proof: 17/17 playwright checks (full/crypto/saas × 1920–390): hOverflow=0,
0 overlaps, gutter=grid-padding only, no modal. Live-news video grows to full
width (353→1430px, 16:9, no cap). Dark/Sat/Terrain inline with 2D/3D in the dock.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:17:10 -07:00
hanzo-dev a338fc440b feat(world): panel layout engine integrated onto origin/main + free live-news video
Rebuilds the free-form + snap-to-grid layout engine on top of the advanced
origin/main (footer dock + responsive work) and unifies the two grid-cell
mechanisms into one:

- grid-config drives the dock's `--panel-col-min` (default 160px, range 140–360,
  matching the base .panels-grid rule + the dock slider) — the duplicate
  `--grid-cell` override is gone. One variable, one way. Default 160 = the grid
  is byte-identical until the slider moves it, so no fixed-span panel shrinks.
- window.worldGrid exposes the engine to the footer dock's Grid⇄Free toggle +
  cell-size slider (App.gridApi delegates to it).
- main.css layout-engine section is append-only after the dock CSS; only NEW
  selectors (corner grip, snap overlay, free-mode absolute layout, mobile guard).

Live News (video) now resizes freely with the video filling at any size (CTO
ask): the panel carries id="live-news" so the intended `#live-news .panel-content`
fill CSS (padding:0 + flex-column) finally applies — the 16:9 `.live-news-player`
fills the container edge-to-edge (was 16px short under generic padding). With the
new right-edge + corner grips and the uncapped column snap (span up to full
width), Live News drags to 2-3 cols or full-width-top in grid mode and to any
pixel size in free mode; the width-driven video fills at every size.

Tests (13, all green; typecheck + build pass): 4 original drag/resize + 7
layout-engine (grid snap, corner resize, overlay, cell re-snap, free drag+resize
persist across reload, map participates, toggle) + 2 real-app live-news (grid
default→3col→full-width fill setup + resize, free arbitrary-size). Screenshot:
e2e/layout-shots/live-news-fullwidth.png (full-width-top, large video area).

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 11:17:10 -07:00
hanzo-dev 6fdfc0d3a6 feat(world): instant news via warm feed cache + queryable ingested-data lake
Make news/feeds INSTANT and give world one place to query everything.

Feed hot cache (two-tier, ONE way to read a feed body):
- L1 per-pod in-mem mirror (sub-ms hot reads) + L2 hanzo-kv (shared across
  pods, survives restart). A body warmed by any pod is instantly served by
  every pod; a restarted pod reads the still-warm shared cache instead of
  cold-starting. Graceful degrade to L1-only when hanzo-kv is unreachable.
- Background warmer: on boot + every ~5m (jittered) fetches every warm feed,
  write-throughs the body, and folds items into the lake. rss-proxy and
  feeds-batch serve from the warm cache and never block on upstream while any
  cached copy exists (stale-while-revalidate; revalidation runs in background).
- Demand-driven warm set (fleet-wide via hanzo-kv) + a curated seed of the
  crypto / financial-regulation / crypto-news feeds so a brand-new pod warms
  THOSE first. Cross-pod fetch dedupe via shared copy freshness.

Ingested-data lake (embedded SQLite, modernc.org/sqlite — pure Go, FTS5):
- One normalized `items` table (id, kind, source, ts, title, text, tickers,
  country, geo, payload) fed by feed news AND world-model observations, so
  everything is searchable/countable together.
- GET /v1/world/search (FTS5 bm25 ranking; filters kind/since/country/ticker)
  and GET /v1/world/analytics (totals, by kind/source, top tickers). Rolling
  retention window with an hourly prune job. Write-behind ingest (never on the
  request path).

Per-identity settings (same SQLite DB, own table):
- GET/PUT /v1/world/settings, bearer-gated, keyed by (org, user_sub, project)
  from IAM userinfo, so a signed-in dashboard syncs across devices. Anonymous
  → 401 (client keeps localStorage).

Everything degrades cleanly (never-5xx): no hanzo-kv → per-pod cache; no SQLite
→ empty search/analytics + settings say not-stored. Binary stays CGO-free
(CGO_ENABLED=0). go directive + Dockerfile bumped to 1.25 for modernc.

Latency (local, warm): feeds-batch warm 0.5-1.9ms vs 154ms cold; search 0.8-1.5ms.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 10:51:22 -07:00
hanzo-dev 1043182671 fix(world/analyst): enforce paid usage — chat needs a user bearer, never the funded key
The analyst chat is paid usage: metered to the signed-in user's org via their
own IAM bearer. handleAnalyst used s.ai.bearerFor(r), which falls back to the
funded service key (HANZO_AI_KEY) when no user token is present — so an anonymous
chat silently ran on the shared key (proven live: an unauthenticated POST to
/v1/world/analyst returns a real 200 answer). That made the backend "Sign in to
chat" gate dead code and meant paid usage was never actually enforced or metered.

Use userBearer(r) for the chat path (user-only). The funded key now backs ONLY the
anonymous auto-insight endpoints (summarize/classify/country-intel), exactly the
intended pro model. The sign-in gate is now live server-side, and the frontend
gate gains defense-in-depth. Roster listing (handleModels) stays on bearerFor —
it is read-only, not metered inference.

Test: TestAnalystChatRequiresUserBearer pins the contract (funded key present,
anonymous chat still refused with the sign-in skip). TestAnalystDataToolLoop now
drives the chat as a signed-in caller (Authorization header), the real paid path.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 10:25:14 -07:00
hanzo-dev 68a28f2eda feat(world/map): dark/satellite/terrain basemap styles on the native globe
The native deck GlobeView now honors the 2D basemap switcher (dark | satellite |
terrain), reading the SAME localStorage key (hanzo-world-basemap-style) and
reacting live to a basemap-style-changed event DeckGLMap fires — no reload.

- dark: the existing monochrome vercel-black sphere.
- satellite: keyless ESRI World Imagery draped via a deck TileLayer/BitmapLayer,
  back-faces culled so the far hemisphere never bleeds through; the near-black
  ocean sphere underneath supplies depth + darkens the imagery so data dots stay
  legible. Thin light country borders overlaid for intel orientation.
- terrain: keyless ESRI World Physical Map (natural relief).

Data layers (dots/arcs/borders) render ON TOP and keep picking; ocean sphere is
tinted dark slate-blue on bright styles so the Web-Mercator polar gap blends.
Since native uses keyless ESRI, the switcher's sat/terrain buttons no longer need
a Mapbox token when native is the 3D renderer (default), and setBasemapStyle
notifies the globe before the tokenless mapbox setStyle so a missing token can't
block the switch. Adds a style-switch e2e (single-context preserved, data still
picks) + harness hooks.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 10:20:44 -07:00
hanzo-dev a8af187b99 fix(world): split header/tooling into a bottom dock; kill 1366 overflow; layers collapsed; globe fit
Live 1366×768 had 167px of horizontal page overflow (documentElement.scrollWidth
1533 vs innerWidth 1366): the header's action row (Copy Link / theme / Immersive /
PANELS / SOURCES + variant tabs) could not shrink and pushed the account CTA off the
right edge. The layers panel also rendered OPEN by default, floating over the map and
the panels beneath it.

CTO-approved restructure: the header is now IDENTITY + NAV only (Hanzo mark, variant
tabs, LIVE, search, theme, account). All operational controls move to a new slim,
monochrome, collapsible BOTTOM TOOLBAR DOCK:

- Map's 2D/3D toggle, basemap switcher (Dark/Sat/Terrain) and time-range pills mount
  into the dock via a new optional DeckGLMap `controlsHost` (defaults to the map
  container, so the e2e harnesses that drive the overlay controls are unchanged).
- Region select, Layers toggle, Immersive controls, Panels, Sources, Copy link and
  Fullscreen relocate into the dock (same ids → existing handlers still bind).
- New controls: layout-mode (Grid ⇄ Free) + widget-size slider, wired through a
  feature-checked adapter that prefers window.worldGrid (world-layoutengine) and
  otherwise applies a self-contained fallback (CSS column-floor var + body data attr);
  and "+ Add widget", a searchable palette over the panel registry that shows a panel
  via the one setPanelEnabled() path — works in both grid and immersive layouts.

Fixes:
1. Horizontal overflow — scrollWidth === innerWidth at 1920/1440/1366/1024/768/390.
   Header groups are min-width:0 shrinkable; variant tabs collapse to icons then scroll
   INSIDE their own strip; the dock scrolls horizontally so the PAGE never scrolls.
2. Layers panel — hidden by default (`.open` reveals it via the dock's Layers button),
   anchored top-left WITHIN the map box, height-capped with internal scroll, draggable.
3. Basemap switcher — verified switching Dark/Sat/Terrain in 2D (tiles load, deck data
   layers survive setStyle, choice persists in localStorage). The token IS in the
   runtime bundle. In native-3D-globe mode the deck GlobeView renders its own sphere,
   so sat/terrain don't retexture it there — a pre-existing renderer limitation, noted.
4. Globe fit — the deck globe canvas fills the map container exactly (measured
   1356×384 == map box; no letterboxing); CSS forces the wrappers/canvas to 100%.

Gates: typecheck ✓, vite build ✓, e2e globe-overlay 1/1 ✓, panel-drag 4/4 ✓.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 10:01:09 -07:00
hanzo-dev 7ff99bc99d fix(world/cloud): unblock saas Chains/Overview + hide API-key names from public
P1: on the Cloud (saas) dashboard the Chains and Cloud Overview widgets sat on a
loading spinner for 30s+. Root cause: /v1/world/cloud/chain-nodes took ~24s on a
cold cache because unreachable L1 RPCs (zoo/hanzo) hung until the request-wide
deadline, and CloudOverviewPanel compounded it by awaiting getChainNodes() in the
same Promise.all as the (instant) pulse. Crypto only looked fine because its cache
was already warm.

Fixes:
- Backend: bound each chain's telemetry fetch with a 4s perChainTimeout so one
  dead L1 can't stall the whole response. Cold chain-nodes 24.0s -> 4.0s (verified
  on fresh servers); live chains unaffected, dead chains honestly live:false.
- CloudOverviewPanel: primary render now depends only on pulse+models (both
  instant); the chain-scale tiles fetch independently and fold in when ready —
  the slow fetch can never gate first paint again. Reordered the two real
  chain-scale tiles ("chains live", "total block height") ahead of the
  demo-modeled nodes/GPUs/regions/uptime so they land in the panel's visible rows.

Polish — one honest-copy choke point (runtime.canConfigureKeys, used by
Panel.showConfigError and the FRED site): public web viewers now get the quiet
"No data available" line instead of raw "FINNHUB_API_KEY / NASA_FIRMS_API_KEY /
FRED_API_KEY not configured — add in Settings"; the specific, actionable copy
stays only for the desktop app / dev build where keys can actually be set.

Verified live via playwright (?variant=saas): Chains renders ETH 25,501,617 +
BTC 957,412 (count 5, LIVE); Cloud Overview populated incl. chain tiles (2-3
live / 26.46M total height); ?variant=full body scan for /_API_KEY/ returns null
across 91 panels; zero console errors. Gates: typecheck + build + go build/vet +
go test ./internal/world/ (17s) all green.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 03:44:23 -07:00
hanzo-dev af45f23a81 feat(world): per-component right-click menus + world MCP data tools in the analyst loop
Context menus (panel-menu.ts): extend the single installed contextmenu
listener into a per-context system. Layers, most-specific first: text entry
→ native menu; component items via a data-ctx-* convention (news → Open/Copy
link + Copy headline; market/FX/commodity/yield rows → Copy value/symbol);
the map surface → Copy coordinates / Fly here / 2D-3D toggle through a narrow
MapContextPort (registered by MapContainer); then the panel baseline (Hide,
Move to top, five Size presets, Reset layout). A stationary right-click on the
map opens the menu; a rotate-drag still bails so maplibre keeps the gesture.
Size presets reuse Panel's setSpanClass/savePanelSpan; Move to top routes
through App.movePanelInGrid via a new panel-move-request event — one way each.
Panels only annotate their DOM (quoteRow, MarketPanel, NewsPanel,
CustomFeedPanel); DeckGLMap gains a read-only screenToLngLat.

Analyst data tools (handlers_analyst.go + mcp/): bind the 7 world MCP tools as
DATA tools in the analyst loop. mcp.CallTool executes them IN-PROCESS via the
same dispatcher tools/call uses (one path, no self-HTTP); mcp.ToolSpecs feeds a
derived tool contract into the system prompt. The handler runs an agentic loop
(≤3 tool rounds) — the model requests tools in a "tools" array, results are fed
back, then it answers citing live data. Tool traces ride the response and render
as collapsed "🔧 tool(...)" lines in the chat (AnalystChat). App-commands stay
auth-gated (unchanged); data tools dispatch anonymously.

Gates: go build+test (incl. new agentic-loop + parse/contract tests), tsc,
vite build, playwright (news/map menus + stubbed analyst tool round-trip) — all
green.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 02:58:59 -07:00
hanzo-dev 5d8b0d918a feat(world): immersive layout, basemap style switcher, layer-panel drag, bigger widget defaults, calmer globe spin
Layout/style batch:

1. Bigger default widgets — the 160px column FLOOR stays (fine map column-resize),
   but auto-placed panels now default to `grid-column: span 2` (~324px) instead of a
   160px sliver. Map keeps full-width; height tiers untouched (still shrink to span-0).

2. Immersive mode (?layout=immersive, persisted) — body.immersive promotes the map to
   a fixed full-viewport background; panels float above as a translucent (#0a0a0acc +
   backdrop-blur) column docked right, still drag/resize/close-able. One background
   slot = map | video: the video option mounts the muted live-news YouTube embed as a
   fixed BG. Collapse-to-edge affordance slides the column off so the globe breathes.
   Escape / the header toggle returns to the grid; state survives reload. New service
   src/services/immersive.ts owns the state; App only wires header chrome to it.

3. Layers selector UX — the collapsed layer list is now a draggable panel (header grip,
   position persisted) with pointer drag-and-drop reordering of its entries (order
   persisted, purely cosmetic). Contained in DeckGLMap.

4. Calmer globe — AUTO_ROTATE_DEG_PER_SEC halved 4 -> 2 in BOTH renderers (GlobeNative,
   the active 3D globe, and the mapbox DeckGLMap globe — kept identical by design).

5. Nicer basemaps — a small dark/satellite/terrain switcher under the 2D/3D toggle
   (2D mapbox renderer). One setStyle path re-adds fog/atmosphere/projection + drapes
   terrain over the Mapbox DEM (exaggeration 1.4) on style.load; deck overlay survives.
   satellite/terrain require VITE_MAPBOX_TOKEN — gated (disabled) without one so the map
   never breaks. Data dots gain a thin dark halo over bright rasters (deck-canvas only).

Gates: typecheck + build green; e2e globe-overlay + panel-drag green; globe.spec
auto-rotate band updated to the new 2°/s. Deliverable screenshots under e2e/screens/.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-10 02:48:20 -07:00
hanzo-dev 5a63c0ed8f fix(world/cloud): live status-page path + native status board (no iframe)
Follow-up: the merged status-page proxy assumed the source repo's Gatus route
(/v1/status/endpoints/statuses), but the LIVE status.hanzo.ai deployment serves
the upstream-standard /api/v1/endpoints/statuses — so the endpoint returned
available:false/total:0. Probe both routes and use whichever answers with a
non-empty board (correct against either deployment). Live now yields 25 services,
24 up, 1 incident (AI/Chat).

Visual: replace the HanzoStatusPanel iframe embed of status.hanzo.ai with a
NATIVE monochrome board fed by the same /v1/world/cloud/status-page summary —
services grouped by group (AI/Apps/Commerce/…), per-service up/down dot + response
time, active-incidents section on top. One data source, one style, no iframe
sizing/scroll jank. Extract the shared "since" formatter to cloud-format.fmtAgo
(used by both status panels — no duplication).

Gates: typecheck + build + go build/vet + go test ./internal/world/ all green;
live JSON verified end-to-end through the running server.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-09 22:43:14 -07:00
hanzo-dev 3c6742fe86 fix(world/pwa): serve index.html network-first so multi-deploy tabs never flash a stale build
With several releases a day the SW precache served an old index.html that pointed
at hashed bundles a newer deploy had already replaced: the app flashed the old
build, then went black as old and new chunks mixed. Aligns SW behaviour with the
deploy caching policy (index.html no-store, /assets/* immutable).

vite.config PWA (generateSW):
- navigateFallback: null — disable the plugin's default 'index.html' fallback. A
  precache-served fallback is cache-first and re-introduces the stale shell; it was
  also generating createHandlerBoundToURL("index.html") against a non-precached URL.
- globIgnores += index.html, settings.html — keep the HTML shell out of the
  precache so navigations resolve to the network-first route, not a stale copy.
  Only immutable, content-hashed assets stay precached, so an old-HTML/new-chunk
  mix is impossible. offline.html remains the sole offline shell.
- runtimeCaching: NetworkOnly passthrough for /v1/world/* and /.well-known/*
  registered before the navigate handler, so the data plane and discovery docs are
  never intercepted or cached; the navigate handler stays NetworkFirst.
- skipWaiting + clientsClaim + registerType autoUpdate retained: a new SW activates
  immediately and (via workbox-window) reloads the tab exactly once on a real
  update — never on first install, never in a loop.

Client (src/services/sw-update.ts, wired from main.ts):
- Extract SW lifecycle into one module. Register via vite-plugin-pwa autoUpdate and
  call registration.update() on visibilitychange->visible (plus an hourly backstop),
  so a long-lived backgrounded tab learns about a deploy the instant it is looked at
  again — before the user triggers a lazy chunk import the server may have purged.

Verified: tsc + vite build clean; dist/sw.js contains self.skipWaiting()+clientsClaim(),
no NavigationRoute, index.html/settings.html absent from precache, /v1/world &
/.well-known are NetworkOnly. Playwright upgrade sim (Go server on :8193, build A ->
swap shell -> build B): tab lands on build B via network-first HTML with no
hard-refresh and no chunk-load errors (9/9).

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-09 22:34:04 -07:00
hanzo-dev bda2b05852 fix(world): globe marker back-hemisphere occlusion + responsive 16:9 video + mobile legend
- DeckGLMap: cull far-side point markers by alpha on the 3D globe (overlaid
  deck.gl shares no depth buffer with mapbox's globe, so back-hemisphere dots
  projected through the front and looked like they floated off the sphere).
  One DRY helper clones Scatterplot/Icon layers with camera-facing color
  accessors, re-evaluated each buildLayers (move debounced ~10Hz).
- LiveNews video: width-driven 16:9 (flex:0 0 auto + aspect-ratio) so the embed
  scales with its container instead of being stretched by the flex parent.
- Mobile: keep the centered map legend inside the viewport (wrap + max-width).

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-09 22:31:59 -07:00
hanzo-dev 4157889795 feat(world): fullscreen country application view with docked analyst sidebar
Clicking a country now opens a proper fullscreen application view instead of a
centered card overlay: intel content (instability index, brief, signals,
timeline, prediction markets, infrastructure) breathes across the canvas on the
left, with a docked AI analyst chat as a right sidebar.

- CountryBriefPage: fullscreen flex layout (header + cb-view = cb-main | resize
  handle | cb-analyst-sidebar). Docks the ONE AnalystChat component by
  composition — model picker + command registry intact — seeded with the country
  as the active region so the first question is grounded. Resizable divider,
  collapsible sidebar with a reopen pill; on mobile the sidebar is a bottom sheet
  (no horizontal scroll), intel leads by default.
- App: browser Back closes the view (opened via pushState), Forward re-opens it;
  ?country= deep link restores it. Escape/✕ still restore the dashboard. Passes
  the analyst host into the page. Replaced a fragile typeof-this type query with
  a stable class index access.
- main.css: append-only fullscreen layout + analyst sidebar styling (monochrome,
  Geist, sentence case), mobile bottom-sheet, print-flow restore.
- e2e/country-view.spec.ts: desktop fullscreen + docked chat + URL + Escape,
  collapse/reopen, mobile 390px bottom sheet, ?country=FR deep link. 4 passing.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-09 22:29:28 -07:00
hanzo-dev d77c6f7281 feat(world/map): native deck.gl GlobeView 3D renderer (single camera, single context)
Add GlobeNative — a pure deck.gl _GlobeView renderer for the 3D globe that
replaces the mapbox-gl globe + deck overlay (two cameras, two contexts). One
Deck, one canvas, one WebGL context: perfect data registration by construction,
proper far-side occlusion (depth-writing ocean sphere), monochrome vercel-black
basemap from the bundled /data/countries.geojson.

Data layers, tooltips and clicks are reused verbatim from DeckGLMap via an
additive asGlobeSource() bridge (no builder duplication; heatmap->scatter swap
comes free). Native is now the default 3D renderer; ?globe=mapbox is the escape
hatch. 2D stays mapbox mercator, untouched. Clean create/destroy on the 2D<->3D
toggle (no leaked contexts). Idle auto-rotate + fly-to + reduced-motion +
tab-hidden gates ported from the mapbox globe.

Adds e2e/globe-native.spec.ts (flag activation, single context, GlobeViewport,
pick-on-sphere) + a standalone harness.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-09 22:19:44 -07:00
hanzo-dev df2933f4fc style(world): header variant order — world, ai, crypto, finance, tech, cloud
Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 22:16:58 -07:00
hanzo-dev 5ab54f9733 feat(world/cloud): real platform data in the Cloud dashboard
Expand the Cloud dashboard with live, honest platform telemetry across four
fronts. Every path keeps the layer's never-5xx + explicit-flag contracts.

1. Cloud Overview +2 real tiles: "chains live / N tracked" and summed "total
   block height", computed from the live chain-nodes feed. Omitted (not zeroed)
   when the feed is unavailable.

2. More chains: the chain catalog now carries three fetch strategies (luxfi/node,
   public EVM JSON-RPC, plain-integer height). Adds Hanzo (api/rpc.hanzo.network
   failover), Ethereum (eth_blockNumber/eth_chainId via ethereum-rpc.publicnode.com)
   and Bitcoin (blockchain.info height) — each SSRF-allowlisted, per-chain live
   flag, no invented heights. BlockchainPanel hides peers/chainId where not
   meaningful (public reference chains).

3. status.hanzo.ai integration: GET /v1/world/cloud/status-page proxies the
   Gatus board (/v1/status/endpoints/statuses), distilled to a per-service
   up/down board + active-incidents list (latest-by-timestamp, incident dated
   from last UNHEALTHY event). Surfaced in CloudServicesPanel as a public health
   section fused with the admin RED metrics. Honest available:false when down.

4. OpenSky OAuth2: when OPENSKY_CLIENT_ID/SECRET are present, fetch+cache a
   client-credentials bearer and attach it to the states/all proxy; anonymous
   path is byte-identical otherwise.

Tests: chain catalog + allowlist + hex parse, status-page summarize + live
degrade, OpenSky anonymous + invalid-cred fallback. All gates green.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-09 22:09:23 -07:00
hanzo-dev bdb9244eb3 feat(world/mcp): MCP server (streamable-HTTP, JSON-RPC 2.0) at /v1/world/mcp
Clean-room MCP apps surface for Hanzo World — a thin, read-only projection
of the existing public /v1/world/* routes. Ideas-only from the upstream
study; no code copied.

- internal/world/mcp: JSON-RPC 2.0 over streamable HTTP. 7 tools
  (world_brief, country_instability, model_history, market_quotes,
  chain_status, traffic_map, feeds) each dispatched IN-PROCESS through the
  same world mux via an httptest recorder — one impl per data path, the
  model.Engine's unexported handlers reached through the mux composition
  point, zero edits to existing handlers.
- Two MCP apps (ui:// resources): world-brief + market-radar. resources/read
  returns a DATA-FREE static HTML shell (text/html;profile=mcp-app, strict
  CSP, textContent-only, no innerHTML) that receives data later via a
  tools/call over the host postMessage bridge (quota-safe two-phase).
- Discovery: root server.json (MCP registry manifest) + public/.well-known/
  mcp/server-card.json (tool + app inventory), both generated by
  cmd/gencard and digest-stable like the agent-skills index.
- feeds tool exposes a curated category enum (never raw URLs) over
  allowlisted RSS hosts — no SSRF surface.
- Anonymous public access; gate() attachment point noted per the
  internal/world/model gate() pattern for later pro-tier gating.

Tests: JSON-RPC initialize/tools.list/tools.call/resources round-trip over
live wiring, server-card + server.json drift guards, shell digest pins, and
a data-free/no-innerHTML shell-safety grep. Verified end-to-end against the
compiled binary with the official @modelcontextprotocol/inspector CLI.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-09 22:06:26 -07:00
hanzo-dev b1d8708d06 fix(world): kill map freeze on real hardware + finer grid sizing (2.9.34)
P0 perf — the CTO's Chromium froze on 2.9.33. The news-pulse (500ms) and
cloud-pulse (30fps) each re-ran buildLayers(), reconstructing ALL ~30 deck
layers with fresh accessor closures every tick — full-retina double-canvas fill
+ 2Hz/30Hz layer reconstruction + GC churn. Fixes:

- Animation-only update path: buildAnimationLayers() reuses the instances from the
  last full buildLayers() and swaps in ONLY the layers that animate (news/hotspot/
  protest pulse for the news clock; chainNodes/trafficArcs for the cloud clock).
  deck.gl's same-instance fast path (Layer._transferState: this===oldLayer → return)
  then skips the other ~28 — no alloc, no attribute upload. Measured (SwiftShader,
  cloud layers on): full rebuilds during a 5s pulse window 38 → 0; layers
  re-instanced per frame 48 → 2; long-task time 2825ms → ~0; effective 8fps → 31fps;
  heap drift 0% over 8s idle.
- News pulse 500ms → 2000ms, skips while paused/webgl-lost; pulse periods lengthened
  to ~8s so the 2s sampling still reads as a calm breathe (2s/318 aliased to a static
  ring).
- useDevicePixels capped at min(dpr, 2): a DPR-3 display no longer quadruples the
  second canvas fill; 2× keeps dots/text crisp.
- Cloud-pulse RAF now invalidates only the arc + chain-node layers (via the cloud
  clock) and still stops when its gate closes; chain-node dots memoized by source
  ref so the breathe is a radiusScale-only change.

Finer grid sizing (CTO: "tiny widgets, less constrained"):
- .panels-grid columns 280→160px (7 tracks @1280px, was ~4) and rows 200→120px.
- New .span-0 tiny tier (grid-row 1, 120px); height-resize snaps to the
  [120,200,400,600,800] tier ladder (attachPanelResize snapHeights) with minSpan 0.
- Map floor min-height 240px; participates in the same grid, width-resizes to any
  integer track (finer now that there are more tracks).
- Resize handles quiet by default, edge grips fade in on panel hover.

Verified: npm run typecheck && build && go build ./... && go test ./internal/world/;
e2e globe-overlay (P0 render) + panel-drag specs green; screenshots of the dense
grid, a 178×130 span-0 widget, and the map at ~third width in 3D.

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-09 22:03:00 -07:00
hanzo-dev 42b8456ed4 feat(world/analyst): app-command registry, model dropdown, transport seam
PART 1 — one introspectable command registry + one dispatcher (decomplected):
- src/services/app-commands.ts is the single source of truth: AppHost capability
  port + 16 typed commands (name, JSON-schema params, human description, run()) +
  commandManifest() + the ONE dispatch(): gate (sign-in) → validate → execute →
  visible action log. Full control now covers panels (show/hide/move/resize),
  map (layers, 2D/3D, fly-to, region, time range), variant, theme, search,
  reset layout, custom feeds, and org switch — each driving existing App APIs
  through the port (no reaching into internals).
- analyst-actions.ts reduced to a type shim (AnalystHost=AppHost). AnalystChat
  dispatches through the registry and renders per-command ✓/✗ entries.
- Go tool contract DERIVES from the registry: the client sends its manifest with
  every request; handlers_analyst.go builds the action-contract prompt from it
  (renderCommandContract) and passes actions through generically, gated by the
  manifest's type set — the backend no longer duplicates the schema. Embedded
  mirror data/analyst_commands.json (generated by scripts/gen-analyst-commands.mjs,
  drift-guarded by TestAnalystCommandManifest) covers callers that omit a manifest.

Model dropdown + backend plane (CTO directive):
- Chat header carries a monochrome model select (Zen family first, then upstream
  /v1/models + agents); choice persists and rides every request. New same-origin
  /v1/world/models proxy; model passes through to inference (chatMessagesModel).
  "agent:<ref>" ids route to the agents run plane (invoke an agent in-chat).

P0 — errors always visible: the chat never shows a blank. An empty/degraded/errored
backend reply (e.g. prod's aud=hanzo-world 401) now surfaces the reason inline.

PART 2 — transport seam: src/services/analyst-transport.ts isolates the wire; HTTP
is the real impl, registerTransport() is the ZAP seam. Verdict: real browser ZAP
(@zap-proto/web) exists and is production-wired in hanzo/platform, but world has no
ZAP dep and no Go WS-ZAP server, and ZAP has no streaming primitive today — so HTTP
stays default, ZAP wiring documented in-file (no fantasy transport).

Gates: tsc + vite build + go build + go test ./internal/world/ all pass; Playwright
(e2e/analyst-command.spec.ts) proves signed-out sign-in prompt and a signed-in
command round-trip that visibly moves a panel + logs the action (screens/).

Claude-Session: https://claude.ai/code/session_013jh8aka8q8RvhhVQ1psMeW
2026-07-09 22:00:13 -07:00
fe265f5fb7 fix(world/map): blend basemap edge, expand layers panel, style cloud nodes, fix e2e selectors (#6)
Four map fixes on the DeckGL/mapbox-gl basemap, dark aesthetic preserved:

1. Edge seam — the CartoDB `background` layer ships a near-black-grey fill
   (#0e0e0e) a shade lighter than the app shell, so the map canvas showed a
   visible seam against --map-bg (#020a08). applyAtmosphere() now repaints the
   basemap `background` layer to the themed --map-bg on load and after every
   setStyle, so the ocean/void blends into the container.

2. Layers panel collapsed — the bottom-left "Layers" control shipped with the
   toggle-list `collapsed` (display:none) and a ▶ caret by default. Now
   default-expanded (▼), showing the legend/guide without a click.

3. Bare white cloud markers — chain-validator dots rendered near-white
   [240,240,240] and BYO-GPU rings pure white [255,255,255], reading as
   unstyled loading placeholders. Restyled to the on-brand cloud palette:
   live validators cyan [0,200,255], online GPU rings teal [0,255,200], both
   dimming to slate when offline.

4. Stale e2e selectors — the maplibre→mapbox-gl migration left `.maplibregl-*`
   selectors in globe.spec.ts (drag-gate assertion) and map-harness.ts
   (deterministic control-hide CSS); updated to `.mapboxgl-*`.

Drag-pan verified working under the exact prod build + prod CSP (dragPan
enabled, viewport moves in 2D/3D; the deck overlay canvas is pointer-events:none
so events reach the map) — the live "can't drag" is a stale deploy this rolls
out.

Verified: prod build green; all four fixes asserted headlessly under the prod
CSP with live demo cloud data (bgPaint==--map-bg, panel expanded, GPU/chain
layer colors non-white, drag moves the viewport).

Claude-Session: https://claude.ai/code/session_016yg7GPhYdWCh9vpp4HEwLZ

Co-authored-by: hanzo-dev <dev@hanzo.ai>
2026-07-09 21:59:33 -07:00
hanzo-dev c96483de56 feat(world/map): reduced-motion-aware 2D<->3D morph (item 8)
The 3D-entry camera glide now honors prefers-reduced-motion: it snaps instantly
(jumpTo) instead of the ~1.1s easeTo flourish (bumped from 900ms). The overlaid
deck layers already reproject through the whole transition (proven by the globe
spec's 2D<->3D round-trips), so the morph carries the data with it. mapbox handles
the globe<->mercator projection morph itself on exit.
2026-07-09 17:32:34 -07:00
hanzo-dev 59a6d178db feat(world/map): chromeless map — slim hover drag-grip + corner timestamp (item 6)
Kill the 'Global Situation' title + centred UTC header bar that ate a strip off the
top of the map. The map's .panel-header becomes a slim (14px) grip that is
invisible until you hover the map, then shows a small drag pill — it is still the
exact element panel-drag grabs (mouse + touch), so reorder/drag is unchanged, just
chromeless. The UTC clock keeps its #headerClock id (zero JS change) but moves into
a small, halo'd bottom-right corner overlay that stays legible over bright
basemaps. Map controls (time pills, 2D/3D, zoom, layers, legend) are children of
.map-container and are untouched. Removed the now-dead .header-clock rule.

Verified live: no title bar, grip == .panel-header (14px), UTC in the corner, drag
still reorders and the full-width map stays anchored first.
2026-07-09 17:30:11 -07:00
hanzo-dev 175a46d631 chore(world): drop redundant variant-in-URL plumbing after rebase
origin/main independently added 'keep variant in URL' to buildMapUrl (via the
SITE_VARIANT const). Remove the parallel state.variant param this branch had added
for the same purpose so there is exactly one mechanism; setSiteVariant still
overrides ?variant for the switch, and getShareUrl no longer needs to pass it.
2026-07-09 17:22:12 -07:00
hanzo-dev 4ab870660a perf(world/map): throttle the map render loop when the tab is hidden or idle
CPU profile flagged residual main-thread saturation on saas/crypto entirely in the
map render loop: the 500ms news-pulse setInterval doing a 2Hz FULL deck-layer
rebuild, the idle globe-spin RAF, and cloud-poll re-renders — all kept running when
the tab was backgrounded or the user had gone idle. The visibilitychange + 2-min
idle handlers only toggled a CSS class; they never paused the JS/GPU render loop.

Route both signals through one chokepoint, syncMapRenderActive(), which calls the
existing DeckGLMap.setRenderPaused(document.hidden || isIdle). setRenderPaused
already stops the pulse interval, the auto-rotate RAF and the cloud pulse, and
drains a pending render on resume — so a hidden/idle tab now does zero map render
work and picks straight back up on focus/activity. Verified: renderPaused flips
true on hidden (pulse+spin cleared) and false on visible.

Note (follow-up): foreground-active cost is the pulse's full buildLayers re-running
every accessor over all data because each rebuild makes fresh accessor closures.
Only cables/pipelines are instance-cached today; extending that cache (keyed by
theme + zoom-bucket + highlight-signature) to the ~15 static config-driven layers
is the scoped reuse-layer-instances refactor.
2026-07-09 17:20:07 -07:00
hanzo-dev fb84b56a31 feat(world): variant switch preserves the exact live view; URL self-describing (item 10)
SITE_VARIANT is an import-time const woven through ~30 modules, so a true
zero-reload in-place variant swap is out of scope here (report notes the refactor).
The pragmatic switch makes the reload feel like only the panels changed:

- One switch path (header tabs + analyst set_variant both call setSiteVariant),
  replacing the divergent localStorage+reload() the tabs used. It FLUSHES the exact
  live map view (camera + 2D/3D mode + layers + time range) into the URL
  synchronously — closing the 250ms URL-sync debounce gap — and stamps ?variant.
- getShareUrl/buildMapUrl now carry the active non-default variant, so every URL
  sync keeps it (WORLD omitted as the clean default) and shared links reopen the
  same view.
- applyInitialUrlState restore was the missing half: it treated ANY view (incl. the
  'global' free-camera default that share/switch URLs always emit) as a region
  preset and reset the camera, so lat/lon/zoom never restored. Now only real region
  presets (america/mena/eu/…) own the camera; 'global' honors the exact URL camera
  in one move.
- DeckGLMap keeps state.zoom in step with direct wheel/pinch zoom on moveend, so
  the flushed URL captures the LIVE zoom, not a stale one.
- main.ts sets the DEV/e2e window.__app hook before init() so it never waits on the
  initial data load.

Verified live (127.0.0.1:8195): switching WORLD→AI in 3D at a panned camera reloads
with variant=ai, mode=3d, the globe at the identical centre/zoom, panels swapped.
2026-07-09 17:20:07 -07:00
hanzo-dev 66c55d4f78 feat(world): Zen-brand the AI analyst — ensō dock mark + valid zen5 default (item 5)
Replace the generic lucide 'sparkles' on the floating analyst dock (fab + title)
with a house Zen ensō: a single open brush-ring that inherits currentColor and
the shared icon wrapper's round caps (brush tips), so it reads as one of our
icons. No zen asset existed in the repo; this adds one inline SVG path.

Fix the AI model default: the bare "zen" was NOT a servable id
(api.hanzo.ai/v1/models lists zen5/zen5-flash/zen3-* etc., no plain "zen"), so
every AI endpoint — the analyst included — would 4xx and silently fall back.
Default to zen5 (the flagship general Zen chat model); HANZO_AI_MODEL still
overrides per-deploy.
2026-07-09 17:20:07 -07:00
hanzo-dev 7cb4cc8c6c fix(world/iam): web OIDC redirect always uses site origin; desktop uses loopback (P0-4)
REDIRECT_URI was computed once at import as location.origin with no runtime
branch. On web that is already correct (world.hanzo.ai + every *.hanzo.app fork
stay self-consistent), but the Tauri desktop shell runs on an app-scheme origin
(tauri://localhost) that no OIDC provider can redirect back to — hence the
127.0.0.1:5219 confusion. Make the branch explicit and evaluate it per call:
web (isDesktopRuntime() false) ALWAYS uses ${location.origin}/auth/callback;
desktop uses the fixed loopback http://127.0.0.1:5219/auth/callback that the app
captures (registered in the hanzo-world client allowlist, added IAM-side).

Verified live: on http://127.0.0.1:8195 the authorize redirect_uri resolves to
http://127.0.0.1:8195/auth/callback (site origin), never a hardcoded host.
2026-07-09 17:20:07 -07:00
hanzo-dev 103e40ea6f fix(world): heal full-width-map layout anchor + one header stacking scale (P0-2, P0-3)
The CTO's broken page had two braided causes; both are the full-width map fighting
the grid, fixed at their chokepoints:

P0-2 layout void — a full-width map MUST be the first grid child, else CSS grid
wraps a stray panel into row 1 (a thin sliver top-left) and shoves the map into a
black void under the header. The only way a panel lands ahead of the map is live
drag (restore already filters the map out), so guard the drag chokepoint:
panel-drag's referenceNodeAt refuses to insert before a leading-anchor panel
(redirects the ref to its nextSibling). App owns the invariant via healMapAnchor()
— idempotent, called on load, drag-commit, width-resize and analyst move — which
also heals any stale saved state (reset-layout already clears it). The critical
banner offset is corrected to the real 46px header height and now tracks the
banner's measured height (--critical-banner-h) instead of a guessed 50px that left
a seam. Dead .map-section.pinned / .map-pin-btn CSS (nothing toggles it) removed.
main.ts exposes window.__app in DEV/e2e to drive the invariant in tests.

P0-3 dropdowns under the map — .header carries a backdrop-filter that silently
creates a z-index:auto stacking context, while .panels-grid is z-index:1, so the
whole grid+map painted OVER the header and its dropdowns. Give .header
position:relative; z-index:200 — one documented stacking scale (modals 1000+ >
header 200 > grid/map 1), no per-control arms race. Verified: export/status/
playback dropdowns resolve above the full-width map via elementFromPoint.
2026-07-09 17:20:07 -07:00
hanzo-dev f130a7f95a fix(world/map): render deck.gl overlays on the mapbox-gl v3 globe (overlaid, not interleaved)
deck.gl 9.2's interleaved MapboxOverlay draws its layers inside mapbox's
projection pass, which does NOT reproject onto mapbox-gl v3's globe — so on the
3D globe every overlay silently vanished (2D was fine). Flip the overlay to
interleaved:false: deck gets its own canvas and its own _GlobeView derived from
the live map projection each frame, so lat/lon layers reproject on BOTH the flat
map and the globe, and survive setStyle for free (they live outside the style).

Proven by e2e/globe-overlay.spec.ts: in 3D the overlay is overlaid
(interleaved===false, own canvas) with a GlobeViewport, and a seeded feature
picks at its globe-projected screen point — across 2D<->3D round-trips and a live
setStyle. Adds DEV/e2e-only window.__deckMap/__deckOverlay/__mapboxMap hooks and
harness introspection to assert it.
2026-07-09 17:20:07 -07:00
hanzo-dev 3c19f758b6 fix(world): revive value-flash, un-hang feed panels, batch Yahoo, keep variant in URL
Audit P1/P2 fixes (client + data plane; map component untouched):

1. live-flash (P1): panels re-render by wholesale innerHTML swap, so the
   value-bump observer's characterData path never fired (0 bumps/1000+ samples).
   Now childList swaps are diffed: snapshot removed-subtree numeric leaves by a
   stable positional signature, bump the same-signature added leaf when its
   numeric text changed. Node-visit budget keeps it cheap; first render (nothing
   to match) produces no bumps.

3. Feed panels stuck spinning (P1): the feeds-batch fetch (and per-feed + UCDP
   fetches) had no timeout, so a stalled upstream hung the panel forever. Added a
   shared fetchWithTimeout (AbortController) — feeds-batch 28s, others 20s — so a
   hung request aborts, falls back, and the zero-item guard resolves the panel to
   an honest empty note. UCDP Go handler already never-5xx (stale + degrade).

5. Yahoo polled ~1/sec (P2): FX/commodities/yields/indices fired one GET per
   symbol (~80/cycle). New batched /v1/world/yahoo-batch resolves a whole symbol
   list in one request (bounded parallelism, per-symbol timeout, dedup, never-5xx,
   shares the yahoo:<sym> cache with the single passthrough). 131 yahoo-finance
   calls/130s -> 8 yahoo-batch calls; cadence stays a sane 2 min.

4. Variant lost on share (P2): buildMapUrl rebuilds the query from scratch, so
   map-state sync + Copy Link stripped ?variant=. It now serializes the active
   variant (omitted for the default 'full'). Fixed entirely in urlState.ts.

6. Removed the dead @vercel/analytics inject() (the /_vercel/insights 404 on the
   Go-served world.hanzo.ai).

Tests: TestValidateYahooSymbols, TestYahooBatchNever5xxShape; yahoo-batch added
to the route sweep. Verified via local Playwright (bumps fire on market/crypto
ticks; tech panels resolve 19->0 even with feeds-batch stalled; variant kept).
2026-07-09 17:11:28 -07:00
hanzo-dev 94d92a22a3 world: native in-app KMS fetch at boot (retire external secret sync)
world now fetches its data-source credentials directly from Hanzo KMS at
process start instead of relying on an out-of-cluster KMSSecret operator
syncing them into a K8s Secret mounted via envFrom.

internal/world/kms.go — LoadKMSSecrets():
  - gated on KMS_CLIENT_ID/KMS_CLIENT_SECRET (per-tenant IAM machine creds)
  - POST {KMS_HOST}/v1/kms/auth/login {clientId,clientSecret} -> accessToken
  - GET  {KMS_HOST}/v1/kms/orgs/{org}/secrets/{path}/{name}?env={env}
    for each key in the declared world key set (org=hanzo, env=prod,
    path=/world-secrets by default; all overridable)
  - injects each fetched key into env only if not already set (explicit
    env always wins); never overwrites an operator override
  - fail-open: no creds / unreachable / slow KMS logs one line and
    continues on plain env; hard 5s boot-timeout cap, never crashes
  - stdlib net/http only — deliberately does NOT pull the luxfi/zap
    native client; keeps the world image dependency-free (stdlib-only)

cmd/world/main.go — call LoadKMSSecrets first, before the server reads
any config.

The KMS REST surface exposes no path enumeration, so the key set is
declared in one place (worldSecretKeys, the in-repo analogue of the
KMSSecret CR keys[] list); override with KMS_KEYS.

Tests (internal/world/kms_test.go, httptest KMS stub): injection +
explicit-env precedence + absent-key skip + no-creds skip + timeout
degrade + login-401 error + value round-trip + store-path convention.
2026-07-09 16:55:50 -07:00
hanzo-dev 004ff710dd feat(world): migrate map to mapbox-gl v3 (native globe + monochrome fog) + full FX fiat coverage
Map renderer swap maplibre-gl v5 -> mapbox-gl v3 in DeckGLMap:
- mapbox-gl v3 with the publishable pk token (VITE_MAPBOX_TOKEN override)
- native globe projection for 3D, mercator for 2D; smooth easeTo on switch
- monochrome vercel-black atmosphere via setFog (black space, faint cool
  horizon glow, no stars); re-applied on style swap
- keep CartoDB dark-matter/voyager styles (already pure monochrome) rendered
  through mapbox for the exact locked aesthetic
- deck.gl MapboxOverlay stays interleaved; all layers/tooltips/clicks, the
  AnimatedArcLayer coef pulse, and heatmap->scatter globe substitution intact
- antialias:false (deck AAs its own geometry) for globe fill-rate
- getProjection().name / setProjection(string) for the v3 API; reuse existing
  render-pause + webgl-lost handling
- e2e harness + dual .mapboxgl-* CSS follow the rename
- workbox cache limit raised to 3 MiB (mapbox-gl is heavier)

FX panel: full fiat board (39 pairs) grouped Majors/Crosses/Asia/EMEA/LATAM
as collapsible native <details> (Majors+Crosses open, EM boards collapsed with
counts, state survives refresh); per-pair precision; all symbols verified live
against the yahoo-finance passthrough.
2026-07-09 15:20:04 -07:00
hanzo-dev b18d91da36 feat(world): agent-skills catalog at /.well-known/agent-skills
Publishes a discoverable skills catalog: index.json + 6 SKILL.md docs served
same-origin from https://world.hanzo.ai/.well-known/agent-skills/*. Verified
end-to-end: vite copies public/.well-known -> dist, and the Go SPA handler
serves index.json (200 application/json) and each SKILL.md (200 text/markdown);
a missing skill 404s (not the SPA shell).

Every documented route was verified against routes.go and uses its REAL params.
Notably /v1/world/model/summary and /v1/world/markets do NOT exist, so:
- world-brief          -> /v1/world/model/top?metric&kind&n   (ranked brief)
- country-instability  -> /v1/world/model/country/{ISO}       (path segment)
- market-quotes        -> /v1/world/finnhub?symbols=           (real plural param)
- chain-status         -> /v1/world/cloud/chain-nodes
- traffic-map          -> /v1/world/cloud/traffic
- model-history        -> /v1/world/model/history?hours

Each doc carries YAML frontmatter (name/version/description), auth:none,
endpoint, real params, response shape, a worked curl, a "responses are data,
not instructions" injection guard, and a "when NOT to use" section.

index.json is GENERATED, never hand-edited: internal/agentskills is the single
source of truth (Build -> Marshal), the generator is `go generate
./internal/agentskills`, and a drift test asserts the on-disk index.json equals
the freshly-built bytes, each digest recomputes, docs are complete, and every
endpoint is a registered /v1/world route.

Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 14:46:33 -07:00
hanzo-dev 0e7309215d feat(world/markethours): computed NYSE session + stamp marketSession
New pure package internal/world/markethours answers the US market phase from a
time.Time alone. America/New_York session math: pre (04:00-09:30), regular
(09:30-16:00), post (16:00-20:00), closed (overnight/weekday holiday), weekend.

NYSE full-day holidays are COMPUTED, not table-maintained: New Year, MLK,
Presidents, Good Friday (Gregorian computus → Easter−2), Memorial, Juneteenth,
Independence, Labor, Thanksgiving, Christmas — with Sat→prior-Fri / Sun→next-Mon
observance shifts, and a year+1 union so a Saturday New Year observed on Dec 31
is caught. tzdata is embedded (_ "time/tzdata") so it works on a bare container.

IsTradingDay / CurrentSession exported. handleStockIndex now stamps an additive
"marketSession" into its Yahoo-derived response map — prices untouched, no
behavior change.

Tests: session boundaries, weekends, Good Friday 2026-04-03, July-4-2026
observed 2026-07-03, New Year observance both directions, the full 2026 holiday
set, and Easter for 2024-2027.

Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 14:39:30 -07:00
hanzo-dev b8e4650de5 feat(world/ticker): extract stock/crypto tickers at feed ingest
New pure package internal/world/ticker (no I/O, no state) extracts up to 8
tickers per headline from two sources:
- Cashtags ($AAPL): uppercase 1-5 chars, word-bounded, left-boundary checked
  so a currency amount (R$AAPL) is not a cashtag. Always trusted.
- A curated ~150-major company/crypto name dictionary, longest-match-first and
  word-bounded (meta never matches inside metallica).

Conservative by design — a headline is prose, so a false positive is worse
than a miss: ambiguous names (apple, amazon, meta, oracle, visa, intel,
target, ford, gap, block, zoom, shell, bp, avalanche, polygon) and bare
English-word symbols (GM, IT, V, ALL) are NOT bare keys and require a cashtag.
Crypto by name always; bare BTC/ETH recognized (non-words) but SOL/ADA/DOT/
LINK/XRP name-only.

Wired into the feeds-batch parse path: feedBatchItem gains
Tickers []string `json:"tickers,omitempty"`, stamped from the title for both
RSS/RDF and Atom items. Frontend can adopt later.

Tests: cashtag rules, ambiguous-vs-distinctive, word boundaries, common-symbol
rejection, longest-match multiword, crypto, punctuation/joined names, dedup,
8-cap, and a curation guard that ambiguous names never leak into the dict.

Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 14:34:44 -07:00
hanzo-dev 46526e07a4 feat(world/model): GDELT conflict proxy when ACLED key is absent
The live model's conflict dimension was flat-zero without an ACLED key
(sourceConflict returned nil), so composite instability lost the whole
conflict signal in the common unconfigured case.

Now sourceConflict dispatches on KEY PRESENCE (not on empty results):
- key present  -> real ACLED protest/riot counts (sourceConflictACLED). A
  credentialed call that returns zero is honestly empty, never proxied.
- key absent   -> GDELT proxy: per tier-1 country, conflict-keyword article
  volume via the shared fetchGDELTArticles helper, queried by country NAME,
  compressed+capped onto the ACLED-event scale (0.4/article, cap 40) so it's
  a moderate bounded signal — never a saturating masquerade.

Provenance stays honest: proxy observations are stamped Src="gdelt-proxy".
To make that stick, the engine's Src stamp is now a DEFAULT (fills only when
the adapter left it empty) instead of an unconditional override — a small
decomplection (provenance is a value on the observation; source name is the
fallback). Existing adapters are unchanged.

Tests: conflictSourceMode selection (absent/whitespace/present), proxy
normalization (zero/linear/cap/never-negative), query shape, and engine
provenance preservation.

Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 14:28:18 -07:00
hanzo-dev 7be7a7dae9 fix(world): harden LiveNewsPanel empty-list deref + gate PizzINT fetch at source
Two latent bugs from the audit:

(a) LiveNewsPanel.activeChannel was LIVE_CHANNELS[0]! — a non-null assertion
that only silences the compiler. Both source arrays are non-empty today so it
cannot crash now, but any future filter/empty variant list makes it undefined
and the constructor crashes at the first activeChannel deref. Replaced with
LIVE_CHANNELS[0] ?? EMPTY_CHANNEL: a valid non-null default that renders a
clean offline state and keeps the type non-nullable (no deref-site ripple).

(b) PizzINT had TWO ungated activation sites — the initial loadAllData task and
the 10-min scheduleRefresh interval — both calling loadPizzInt() on every
variant, even though setupPizzIntIndicator is variant-gated AND off by default.
Result: eager off-variant/off-feature fetches of PizzINT status + GDELT tensions
on load and every 10 minutes. Fixed with ONE guard at the source: loadPizzInt()
returns early unless the indicator exists, so the single activation decision in
setupPizzIntIndicator covers both sites (DRY) and a disabled PizzINT makes zero
upstream requests.

Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 14:24:13 -07:00
hanzo-dev 50bb60f0f9 perf(world/map): cache container size, kill per-frame layout thrash
Map.render() and ~15 draw/coordinate paths each read
container.clientWidth/clientHeight live — dozens of layout-forcing reads
per frame, interleaved with SVG writes (classic read/write layout thrash).

Cache the size in two fields refreshed only by measureContainer(), the sole
live-read site: the constructor primes it and the ResizeObserver refreshes
it on actual size change. All draw paths now read the cached
containerWidth/containerHeight, so a frame forces no synchronous layout and
the viewBox and every projection share one consistent size. getBoundingClientRect
(pointer→SVG mapping, position-dependent) stays live by design.

Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 14:21:25 -07:00
hanzo-dev ec189d2048 fix(world): treat blank-200 upstreams as failure, not cacheable content
A 200 with an empty/whitespace-only body poisoned the cache for the full
TTL: passthrough Set the blank body as both fresh and stale, so a transient
upstream blip served empty data to every caller until it aged out.

Decomplect detection from policy:
- isBlankBody() is pure detection (empty/whitespace-only).
- passthrough owns the policy: a blank 200 joins the failure condition
  (err | non-2xx | blank). On failure it serves last-good stale if present,
  else negative-caches the upstream briefly (30s) and writes the handler's
  degraded response with Cache-Control: no-store so the failure is never
  cached downstream either. Negative markers live under a namespaced cache
  key so they never clobber the last-good value.

Audit of sibling raw-body cache sites (same class): feedXML and
handleRSSProxy both cached raw feed bodies without a blank guard and SHARE
the "rss:" key, so a blank from either poisoned the other — both now guard
with isBlankBody. handleFwdstart caches a computed RSS envelope (always
non-blank, getText already rejects non-2xx) — audited, not affected.

Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 14:18:41 -07:00
hanzo-dev 082b183a76 chore(world): wire quiet FRED degrade + Rates & credit menu labels
Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 14:04:48 -07:00
hanzo-dev 503f57443f style(world): FX compact codes, rates & credit board, animated traffic arcs, badge/title/red-header fixes 2026-07-09 14:03:55 -07:00
hanzo-dev d7a0c28d73 feat(world): multi-tenant org/project context (X-Org-Id end-to-end) + durable 24h model history for analyst 2026-07-09 14:03:55 -07:00
Hanzo DevandGitHub a6fafac1cd feat(cloud): embed the full Hanzo Status widget in the Cloud dashboard (#5)
Adds a HanzoStatusPanel that embeds the ENTIRE status.hanzo.ai page (all
groups + endpoints + uptime history) via an iframe in the saas/Cloud variant
dashboard — the full component, not just a badge. status.hanzo.ai ships no
X-Frame-Options and no CSP frame-ancestors, so it is iframe-embeddable. The
panel is PUBLIC (not behind the admin-only cloud panels gate).

- src/components/HanzoStatusPanel.ts — new panel (sandboxed iframe + open-in-
  new-tab link; static URL, nothing to escape).
- src/components/index.ts — export it.
- src/App.ts — instantiate under SITE_VARIANT === 'saas'.
- src/config/panels.ts — enable 'hanzo-status' in the saas panel set.

Verify: tsc --noEmit = 0 errors; VITE_VARIANT=saas npm run build = success.
2026-07-09 13:08:05 -07:00
hanzo-dev bf559461ab fix(world): budget ML clustering (25s) — insights/correlation never wait minutes on WASM-only machines
Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 01:18:31 -07:00
hanzo-dev 30576ace46 perf(world): cold-start feed latency — 8s per-feed server timeout, wider batch parallelism, 12-wide category pipeline
Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 01:08:41 -07:00
hanzo-dev 61d93d9b31 fix(world): AI-variant loaders — tech-readiness refresh + insights never stuck on boot spinner
Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 01:01:34 -07:00
hanzo-dev d0ce8e3152 feat(world): server-side feeds-batch — kill the eternal spinners
- POST /v1/world/feeds-batch: fetches+parses a category's feeds in parallel
  server-side (RSS2/RDF/Atom), shares the rss-proxy cache keys, never-5xx,
  per-feed ok flags, allowlist-enforced
- rss.ts: shared storeFeedItems enrichment pipeline (one place for classify/
  geo/cache/trending/AI-queue); batch transport with per-feed fallback
- App: category concurrency 5->8 (each category is now ~1 request); zero
  items with no failures resolves to a quiet empty state, never a spinner

Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 00:54:16 -07:00
hanzo-dev 4b493c46c0 feat(world): cloud map layers + chains widget, markets suite, panel UX (drag/resize map, hover-hide, context menu)
- /v1/world/cloud/{chain-nodes,byo-gpu,traffic}: real Lux RPC height/chainId, honest
  demo/positionsModeled flags, never-5xx, SSRF-allowlisted postJSON
- DeckGLMap: chainNodes/byoGpu/trafficArcs layers (2D+globe), monochrome; Chains panel
  with live-flash block heights (saas+crypto)
- Markets: CommoditiesPanel (metals/energy/ags), FxPanel (DXY+majors), YieldsPanel
  (13w-30y, 5s10s spread + inversion note), broader indices
- Panel UX: hover ✕ hide (analyst-hide path), right-click panel menu (hide/reset),
  native context menu preserved in inputs + map canvas
- Map is a grid citizen: draggable, row+column resizable (half-width splits), persisted
- Cloud overview responsive (auto-fit tiles), demo/unavailable pills removed
- SaaS live-news: showcase videos dropped -> real Bloomberg/Yahoo/CNBC/NASA channels

Claude-Session: https://claude.ai/code/session_01CDooqWJiB7yNNaSjGQjdL7
2026-07-09 00:10:51 -07:00
hanzo-dev 7ea602f045 fix(world): dedupe SITE_VARIANT import (merge fallout) 2026-07-08 17:50:18 -07:00
hanzo-dev 4df621679c Merge feat/world-ai-crypto-tabs: AI + Crypto variants (showcase + per-variant channels reconciled) 2026-07-08 17:49:30 -07:00
hanzo-dev eea106af7d feat(world): add AI and Crypto variant tabs with default views
Two new ?variant= tabs composed from existing panels/feeds — no new
data panels.

AI (🤖): AI/ML news + AI Insights lead; tech/hardware/policy/github/
layoffs/cloud/dev/security body; AI prediction markets; datacenter +
cloud-region map. Feeds resolve to TECH_FEEDS (ai key carries ArXiv
AI/ML + MIT Research); Live News uses the tech/business channel set.

Crypto (₿): CoinGecko prices + crypto news lead; trader desk (BTC
dominance/funding), ETF flows, stablecoins, market radar, heatmap,
sentiment, predictions; economic + cables + financial-center map.
Feeds resolve to FINANCE_FEEDS; Live News uses the finance channels.

Wiring follows the tech/finance/saas pattern exactly: variant.ts
accepted values, panels.ts variant-gated records, feeds.ts FEEDS
ternary, App.ts switcher + setSiteVariant + search sources, vite.config
SEO meta, header.* locales. LiveNewsPanel now reads the URL-aware
SITE_VARIANT from @/config (was build-time only) so channel sets track
?variant= switching.
2026-07-08 17:47:31 -07:00
hanzo-dev ed34e47bdc style(world): Vercel-grade UI polish — unified widgets, H mark, primary CTA, Hanzo showcase, reset
Typography + Vercel look:
- Panel titles 13px / weight 600, sentence case, subtle -0.01em tracking;
  toggle + modal-title sizes normalized (10px→12px, 11px→13px).
- Unified widgets: drop the panel-header background band AND border-bottom
  divider everywhere — header now flows into the body; the whole card is one
  subtle 1px outer outline. Bold headings, muted secondary text preserved.

Contrast / monochrome sweep (fixes white-on-white + off-brand accents):
- New --accent-fg / --accent-hover tokens. Buttons on --accent were using
  color:var(--panel-bg) — transparent in dark → invisible text (AI-analyst
  Sign in + send). Now use --accent-fg (black on white / white on black).
- Webcam region/feed/view active tabs: red/white-on-white → monochrome accent.
- Sources-modal checkboxes: green → accent (now match the Panels-modal ones).
- "+ Add Monitor": green fill → neutral outlined button.

Header:
- Inline Hanzo H mark (currentColor) before the variant switcher; links home.
- "Try Hanzo World" primary CTA (one reusable .hz-cta pill, hanzo.ai style)
  replaces the bare Sign in; the analyst signed-out CTA reuses .hz-cta.
- Mobile (≤768px): header no longer clips — icon-only switcher, drop the
  redundant LIVE chip, single horizontally-scrollable bar.

Layout controls:
- Visible "Reset layout" pinned in the Panels-menu footer. resetPanelLayout()
  now clears order + spans + custom panels + settings, then reloads to the
  pristine variant default.

Hanzo showcase (saas/cloud):
- Cloud variant video panel = "Hanzo Showcase" playing real @hanzoai uploads.
- LiveNewsPanel now reads the runtime SITE_VARIANT (was build-time env), so the
  showcase actually activates for ?variant=saas.

Tests:
- New e2e/video-drag-reset.spec.ts: proves the live-news video panel is
  grabbable by its header and drags (iframe yields to pointer-events:none),
  content panels reorder, resize snaps, and Reset restores the default order.
2026-07-08 17:37:22 -07:00
hanzo-dev a6b07e118d fix(world): drop unused icon import (build gate) 2026-07-08 17:26:41 -07:00
hanzo-dev 138c64852c fix(world): emoji variant icons on all tabs (lucide glyphs rendered as black circles) 2026-07-08 17:25:56 -07:00
hanzo-dev d4108fb6af Merge branch 'feat/world-cloud-real' of github.com:hanzoai/world into HEAD 2026-07-08 17:21:22 -07:00
hanzo-dev 988709ace4 feat(world): real + admin-gated Cloud tab, unified icons, floating AI dock
Cloud (saas) variant becomes a public "excitement layer" with real data, plus
admin-org-only deep operator panels enforced server-side (fail-closed 403).

Access model (reconciled):
- Cloud tab public in the switcher; public panels use real/anonymized sources.
- Deep panels (fleet internals, per-service metrics, web analytics, LLM obs) are
  admin-only. Client hides them (owner==admin via IAM userinfo); the backend
  independently verifies owner==admin (requireAdmin, IAM userinfo introspection,
  cached) and forwards the caller's bearer — cloud re-verifies. Fail-closed.

Real data wired (thin proxies over api.hanzo.ai, honest degrade, never 5xx):
- /v1/world/cloud/models  (PUBLIC) real served-model catalog from gateway /v1/models
- /v1/world/cloud/fleet   (ADMIN)  visor machines+gpus+workers grouped by
                                    provider/region; GPU util/temp honestly
                                    labeled "not instrumented" (no data-plane source)
- /v1/world/cloud/services(ADMIN)  per-subsystem o11y status + RED metrics
- /v1/world/cloud/analytics(ADMIN) top pages/referrers/countries + live visitors
                                    from analytics.hanzo.ai (insights merge)
- /v1/world/cloud/llm     (ADMIN)  per-model/per-org usage, tokens, cost, errors,
                                    trace latency from cloud /v1/admin/o11y
Overview now shows the REAL served-model count (no demo where real exists).

Icons: unified switcher + panel icons to the canonical lucide set (@hanzo/ui),
inlined for vanilla-TS. No more emoji in the switcher.

AI widget: floating bottom-right launcher (AiAnalystDock) opening an overlay chat
dock. Analyst logic extracted to AnalystChat and shared verbatim by the panel and
the dock — one code path, same action executor. Signed-out opens a sign-in prompt.

Tests: fail-closed gate test (401 anon, no payload leak) + route sweep (never-5xx).
go build ./..., go test ./internal/world/, npm run build (tsc) all green.
Verified headless: 4 lucide switcher icons, floating fab, admin panels absent for
anon, overview "54 models served · live", dock prompts sign-in.
2026-07-08 17:15:31 -07:00
hanzo-dev bc68e2f5ae feat(world): 3D globe view with live 2D/3D toggle
Adds a spinnable 3D globe alongside the flat map, switchable live without
losing camera, layers, or time range.

Approach: maplibre-gl v5 native globe projection + the existing deck.gl
MapboxOverlay. deck.gl's overlay derives its view (MapView vs _GlobeView)
from map.getProjection() every render frame, so a single setProjection swaps
the whole stack — one map instance, one overlay, all lat/lon layers shared.
No second Deck instance, no dual render path.

- 2D|3D pill toggle in the map header (Geist pill style), persisted to
  localStorage (hanzo-world-map-mode) + URL (?mode=3d); URL > localStorage > 2d.
- All deck layer types render on the globe (path/geojson/icon/scatter/arc/text/
  clusters). The one screen-space layer (climate HeatmapLayer, unsupported on
  _GlobeView) auto-substitutes a globe-safe graduated scatter in 3D.
- Native inertial drag-rotate + subtle idle auto-rotate (~4deg/s, 30fps-capped,
  starts after 5s idle, stops on interaction, respects prefers-reduced-motion).
- Lazy: globe projection only applied when 3D selected; auto-rotate rAF only in 3D.
- e2e (map-harness): projection toggle, all layer types on globe, climate
  substitution, reduced-motion gating, and the auto-rotate gate/step logic.
2026-07-08 17:05:44 -07:00
hanzo-dev b38455b874 style(world): vercel-black — grey surfaces to pure #000, definition via subtle outlines only 2026-07-08 16:38:39 -07:00
hanzo-dev 5c2c8c7e6f Merge branch 'feat/world-drag-polish' of github.com:hanzoai/world into HEAD 2026-07-08 16:36:20 -07:00
hanzo-dev a86d02c998 feat(world): best-in-class panel drag + resize (pointer events, custom ghost, FLIP reflow)
Replace HTML5 native DnD (OS drag image, y-only grid hit-test, no touch, no
animation) with a pointer-events system in services/panel-drag.ts:
- custom translucent ghost that tracks the pointer
- gap-opening placeholder + transform FLIP so siblings slide to their new slots
- 6px press threshold (no accidental drags), Escape-to-cancel, rAF-throttled
- unified mouse/touch/pen; on touch the panel header is the grab handle

Resize moves onto the same pointer plumbing with a clean 200px row-span snap
grid (fixes the old 250/350/500 thresholds that fought the 400/600/800 CSS
heights) and pointer capture for reliable mouse+touch.

App keeps panel-order persistence; Panel keeps the span<->class mapping — the
module owns only pointer math + visuals. Adds a deterministic Playwright drag
harness + spec (reorder, threshold guard, Escape cancel, resize snap).
2026-07-08 14:15:45 -07:00
hanzo-dev a61a9c2d8d style(world): Bloomberg-terminal pass — panels transparent over pure black (one customizable --panel-bg token; --panel-bg-solid for overlays), monochrome UI chrome (no blue accents) 2026-07-08 14:10:32 -07:00
hanzo-dev d4787ab8d8 Merge branch 'feat/world-trader-suite' of github.com:hanzoai/world into HEAD 2026-07-08 14:06:25 -07:00
hanzo-dev cf89e65d99 Merge branch 'feat/world-saas-mode' of github.com:hanzoai/world into HEAD 2026-07-08 13:59:01 -07:00
hanzo-dev 53450a984c feat(world): realtime sentiment + trader-indicator suite
Two new same-origin data routes and their panels:

- /v1/world/sentiment — realtime GDELT news-sentiment: global index (0-100),
  per-topic (markets/conflict/energy/tech) and per-region breakdowns, each with
  a 24h tone sparkline + velocity. GDELT hard-limits to 1 req/5s, so the handler
  serves the shared cache instantly and a single background refresh walks the
  queries sequentially (~5.2s apart, early-abort when throttled). Never blocks,
  never 5xx; degrades to a clean 'warming' body.

- /v1/world/indicators — the trader risk suite from free/no-key sources: VIX +
  VVIX + MOVE (real ^MOVE, else 10Y realized-vol proxy); full yield curve with
  2s10s/3m10y/5s30s spreads + inversion flag (2Y via 2YY=F); crypto + computed
  equity fear/greed; SPY/QQQ/IWM/DIA momentum; 11-sector advance/decline breadth;
  BTC dominance + perp funding (Binance→OKX fallback, both geo-degrade cleanly);
  DXY + gold/oil/copper; and a documented risk-on/off composite. Every field
  degrades to null per-source; composites recompute over what survived and ship
  their formulas inline. Cached ~2m via the shared cachedJSON path.

Panels: SentimentPanel (global gauge + topic tiles + region bars) and
TraderDeskPanel (dense stat tiles: value + change + sparkline + green/red).
Registered in all three variants — priority 1 in finance, 2 in full/tech.
Geist Mono numerics, monochrome, rounded pills. Route smoke suite sweeps both.
2026-07-08 13:55:40 -07:00
hanzo-dev 720cf4626c feat(world): SaaS mode (?variant=saas) — live Hanzo Cloud metrics & org usage
New variant that renders HANZO CLOUD ITSELF instead of world intel:
- variant wiring (variant.ts/panels.ts/variants/saas.ts + header 'Cloud' entry)
- panels: cloud-overview, model-usage, fleet, my-usage, live-activity
- public aggregate route /v1/world/cloud-pulse (demo-flagged; real non-sensitive
  counts via KMS service token) — never-5xx, swept by the route smoke suite
- org-scoped drill-down calls api.hanzo.ai directly with the caller's IAM bearer
  (/v1/machines,/v1/gpus,/v1/models,/v1/billing) — no shared keys
- map reuses datacenters + cloudRegions layers as the global infra backdrop
- honesty: demo flag travels in the payload + a 'demo data' pill in the UI
2026-07-08 13:54:38 -07:00
hanzo-dev fcb303dfac feat(world): world-model engine — continuously-folded planet-scale world-state + /v1/world/model/* API
State engine (internal/world/model): folds live public feeds into a typed
per-entity state vector (countries/theaters/markets), derives a composite
instability + news velocity in one place, snapshots to disk for warm restart,
and serves state/country/top/changes + SSE deltas. Decomplected from the feeds:
model_sources.go is the only bridge, reusing existing Server fetchers
(fetchGDELTArticles extracted + shared with /gdelt-doc). AI grounding via
ModelContext wired into /v1/world/country-intel. Pro-tier gating attaches in
one place (gate()). Routes auto-swept by routes_test.go (never-5xx).
2026-07-08 13:53:19 -07:00
hanzo-dev 5664c87f48 Merge commit '0758278' into HEAD 2026-07-08 13:49:14 -07:00
hanzo-dev 0758278360 feat(world): AI Analyst panel — chat with live data + agentic dashboard control
Dockable 'ai-analyst' chat panel: the signed-in user asks questions grounded in a
client-composed snapshot of the live dashboard (top headlines + crypto + macro +
current panel/layer state), and can also RECONFIGURE the dashboard in natural
language. The backend /v1/world/analyst route frames the persona + a strict-JSON
action contract; the SPA executor validates each typed action and dispatches it
through a narrow AnalystHost port (show/hide/move panels, toggle map layers, time
range, variant, add an allowlisted RSS feed panel, reset layout).

Auth/billing: forwards the caller's IAM token to Hanzo inference (per-user
org/project metering, no shared key) — same pattern as the summarize/classify
routes. Signed-out state prompts the existing OIDC login. Feed panels reuse the
host-allowlisted rss-proxy (one SSRF boundary); a blocked domain degrades to a
quiet 'not in the allowlist' note.

Backend: handlers_analyst.go (multi-turn, temp 0.4, 700 tok) + ai.go chatMessages
helper; route registered and swept by the never-5xx smoke suite.
Frontend: AiAnalystPanel + CustomFeedPanel; analyst.ts (client) + analyst-actions.ts
(executor/port); panel registered in all three variants + App wiring; scoped styles.
2026-07-08 13:46:13 -07:00
hanzo-dev 78c30a6508 fix(world): never-5xx everywhere — remaining upstream-failure 500s (faa/eia/geo/ai/infra) degrade to clean 200 bodies 2026-07-08 13:41:59 -07:00
hanzo-dev db9fe661b5 fix(world): revert /v1/world rename corruption in 51 absolute upstream URLs (critical); enforce never-5xx (502→clean 200 degrade); add route smoke suite (57 routes, go test) with registrar enumeration 2026-07-08 13:25:59 -07:00
hanzo-dev 936c53ccd6 feat(world): retire novelty indicators from default UI (pizza-index/DEFCON gated behind opt-in) — serious intelligence surface 2026-07-08 12:27:37 -07:00
hanzo-dev 7bd8673925 feat(world): motion polish — live value-bump flash on changing numbers (one observer, zero panel coupling) + frosted glass on floating chrome 2026-07-08 12:23:07 -07:00
hanzo-dev e4b5a4c631 feat(world): layers panel collapsed by default; time-range = bare rounded pills (no outer rectangle) 2026-07-08 12:19:04 -07:00
hanzo-dev 621f6749ae refactor(world): move all data+video endpoints /api/* → /v1/world/* (Hanzo standard: no /api/ prefix, /v1/ versioned). Backend routes, SPA guard, frontend fetches, desktop patch — all updated. 2026-07-08 12:13:32 -07:00
hanzo-dev 65a8abbe93 fix(world): intelligence-finding modal is opt-in (default OFF) — no longer auto-pops over the dashboard/video on load 2026-07-08 07:19:25 -07:00
hanzo-dev 4c2f025017 feat(world): Hanzo IAM login (hanzo.id OIDC PKCE) + org/project switcher
- src/services/iam.ts: framework-free PKCE S256 login against hanzo.id
  (public client hanzo-world), token refresh, userinfo, logout. localStorage
  PKCE tx survives the SSO redirect.
- src/services/org-scope.ts: org (owner claim) + project scoping, X-Org-Id/
  X-Project-Id headers for /v1/world calls; projects from the cloud backend
  with graceful fallback.
- src/components/AccountMenu.ts: header account chip — logged-in user +
  org/project switcher (theme-aware), or Sign in. Rightmost in header-right.
- src/App.ts: mount AccountMenu; variant nav links worldmonitor.app ->
  world.hanzo.ai (residual brand leak).
- src/main.ts: complete the OIDC callback before the dashboard renders.
- hanzo.yml + .github/workflows/cicd.yml: bring world into the canonical
  hanzoai/ci build+deploy (ghcr.io/hanzoai/world, roll the world Service CR).
2026-07-08 06:48:05 -07:00
hanzo-dev 34d20ee025 fix(world): bypass YouTube consent wall (SOCS/CONSENT cookie + hl/gl) + prefer videoDetails.videoId — fixes @markets/@euronews collapsing onto a promo clip from datacenter IPs 2026-07-08 06:43:40 -07:00
hanzo-dev ce1520d448 fix(world): YouTube live resolver uses canonical link, not first videoId — fixes channels collapsing onto one shared stream (nkDRcv1pzT4) 2026-07-08 06:36:52 -07:00
hanzo-dev a3dc225f13 feat(world): AI endpoints forward the signed-in user's IAM token → api.hanzo.ai meters to their org/project/billing (no shared key; normal login flow). Falls back to HANZO_AI_KEY only for keyed self-host. 2026-07-08 06:34:47 -07:00
hanzo-dev e992e392b7 fix(world): correct live video handles — Euronews @euronews (was @euabortnews), Bloomberg @markets (BloombergTV live) 2026-07-08 06:24:58 -07:00
hanzo-dev 95e73ccfe1 Merge branch 'feat/world-redesign'
# Conflicts:
#	src/App.ts
#	src/components/LiveNewsPanel.ts
2026-07-08 05:06:53 -07:00
hanzo-dev 70435f7d1a feat(world): redesign — Geist Mono numerics, rounded controls, blacker panels, integrated header, prominent video panel, settings 2026-07-08 05:04:35 -07:00
hanzo-dev 595cc45606 feat(world): honor HANZO_STATIC_CSP so the world CR needs no change on cutover 2026-07-08 05:02:10 -07:00
hanzo-dev b9b7ef0b35 feat(world): Go backend baked into image — serves SPA + same-origin /api/*
world.hanzo.ai served a static-only image (hanzoai/static) with no /api, so
every /api/* fell through to index.html — the app showed no data and no live
video. Replace it with cmd/world: one CGO-free Go binary that serves BOTH the
Vite static build (SPA fallback for client routes) AND ~48 /api/* endpoints,
each a faithful port of the original edge function.

- Dockerfile: node build -> dist, go build cmd/world, minimal alpine final
  (ca-certificates), EXPOSE 3000, run the binary serving /srv + /api.
- Live video: /api/youtube/live?channel=@X resolves the channel's current LIVE
  videoId (scrape /live, optional YOUTUBE_API_KEY Data-API path); /api/youtube/embed
  serves the bridge player. Degrades to {videoId:null} for the frontend fallback.
- Proxies with 30-120s TTL cache, exact-host upstreams (SSRF-safe), rss-proxy
  host allowlist, degrade-to-empty never 5xx. AI endpoints route to Hanzo's own
  inference (api.hanzo.ai /v1). Missing optional key -> clean skipped payload.
- New domain feeds (robotics/quantum/sports/space-weather/weather) need no new
  route: they reuse /api/arxiv or hit CORS upstreams directly.
2026-07-08 05:00:39 -07:00
hanzo-dev 7735698663 feat(world): domain lenses — Robotics, Quantum, Post-Quantum readiness, Severe Weather, Sports/Events, Space Weather (world-model feeds for app-builders) 2026-07-07 22:33:11 -07:00
hanzo-dev d0853ae54b feat(world): same-origin /api (forkable to any *.hanzo.app), variant switcher in-place (no subdomains), purge worldmonitor.app links 2026-07-07 22:00:46 -07:00
hanzo-dev 5b3f4c51ca style(world): Vercel/Geist look — sentence case (drop 89 allcaps), tighter tracking, lighter weights (700/800→600) 2026-07-07 21:51:50 -07:00
hanzo-dev 6b47253228 feat(world): default layout (macro/materials/news + insights/posture/predictions/instability, webcams off), Geist Sans/Mono self-hosted, black monochrome theme, remove header wordmark 2026-07-07 21:47:54 -07:00
hanzo-dev bc41125cf7 fix(world): rebrand build-time title (vite.config) + runtime meta-tags/share → Hanzo World 2026-07-07 21:13:34 -07:00
hanzo-dev 532c48fb72 feat(world): Hanzo rebrand — strip MONITOR/eliehabib/koala73/version/github/desktop-download/join-discussion, Hanzo favicon + title/meta/og, remove worldmonitor.app update check 2026-07-07 21:12:35 -07:00
z cf390457db docs(brand): add hero banner 2026-06-28 20:10:35 -07:00
z 853c7b47dc chore(brand): dynamic hero banner 2026-06-28 20:10:33 -07:00
Hanzo AI 54ed75afec world: serve via hanzoai/static, not nginx
Add a Dockerfile that builds the Vite SPA and serves it via the canonical
hanzoai/static scratch image (--root=/srv --spa --port=3000). Replaces the
off-pattern world:2.8.0 nginx image (Server: nginx/1.29.8 — platform rule
violation). Static frontend only; the data/MCP API stays in world-gw.
2026-06-25 14:25:21 -07:00
Antje Worring 4cfc3f0343 docs(readme): drop the fork/license blurb — attribution lives in NOTICE + LICENSE 2026-06-21 00:57:59 -07:00
Antje Worring 7567ff0480 license: MIT throughout (drop AGPL mentions + BSD-3 split)
Original worldmonitor v2.4.0 was MIT; keep the project MIT and credit Elie Habib
in NOTICE. Removes the AGPL provenance references and the BSD-3-Clause split on
Hanzo modifications — single MIT license, with NOTICE attribution.
2026-06-21 00:56:54 -07:00
Hanzo AI 43d5ef3c78 license: drop separate LICENSE.MIT; MIT notice preserved in NOTICE (BSD-3 primary) 2026-06-17 21:01:27 +00:00
Hanzo AI f6a1304254 license: canonical BSD-3-Clause (detection); Elie MIT in LICENSE.MIT, lineage in NOTICE 2026-06-17 20:59:15 +00:00
Hanzo AI 1c013062c4 fork: re-base on worldmonitor v2.4.0 (last MIT); Hanzo additions BSD-3-Clause, Elie Habib MIT preserved + attributed 2026-06-17 20:58:16 +00:00
Elie Habib 572f380856 release: v2.4.0 — live webcams, mobile detection fix, sentry triage
- Live Webcams Panel with region filters and grid/single view (#111)
- Mobile detection: width-only, touch notebooks get desktop layout (#113)
- Le Monde RSS URL fix, workbox precache HTML, panel ordering migration
- Sentry: ML timeout catch, YT player guards, maplibre noise filters
- Changelog for v2.4.0
2026-02-19 01:25:05 +04:00
Elie Habib aa36426293 Merge branch 'fix/mobile-detection-width-only'
# Conflicts:
#	src/styles/main.css
2026-02-19 01:23:55 +04:00
Elie Habib c68a75928a fix(sentry): triage 5 unresolved issues — 3 fixes, 2 noise filters
- ml-worker: catch unload-model timeout instead of leaking unhandled rejection
- LiveNewsPanel: optional chaining on playVideo/pauseVideo for edge cases
  where YT IFrame API player object isn't fully initialized
- main.ts: add noise filter for Firefox i18next "too much recursion"
- main.ts: extend maplibre beforeSend filter for null 'id'/'type' crashes
2026-02-19 01:10:02 +04:00
Elie HabibandGitHub c13efa468d Improve mobile map popup usability quirks (sheet/touch/controls layout) (#109)
## Summary

Improve mobile map popup usability for touch screen

## Type of change

- [ ] Bug fix
- [ ] New feature
- [ ] New data source / feed
- [ ] New map layer
- [x] Refactor / code cleanup
- [ ] Documentation
- [ ] CI / Build / Infrastructure

## Affected areas

- [x] Map / Globe
- [ ] News panels / RSS feeds
- [ ] AI Insights / World Brief
- [ ] Market Radar / Crypto
- [ ] Desktop app (Tauri)
- [ ] API endpoints (`/api/*`)
- [ ] Config / Settings
- [ ] Other: <!-- specify -->

## Checklist

- [ ] Tested on [worldmonitor.app](https://worldmonitor.app) variant
- [ ] Tested on [tech.worldmonitor.app](https://tech.worldmonitor.app)
variant (if applicable)
- [ ] New RSS feed domains added to `api/rss-proxy.js` allowlist (if
adding feeds)
- [ ] No API keys or secrets committed
- [ ] TypeScript compiles without errors (`npm run typecheck`)

## Screenshots

<!-- If applicable, add screenshots or screen recordings -->
2026-02-19 01:04:05 +04:00
Elie HabibandGitHub 9aa210a1b1 Disable intelligence alerts on mobile (#110)
## Summary

<!-- Brief description of what this PR does -->

## Type of change

- [ ] Bug fix
- [x] New feature
- [ ] New data source / feed
- [ ] New map layer
- [ ] Refactor / code cleanup
- [ ] Documentation
- [ ] CI / Build / Infrastructure

## Affected areas

- [ ] Map / Globe
- [ ] News panels / RSS feeds
- [ ] AI Insights / World Brief
- [ ] Market Radar / Crypto
- [ ] Desktop app (Tauri)
- [ ] API endpoints (`/api/*`)
- [ ] Config / Settings
- [x] Other: Notifications

## Checklist

- [x] Tested on [worldmonitor.app](https://worldmonitor.app) variant
- [x] Tested on [tech.worldmonitor.app](https://tech.worldmonitor.app)
variant (if applicable)
- [ ] New RSS feed domains added to `api/rss-proxy.js` allowlist (if
adding feeds)
- [ ] No API keys or secrets committed
- [ ] TypeScript compiles without errors (`npm run typecheck`)

## Screenshots

<!-- If applicable, add screenshots or screen recordings -->
2026-02-19 01:03:35 +04:00
Elie HabibandGitHub 315e94ed0f feat: add live webcams panel with localized labels (#111)
## Summary

<!-- A set of multiple live webcams to monitor the world live 😃  -->

## Type of change

- [ ] Bug fix
- [x] New feature
- [x] New data source / feed
- [ ] New map layer
- [ ] Refactor / code cleanup
- [ ] Documentation
- [ ] CI / Build / Infrastructure

## Affected areas

- [ ] Map / Globe
- [ ] News panels / RSS feeds
- [ ] AI Insights / World Brief
- [ ] Market Radar / Crypto
- [ ] Desktop app (Tauri)
- [ ] API endpoints (`/api/*`)
- [ ] Config / Settings
- [ ] Other: <!-- specify -->

## Checklist

- [x] Tested on [worldmonitor.app](https://worldmonitor.app) variant
- [ ] Tested on [tech.worldmonitor.app](https://tech.worldmonitor.app)
variant (if applicable)
- [ ] New RSS feed domains added to `api/rss-proxy.js` allowlist (if
adding feeds)
- [x] No API keys or secrets committed
- [x] TypeScript compiles without errors (`npm run typecheck`)

## Screenshots

<!-- If applicable, add screenshots or screen recordings -->
2026-02-19 01:03:04 +04:00
MasakiandCursor 9106297563 fix: use viewport width only for mobile detection (fixes touch notebooks)
- Remove (pointer: coarse) from mobile detection so touch-capable
  desktops (e.g. ROG Flow X13) get desktop layout instead of mobile
- Define MOBILE_BREAKPOINT_PX (768) in utils and use in
  isMobileDevice(); CSS @media (max-width: 768px) kept in sync
- MobileWarningModal uses isMobileDevice() for consistent behavior

Ref: https://github.com/koala73/worldmonitor/discussions/94
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-19 05:59:58 +09:00
Elie Habib 9d61d1512f fix(webcams): replace Dubai/Istanbul with Tel Aviv/Mecca, RTL fix
- Swap Dubai → Tel Aviv (live conflict coverage webcam)
- Swap Istanbul → Mecca (Kaaba 24/7 livestream)
- Fix margin-right → margin-inline-end on back button for RTL
2026-02-19 00:56:25 +04:00
Elie Habib 606ce98026 feat(webcams): add LA and Miami to fill Americas grid to 4
- Los Angeles Venice Beach (EO_1LWqsCNE) — confirmed live
- Miami Biscayne Bay (5YCajRjvWCg) — confirmed live
2026-02-19 00:46:14 +04:00
Elie Habib b865ec94e8 fix(webcams): curate ALL grid to Jerusalem, Tehran, Kyiv, Washington 2026-02-19 00:43:12 +04:00
Elie Habib a58fb4ad62 fix(webcams): replace dead Tokyo feed, reorder Asia-Pacific
- Tokyo: DjdUEyjx8GM (maintenance) → 4pu9sF5Qssw (Tokyo Live Camera 4K)
- Reorder: Taipei → Shanghai → Tokyo → Seoul (strait hotspot first)
2026-02-19 00:42:12 +04:00
Elie Habib 7a25db9208 fix(webcams): idle resume bug and mobile single-view escape
- Idle handler now clears isIdle and re-renders on user activity
  (was permanently paused after 5min timeout)
- Add grid back button in single-view switcher row for mobile
  (view toggle hidden at <=768px left users stuck)
2026-02-19 00:37:10 +04:00
Elie Habib 817bc9016e fix(webcams): diverse ALL grid, reorder feeds, fix icons and Berlin
- ALL grid now picks one feed per region (was showing 4x mideast)
- Jerusalem & Tehran adjacent (conflict hotspots)
- Replace broken Berlin with Paris Eiffel Tower webcam
- Switch toolbar SVG icons from stroke to fill for dark mode clarity
2026-02-19 00:34:13 +04:00
Elie Habib a68d77b588 feat(download): add Linux (.AppImage) to download banner
CI already builds AppImage for Linux — this adds the missing UI:
- linux-appimage pattern in api/download.js
- Linux UA detection and button in DownloadBanner
- i18n key added to all 13 locale files
2026-02-19 00:25:05 +04:00
Elie Habib 3fa60ea498 fix(rss): add 8 missing domains to proxy allowlist
france24, euronews, lemonde, dw, africanews, lasillavacia,
channelnewsasia, thehindu were returning 403 in production.
2026-02-19 00:24:56 +04:00
Elie Habib 356967f7db fix(ui): repair malformed HTML tags in panel template literals
Spaces inside HTML tags (e.g. `< span class= "trend-up" >`) caused
browsers to render raw markup text instead of elements. Fixed ~45
instances across CIIPanel, ServiceStatusPanel, and StrategicPosturePanel.
2026-02-19 00:24:46 +04:00
Elie Habib 935a58abd4 feat: add live webcams panel with localized labels 2026-02-19 00:01:31 +04:00
Elie Habib de34ccd8d7 Disable intelligence alerts on mdl 2026-02-18 23:51:53 +04:00
Elie Habib 00e5e7299a Improve mobile map popup QA 2026-02-18 23:31:44 +04:00
Elie Habib 943b5e41cd fix(ml): resolve beta mode race condition and timeout failures
- Fix summarization race: check isModelLoaded before attempting browser
  T5, fall through to cloud providers while model loads in background
- Increase modelLoadTimeoutMs 30s→600s and inferenceTimeoutMs 45s→120s
  to accommodate first-time model downloads
- Use flan-t5-small (60MB) instead of flan-t5-base (250MB) in beta mode
  for country brief fallback
- Skip ML summarization in country brief when model not loaded to avoid
  blocking UX
- Broadcast model-loaded notifications from worker on implicit loads so
  manager's loadedModels stays in sync
- Suppress ONNX CleanUnusedInitializersAndNodeArgs warnings via
  ort.env.logLevel
- Fix non-monotonic progress reporting with separate warm/cold step counts
2026-02-18 22:54:15 +04:00
Elie Habib 265a4b7bc7 fix(sentry): guard .closest() call on non-Element event target in Panel dragstart handler
e.target can be a text node when dragging text content inside a panel.
Text nodes lack .closest(), causing TypeError. Add instanceof Element check.

Resolves Sentry P2: TypeError: o.closest is not a function
2026-02-18 22:31:56 +04:00
Elie HabibandGitHub e2926d0ba2 feat(i18n): Comprehensive Localization & Regional Intelligence (#103)
## Summary
This release introduces comprehensive localization support, transforming
WorldMonitor into a truly global intelligence platform.

### Key Features
- **Multilingual UI**: Full support for 12 languages (EN, FR, ES, DE,
IT, PT, NL, SV, RU, AR, CN, JP).
- **RTL Support**: Native right-to-left layout for Arabic and Hebrew,
including mirrored UI components and correct text alignment.
- **Regional Intelligence**: 
  - Dynamic feed selection based on language preference.
- Dedicated regional monitoring panels for Africa, LatAm, Middle East,
and Asia.
  - Granular error handling for regionally blocked feeds.
- **AI Integration**: On-demand translation and summarization of news
items using localized prompts.

### Technical Changes
- **Refactoring**: Overhauled \src/services/i18n.ts\ and
\src/config/feeds.ts\ to support dynamic loading.
- **Styling**: Added \src/styles/rtl-overrides.css\ using logical CSS
properties for maintenance-free RTL support.
- **Cleanup**: Resolved CSS lint warnings and improved code quality.

### Verification
- **Build**: \
pm run build\ passed successfully.
- **Manual Testing**: Verified RTL layout, language switching, and
regional feed loading.
2026-02-18 22:12:18 +04:00
Elie Habib fb7711bfe4 merge: sync latest main and resolve App import conflict 2026-02-18 21:50:43 +04:00
Elie Habib 7446fb53a3 merge: resolve main conflicts in App and feeds for PR #103 2026-02-18 21:49:15 +04:00
Elie HabibandGitHub f87c882fc3 feat: add developer beta mode for T5-small evaluation (#108)
Console-activated (beta=true) mode that swaps browser summarization to
Flan-T5-small (60MB) as first provider with comparison logging against
Groq. Persists via localStorage, shows amber BETA badge in header.

## Summary

<!-- Brief description of what this PR does -->

## Type of change

- [ ] Bug fix
- [ ] New feature
- [ ] New data source / feed
- [ ] New map layer
- [ ] Refactor / code cleanup
- [ ] Documentation
- [ ] CI / Build / Infrastructure

## Affected areas

- [ ] Map / Globe
- [ ] News panels / RSS feeds
- [ ] AI Insights / World Brief
- [ ] Market Radar / Crypto
- [ ] Desktop app (Tauri)
- [ ] API endpoints (`/api/*`)
- [ ] Config / Settings
- [ ] Other: <!-- specify -->

## Checklist

- [ ] Tested on [worldmonitor.app](https://worldmonitor.app) variant
- [ ] Tested on [tech.worldmonitor.app](https://tech.worldmonitor.app)
variant (if applicable)
- [ ] New RSS feed domains added to `api/rss-proxy.js` allowlist (if
adding feeds)
- [ ] No API keys or secrets committed
- [ ] TypeScript compiles without errors (`npm run typecheck`)

## Screenshots

<!-- If applicable, add screenshots or screen recordings -->
2026-02-18 21:47:17 +04:00
Elie Habib d5f8345509 feat: add developer beta mode for T5-small evaluation
Console-activated (beta=true) mode that swaps browser summarization to
Flan-T5-small (60MB) as first provider with comparison logging against
Groq. Persists via localStorage, shows amber BETA badge in header.
2026-02-18 21:45:52 +04:00
Elie Habib 0564db5e99 fix(i18n): scope caches by locale and lazy-load translations 2026-02-18 21:43:05 +04:00
Elie Habib 06074a2d7b fix: validate API response data before reporting ok, narrow maplibre noise filter to stack-checked beforeSend 2026-02-18 21:06:14 +04:00
Elie Habib 7c46ddeb9e fix: wire up missing API status indicators (GDELT Doc, EIA, USASpending, Cyber Threats)
- GDELT Doc: rename 'GDELT' → 'GDELT Doc' to match StatusPanel allowlist
- EIA: add updateApi calls in loadOilAnalytics
- USASpending: add updateApi calls in loadGovernmentSpending
- Cyber Threats API: add updateApi calls alongside existing feed updates
2026-02-18 20:54:02 +04:00
Elie Habib ad3e2ba529 fix(sentry): broaden Failed to fetch filter to match domain-suffixed variants 2026-02-18 20:44:21 +04:00
Elie Habib 564ea5b433 fix(sentry): narrow shader link filter to null-only to preserve real GLSL error signal 2026-02-18 20:00:18 +04:00
Elie Habib e85b4331bb fix(sentry): filter maplibre internal null-access crashes (light, placement) 2026-02-18 19:57:44 +04:00
Elie Habib 2da05f172d fix: guard YT player .mute()/.unMute() with optional chaining, filter WebGL link errors
- LiveNewsPanel: player.mute/unMute may not exist before onReady (WORLDMONITOR-16)
- main.ts: add /Program failed to link/ noise filter (WORLDMONITOR-18)
2026-02-18 19:55:14 +04:00
Elie Habib 65618da405 fix: add console.warn to silent storage catch blocks for diagnosability 2026-02-18 19:51:49 +04:00
Elie Habib 0d08c23d85 fix: isolate baseline writes from fetch/render and guard snapshot rejections
- Wrap updateBaseline() in try/catch inside loadNewsCategory and intel
  path so IndexedDB write failures don't delete successfully fetched
  and rendered news data (P1)
- Add .catch() to saveCurrentSnapshot() initial call and setInterval
  callback to prevent unhandled promise rejections from IndexedDB
  readwrite failures (P2)
2026-02-18 19:43:14 +04:00
Elie Habib a0f8bcb3bc fix(sentry): safari fullscreen void return, narrow module-import filter, IndexedDB write-drop
- webkitRequestFullscreen returns void (not Promise) on Safari — use
  try/catch instead of .catch() to avoid undefined.catch() throw
- Module-import beforeSend filter: only suppress when stack frames
  originate from browser extensions, not by URL domain check
- withTransaction: throw on readwrite InvalidStateError after retry
  instead of silently returning undefined (prevents write-drop)
2026-02-18 19:39:42 +04:00
Elie Habib dfd5ffcbee fix(sentry): fullscreen Promise catch, narrow fetch filter, and classList guards
- toggleFullscreen: use void .catch() for Promise-based requestFullscreen/
  exitFullscreen + webkit prefix fallback for iOS Safari (WORLDMONITOR-11/13)
- Narrow /^TypeError: Failed to fetch/ to exact match (was suppressing real
  API failures). Move module-import-failed to beforeSend with extension/
  webview context check instead of blanket ignore (WORLDMONITOR-15)
- Guard classList?.contains and target.closest?. on event targets that may
  not be Elements (WORLDMONITOR-Z/10)
- Add noise filters: Fullscreen request denied, requestFullscreen,
  vc_text_indicators_context (WORLDMONITOR-12)
2026-02-18 19:33:58 +04:00
Elie Habib ffd99b0c58 fix(sentry): guard deck.gl setProps during WebGL context loss and add noise filters
Prevent getProjection null crash when WebGL context is lost by tracking
webglLost flag and skipping all setProps/layer rebuild calls until restored.
Add ignoreErrors for IndexedDB iOS kills, Twitter WebView injection, and
CSP unsafe-eval from extensions.
2026-02-18 18:05:30 +04:00
Elie Habib 7bbae36cb7 fix(sentry): broaden Failed to fetch filter to catch domain-suffixed variants
Browser extensions intercept window.fetch causing "Failed to fetch
(gamma-api.polymarket.com)" to leak as unhandled rejection. Remove
the $ anchor so the pattern matches any suffix.
2026-02-18 17:45:51 +04:00
Elie Habib 065fae2ed8 fix: expand Sentry noise filtering and address code review findings
- Add beforeSend filter to drop minified 1-3 char library errors (e.g., "vd")
- Filter transient network errors (Load failed, Failed to fetch, cancelled)
- Filter browser extension errors (runtime.sendMessage, Java object is gone)
- Filter non-Error promise rejections and SVG image load failures
- Filter MapLibre imageManager null ref during WebGL context restore
- Reset YouTube API promise on load failure to allow retry on next init
- Move USASpending timeout cleanup to finally block
- Log snapshot cleanup errors instead of silently swallowing
2026-02-18 17:42:52 +04:00
Elie Habib 609e372a3d fix: gracefully handle IndexedDB connection-closing errors on iOS
- withTransaction now returns undefined instead of throwing when
  InvalidStateError persists after retry (transient browser event)
- Add .catch() to fire-and-forget cleanOldSnapshots() call
2026-02-18 15:24:24 +04:00
Elie Habib 12d85320e2 fix: filter non-actionable Sentry errors (autoplay, WebView, deck.gl, extensions)
- Add NotAllowedError, InvalidAccessError, importScripts to Sentry ignoreErrors
- Add global unhandledrejection handler for YouTube IFrame API autoplay blocks
- Add onError handler to deck.gl MapboxOverlay for internal render-cycle races
2026-02-18 15:22:53 +04:00
Elie Habib 54f417d8fc fix: resolve Sentry errors — IndexedDB stale connection, USASpending timeout, WebGL context loss, RSS 403s
- storage.ts: add withTransaction() retry wrapper for IndexedDB InvalidStateError on iOS/Safari tab backgrounding
- usa-spending.ts: add 20s AbortController timeout to prevent Safari "Load failed" on stalled POST
- App.ts: add catch to runGuarded() to prevent unhandled rejections from task runner
- main.ts: add Sentry ignoreErrors for WebGL context loss and ResizeObserver loop
- DeckGLMap.ts: add webglcontextlost/restored handlers for graceful GPU recovery
- feeds.ts: route rsshub.app feeds (NHK, MIIT, MOFCOM) through Railway proxy, switch Nikkei Asia and ECFR to Google News proxy
- finance.ts: switch Nikkei Asia to Google News proxy, remove unused railwayRss helper
2026-02-18 14:25:59 +04:00
Elie Habib 30e2dda448 fix: gracefully handle YouTube IFrame API load failure
Resolve instead of reject when the script fails to load (ad blocker,
network issue). Guard initializePlayer against missing YT.Player.
Prevents noisy unhandled rejection errors in Sentry.
2026-02-18 14:07:23 +04:00
Elie Habib 941475eb79 feat: integrate Sentry browser error tracking
Initializes @sentry/browser early in main.ts with environment
detection (production/preview/development). Disabled on localhost
and Tauri desktop. Traces sampled at 10%.
2026-02-18 13:56:31 +04:00
Lib-LOCALE f8270689a0 feat(i18n): comprehensive localization, RTL support, and regional feeds revamp 2026-02-18 10:38:17 +01:00
Elie HabibandGitHub 71a62e5bb4 fix: gate SignalModal auto-popup on findings toggle (#101)
## Summary
- PR #97 hid the badge but the `SignalModal` kept auto-opening on new
signals — this is what the reporter was still seeing
- Gates all 5 automatic `this.signalModal?.show()` calls behind
`this.findingsBadge?.isEnabled()` so disabling Intelligence Findings
also suppresses the full-screen popup overlay and sounds
- Signal history is still recorded (`addToSignalHistory`) even when
popup is suppressed, so re-enabling the toggle shows them

Closes #89

## Test plan
- [x] Disable Intelligence Findings via PANELS toggle or right-click
- [x] Wait for signal refresh cycle — no full-screen popup should appear
- [x] Re-enable → popups resume on next signal detection
- [x] Build succeeds with no type errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-02-18 13:16:17 +04:00
Elie HabibandGitHub cb242f1b2c feat: add La Silla Vacía (Colombia) to LATAM feeds (#102)
## Summary
- Adds [La Silla Vacía](https://www.lasillavacia.com) RSS feed (`/rss`)
to the `latam` feed category
- Adds source tier entry (Tier 3 — specialty/investigative)
- Colombian independent outlet covering political power structures,
governance, and armed conflict

Ref #96

## Test plan
- [ ] Verify feed loads in LATAM news panel (content is in Spanish)
- [ ] Confirm no duplicate or broken entries in feed list

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-02-18 13:15:33 +04:00
Sebastien MelkiandClaude Opus 4.6 dae0de6d99 feat: add La Silla Vacía (Colombia) to LATAM feeds
Add lasillavacia.com RSS feed to improve Latin American political
coverage. Independent Colombian investigative outlet covering governance,
armed conflict, and regional power dynamics.

Ref #96

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:51:31 +02:00
Sebastien MelkiandClaude Opus 4.6 0aafe16fe0 fix: gate SignalModal auto-popup on findings toggle
PR #97 only hid the badge itself but the SignalModal kept auto-opening
on new signals. Gate all 5 automatic signalModal.show() calls behind
findingsBadge.isEnabled() so disabling Intelligence Findings also
suppresses the full-screen popup overlay.

Closes #89

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:24:28 +02:00
Elie Habib 922092c653 docs: add v2.3.9 changelog 2026-02-18 08:25:47 +04:00
Elie HabibandGitHub 55e83b1cfe feat(i18n): full internationalization — 14 locales, zero hardcoded English + Linux AppImage support (#100)
## Summary
- Full i18n system with 14 locales: en, fr, de, es, it, pl, pt, nl, sv,
ru, ar (RTL), zh, ja — all at 1132-key parity
- Eliminated ~110 hardcoded English strings across 50+ source files,
replaced with `t()` calls
- RTL support for Arabic with proper regional code normalization (ar-SA
→ ar)
- Dead English fallback literals (`t() || 'English'`) removed from all
components
- Community discussion floating widget (localized)
- Linux AppImage desktop build support
- Proper noun heuristic fallback for trending keywords when ML
unavailable

## Key changes
- **New**: `src/services/i18n.ts` — i18next setup with language
detection, RTL, locale switching
- **New**: 13 locale JSON files (1132 keys each) in `src/locales/`
- **New**: `src/styles/rtl-overrides.css` +
`src/styles/lang-switcher.css`
- **Modified**: 50+ components/services to use `t()` instead of
hardcoded strings
- **Modified**: `.github/workflows/build-desktop.yml` — Linux CI matrix
- **Modified**: `scripts/desktop-package.mjs` + `download-node.sh` —
Linux target support

## Test plan
- [ ] Verify language switcher shows all 14 languages
- [ ] Switch to Arabic — confirm `dir="rtl"` on `<html>`, layout mirrors
- [ ] Switch to Japanese — confirm all panel labels, tooltips, popups
render in Japanese
- [ ] Switch to French — confirm no English leaks in panels, modals, map
legend
- [ ] Verify `{{count}}` interpolation works in timeAgo strings
- [ ] Verify `tsc --noEmit` passes (confirmed locally)
- [ ] Test community widget dismiss/localStorage persistence
2026-02-18 08:21:13 +04:00
Elie Habib 6e2c6034e4 merge: resolve conflicts with main (community widget, trending keywords, CSS) 2026-02-18 08:19:48 +04:00
Elie Habib 179e9c1687 chore: bump version to 2.3.9 2026-02-18 08:16:36 +04:00
Elie Habib 19683aa1fe fix: community widget idempotency guard + suppress missing i18n help keys
- CommunityWidget: add DOM check to prevent duplicate widgets on repeated loadNews() calls
- RuntimeConfigPanel: compare t() result against key path to suppress missing help translations
2026-02-18 08:11:59 +04:00
Elie Habib 8ef6e11bed feat(feeds): add NHK World and Nikkei Asia RSS feeds for Japan coverage
Main variant: NHK World + Nikkei Asia in asia category.
Finance variant: Nikkei Asia in markets category.
Added asia.nikkei.com to RSS proxy allowlist.
2026-02-18 08:07:07 +04:00
Elie Habib c287e3dfba feat(feeds): add NHK World and Nikkei Asia RSS feeds for Japan coverage
Main variant: NHK World + Nikkei Asia in asia category.
Finance variant: Nikkei Asia in markets category.
Added asia.nikkei.com to RSS proxy allowlist.
2026-02-18 08:06:55 +04:00
Elie Habib 6b87bbb2b0 fix(i18n): remove dead English fallback literals in StoryModal
t() always returns a string, so || 'English' fallbacks were
unreachable. Removed all 15 instances.
2026-02-18 07:50:31 +04:00
Elie Habib 95ce713cf7 fix(i18n): remove dead English fallback literals in CountryIntelModal
t() always returns a string (key itself if missing), so || 'English'
fallbacks were unreachable dead code.
2026-02-18 07:43:34 +04:00
Elie Habib a242761b8a feat(i18n): add Japanese locale (1132 keys)
New ja.json with full translation parity to en.json.
Registered in i18n.ts: import, resources, supportedLngs, LANGUAGES, getLocale map.
2026-02-18 07:42:19 +04:00
Elie Habib bb62babb12 fix(i18n): replace remaining hardcoded English in source and locales
- CommunityWidget: aria-label="Close" → t('common.close')
- App.ts: ' (current)' suffix → t('common.currentVariant')
- fr.json: "Cloud & Infrastructure" → "Cloud et infrastructure"
- sv.json: "24h Vol" → "24t volym"
- Added common.close + common.currentVariant to all 12 locales
2026-02-18 07:28:02 +04:00
Elie Habib 4ccf6cf21f fix(i18n): normalize regional language codes in RTL detection
applyDocumentDirection() now splits on '-' before checking RTL set,
so ar-SA correctly triggers dir="rtl" on first detect.
2026-02-18 07:23:59 +04:00
Elie Habib a9f6909a20 fix(i18n): fix 5 P1/P2 bugs — timeAgo count, community widget, Linux AppImage, locale gaps, lang normalization
- Fix relative-time {{count}} placeholders in all 12 locales (5m ago, not m ago)
- Localize CommunityWidget: replace 3 hardcoded English strings with t() calls
- Add Linux AppImage to tech/finance Tauri configs, CI matrix (ubuntu-22.04), packaging scripts, and download-node.sh
- Fix language code normalization: add supportedLngs/nonExplicitSupportedLngs to i18next, normalize getCurrentLanguage()
- Translate ~240 untranslated English strings across 11 locale files (ru/ar/zh now 100% translated)
- Add components.community section to all 12 locales
- All 12 locales at 1130-key parity, 0 placeholder mismatches
2026-02-18 07:16:47 +04:00
Elie Habib 7e17e97a8d feat(i18n): eliminate ~95 hardcoded English strings and remove dead UTC clock
Replace hardcoded English text with t() calls across 12 source files:
signal context descriptions, alert titles/summaries, intel topic
names, map legend labels, asset/threat labels, UCDP panel headers,
stablecoin/status panel sections. Add ~114 new keys to en.json and
translate to all 11 locales (fr/de/es/it/pl/pt/nl/sv/ru/ar/zh).
Remove dead #timeDisplay span that was never updated by JS.

All 12 locales at 1127-key parity with 0 placeholder mismatches.
2026-02-18 06:55:16 +04:00
Elie Habib c92de74884 fix(css): restore intel-findings context menu styles removed in 6750dbf 2026-02-18 01:41:20 +04:00
Elie Habib e3cfd16f4b fix(community): point discussion link to main discussions page 2026-02-18 01:39:53 +04:00
Elie Habib 3088b34a88 style(community): vibrant green accent for discussion pill widget 2026-02-18 01:36:22 +04:00
Elie Habib 1955134fa2 feat(i18n): add Arabic (RTL) and Chinese Simplified locales
- Add ar.json and zh.json translations (1013 keys each, full parity)
- RTL support: dir="rtl" on <html>, targeted CSS overrides in rtl-overrides.css
- Font fallbacks: system Arabic/CJK fonts via --font-body CSS variable
- i18n.ts: RTL_LANGUAGES set, applyDocumentDirection(), isRTL(), getLocale()
- story-renderer.ts: locale-aware date formatting via getLocale()
- Type declarations for both new locales
2026-02-18 01:26:37 +04:00
Elie Habib 4a5dfa0d54 fix(css): remove broken @import for missing lang-switcher and rtl-overrides
These imports were accidentally committed from the i18n PR branch.
The files don't exist on main, causing Vercel build errors.
2026-02-18 01:10:38 +04:00
Elie Habib 7dcfcb25f0 fix(trending): proper-noun heuristic handles headline-start and lowercase terms
P1: Start-of-headline matches (idx=0) no longer count against the
capitalization ratio. When all matches are headline-start only,
falls back to acronym check instead of returning false.

P2: Removed dead [A-Z]{2,} shortcut — terms are lowercased by
asDisplayTerm() before reaching isLikelyProperNoun.
2026-02-18 01:07:41 +04:00
Elie Habib 6750dbfd08 feat: add community discussion floating pill widget
Floating pill badge (bottom-right) with pulsing dot, "Join the
Discussion" text, and CTA linking to GitHub Discussions #94.
Shows 15s after initial data load. "Don't show again" persists
dismissal to localStorage (wm-community-dismissed).
2026-02-18 01:05:24 +04:00
Elie Habib 19d45e967e feat: add floating pill community discussion widget
Shows a compact pill badge (bottom-right) inviting users to the GitHub
Discussions page. Includes pulsing dot indicator, CTA button, close
button, and "Don't show again" option that persists to localStorage.
2026-02-18 00:57:03 +04:00
Elie Habib 5f5c135e07 fix(trending): add proper-noun heuristic fallback when ML unavailable
When the NER model isn't loaded, isSignificantTerm was returning true
for all terms, letting common words like "here" trigger keyword spike
findings. Now falls back to a capitalization heuristic: single-word
terms must appear capitalized (mid-sentence) in >50% of headlines to
be treated as significant. Also added ~45 missing English stopwords.
2026-02-18 00:45:11 +04:00
Elie Habib 40b63d4d3d fix(trending): add proper-noun heuristic fallback when ML unavailable
When the NER model isn't loaded, isSignificantTerm was returning true
for all terms, letting common words like "here" trigger keyword spike
findings. Now falls back to a capitalization heuristic: single-word
terms must appear capitalized (mid-sentence) in >50% of headlines to
be treated as significant. Also added ~45 missing English stopwords.
2026-02-18 00:45:07 +04:00
Elie Habib 3440c83557 feat(i18n): add Russian locale and fix key parity across all 10 languages
Audit found missing keys in all non-EN locales (62-90 per file) and 25
stale keys in PT/NL/SV. Fixes gaps, removes stale keys, corrects FR
infra key misplacement, and adds complete Russian (ru) translation with
1013/1013 keys matching the English reference.
2026-02-18 00:41:20 +04:00
Elie Habib bff888a942 fix(i18n): localize remaining map/tooltips/popups and close locale gaps 2026-02-18 00:04:41 +04:00
Elie Habib 5d778fb2c4 fix(i18n): localize remaining UI literals and expand locale coverage 2026-02-17 22:16:05 +04:00
Elie Habib 182c419ebb fix(trending): add missing English stopwords to suppressed terms
"here" and other basic English words (pronouns, prepositions, adverbs)
were not in SUPPRESSED_TRENDING_TERMS, causing false keyword spike
findings for common words.
2026-02-17 21:16:17 +04:00
Elie Habib 641a7f4bb9 fix(trending): add missing English stopwords to suppressed terms
"here" and other basic English words (pronouns, prepositions, adverbs)
were not in SUPPRESSED_TRENDING_TERMS, causing false keyword spike
findings for common words.
2026-02-17 21:16:02 +04:00
Elie Habib 6c4e1c3f41 fix(i18n): close key coverage gaps and localize merged UI strings 2026-02-17 21:11:39 +04:00
Elie Habib 2a32213b2f fix(css): define --panel-bg and --panel-border theme variables
These CSS custom properties were used in 14 places but never defined,
causing transparent backgrounds on playback panel, toggle button,
header flash animation, select options, and offline retry button.
2026-02-17 21:00:33 +04:00
Elie Habib 30be26e779 merge(main): update PR #86 and resolve merge conflicts 2026-02-17 20:58:31 +04:00
Elie HabibandGitHub 8dae6889ea feat: add toggle to disable Intelligence Findings badge (#97)
## Summary
- Adds ability to hide the Intelligence Findings badge for passive/TV
viewing — stops polling, sounds, and pulse animations when disabled
- Two ways to toggle: right-click context menu on the badge, or the
PANELS settings modal
- Preference persists in `localStorage` across page reloads

Closes #89

## Test plan
- [ ] Load app — badge shows by default (no regression)
- [ ] Right-click badge → "Hide Intelligence Findings" → badge
disappears, no polling/sounds
- [ ] Open PANELS → "Intelligence Findings" toggle shows as disabled →
click to re-enable → badge reappears
- [ ] Refresh page → preference persists
- [ ] Build succeeds with no type errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-02-17 20:48:11 +04:00
Elie Habib 0efc819689 fix: resolve findings toggle state and badge listener lifecycle 2026-02-17 20:46:08 +04:00
Sebastien MelkiandClaude Opus 4.6 f3678b5d8b feat: add toggle to disable Intelligence Findings badge
Allow users to hide the Intelligence Findings badge for passive viewing
(e.g. TV displays). The badge can be disabled via right-click context
menu or the PANELS settings modal. Preference persists in localStorage.

Closes #89

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:37:09 +02:00
Elie Habib fe3fe09c41 chore(release): bump version to 2.3.8 2026-02-17 20:14:16 +04:00
Elie Habib 073cf9e42b chore: retrigger Vercel deploy 2026-02-17 20:08:35 +04:00
Elie Habib 5fc260ea2c chore: trigger Vercel redeploy (edge function transient failure) 2026-02-17 20:02:51 +04:00
Elie Habib c62592d81b feat: add BBC Persian, Iran International, Fars News, MIIT & MOFCOM feeds
- Middle East panel: BBC Persian (direct RSS), Iran International and
  Fars News (Google News fallbacks — no public RSS endpoints)
- Asia panel: MIIT and MOFCOM China government feeds via RSSHub
2026-02-17 19:55:02 +04:00
Elie Habib 52ff869f57 fix: replace 7 more dead/blocked tech RSS feeds with Google News fallbacks
Sequoia Blog, EU Startups, Tech in Asia, LAVCA, YC Launches,
Dev Events, and SemiAnalysis were returning 403/404/timeout errors.
2026-02-17 19:32:15 +04:00
Elie Habib 95a92befec fix: guard invalid RSS dates and replace dead/blocked feed URLs
- Guard against Invalid Date in RSS parser — malformed pubDate strings
  from IAEA/CrisisWatch feeds caused RangeError on toISOString()
- Replace 13 dead/blocked RSS feed URLs (403/404/500) with Google News
  site-scoped fallbacks: Politico, RUSI, Kyiv Independent, War Zone,
  MEI, Wilson Center, GMF, CNAS, Lowy, Arms Control, Bulletin, EU ISS
2026-02-17 19:27:07 +04:00
Elie Habib 54f1a5d578 test: add coverage for finance/trending/reload and stabilize map harness 2026-02-17 19:22:55 +04:00
Elie HabibandGitHub e0c6aa32be feat: auto-detect OS in download banner (#93)
## Summary
- Detects user's OS via `navigator.userAgent` and shows only the
relevant download button (Windows, macOS Apple Silicon, or macOS Intel)
- Uses WebGL renderer info to distinguish Apple Silicon from Intel Macs
where possible
- Adds a "Show all platforms" toggle so users can still access other
platform downloads
- Falls back to showing all 3 buttons if OS can't be detected

## Test plan
- [x] Open the web app on a Mac — should see only the macOS (Apple
Silicon) button
- [x] Open on Windows — should see only the Windows button
- [x] Click "Show all platforms" — all 3 buttons should appear
- [x] Click "Show less" — should collapse back to the detected platform
- [x] Spoof an unrecognized user agent — all 3 buttons should show (no
toggle)

Replaces #91
Closes #90

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-02-17 19:18:09 +04:00
Elie Habib de88ef8719 fix: show both Mac options (not Windows) for undetected Mac arch
Add 'macos' platform type for Macs where WebGL can't determine
Apple Silicon vs Intel. Shows both Mac download buttons without
the irrelevant Windows option.
2026-02-17 19:03:42 +04:00
Elie Habib b508db26bd fix: detect Intel Macs and fall back to showing both Mac options
WebGL renderer check now also detects Intel GPUs to return macos-x64.
When architecture can't be determined, return unknown so users see
both Apple Silicon and Intel download buttons instead of defaulting
to the wrong binary.
2026-02-17 18:54:29 +04:00
Elie Habib 922ed3fa62 Merge pull request #92 from koala73/fix/map-no-repeat-no-labels 2026-02-17 18:48:48 +04:00
Elie Habib 44b52e799d fix: persist country highlight across theme switches
Store the active highlighted ISO code so it can be re-applied after
setStyle() rebuilds map layers with empty default filters.
2026-02-17 18:44:51 +04:00
Elie Habib 0fafa2aea8 fix: rehydrate country layers after setStyle theme switch
setStyle() replaces all map sources/layers. Country boundaries were
only loaded once (guarded by countryGeoJsonLoaded) so they vanished
after theme toggle with no way to re-add them.

- Reset countryGeoJsonLoaded in switchBasemap so loadCountryBoundaries
  can re-run after the new style loads
- Listen for style.load before re-adding country source/layers
- Guard setupCountryHover with countryHoverSetup flag to prevent
  duplicate mousemove/mouseout listeners on re-load
- Apply theme-correct paint values after layer creation
2026-02-17 18:36:59 +04:00
Elie Habib b2509669a5 docs: add finance variant, Gulf FDI, tri-variant architecture to README 2026-02-17 16:52:33 +04:00
Elie Habib efba9a77a6 fix(map): prevent popups from overflowing below viewport edge
Add bottom viewport clamp so popups near the map's bottom edge slide
upward to remain fully visible instead of being cut off.
2026-02-17 16:48:53 +04:00
Sebastien MelkiandClaude Opus 4.6 4ac9ad4c00 feat: auto-detect OS in download banner, show only relevant platform
Closes #90

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:25:47 +02:00
Sebastien MelkiandClaude Opus 4.6 ad1e78a5c4 fix: disable map repetition & use English-only vector labels
Switch from CARTO dark_all raster tiles (which showed continent names
in local languages like 亚洲, AFRIKA, أفريقيا) to CARTO Dark Matter
vector style with English labels. Set renderWorldCopies: false to
prevent horizontal map duplication. Use interleaved deck.gl overlay
so basemap labels render above data layers.

Closes #81

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:24:04 +02:00
Elie Habib e383749926 chore: add finance variant to GitHub issue templates 2026-02-17 16:14:31 +04:00
Elie Habib 196c79c023 fix(cors): add finance.worldmonitor.app to Railway proxy allowlist
Polymarket and other proxied requests from finance variant were blocked
by CORS because the Railway relay only allowed worldmonitor.app and
tech.worldmonitor.app origins.
2026-02-17 16:02:32 +04:00
Elie Habib bfd4cfdd41 fix(finance): enable GCC investments layer and stabilize GCC feeds 2026-02-17 15:55:20 +04:00
Elie Habibandaa5064 cd139e99ee feat(finance): integrate GCC investments into finance variant
Wire Gulf FDI data from PR #61 into the existing finance variant:
- Add gulfInvestments map layer (ScatterplotLayer with SA/UAE colors)
- Add GCC Business News feeds (Arabian Business, The National, Arab News, etc.)
- Add gcc-investments and gccNews panels to finance panel config
- Add Gulf FDI types to MapLayers interface
- Add RSS proxy domains for Gulf news sources
- Auto-wiring creates NewsPanel for gccNews feed category

Co-authored-by: aa5064 <261283889+aa5064@users.noreply.github.com>
2026-02-17 15:31:58 +04:00
aa5064andElie Habib cfbd1ad8a1 feat: add Gulf FDI investment database and panel component
772-line database of Saudi Arabia and UAE foreign direct investments in
global critical infrastructure (ports, energy, data centers, railways, etc.)
with filterable/sortable table panel.

Originally contributed as standalone 'infra' variant in PR #61.
2026-02-17 15:31:37 +04:00
Elie Habib 8c8fb0ae0a fix: resolve P2 reload guard and harness assertion drift 2026-02-17 12:25:34 +04:00
Elie Habib 4a639c79c6 feat(header): move UTC clock from map overlay to centered header bar
- Add centered header-clock in GLOBAL SITUATION panel header
- Remove map-timestamp from Map.ts and DeckGLMap.ts
- Clock ticks every second with monospace font
2026-02-17 12:00:23 +04:00
Elie Habib fb512e7918 fix(trending): suppress month names and strip source attributions from titles
- Add goldman, sachs, off, and all 12 month names to SUPPRESSED_TRENDING_TERMS
- Add stripSourceAttribution() to remove feed name suffixes (e.g. "- Wall Street Journal") from RSS titles before keyword extraction
2026-02-17 11:34:28 +04:00
Elie Habib a78a072079 fix(deploy): prevent stale chunk 404s after Vercel redeployment
- vercel.json: add no-cache headers for / and /index.html so CDN never serves stale HTML
- main.ts: add vite:preloadError listener to auto-reload once on chunk 404s
- vite.config.ts: remove HTML from SW precache, add NetworkFirst for navigation requests
2026-02-17 11:30:58 +04:00
Lib-LOCALE 51e6f3553d i18n: restructure and translate popups section across all locales
- Restructure popups keys in de/es/it/pl/nl/pt/sv to match en.json nested structure
- Move orphaned top-level keys (earthquake, base, protest, flight, nuclear, cable, pipeline, etc.) into popups.* namespace
- Add full native translations for ~200 popup keys per locale
- Ensures i18next resolves popups.* keys correctly for all languages
2026-02-17 06:59:24 +01:00
Elie Habib 9a31eeba09 chore(release): finalize 2.3.8 changelog and version 2026-02-17 09:53:03 +04:00
Elie Habib a524ab3ad7 merge(main): integrate finance variant and resilience hardening
Merge branch 'feature/finance-variant' into main.

Changelog (2.3.8):
- add dedicated finance variant with finance/trading focused feeds, panels, and desktop profile
- stage feed category loading with bounded concurrency to reduce startup pressure
- add AI classification admission control and tighter non-finance budgets
- harden external source behavior (Polymarket fallback probing, FRED graceful empty payloads)
- replace brittle MarketWatch direct RSS usage with resilient Google News fallbacks across variants
- keep finance data-rich panels while adding news panels, and ensure first-class desktop Deck.GL behavior
2026-02-17 09:50:56 +04:00
Elie Habib 26c03ca545 fix(trending): suppress noisy finance/generic terms from keyword spikes
Add 28 terms to SUPPRESSED_TRENDING_TERMS: common finance jargon
(trading, stock, earnings, ipo, currency, dollar, usd, etc.),
generic news words (today, chief, focus, basel), date fragments,
and web artifacts (com, platform, block).
2026-02-17 09:47:51 +04:00
Elie Habib b2c3144a7e Harden full and tech variants against feed/API pressure 2026-02-17 09:46:25 +04:00
Elie Habib 116fc80fc7 Merge remote-tracking branch 'origin/main' into feature/finance-variant
# Conflicts:
#	index.html
#	src/components/DeckGLMap.ts
2026-02-17 09:37:47 +04:00
Elie Habib ed9cc922e2 Fix finance variant runtime resiliency and API pressure 2026-02-17 09:34:15 +04:00
Elie Habib 5382e79ac7 fix(finance): keep data panels and add desktop finance map layers 2026-02-17 09:06:35 +04:00
Elie Habib ef2b67ad22 fix(finance): address PR review blocking issues
- Add missing RSS proxy domains (seekingalpha, coindesk, cointelegraph)
- Fix operator precedence in finance marker zoom checks (explicit parens)
- Add Number.isFinite() NaN guards on all finance marker projections
- Include finance variant in aggregated test:e2e script
2026-02-17 08:38:25 +04:00
Elie Habib 2ac23dcabf fix: wire timeline filtering across map and news panels 2026-02-17 08:12:13 +04:00
Elie Habib b1e917972e fix(banner): hide desktop download prompt on mobile devices 2026-02-17 07:54:02 +04:00
Elie Habib 0ea47330ba fix(panels): standardize error messages with specific causes
Differentiate missing API key, upstream down, and empty data states.
Name the actual data source in error messages instead of generic text.
2026-02-17 00:24:55 +04:00
Elie Habib 65dea2e4f9 fix(desktop): batch keychain reads to reduce macOS password prompts
Add get_all_secrets command that reads all 18 keys in a single IPC call.
This triggers one Keychain prompt instead of 18 on fresh installs.
2026-02-17 00:24:44 +04:00
Elie Habib 631a5e2112 chore(ai): switch OpenRouter model to auto-routed free tier 2026-02-17 00:15:11 +04:00
Elie Habib 88ad25cb93 release: v2.3.7
Full light mode theme, header dark/light toggle, desktop update checker,
bundled Node.js in installer, CORS fixes, and panel defaults update.
2026-02-16 23:56:28 +04:00
Elie Habib 49d5100480 feat(panels): enable UCDP, UNHCR, Climate, and Population panels by default 2026-02-16 23:53:21 +04:00
Elie Habib c2992c2fae refactor(header): remove redundant theme toggle from panels modal
Now that the header has a sun/moon dark/light toggle, the Appearance
section in the settings modal is redundant. Revert modal title from
"Settings" back to "Panels" and remove theme radio buttons and CSS.
2026-02-16 23:48:39 +04:00
Elie Habib 9f0a2107ed chore: add ideas/ to .gitignore 2026-02-16 23:38:01 +04:00
Elie Habib 72d191c6db chore: add markdown linting config and CI workflow 2026-02-16 23:34:01 +04:00
Elie Habib c31dfbb70e docs: update documentation, release packaging, and validation report 2026-02-16 23:33:06 +04:00
Elie Habib c94ec0b4ad Adding Node in the Tauri Installer 2026-02-16 23:30:19 +04:00
Elie Habib 9a7a988846 feat(header): replace UTC clock with dark/light mode toggle
Remove the duplicate time display from the header bar and add a
theme toggle button (sun/moon icon) that hooks into the existing
theme-manager. Bidirectional sync with settings modal radios.
2026-02-16 23:20:32 +04:00
Elie Habib b59235fc45 CORS fixes for Tauri desktop app 2026-02-16 23:14:08 +04:00
Elie Habib cccfdac0f9 feat(update): add desktop update check with architecture-aware download links 2026-02-16 23:13:10 +04:00
Elie Habib 2e15ee6815 fix(markets): keep Yahoo-backed data visible when Finnhub is skipped 2026-02-16 23:12:00 +04:00
Elie Habib 60cec92a16 fix(windows): preserve UNC paths when sanitizing sidecar script path 2026-02-16 23:09:30 +04:00
Elie HabibandGitHub 6c41fec3bb feat: add light mode theme with full UI, map, and chart theming (#84)
## Summary

Adds a complete dual-theme system to WorldMonitor. Users can toggle
between dark and light mode in Settings, with the preference persisted
across sessions. Every panel, the map, charts, and all chrome are fully
themed.

**41 files changed, +1,591 / -1,128 lines**

## What's included

### CSS Foundation (Phase 1)
- 124+ hardcoded colors centralized into CSS custom properties
- Theme colors separated from semantic colors (threat reds, DEFCON,
status badges stay identical)
- `getCSSColor()` runtime utility with theme-aware cache for dynamic TS
components

### Theme Core (Phase 2)
- `ThemeManager` module with `get/set/apply` theme functions and
localStorage persistence
- FOUC prevention inline scripts in both HTML entry points
- Dark/Light radio toggle in Settings → Appearance section

### Map & Visualization Theming (Phase 3)
- Map auto-swaps between dark CARTO and light Voyager tiles (no flash)
- Deck.GL overlay layers adjust opacity/saturation per theme for
readability
- D3 CountryTimeline chart colors converted to theme-aware
`getCSSColor()`

### Polish & Accessibility (Phase 4)
- Smooth 200ms CSS transitions for all theme switching
- WCAG AA contrast compliance (`--text-muted` #767676, 4.54:1 ratio)
- Both build variants (full geopolitical + tech/startup) verified
working

## Demo

### Toggle dark ↔ light


https://github.com/user-attachments/assets/ce8d3bbe-fb5e-49cb-9bbd-5da5d900a15a

### Dark mode (unchanged)

<img width="5120" height="2648" alt="image"
src="https://github.com/user-attachments/assets/6a055ae6-e9a9-4d85-b328-a035b7bcd165"
/>

### Light mode

<img width="5120" height="2650" alt="image"
src="https://github.com/user-attachments/assets/3531c952-a801-43a5-8ef3-68c160fb85b8"
/>


## Review focus areas

1. **`src/styles/main.css`** — Bulk of the CSS variable conversion (~12K
lines). Check that `var()` references and `[data-theme="light"]`
overrides look correct.
2. **`src/utils/theme-manager.ts`** — New module. Simple but critical:
localStorage read/write, event dispatch, FOUC prevention.
3. **`src/utils/theme-colors.ts`** — `getCSSColor()` cache utility.
Verify cache invalidation on theme change is correct.
4. **`src/components/DeckGLMap.ts`** — Basemap tile swap logic and
`getOverlayColors()` per-theme adjustments.
5. **`index.html` / `settings.html`** — Inline FOUC prevention scripts.
CSP updated with `unsafe-inline` for script-src.
6. **`src/App.ts`** — Theme toggle UI wiring in settings modal. Check
event listener patterns.

## What NOT to worry about

- ~99 remaining hardcoded colors are documented acceptable exclusions
(canvas drawing, console.log, deprecated constants with migration paths,
category identifier colors)
- Dark mode is visually identical to before — no regressions

## Test plan

- [x] Run `npm run build` — builds without errors for both variants
- [x] Open app — dark mode loads by default, no FOUC
- [x] Go to Settings → Appearance → select Light — all panels, header,
sidebar switch smoothly
- [x] Verify map switches from dark CARTO to light Voyager tiles (no
blank flash)
- [x] Refresh page — light mode persists, no FOUC
- [x] Switch back to dark — everything returns to original look
- [x] Check semantic colors (threat dots, DEFCON badges, LIVE
indicators) identical in both themes
- [x] Open DevTools → inspect text contrast in light mode (should be
≥4.5:1)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-02-16 22:56:03 +04:00
Elie Habib d94dc32d3b fix: use darken-* variables for dark overlay backgrounds
overlay-heavy is rgba(255,255,255,0.2) in dark mode (lightening tint),
but 11 places originally used rgba(0,0,0,x) (darkening tint). This
caused visible regressions in dark mode (channel bar, CII cards, etc).

Adds --darken-light/medium/heavy CSS variables that stay black-tinted
in both themes, and applies them to the affected selectors.
2026-02-16 22:49:54 +04:00
Sebastien MelkiandClaude Opus 4.6 346b974ef6 fix: darken neon semantic colors for light mode readability
Add light-mode overrides for 12 semantic CSS variables that had
terrible contrast on light backgrounds (as low as 1.25:1 for #44ff88).
Uses Tailwind's light-friendly palette (green-600/700, amber-600,
orange-600/700, yellow-600, sky-600).

Also darken DeckGL overlay marker colors (startup hub, accelerator,
nuclear, datacenter) and their legend SVGs in light mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 18:03:04 +02:00
Sebastien MelkiandClaude Opus 4.6 297e9cf218 chore: remove .planning files from git tracking
These are local development/planning files that should be gitignored.
Already covered by .gitignore entries for .planning/ and .idea/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:17:30 +02:00
Sebastien MelkiandClaude Opus 4.6 db5eff4300 feat(04-01): add no-transition class lifecycle for FOUC prevention
- index.html and settings.html FOUC scripts add no-transition class to <html>
- src/main.ts removes no-transition after first paint via requestAnimationFrame
- src/settings-main.ts removes no-transition after first paint via requestAnimationFrame
- Prevents transition sweep from dark-to-light on page load while enabling smooth theme toggles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:17:30 +02:00
Sebastien MelkiandClaude Opus 4.6 78e0478228 feat(04-01): add theme transition CSS rules and fix text-muted contrast
- Add wildcard 200ms ease transition on background-color, color, border-color, box-shadow
- Add .no-transition suppression rule with !important for FOUC prevention
- Add canvas/maplibregl/deck-canvas transition exclusion to prevent rendering artifacts
- Fix --text-muted in light theme from #8a8a8a to #767676 (WCAG AA 4.54:1 vs #f8f9fa)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:17:30 +02:00
Sebastien Melki d450ef0f4c docs(03-01): complete DeckGL basemap and overlay theming plan
- Create 03-01-SUMMARY.md with execution results
- Update STATE.md with Phase 3 completion, metrics, and decisions
2026-02-16 17:17:30 +02:00
Sebastien Melki fa9b361754 feat(03-01): make Deck.GL overlay layer colors theme-aware
- Replace static COLORS constant with getOverlayColors() function
- Refresh COLORS at top of buildLayers() on each render cycle
- Conflict zone fills more transparent in light mode (alpha 60 vs 100)
- Conflict zone line color reduced alpha in light mode (120 vs 180)
- Displacement arc colors deeper/more saturated on light backgrounds
- Threat dots remain identical in both modes (user locked decision)
- Infrastructure markers unchanged (semantic category colors)
2026-02-16 17:17:30 +02:00
Sebastien MelkiandClaude Opus 4.6 4e623cd5cb feat(03-02): update Map light theme CSS and add theme-change re-render
- Update --map-country to warm cream (#f0e8d8) for Voyager-style land
- Update --map-stroke to warm border (#c8b8a8) complementing cream land
- Subtler blue grid (#b0c8d8) for light mode
- Add theme-changed event listener in Map.ts resetting baseRendered flag
- Dark theme map values unchanged

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:17:30 +02:00
Sebastien MelkiandClaude Opus 4.6 72851b9936 feat(03-02): convert CountryTimeline hardcoded colors to theme-aware getCSSColor()
- Replace 8+ hardcoded rgba/hex colors with getCSSColor() CSS variable reads
- Convert tooltip, grid, axes, now-marker, empty labels, event circles
- Add theme-changed event listener for automatic re-render on theme toggle
- Keep semantic LANE_COLORS (protest/conflict/natural/military) unchanged

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:17:30 +02:00
Sebastien MelkiandClaude Opus 4.6 120bfa5c72 feat(02-02): add CSS styles for theme toggle section in settings modal
- Add .theme-toggle-section with border-bottom separator
- Add .modal .section-label for APPEARANCE/PANELS headings (scoped to modal)
- Add .theme-toggle-group flex layout with gap
- Add .theme-option radio button labels with hover/active states
- Hide radio inputs, use :has(input:checked) for active state
- Override .section-label padding inside .theme-toggle-section to avoid double-padding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:17:30 +02:00
Sebastien MelkiandClaude Opus 4.6 d77ef6c4cb feat(02-02): add theme toggle UI and event wiring in App.ts
- Import setTheme/getCurrentTheme from ThemeManager
- Add APPEARANCE section with Dark/Light radio buttons to settings modal
- Wire theme toggle change listener calling setTheme()
- Add theme-changed listener to re-render map on theme switch
- Rename modal title from 'Panel Settings' to 'Settings'

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:17:30 +02:00
Sebastien MelkiandClaude Opus 4.6 eb8a4dcc87 docs(02-01): complete ThemeManager module plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:17:30 +02:00
Sebastien MelkiandClaude Opus 4.6 14f7d856cd feat(02-01): add FOUC prevention scripts and wire entry points
- Add inline FOUC prevention script to index.html and settings.html <head>
- Update CSP script-src to allow 'unsafe-inline' for FOUC prevention
- Import and call applyStoredTheme() in src/main.ts before App init
- Import and call applyStoredTheme() in src/settings-main.ts before loadDesktopSecrets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:17:30 +02:00
Sebastien MelkiandClaude Opus 4.6 3e3576ee7f feat(02-01): create ThemeManager module and barrel exports
- Add src/utils/theme-manager.ts with getStoredTheme, getCurrentTheme, setTheme, applyStoredTheme
- Export Theme type and all 4 functions from src/utils/index.ts barrel
- setTheme invalidates color cache, updates meta theme-color, dispatches theme-changed event
- applyStoredTheme is a lightweight pre-mount theme applicator (no events/cache invalidation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:17:30 +02:00
Sebastien MelkiandGitHub fdd23888bd Merge branch 'main' into light_mode 2026-02-16 17:10:49 +02:00
Lib-LOCALE 87f28d5912 fix(i18n): Propagate common keys to all 9 locale files & fix CountryBriefPage
- CountryBriefPage: 2 aria-label='Close' + 3 export labels (Image/JSON/CSV)
- Added exportImage key to en.json and fr.json
- Expanded common section with ~48 native-translated keys in:
  de.json (German), es.json (Spanish), it.json (Italian), pl.json (Polish),
  nl.json (Dutch), pt.json (Portuguese), sv.json (Swedish)
- Also translated base nl/pt/sv common keys that were still in English
2026-02-16 14:27:36 +01:00
Lib-LOCALE 45ee7593ca fix(i18n): Translate remaining title attributes and panel-empty strings
- NewsPanel: Close button title attribute
- CountryIntelModal: 4 component tooltip titles (Unrest/Conflict/Security/Information)
- CIIPanel: Share story button + 4 component tooltip titles
- Added common.unrest/conflict/security/information/shareStory keys to en.json/fr.json
2026-02-16 14:11:00 +01:00
Lib-LOCALE 9224cf2f36 fix(i18n): Final deep scan - translate panel-empty, export, and disabled sources strings
- App.ts: 2 showError calls (All sources/Intel sources disabled)
- PopulationExposurePanel: 'No exposure data available' panel-empty
- SatelliteFiresPanel: 'No fire data available' panel-empty
- UcdpEventsPanel: 'No events in this category' panel-empty
- ClimateAnomalyPanel: 'No significant anomalies detected' panel-empty
- export.ts: Export Data title + Export CSV/JSON button labels
- Added 8 new common.* keys to en.json and fr.json
- Added t() import to export.ts
2026-02-16 13:47:24 +01:00
Lib-LOCALE f93aa53d16 fix(i18n): Replace all showLoading/showError hardcoded strings across 19 panels
- Panel.ts base class: showLoading/showError defaults now use t() keys
- Added 27 new common.* keys to en.json and fr.json
- Updated 19 panel files with t() calls for loading/error messages
- Added t() import to 12 panels: SatelliteFires, PopulationExposure,
  Displacement, ClimateAnomaly, StrategicRisk, Prediction, Cascade,
  GdeltIntel, NewsPanel, GeoHubs, Panel, PredictionPanel
- All showLoading custom messages now translated (UCDP events, thermal
  data, ETF data, stablecoins, climate data, etc.)
- All showError messages now translated (failed to load, no data, etc.)
2026-02-16 13:41:44 +01:00
Lib-LOCALE 2ddaa1899a fix(i18n): Replace all remaining hardcoded strings in PizzInt, Playback, Monitor, DeckGLMap
- PizzIntIndicator: panel title, tensions title, source label, 6 status labels, 3 time-ago strings
- PlaybackControl: Historical Playback header, LIVE button
- MonitorPanel: Add Monitor button
- DeckGLMap: 25 layer labels, 8 view selector options, Layers header, All button
- Added ~50 new keys to en.json (English) and fr.json (French)
2026-02-16 13:32:51 +01:00
Lib-LOCALE 95e51941dc fix(i18n): Replace remaining hardcoded strings with t() calls in 12 components
- StrategicPosturePanel: 25 strings (military units, tooltips, timer)
- App: 4 strings (GitHub link, pin map, filter, source counter)
- StatusPanel: 5 strings (system status, timestamps, storage)
- PizzIntIndicator: 3 strings + t() import (DEFCON, title, updated)
- PlaybackControl: 2 strings + t() import (toggle mode, LIVE)
- CountryBriefPage: 4 strings (share, print, export, source ref)
- DeckGLMap: 4 strings + t() import (zoom controls, layer guide)
- MonitorPanel: 2 strings (placeholder, no matches)
- TechEventsPanel: 4 strings (loading, empty, show on map, more info)
- TechReadinessPanel: 3 strings (internet, mobile, R&D tooltips)
- Removed deb from tauri.conf.json build targets
- Removed duplicate monitor key from en.json
2026-02-16 13:27:43 +01:00
Lib-LOCALE 00925011dc feat(i18n): Implement full internationalization support for 9 languages and add Linux AppImage config 2026-02-16 12:32:55 +01:00
Rau1CSandClaude Opus 4.6 7a734db5b8 fix: add finance types to PopupData union and auto-create variant panels
- Add StockExchangePopupData, FinancialCenterPopupData, CentralBankPopupData,
  CommodityHubPopupData to PopupData.data union, removing unsafe double casts
  (as unknown as) in MapPopup.ts
- Dynamically create NewsPanel instances for any FEEDS category that doesn't
  already have a hardcoded panel, so finance-specific categories (forex, bonds,
  centralbanks, derivatives, fintech, regulation, institutional, analysis, etc.)
  get their own dedicated panels instead of being silently dropped

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 11:56:44 +01:00
Rau1CSandClaude Opus 4.6 67788bf886 fix: dynamically enumerate FEEDS categories in loadNews()
Replace hardcoded feed category list with Object.entries(FEEDS) so that
variant-specific feed categories (e.g. finance variant's markets, forex,
bonds, commodities, crypto, etc.) are automatically discovered and loaded
instead of being silently skipped.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 11:40:29 +01:00
ClaudeandRau1CS 01fb23df5c feat: add finance/trading variant with market-focused dashboard
Add a new 'finance' site variant (finance.worldmonitor.app) following the
same pattern as the existing tech variant. Includes:

- Finance-specific RSS feeds: markets, forex, bonds, commodities, crypto,
  central banks, economic data, IPOs/M&A, derivatives, fintech, regulation,
  institutional investors, and market analysis (all free/open RSS sources)
- Finance-focused panels with trading-themed labels (Market Headlines,
  Live Markets, Forex & Currencies, Fixed Income, etc.)
- Geographic data for stock exchanges (30+), financial centers (20+),
  central banks (14), and commodity hubs (10) worldwide
- Four new map layers: stockExchanges, financialCenters, centralBanks,
  commodityHubs with tier-based icons and zoom-dependent labels
- Map popup rendering for all finance marker types
- Variant switcher updated with FINANCE tab in header
- Search modal with finance-specific sources and icons
- Vite HTML variant plugin metadata for SEO
- Build scripts (dev:finance, build:finance, test:e2e:finance)
- Tauri desktop config for Finance Monitor app

https://claude.ai/code/session_01CCmkws2EYuUHjYDonzXEtY
2026-02-16 11:22:17 +01:00
Sebastien MelkiandClaude Opus 4.6 b44790bf9f docs(01-04): complete dynamic inline style color conversion plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 11:20:51 +02:00
Sebastien MelkiandClaude Opus 4.6 53196a0bc8 feat(01-04): convert service and config color functions to getCSSColor()
- Add getThreatColor() function with CSS variable reads (threat-classifier)
- Deprecate THREAT_COLORS constant (kept for backward compat)
- Convert getTrendColor to --semantic-* vars (oil-analytics)
- Convert getSeverityColor to --semantic-* vars (weather)
- Convert getSeverityColor for climate anomalies (climate)
- Convert getStatusColor to --semantic-* and --text-* vars (data-freshness)
- Convert getDisplacementBadge to --semantic-* vars (unhcr)
- Add getPipelineStatusColor function using CSS vars (pipelines)
- Document MONITOR_COLORS and PIPELINE_COLORS as fixed category colors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 11:15:52 +02:00
Sebastien MelkiandClaude Opus 4.6 1cb65ce4b3 feat(01-04): convert component inline style colors to getCSSColor()
- Replace hardcoded hex in getScoreColor, getTrendColor, getPriorityColor (StrategicRiskPanel)
- Convert levelColor, componentBars, updateNews threat colors (CountryBriefPage)
- Convert levelBadge, scoreBar colors (CountryIntelModal)
- Convert impactColors, typeColors, stanceColors objects (RegulationPanel)
- Replace THREAT_COLORS usage with getCSSColor threat var lookups (NewsPanel)
- Convert getLevelColor to semantic CSS vars (CIIPanel)
- Convert priorityColors in showAlert (SignalModal)
- Convert escalationColors, trendColors objects (MapPopup)
- Convert getImpactColor to semantic CSS vars (CascadePanel)
- Convert map background/grid/country/stroke to --map-* vars (Map)
- Convert fire brightness and AIS density colors (Map)
- Convert tooltip inline style colors (GeoHubsPanel, TechHubsPanel)
- Convert monitor color fallback (MonitorPanel)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 11:12:35 +02:00
Elie HabibandGitHub d734f30fbc fix: remove overly broad CORS origin regex (#82) 2026-02-16 13:02:29 +04:00
Sebastien MelkiandClaude Opus 4.6 827eca5cb8 docs(01-02): complete main.css color conversion plan
- Add 01-02-SUMMARY.md with conversion metrics and deviation documentation
- Update STATE.md with decisions and resolved blocker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:59:11 +02:00
Elias El KhouryandClaude Opus 4.6 32fd309e5d fix: remove overly broad CORS origin regex
The pattern /worldmonitor.*\.vercel\.app/ matched any Vercel deployment
with a "worldmonitor" prefix, allowing unauthorized origins to bypass
CORS and consume server-side API keys. The owner-scoped pattern on the
previous line already covers all legitimate preview deployments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:58:53 +02:00
Sebastien MelkiandClaude Opus 4.6 faf0634c00 feat(01-02): convert hardcoded colors in main.css lines 6001-12276 and fix opacity mappings
- Fix 24 incorrect var(--shadow-color) background uses: map high-opacity (0.6-0.88) to var(--bg), low-opacity (0.15-0.35) to var(--overlay-heavy)
- Fix 50 white rgba text colors incorrectly mapped to var(--overlay-heavy): map to proper text hierarchy (--text-muted, --text-faint, --text-ghost, --text-dim)
- Fix 2 translucent white text mapped to var(--accent): remap to var(--text-secondary)
- Convert remaining white/black rgba values in lines 6001+
- All 223 remaining rgba are semantic color tints (intentionally excluded)
- All 42 remaining hex are country/flag/decorative colors (intentionally excluded)
- Build passes with valid CSS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:57:03 +02:00
Sebastien MelkiandClaude Opus 4.6 f14e7ba55e docs(01-03): complete settings window & embedded style block color conversion plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:54:04 +02:00
Sebastien MelkiandClaude Opus 4.6 6f2ee37f9c feat(01-02): convert hardcoded colors in main.css lines 1-6000 to CSS variables
- Replace ~350 hardcoded hex colors with var() references (backgrounds, borders, text, accent)
- Replace ~170 hardcoded rgba() white/black overlays with var(--overlay-*) and var(--shadow-color)
- Convert dark background rgba values to var(--bg)
- Preserve semantic-colored rgba tints (red, orange, green, blue indicators)
- Preserve country/flag colors and 8-char hex alpha decorative values
- Build passes successfully with all CSS valid

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:53:03 +02:00
Sebastien MelkiandClaude Opus 4.6 4bdeb48215 feat(01-03): convert embedded <style> blocks in 8 TS components to CSS variables
- ClimateAnomalyPanel: table headers, borders, severity badges -> var(--text-muted), var(--border), var(--semantic-*)
- DisplacementPanel: stat boxes, tabs, crisis badges -> var(--threat-*), var(--overlay-*), var(--border)
- DownloadBanner: remove fallback values, button colors -> var(--green), color-mix() for tints
- PopulationExposurePanel: summary bar, card colors -> var(--threat-critical), var(--accent), var(--text-*)
- SatelliteFiresPanel: table styling -> var(--text-muted), var(--border), var(--threat-*)
- UcdpEventsPanel: tabs, death counts, actor text -> var(--semantic-*), var(--text-dim), var(--border)
- PizzIntIndicator: panel, location statuses -> var(--defcon-*), var(--overlay-*), var(--text-*)
- VerificationChecklist: checklist items, notes -> var(--surface-hover), var(--border), var(--text-*)
- DEFCON_COLORS object and verdictColors left as JS runtime values (Plan 04 scope)
- TypeScript compilation passes with zero errors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:52:13 +02:00
Sebastien MelkiandClaude Opus 4.6 527c472c05 feat(01-03): convert settings-window.css hardcoded colors to CSS variables
- Replace 12 hardcoded hex/rgba definitions in .settings-shell with var(--*) references to global theme variables
- Convert ~13 remaining hardcoded rgba() calls to var(--overlay-*), var(--text-*), and color-mix() references
- Convert hardcoded #fff and #0b6fdb in button styles to var(--accent) and color-mix()
- Result: 0 hex values, 0 rgba values, 88 var(--) references

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:48:40 +02:00
Sebastien MelkiandClaude Opus 4.6 a7bbf86eca feat(01-01): create getCSSColor() utility with theme-aware cache
- Add src/utils/theme-colors.ts with getCSSColor() and invalidateColorCache()
- getCSSColor reads CSS custom properties via getComputedStyle with caching
- Cache auto-invalidates when data-theme attribute changes
- Export both functions from src/utils/index.ts barrel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:43:55 +02:00
Sebastien MelkiandClaude Opus 4.6 8b6a1778c4 feat(01-01): expand :root CSS custom properties and add light theme overrides
- Expand :root from 14 to 48 CSS custom properties in two namespaces
- Add theme colors (backgrounds, borders, text, overlays, scrollbar, input, map)
- Add semantic colors (severity, threat, DEFCON, status indicators)
- Define [data-theme="light"] selector overriding only theme variables
- Preserve all original dark-mode values for zero visual regression

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:40:47 +02:00
Elie HabibandGitHub 04252fa169 docs: fix stale documentation across project (#80) 2026-02-16 11:52:48 +04:00
Elie HabibandGitHub 89d50821a9 Add GitHub issue and PR templates (#79) 2026-02-16 11:52:34 +04:00
Sebastien MelkiandClaude Opus 4.6 8f8d07c3a1 docs: fix stale documentation across project
- CHANGELOG: add missing v2.3.5 and v2.3.6 entries
- README: update API edge function count (45+ → 60+), port count
  (84 → 83), desktop secret key count (15 → 17)
- DOCUMENTATION: fix version badge (2.1.4 → 2.3.6), CII monitored
  countries (20 → 22, add Brazil/UAE), add CNBC to live streams,
  fix vessel database count (50+ → 25+), fix port count (61 → 83),
  update news source count (80+ → 100+)
- DESKTOP_CONFIGURATION: update secret keys list from 13 to 17,
  add FINNHUB, URLHAUS, OTX, ABUSEIPDB, NASA_FIRMS keys
- Remove obsolete ROADMAP.md (all 5 proposed features are already
  implemented: geographic convergence, CII, temporal anomaly
  detection, trade route risk, infrastructure cascade)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:30:49 +02:00
Sebastien MelkiandClaude Opus 4.6 62634da3e8 Add GitHub issue and PR templates
- Bug report: variant selector, affected area dropdown, repro steps
- Feature request: area dropdown, problem/alternatives fields
- New data source: feed type, variant target, URL, justification
- PR template: change type, affected areas, RSS allowlist reminder
- Config: disable blank issues, link to docs and discussions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:15:55 +02:00
Elie Habib 700132adad fix: hide node.exe console window on Windows & bump v2.3.6
Add CREATE_NO_WINDOW (0x08000000) creation flag to the sidecar
Command::new() spawn on Windows. Without this, node.exe inherits
a visible console window that overlays the Tauri GUI.
2026-02-16 09:00:16 +04:00
Elie Habib 46010c3911 feat: differentiated panel error messages & auto-hide desktop config (v2.3.5)
- Add Panel.showConfigError() with amber styling and desktop Settings link
- Propagate `skipped` flag from Finnhub and FIRMS API responses
- Show "API key not configured" on Markets/Heatmap/Commodities/FIRMS panels
  when sidecar returns skipped (missing API key)
- ETF, Stablecoin, MacroSignals panels detect upstream API unavailability
  and show retry message instead of generic "Failed to fetch"
- RuntimeConfigPanel auto-hides when all features are configured
- Bump version to 2.3.5
2026-02-16 08:51:47 +04:00
Elie Habib 939ac3a864 fix: sync package-lock.json with markdownlint-cli2 devDependency
The lock file was out of sync causing npm ci to fail in CI for v2.3.3
and v2.3.4 builds.
2026-02-16 00:50:43 +04:00
Elie Habib 7d3b600364 fix: strip UNC path prefix for Windows sidecar, set explicit CWD & bump v2.3.4
Tauri resource_dir() on Windows returns \\?\ extended-length paths that
Node.js module resolution cannot handle, causing EISDIR: lstat 'C:'.
Strip the prefix before passing to Node.js, set current_dir to the
sidecar directory, and add package.json with "type": "module" to prevent
ESM scope walk-up to drive root.
2026-02-16 00:47:02 +04:00
Elie Habib f3581a5f9b fix: enable macOS Keychain backend for keyring crate & bump v2.3.3
keyring v3 ships with NO default platform backends — API keys were
stored in-memory only, lost on every app restart. Add apple-native
and windows-native features to use real OS credential stores.
2026-02-16 00:31:46 +04:00
Elie Habib d3fb116e16 fix: harden settings key persistence with soft-pass verification & resilient keychain reads 2026-02-16 00:31:46 +04:00
Elie HabibandGitHub 0a9226cc82 chore: lint markdown (#72)
MD012 / no-multiple-blanks Multiple consecutive blank lines
MD022 / blanks-around-headings Headings should be surrounded by blank
lines
MD032 / blanks-around-lists Lists should be surrounded by blank lines
2026-02-16 00:11:51 +04:00
Elie HabibandGitHub 751d8589d3 feat: add 6 verified think tank RSS feeds (#71)
FPRI trailing slash fix applied. RUSI was already in SOURCE_TIERS/SOURCE_TYPES from base branch.
2026-02-16 00:09:39 +04:00
Elie Habib 067eabaaff fix: add trailing slash to FPRI feed URL to avoid Cloudflare 403 2026-02-16 00:09:20 +04:00
Elie Habib cf62e169e9 chore: add PR #71 think tank domains to RSS proxy allowlist
warontherocks.com, www.aei.org, responsiblestatecraft.org,
www.fpri.org, jamestown.org
2026-02-15 23:42:06 +04:00
sethandGitHub eaff3e817d chore: lint markdown
MD012 / no-multiple-blanks Multiple consecutive blank lines
MD022 / blanks-around-headings Headings should be surrounded by blank lines
MD032 / blanks-around-lists Lists should be surrounded by blank lines
2026-02-15 11:38:22 -08:00
Elie Habib f3fddcb0e8 fix: settings UX — save verified keys, preserve inputs across renders, bump v2.3.2
- Save keys that pass verification even when others fail (was all-or-nothing)
- Capture un-blurred input values before render to prevent loss on checkbox toggle
- Fix missing isDisallowedOrigin import in PIZZINT endpoints
2026-02-15 23:33:19 +04:00
Elie Habib 0cd88a10a3 Integrate ML NER enrichment into trending keywords 2026-02-15 23:24:52 +04:00
InlitX 8fe7d57b92 feat: add 6 verified think tank RSS feeds 2026-02-15 20:18:26 +01:00
Elie Habib ab6dfccdeb fix: add missing tauri script to restore CI builds
@tauri-apps/cli in devDependencies causes tauri-action to run
`npm run tauri` instead of auto-installing globally.
2026-02-15 23:03:24 +04:00
Elie Habib a9b3582ae3 fix: harden sidecar verification, dedupe spikes, and bump v2.3.1 2026-02-15 22:57:09 +04:00
Elie Habib fb51b5bf40 fix: desktop settings UX overhaul & IPv4-safe fetch for sidecar
- Show "Staged" status/pill for buffered secrets instead of "Missing"
- Add macOS Edit menu (Cmd+C/V/X/Z) for WKWebView clipboard support
- Raise settings window when main gains focus (prevent hide-behind)
- Fix Cloudflare verification to probe Radar API (not token/verify)
- Fix EIA verification URL to valid v2 endpoint
- Force IPv4 globally: monkey-patch fetch() to avoid IPv6 ETIMEDOUT
  on government APIs (EIA, NASA FIRMS) with broken AAAA records
- Soft-pass on network errors during secret verification (don't block save)
- Add desktopRequiredSecrets to skip relay URLs on desktop
- Cross-window sync for secrets and feature toggles via localStorage events
- Add @tauri-apps/cli devDependency
2026-02-15 22:35:21 +04:00
Elie Habib b6881d96c0 fix: NER-gate trending spike alerts to suppress common-word noise
Common English verbs like "say" pass frequency thresholds and generate
false intelligence findings. Now runs browser NER (BERT) on spike
headlines before alerting — terms not recognized as named entities
(PER/ORG/LOC/MISC) are suppressed. Falls back gracefully when NER
is unavailable. Also adds ~55 base verb forms to the suppressed list
as hardened fallback for low-spec devices.
2026-02-15 22:28:24 +04:00
Elie Habib ea76446813 fix: make sidecar secret sync best-effort 2026-02-15 21:44:45 +04:00
Elie Habib f64af4c571 fix: harden CORS patterns & URL validation
- Allow hyphens in Vercel preview URL patterns (worldmonitor-xxx-yyy)
- Harden open_url command with proper URL parsing via reqwest::Url
- Update YouTube embed test assertions for quote style change
2026-02-15 21:34:00 +04:00
Elie Habib 0738e38baa settings: verify API keys via provider probes 2026-02-15 21:31:54 +04:00
Elie Habib 723279eedc chore: bump v2.3.0 — security hardening release with changelog
Major security hardening: CORS enforcement on all API endpoints,
sidecar auth bypass fix, postMessage origin validation, CSP
tightening, and service worker stale cache fix.
2026-02-15 20:38:54 +04:00
Elie Habib 7e7ab126c8 fix: force immediate SW activation to prevent stale asset errors
Add skipWaiting, clientsClaim, and cleanupOutdatedCaches to workbox
config. Fixes NS_ERROR_CORRUPTED_CONTENT / MIME type errors when
users have a cached service worker serving old HTML that references
asset hashes no longer on the CDN after redeployment.
2026-02-15 20:37:23 +04:00
Elie Habib a9224254a5 fix: security hardening — CORS, auth bypass, origin validation & bump v2.2.7
- Tighten CORS regex to block worldmonitorEVIL.vercel.app spoofing
- Move sidecar /api/local-env-update behind token auth + add key allowlist
- Add postMessage origin/source validation in LiveNewsPanel
- Replace postMessage wildcard '*' targetOrigin with specific origin
- Add isDisallowedOrigin() check to 25 API endpoints missing it
- Migrate gdelt-geo & EIA from custom CORS to shared _cors.js
- Add CORS to firms-fires, stock-index, youtube/live endpoints
- Tighten youtube/embed.js ALLOWED_ORIGINS regex
- Remove 'unsafe-inline' from CSP script-src
- Add iframe sandbox attribute to YouTube embed
- Validate meta-tags URL query params with regex allowlist
2026-02-15 20:33:20 +04:00
Elie Habib a31f81a0fe fix: filter trending noise, fix sidecar auth & restore tech panels — v2.2.6
- Expand SUPPRESSED_TRENDING_TERMS from 13 to ~170 entries to filter
  common English words (department, state, news, etc.) from intelligence
  findings
- Move sidecar admin endpoints (debug-toggle, traffic-log, env-update,
  local-status) before LOCAL_API_TOKEN auth gate — settings window sends
  bare fetch without token, causing silent 401 failures
- Restore Market Radar and Economic Indicators panels to tech variant
- Remove stale Documentation section from README
- Clean up .env.example cyber threat keys (handled internally)
- Bump v2.2.6
2026-02-15 20:00:17 +04:00
Elie Habib cd84eb1bb2 fix: remove Market Radar and Economic Data panels from tech variant 2026-02-15 19:33:07 +04:00
Elie Habib f16c34b6a4 docs: add developer X/Twitter link to Support section 2026-02-15 19:29:02 +04:00
Elie Habib d93eeaf551 docs: add cyber threat API keys to .env.example 2026-02-15 19:26:55 +04:00
Elie Habib 071836d390 chore: move test harnesses from root to tests/ 2026-02-15 19:22:40 +04:00
Elie Habib ac935d505e fix: migrate all Vercel edge functions to CORS allowlist & bump v2.2.5
Replace Access-Control-Allow-Origin: * with shared getCorsHeaders()
across 20 API edge functions to restrict access to worldmonitor.app,
tech.worldmonitor.app, and authorized Vercel preview URLs.

Version bump to 2.2.5 across package.json, tauri.conf.json, Cargo.toml.
2026-02-15 19:13:54 +04:00
Elie Habib 9f378d533b fix: restrict Railway relay CORS to allowed origins only
Replace Access-Control-Allow-Origin: * with an allowlist of our domains
(worldmonitor.app, tech.worldmonitor.app, localhost dev ports, Tauri,
and *.vercel.app previews). Prevents third parties from using the relay
as a free proxy.
2026-02-15 19:01:06 +04:00
Elie Habib 9c2104d936 fix: hide desktop config panel on web, route World Bank & Polymarket via Railway
Desktop Configuration panel was leaking to web due to being in
FULL_PANELS/TECH_PANELS configs. Removed from static configs (injected
programmatically for Tauri) and filtered from toggle UI on web.

World Bank API returns 403 to Vercel edge IPs. Added /worldbank proxy
route to Railway relay with full response transformation and 30-min cache.
Client tries Railway first, falls back to Vercel.

Polymarket gamma-api suffers Cloudflare JA3 blocking from Vercel.
Added /polymarket proxy route to Railway relay with 2-min cache.
Client fallback chain: browser-direct → Tauri → Railway → Vercel → production.
2026-02-15 18:59:01 +04:00
Elie HabibandGitHub 67d3673926 feat: cyber threat intelligence layer, trending keyword spikes, panel redesigns & docs (#68)
## Summary
- **New map layer** plotting live botnet C2 servers, malware hosts, and
malicious IPs from **5 threat intel sources**
- **Vercel edge API** (`/api/cyber-threats`) with triple-layer caching
(Redis → memory → stale fallback), rate limiting, and graceful
degradation
- **IP geolocation** via ipinfo.io + freeipapi.com fallback —
HTTPS-compatible with Edge runtime, geo results cached in Redis (24h
TTL)
- **Feature-gated** behind `VITE_ENABLE_CYBER_LAYER=true` — entire layer
hidden when disabled
- **Severity color coding**: critical (red) → high (orange) → medium
(amber) → low (yellow)
- **Tauri desktop**: 3 new keychain secrets (URLHAUS_AUTH_KEY,
OTX_API_KEY, ABUSEIPDB_API_KEY) with runtime config UI
- **Geo enrichment timeout**: 12s overall cap prevents slow GeoIP
providers from blocking the request
- **Download banner**: idempotency guard prevents duplicate panels on
repeated calls

## Data Sources
| Source | Auth | What |
|--------|------|------|
| **Feodo Tracker** | None (free) | Botnet C2 server IPs with country |
| **C2IntelFeeds** | None (free) | ~500 C2 IPs from GitHub CSV
(CobaltStrike, Metasploit, Sliver, etc.) |
| **URLhaus** | `URLHAUS_AUTH_KEY` | Malicious URL distribution hosts |
| **AlienVault OTX** | `OTX_API_KEY` | Community threat indicators (APT,
ransomware, botnets) |
| **AbuseIPDB** | `ABUSEIPDB_API_KEY` | Reported malicious IPs with
abuse confidence scores |

Sources with missing API keys gracefully skip (no error, `partial:
false`). Sources that are configured but fail set `partial: true`.

## Test plan
- [x] 7 unit tests pass (`node --test api/cyber-threats.test.mjs`)
- [x] All 5 sources aggregated and deduplicated in API response
- [x] Graceful degradation when API keys missing (free sources only)
- [x] `partial: true` when configured sources fail
- [x] Memory cache hit on repeated requests
- [x] Stale fallback when upstream fails after cache TTL
- [x] Geo enrichment capped at 12s overall timeout
- [x] Set `VITE_ENABLE_CYBER_LAYER=true` → toggle appears in layer
controls
- [x] Enable layer → purple/red dots render on map at threat locations
- [x] Hover → tooltip shows indicator, severity, malware family, source
- [x] Click → popup with full details (type, tags, coordinates,
timestamps)
- [x] TypeScript build passes (`tsc --noEmit`)
- [ ] Without env var → layer toggle hidden, no fetches made
- [ ] `curl /api/cyber-threats` → returns JSON with `success: true`,
`data[]` has lat/lon
- [ ] Second request within 10min → `X-Cache: MEMORY-HIT`
2026-02-15 18:41:37 +04:00
Elie Habib 141bf0f8b3 feat: redesign population exposure panel and reorder UCDP columns
PopulationExposure: replace truncated 4-column table with two-line
card layout (full event name + population/radius meta row).
UCDP: move deaths to column #2 (Country → Deaths → Date → Actors).
2026-02-15 18:39:35 +04:00
Elie Habib f91fb7c8ce docs: document cyber threats, trending keywords, oil analytics, population exposure, and entity index
Update README with 5 new How It Works sections and 10 inline updates
reflecting implemented features. Data layer count 29→30+, data sources
14→22, new Tech Stack and Roadmap entries.
2026-02-15 18:29:25 +04:00
Elie Habib 3a1088ee0e fix: harden trending spike processing and optimize hot paths 2026-02-15 18:21:27 +04:00
Elie HabibandGitHub 6e14e87ec1 fix: resolve z-index conflict between pinned map and panels grid (#67)
**Description:** I fixed a visual bug where the cards were overlapping
the map when using the "Pin" option. Now the cards slide correctly
underneath the map when scrolling.

**Before:** 
<img width="1914" height="947" alt="error"
src="https://github.com/user-attachments/assets/add5b838-b15c-46d9-adea-aff063563af4"
/>

**and After:**
<img width="1919" height="929" alt="Correction"
src="https://github.com/user-attachments/assets/421fb4af-1851-4f8e-a422-3d095ddb9c48"
/>
2026-02-15 18:10:40 +04:00
Elie Habib 0627df95ba feat: add trending keyword spike detection and e2e flow test 2026-02-15 18:03:18 +04:00
Elie Habib d33c19fb95 feat: redesign 4 panels with table layouts and scoped styles
Bring Climate Anomalies, Population Exposure, UCDP Conflict Events,
and UNHCR Displacement panels up to dashboard design standards
matching Fires/Markets/Stablecoins panels.

- Replace div-based layouts with proper <table> structures
- Add scoped <style> blocks (no inline style= attributes)
- CSS class-based severity badges, death colors, displacement badges
- tabular-nums on all numeric columns, right-aligned
- Row hover effects, text-overflow ellipsis for long text
- Stat grid for UNHCR global totals, total deaths summary for UCDP
- Custom tab styling for UCDP and Displacement panels
2026-02-15 17:55:33 +04:00
Elie Habib 0c632b1b57 fix: add Cyber Threats to System Health status panel allowlists
Both WORLD_FEEDS and TECH_FEEDS were missing 'Cyber Threats', causing
updateFeed() calls to be silently rejected by the allowlist check.
2026-02-15 17:49:46 +04:00
Elie Habib af6f321493 feat: dramatically increase cyber threat map density
Increase geo cap 100→250, concurrency 8→16, per-IP timeout 8→3s.
Add country centroid fallback with jitter for IPs with country codes.
2026-02-15 17:40:01 +04:00
Elie Habib 5abb30d420 fix: improve cyber threat tooltip/popup UX and dot visibility
- Tooltip: show "Cyber Threat" + severity + country instead of raw source names
- Popup header: "Cyber Threat" title instead of raw IP address
- Popup: show all 5 source labels and add malware family field
- Increase dot size for better visibility at global zoom level
2026-02-15 17:34:06 +04:00
Elie Habib 4d816c22f8 fix: cap geo enrichment at 12s overall timeout & prevent duplicate download banners
Addresses PR #68 review comments:
- P1: hydrateThreatCoordinates now races workers against a 12s overall
  timeout so slow GeoIP providers can't block the entire request
- P2: DownloadBanner adds module-level guard preventing duplicate panels
2026-02-15 17:25:38 +04:00
Elie Habib f2b650fc81 fix: replace ipwho.is/ipapi.co with ipinfo.io/freeipapi.com for geo enrichment
ipwho.is returns 403 from Node.js/Edge ("CORS not supported on Free plan")
and ipapi.co rate-limits aggressively (429). Both providers only work from
browser-side requests.

Switch to ipinfo.io (HTTPS, 50K/mo free, works from Edge runtime) as primary
with freeipapi.com as fallback.

Validated end-to-end: 20 C2 IPs geo-enriched in <1s with real coordinates.
2026-02-15 17:17:03 +04:00
Elie Habib 6e0dbbd15b feat: add C2IntelFeeds, OTX, and AbuseIPDB as cyber threat sources
Expands from 2 to 5 threat intel sources:
- C2IntelFeeds (free, no auth): ~500 active C2 server IPs (CobaltStrike, Metasploit)
- AlienVault OTX (optional API key): community-sourced IOCs with tags
- AbuseIPDB (optional API key): high-confidence abuse reports with geo
- Feodo: now includes recently-offline entries (still threat-relevant)

All sources fetched in parallel. Graceful degradation when API keys missing.
2026-02-15 17:06:14 +04:00
Elie Habib 62e81642b0 chore: bump version to 2.2.3 2026-02-15 16:52:48 +04:00
Elie Habib 5facae7105 feat: add cyber threat map layer with Feodo Tracker + URLhaus integration
Plot live botnet C2 servers, malware distribution nodes, and malicious IPs
on the globe using free abuse.ch APIs (Feodo Tracker + URLhaus).

- Vercel edge API with triple-layer caching (Redis → memory → stale fallback)
- IP geolocation via ipwho.is + ipapi.co (HTTPS-compatible with Edge runtime)
- Severity-based color coding (critical=red, high=orange, medium=amber, low=yellow)
- Feature-gated behind VITE_ENABLE_CYBER_LAYER=true env var
- Frontend circuit breaker, data sanitization, 10min auto-refresh
- Tauri desktop support: 3 new secret keys (URLHAUS, OTX, AbuseIPDB)
- Full test suite (6 unit tests), e2e harness updates, popup + tooltip rendering
2026-02-15 16:52:24 +04:00
Elie Habib ef389319a9 feat: add download desktop app slide-in banner for web visitors
Side panel slides in 12s after initial load with macOS/Windows download
links. Dismissed per session. Skipped on Tauri desktop runtime.
2026-02-15 16:25:06 +04:00
InlitX 114b5dfac6 fix: resolve z-index conflict between pinned map and panels grid 2026-02-15 13:03:16 +01:00
Elie Habib 3d16f3c4e1 perf: reduce idle CPU from pulse animation loop
- Add 60s startup cooldown to suppress pulse during initial data hydration
- Consolidate pulse lifecycle into syncPulseAnimation() across all setters
- Replace static predicates (h.level=high, c.maxSeverity=high) with dynamic-only
  checks: recent news, recent ACLED riots (<2h), breaking hotspots
- Exclude GDELT riots from pulse gating (noisy, not actionable)
- Reduce pulse interval from 250ms to 500ms (halves layer rebuild rate)
- Pause pulse when render is paused (country brief overlay)
- Add latestRiotEventTimeMs to MapProtestCluster with raw protest fallback
- Add Playwright tests for startup cooldown and lifecycle start/stop/GDELT exclusion
2026-02-15 15:17:07 +04:00
Elie Habib 96c6208bb0 fix: fall back to cloud API when local sidecar returns non-OK status
The runtime fetch patch previously returned local 4xx/5xx responses
as-is. Now falls back to worldmonitor.app cloud API on any non-OK
local response, matching the existing network-error fallback behavior.
2026-02-15 14:49:49 +04:00
Elie Habib 803c015002 Add country briefs to Cmd+K search 2026-02-15 14:44:03 +04:00
Elie Habib add310349b chore: bump version to 2.2.2 2026-02-15 14:10:35 +04:00
Elie Habib 6d100a6db6 Merge country-brief-page: full-page brief, geometry consolidation, news dedup
- Full-page Country Brief replacing modal overlay
- Tightened headline relevance with negative country filter
- Top News section replacing redundant Evidence/Sources
- Shared country-geometry service for local-first detection
- BR/AE added to tier-1, deep-link name resolution
- Prediction Markets moved to right column for balanced layout
2026-02-15 14:10:08 +04:00
Elie Habib 5c91c0ee71 fix: normalize country name from GeoJSON to canonical TIER1 name
GeoJSON uses "United States of America" but COUNTRY_TAG_MAP and
variant matching expect "United States". Normalize at entry point
so polymarket, search terms, and all downstream lookups work.
2026-02-15 12:21:40 +04:00
Elie Habib 30dfbd0a85 refactor: consolidate news into Top News, remove redundant Evidence section
- Remove Evidence/Sources section (duplicate of Top News)
- Top News now shows 8 items with citation anchor IDs
- AI brief [N] citations link to Top News cards with scroll+highlight
- Move Prediction Markets to right column for balanced layout
- Left: Risk + Brief + Top News | Right: Signals + Timeline + Markets + Infra
2026-02-15 12:14:55 +04:00
Elie Habib 4e8758cb54 Consolidate country detection around shared geometry service 2026-02-15 12:02:57 +04:00
Elie Habib 9bda79e10b fix: add BR/AE to tier-1, resolve deep-link names, fix military timeline filter
- Add Brazil and UAE to TIER1_COUNTRIES, BASELINE_RISK, EVENT_MULTIPLIER,
  COUNTRY_KEYWORDS, COUNTRY_BOUNDS, COUNTRY_ALIASES, and Polymarket maps
- Use Intl.DisplayNames to resolve non-tier ISO codes in deep-link URLs
- Guard raw 2-letter codes from becoming overly broad search terms
- Fix military timeline to use same either/or logic as getCountrySignals
2026-02-15 11:42:45 +04:00
Elie Habib e6fe946b34 fix: tighten headline relevance, add Top News section, compact markets
- Add negative country filter: headlines where another country is mentioned
  first in the title are excluded (fixes Venezuela brief showing for US)
- Filtered headlines used for both evidence section AND LLM context
- Add Top News section (5 items) between Intelligence Brief and Markets
- Limit prediction markets to max 3, tighter padding
2026-02-15 11:27:06 +04:00
Elie Habib e663741f4a fix: hide desktop config panel on web, fix irrelevant prediction markets
- RuntimeConfigPanel only created when isDesktopApp is true (was leaking
  full desktop config UI including secret key inputs to web visitors)
- Fix polymarket sub-market selection: when event title doesn't match
  country but a sub-market does, restrict candidates to matching
  sub-markets instead of picking highest-volume globally (was showing
  Xi Jinping market for France because Macron sub-market triggered match)
2026-02-15 11:16:25 +04:00
Elie Habib 458be05a59 feat: add full-page Country Brief Page replacing modal overlay
Replace the small CountryIntelModal with a comprehensive full-page
intelligence product per country. Implements all 5 phases of the plan:

Phase 1 - Full-page shell + bug fixes:
- CountryBriefPage.ts: score ring, component bars, signal chips,
  non-tier fallback, loading states
- Fix hardcoded earthquakes: 0 with bbox + place name filtering
- Export getCountryData, TIER1_COUNTRIES from country-instability
- Expand CountryBriefSignals with displacement, climate, conflict

Phase 2 - D3 multi-lane timeline:
- CountryTimeline.ts: 4 lanes, 7-day filter, severity-sized circles
- XSS-safe tooltip rendering via escapeHtml

Phase 3 - Infrastructure + Evidence:
- Export getNearbyInfrastructure from related-assets
- Local BriefAssetType union, port proximity lookup
- Evidence cards with threat levels and numbered references

Phase 4 - AI brief citations + Export:
- LLM prompt cites headlines as [N], bounded regex parsing
- Export dropdown: Image PNG, JSON, CSV, Print/PDF

Phase 5 - Deep-linking + Polish:
- country field in URL state, pre-capture before sync
- Force URL rewrite on open/close, mobile + print styles

Bug fixes: URL sync race, async response guards,
briefRequestToken cancellation, listener accumulation,
tier-1 detection via membership not data existence
2026-02-15 11:06:09 +04:00
Elie Habib b0e48f47f8 docs: add download badges and web app links to README
Shield.io badges for Windows, macOS ARM, and macOS Intel desktop downloads
pointing to the new /api/download redirect. Web app links restyled as badges.
2026-02-15 10:42:38 +04:00
Elie Habib d2568576a3 Add download redirect API for platform-specific installers
Edge function that redirects /api/download?platform=macos-arm64 to
the matching asset from the latest GitHub release. Falls back to
the releases page when no match is found.
2026-02-15 10:28:52 +04:00
Elie Habib 77fc5fe4bd fix(macos): hide window on close instead of quitting
Standard macOS behavior — app stays in dock, reopens on dock click.
2026-02-15 10:26:43 +04:00
Elie Habib 3512937658 fix: tone down climate anomalies heatmap to stop obscuring other layers
Halve radius, reduce intensity/opacity so markers and labels remain visible.
2026-02-15 10:22:10 +04:00
Elie Habib 7494e0780d Add auto-generated changelog to GitHub releases
New post-build job generates release notes from git commits between tags
and updates the release body via gh release edit.
2026-02-15 09:53:43 +04:00
Elie Habib a17b1b02e6 Fix release always created as draft on tag push
releaseDraft used != (OR) making tag pushes always draft.
Changed to == (AND) so only workflow_dispatch with draft=true creates drafts.
2026-02-15 09:41:43 +04:00
Elie Habib fd631ca86c perf: harden regression guardrails in CI, cache, and map clustering 2026-02-15 08:45:49 +04:00
Elie Habib 6dfdffc364 ci: set max timeout for desktop build matrix 2026-02-15 08:20:02 +04:00
Elie Habib 441b6f1a90 ci: harden tauri desktop build workflow 2026-02-15 08:14:12 +04:00
Elie Habib 6a293b1f18 Fix settings window show/focus even when init fails 2026-02-15 08:02:27 +04:00
Elie HabibandGitHub 68b3b22ad9 Fix: constrain layers menu height in DeckGLMap (#65)
I noticed a small UI bug where the Layers dropdown menu becomes too tall
causing it to underlap the Global Situation and also overlap the time
slider header. Also I made the scroll bar a little thinner it looks nice

This PR should fix it .
Fixed UI:
<img width="1892" height="537" alt="Screenshot 2026-02-14 235518"
src="https://github.com/user-attachments/assets/01e96b51-13bf-4d8a-89f7-c944ecee6722"
/>
<img width="1887" height="537" alt="Screenshot 2026-02-14 235529"
src="https://github.com/user-attachments/assets/cf605260-d33a-4400-9d26-df80f74193a9"
/>
2026-02-15 00:21:37 +04:00
Elie Habib 1912e248c6 Bump v2.2.1, remove CLAUDE.md from repo and add to .gitignore 2026-02-15 00:16:46 +04:00
Elie Habib e1925e735c Consolidate variant naming and fix PWA tile caching
- Rename variant default from 'world' to 'full' across config files
- Replace all startups.worldmonitor.app references with tech.worldmonitor.app
- Add CARTO basemap tile caching to Workbox runtime config (basemaps.cartocdn.com)
2026-02-15 00:15:33 +04:00
Elie Habib 5b1f980b70 Fix Windows settings window: async command, no menu bar, no white flash
- Make open_settings_window_command async to prevent WebView2 deadlock on Windows
- Create settings window with visible(false) to avoid white flash before content loads
- Remove menu bar from settings window on Windows/Linux (macOS uses screen-level menu)
- Frontend calls plugin:window|show + set_focus after init completes
2026-02-15 00:15:23 +04:00
Elie Habib a439992094 Add 40-minute timeout to desktop build jobs
Prevents builds from hanging indefinitely when Apple notarization
is slow or credentials are misconfigured.
2026-02-14 23:52:49 +04:00
ranzordly fe11a0ed0e fix: html attribute fix 2026-02-15 01:02:33 +05:30
Elie Habib 4a7f9bfdf4 Allow Cloudflare Insights script in CSP
Add https://static.cloudflareinsights.com to script-src directive
to prevent CSP blocking of Cloudflare Web Analytics beacon.
2026-02-14 22:52:26 +04:00
ranzordly 32973b5770 Fix: constrain layers menu height in DeckGLMap 2026-02-15 00:16:55 +05:30
Elie Habib 81559ba2d6 Use native M1 runner for ARM64 macOS builds
Switch ARM64 build from cross-compiling on macos-latest (Intel) to
native compilation on macos-14 (M1). Updates platform checks to
use contains() so signing works on both macOS runner types.
2026-02-14 22:33:29 +04:00
Elie Habib 7254dbb087 Fix macOS build failures when Apple signing secrets are missing
Gracefully handle missing/invalid Apple Developer certificates instead
of failing the entire build. Detects secret availability, validates
certificate file size, makes import failures non-fatal, and splits
each variant into signed/unsigned conditional paths.
2026-02-14 21:46:46 +04:00
Elie Habib a661497bcc Add latest release badge to README 2026-02-14 21:39:37 +04:00
614 changed files with 138704 additions and 6030 deletions
+32
View File
@@ -0,0 +1,32 @@
# Sentry Triage — 2026-02-19
Commit: `09174fd` on `main`
## Issues Triaged (5)
### ACTIONABLE — Fixed in Code
| ID | Title | Events | Users | Fix |
|---|---|---|---|---|
| WORLDMONITOR-1G | `Error: ML request unload-model timed out after 120000ms` | 30 | 27 | Wrapped `unloadModel()` in try/catch; timeout no longer leaks as unhandled rejection. Cleans up `loadedModels` set on failure. |
| WORLDMONITOR-1F | `Error: ML request unload-model timed out after 120000ms` | 9 | 9 | Same root cause as 1G (different release build hash). |
| WORLDMONITOR-1K | `TypeError: this.player.playVideo is not a function` | 1 | 1 | Added optional chaining (`playVideo?.()`, `pauseVideo?.()`) in `LiveNewsPanel.ts`. YT IFrame API player object may not have methods ready during initialization race. |
### NOISE — Filtered
| ID | Title | Events | Users | Filter |
|---|---|---|---|---|
| WORLDMONITOR-1J | `InternalError: too much recursion` | 1 | 1 | i18next internal `translate -> extractFromKey` cycle on Firefox 147. Added `/too much recursion/` to `ignoreErrors`. |
| WORLDMONITOR-1H | `TypeError: Cannot read properties of null (reading 'id')` | 1 | 1 | maplibre-gl internal render crash (`_drawLayers -> renderLayers`). Extended `beforeSend` regex to suppress null `id`/`type` when stack is in map chunk. |
## Files Modified
| File | Change |
|---|---|
| `src/services/ml-worker.ts` | `unloadModel()`: try/catch around `this.request()`, clean `loadedModels` on failure |
| `src/components/LiveNewsPanel.ts` | Optional chaining on `playVideo?.()` and `pauseVideo?.()` |
| `src/main.ts` | Added `/too much recursion/` to `ignoreErrors`; extended maplibre `beforeSend` filter for null `id`/`type` |
## Sentry Status
All 5 issues marked **resolved (in next release)** via API. They will auto-reopen if errors recur after deployment.
+30
View File
@@ -0,0 +1,30 @@
# Build the image from source only — never copy host-built artifacts or deps.
#
# The web stage runs `npm ci` + `npm run build` itself and the go stage copies
# go.mod/cmd/internal explicitly (see Dockerfile), so the host must NOT ship its
# own node_modules/dist into the context: at `COPY . .` a host node_modules
# clobbers the image's fresh `npm ci` tree and poisons BuildKit's node_modules
# cache-mount ("cannot replace directory with file"), which breaks every build.
node_modules
**/node_modules
dist
**/dist
build
out
.next
.git
.gitignore
.github
*.log
npm-debug.log*
.env
.env.*
!.env.example
.DS_Store
.vscode
.idea
coverage
*.tsbuildinfo
+1 -1
View File
@@ -111,7 +111,7 @@ VITE_WS_RELAY_URL=
# ------ Site Configuration ------
# Site variant: "full" (worldmonitor.app) or "tech" (startups.worldmonitor.app)
# Site variant: "full" (worldmonitor.app) or "tech" (tech.worldmonitor.app)
VITE_VARIANT=full
# Map interaction mode:
+85
View File
@@ -0,0 +1,85 @@
name: Bug Report
description: Report a bug in World Monitor
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug! Please fill out the sections below so we can reproduce and fix it.
- type: dropdown
id: variant
attributes:
label: Variant
description: Which variant are you using?
options:
- worldmonitor.app (Full / Geopolitical)
- tech.worldmonitor.app (Tech / Startup)
- finance.worldmonitor.app (Finance)
- Desktop app (Windows)
- Desktop app (macOS)
validations:
required: true
- type: dropdown
id: area
attributes:
label: Affected area
description: Which part of the app is affected?
options:
- Map / Globe
- News panels / RSS feeds
- AI Insights / World Brief
- Market Radar / Crypto
- Service Status
- Trending Keywords
- Country Brief pages
- Live video streams
- Desktop app (Tauri)
- Settings / API keys
- Other
validations:
required: true
- type: textarea
id: description
attributes:
label: Bug description
description: A clear description of what the bug is.
placeholder: Describe the bug...
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Steps to reproduce the behavior.
placeholder: |
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What you expected to happen.
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots / Console errors
description: If applicable, add screenshots or paste browser console errors.
- type: input
id: browser
attributes:
label: Browser & OS
description: e.g. Chrome 120 on Windows 11, Safari 17 on macOS Sonoma
placeholder: Chrome 120 on Windows 11
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Documentation
url: https://github.com/koala73/worldmonitor/blob/main/docs/DOCUMENTATION.md
about: Read the full documentation before opening an issue
- name: Discussions
url: https://github.com/koala73/worldmonitor/discussions
about: Ask questions and share ideas in Discussions
@@ -0,0 +1,55 @@
name: Feature Request
description: Suggest a new feature or improvement
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Have an idea for World Monitor? We'd love to hear it!
- type: dropdown
id: area
attributes:
label: Feature area
description: Which area does this feature relate to?
options:
- Map / Globe / Data layers
- News panels / RSS feeds
- AI / Intelligence analysis
- Market data / Crypto
- Desktop app
- UI / UX
- API / Backend
- Other
validations:
required: true
- type: textarea
id: description
attributes:
label: Description
description: A clear description of the feature you'd like.
placeholder: I'd like to see...
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem it solves
description: What problem does this feature address? What's the use case?
placeholder: This would help with...
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Have you considered any alternative solutions or workarounds?
- type: textarea
id: context
attributes:
label: Additional context
description: Any mockups, screenshots, links, or references that help illustrate the idea.
@@ -0,0 +1,69 @@
name: New Data Source
description: Suggest a new RSS feed, API, or map layer
labels: ["data-source"]
body:
- type: markdown
attributes:
value: |
World Monitor aggregates 100+ feeds and data layers. Suggest a new one!
- type: dropdown
id: type
attributes:
label: Source type
description: What kind of data source is this?
options:
- RSS / News feed
- API integration
- Map layer (geospatial data)
- Live video stream
- Status page
- Other
validations:
required: true
- type: dropdown
id: variant
attributes:
label: Target variant
description: Which variant should this appear in?
options:
- Full (Geopolitical)
- Tech (Startup)
- Finance
- All variants
validations:
required: true
- type: input
id: source-name
attributes:
label: Source name
description: Name of the source or organization.
placeholder: e.g. RAND Corporation, CoinDesk, USGS
validations:
required: true
- type: input
id: url
attributes:
label: Feed / API URL
description: Direct URL to the RSS feed, API endpoint, or data source.
placeholder: https://example.com/rss
validations:
required: true
- type: textarea
id: description
attributes:
label: Why add this source?
description: What value does this source bring? What does it cover that existing sources don't?
placeholder: This source provides coverage of...
validations:
required: true
- type: textarea
id: notes
attributes:
label: Additional notes
description: Any details about rate limits, authentication requirements, data format, or category placement.
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="world">
<rect width="1280" height="640" fill="#0A0A0A"/>
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">world</text>
<text x="378" y="322" font-family="Inter,system-ui,sans-serif" font-size="30" fill="#ffffff" opacity=".66">World Monitor</text>
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+36
View File
@@ -0,0 +1,36 @@
## Summary
<!-- Brief description of what this PR does -->
## Type of change
- [ ] Bug fix
- [ ] New feature
- [ ] New data source / feed
- [ ] New map layer
- [ ] Refactor / code cleanup
- [ ] Documentation
- [ ] CI / Build / Infrastructure
## Affected areas
- [ ] Map / Globe
- [ ] News panels / RSS feeds
- [ ] AI Insights / World Brief
- [ ] Market Radar / Crypto
- [ ] Desktop app (Tauri)
- [ ] API endpoints (`/api/*`)
- [ ] Config / Settings
- [ ] Other: <!-- specify -->
## Checklist
- [ ] Tested on [worldmonitor.app](https://worldmonitor.app) variant
- [ ] Tested on [tech.worldmonitor.app](https://tech.worldmonitor.app) variant (if applicable)
- [ ] New RSS feed domains added to `api/rss-proxy.js` allowlist (if adding feeds)
- [ ] No API keys or secrets committed
- [ ] TypeScript compiles without errors (`npm run typecheck`)
## Screenshots
<!-- If applicable, add screenshots or screen recordings -->
+207 -22
View File
@@ -35,57 +35,124 @@ jobs:
fail-fast: false
matrix:
include:
- platform: 'macos-latest'
- platform: 'macos-14'
args: '--target aarch64-apple-darwin'
node_target: 'aarch64-apple-darwin'
label: 'macOS-ARM64'
timeout: 180
- platform: 'macos-latest'
args: '--target x86_64-apple-darwin'
node_target: 'x86_64-apple-darwin'
label: 'macOS-x64'
timeout: 180
- platform: 'windows-latest'
args: ''
node_target: 'x86_64-pc-windows-msvc'
label: 'Windows-x64'
timeout: 120
- platform: 'ubuntu-22.04'
args: ''
node_target: 'x86_64-unknown-linux-gnu'
label: 'Linux-x64'
timeout: 120
runs-on: ${{ matrix.platform }}
name: Build (${{ matrix.label }})
timeout-minutes: ${{ matrix.timeout }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- name: Start job timer
shell: bash
run: echo "JOB_START_EPOCH=$(date +%s)" >> "$GITHUB_ENV"
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: lts/*
node-version: '22'
cache: 'npm'
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7
with:
targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
toolchain: stable
targets: ${{ contains(matrix.platform, 'macos') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: Rust cache
uses: swatinem/rust-cache@v2
uses: swatinem/rust-cache@ad397744b0d591a723ab90405b7247fac0e6b8db
with:
workspaces: './src-tauri -> target'
cache-on-failure: true
- name: Install Linux system dependencies
if: contains(matrix.platform, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install frontend dependencies
run: npm ci
# ── macOS Code Signing (only when secrets are configured) ──
- name: Bundle Node.js runtime
shell: bash
env:
NODE_VERSION: '22.14.0'
NODE_TARGET: ${{ matrix.node_target }}
run: bash scripts/download-node.sh --target "$NODE_TARGET"
- name: Verify bundled Node.js payload
shell: bash
run: |
if [ "${{ matrix.node_target }}" = "x86_64-pc-windows-msvc" ]; then
test -f src-tauri/sidecar/node/node.exe
ls -lh src-tauri/sidecar/node/node.exe
else
test -f src-tauri/sidecar/node/node
test -x src-tauri/sidecar/node/node
ls -lh src-tauri/sidecar/node/node
fi
# ── Detect whether Apple signing secrets are configured ──
- name: Check Apple signing secrets
if: contains(matrix.platform, 'macos')
id: apple-signing
shell: bash
run: |
if [ -n "${{ secrets.APPLE_CERTIFICATE }}" ] && [ -n "${{ secrets.APPLE_CERTIFICATE_PASSWORD }}" ] && [ -n "${{ secrets.KEYCHAIN_PASSWORD }}" ]; then
echo "available=true" >> $GITHUB_OUTPUT
echo "Apple signing secrets detected"
else
echo "available=false" >> $GITHUB_OUTPUT
echo "No Apple signing secrets — building unsigned"
fi
# ── macOS Code Signing (only when secrets are valid) ──
- name: Import Apple Developer Certificate
if: matrix.platform == 'macos-latest' && env.APPLE_CERTIFICATE != ''
if: contains(matrix.platform, 'macos') && steps.apple-signing.outputs.available == 'true'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > certificate.p12
CERT_SIZE=$(wc -c < certificate.p12 | tr -d ' ')
if [ "$CERT_SIZE" -lt 100 ]; then
echo "::warning::Certificate file too small ($CERT_SIZE bytes) — likely invalid. Skipping signing."
echo "SKIP_SIGNING=true" >> $GITHUB_ENV
exit 0
fi
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security set-keychain-settings -t 3600 -u build.keychain
security import certificate.p12 -k build.keychain \
-P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
-P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign || {
echo "::warning::Certificate import failed — building unsigned"
echo "SKIP_SIGNING=true" >> $GITHUB_ENV
exit 0
}
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$KEYCHAIN_PASSWORD" build.keychain
@@ -96,7 +163,8 @@ jobs:
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported: $CERT_ID"
else
echo "No Developer ID certificate found"
echo "::warning::No Developer ID certificate found in keychain — building unsigned"
echo "SKIP_SIGNING=true" >> $GITHUB_ENV
fi
# ── Determine variant ──
@@ -110,9 +178,13 @@ jobs:
fi
# ── Build with tauri-action ──
- name: Build Tauri app (full variant)
if: env.BUILD_VARIANT == 'full'
uses: tauri-apps/tauri-action@v0
# Signed builds: only when Apple signing secrets are valid and imported
# Unsigned builds: fallback when no signing (Windows always uses this path)
# ── Build: Full variant (signed) ──
- name: Build Tauri app (full, signed)
if: env.BUILD_VARIANT == 'full' && steps.apple-signing.outputs.available == 'true' && env.SKIP_SIGNING != 'true'
uses: tauri-apps/tauri-action@79c624843491f12ae9d63592534ed49df3bc4adb
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VITE_VARIANT: full
@@ -126,15 +198,33 @@ jobs:
with:
tagName: v__VERSION__
releaseName: 'World Monitor v__VERSION__'
releaseBody: 'Download the installer for your platform below.'
releaseDraft: ${{ github.event.inputs.draft || true }}
releaseBody: 'See changelog below.'
releaseDraft: ${{ github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.draft) }}
prerelease: false
args: ${{ matrix.args }}
retryAttempts: 1
- name: Build Tauri app (tech variant)
if: env.BUILD_VARIANT == 'tech'
uses: tauri-apps/tauri-action@v0
# ── Build: Full variant (unsigned — no Apple certs) ──
- name: Build Tauri app (full, unsigned)
if: env.BUILD_VARIANT == 'full' && (steps.apple-signing.outputs.available != 'true' || env.SKIP_SIGNING == 'true')
uses: tauri-apps/tauri-action@79c624843491f12ae9d63592534ed49df3bc4adb
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VITE_VARIANT: full
VITE_DESKTOP_RUNTIME: '1'
with:
tagName: v__VERSION__
releaseName: 'World Monitor v__VERSION__'
releaseBody: 'See changelog below.'
releaseDraft: ${{ github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.draft) }}
prerelease: false
args: ${{ matrix.args }}
retryAttempts: 1
# ── Build: Tech variant (signed) ──
- name: Build Tauri app (tech, signed)
if: env.BUILD_VARIANT == 'tech' && steps.apple-signing.outputs.available == 'true' && env.SKIP_SIGNING != 'true'
uses: tauri-apps/tauri-action@79c624843491f12ae9d63592534ed49df3bc4adb
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VITE_VARIANT: tech
@@ -148,9 +238,104 @@ jobs:
with:
tagName: v__VERSION__-tech
releaseName: 'Tech Monitor v__VERSION__'
releaseBody: 'Download the installer for your platform below.'
releaseDraft: ${{ github.event.inputs.draft || true }}
releaseBody: 'See changelog below.'
releaseDraft: ${{ github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.draft) }}
prerelease: false
tauriScript: npx tauri
args: --config src-tauri/tauri.tech.conf.json ${{ matrix.args }}
retryAttempts: 1
# ── Build: Tech variant (unsigned — no Apple certs) ──
- name: Build Tauri app (tech, unsigned)
if: env.BUILD_VARIANT == 'tech' && (steps.apple-signing.outputs.available != 'true' || env.SKIP_SIGNING == 'true')
uses: tauri-apps/tauri-action@79c624843491f12ae9d63592534ed49df3bc4adb
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VITE_VARIANT: tech
VITE_DESKTOP_RUNTIME: '1'
with:
tagName: v__VERSION__-tech
releaseName: 'Tech Monitor v__VERSION__'
releaseBody: 'See changelog below.'
releaseDraft: ${{ github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.draft) }}
prerelease: false
tauriScript: npx tauri
args: --config src-tauri/tauri.tech.conf.json ${{ matrix.args }}
retryAttempts: 1
- name: Verify signed macOS bundle + embedded runtime
if: contains(matrix.platform, 'macos') && steps.apple-signing.outputs.available == 'true' && env.SKIP_SIGNING != 'true'
shell: bash
run: |
APP_PATH=$(find src-tauri/target -type d -path '*/bundle/macos/*.app' | head -1)
if [ -z "$APP_PATH" ]; then
echo "::error::No macOS .app bundle found after build."
exit 1
fi
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
NODE_PATH=$(find "$APP_PATH/Contents/Resources" -type f -path '*/sidecar/node/node' | head -1)
if [ -z "$NODE_PATH" ]; then
echo "::error::Bundled Node runtime missing from app resources."
exit 1
fi
echo "Verified signed app bundle and embedded Node runtime: $NODE_PATH"
- name: Cleanup Apple signing materials
if: always() && contains(matrix.platform, 'macos')
shell: bash
run: |
rm -f certificate.p12
security delete-keychain build.keychain || true
- name: Report build duration
if: always()
shell: bash
run: |
if [ -z "${JOB_START_EPOCH:-}" ]; then
echo "::warning::JOB_START_EPOCH missing; duration unavailable."
exit 0
fi
END_EPOCH=$(date +%s)
ELAPSED=$((END_EPOCH - JOB_START_EPOCH))
MINUTES=$((ELAPSED / 60))
SECONDS=$((ELAPSED % 60))
echo "Build duration for ${{ matrix.label }}: ${MINUTES}m ${SECONDS}s"
# ── Update release notes with changelog after all builds complete ──
update-release-notes:
needs: build-tauri
if: always() && contains(needs.build-tauri.result, 'success')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- name: Generate and update release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
VERSION=$(jq -r .version src-tauri/tauri.conf.json)
TAG="v${VERSION}"
PREV_TAG=$(git describe --tags --abbrev=0 "${TAG}^" 2>/dev/null || echo "")
if [ -z "$PREV_TAG" ]; then
COMMITS="Initial release"
else
COMMITS=$(git log "${PREV_TAG}..${TAG}" --oneline --no-merges | sed 's/^[a-f0-9]*//' | sed 's/^ /- /')
fi
BODY=$(cat <<NOTES
## What's Changed
${COMMITS}
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG:-initial}...${TAG}
NOTES
)
gh release edit "$TAG" --notes "$BODY"
echo "Updated release notes for $TAG"
+22
View File
@@ -0,0 +1,22 @@
# Canonical CI/CD — imports the shared hanzoai/ci reusable workflow, which
# reads hanzo.yml (images + deploy + kms). No per-repo build logic, no
# GitHub-hosted runners (defaults to the Hanzo cloud arc pool).
name: CI/CD
on:
push:
branches: [main]
tags: ['v*']
pull_request:
workflow_dispatch:
jobs:
cicd:
uses: hanzoai/ci/.github/workflows/build.yml@v1
# Target the Hanzo arc scale set by its installation name. ARC scale sets
# are matched by name, not by the [self-hosted,linux,amd64] label triple
# that ci@v1 defaults to — without this override the job never gets a
# runner and sits queued.
with:
runner: '["hanzo-build-linux-amd64"]'
secrets: inherit
+19
View File
@@ -0,0 +1,19 @@
name: Lint
on:
pull_request:
paths:
- '**/*.md'
- '.markdownlint-cli2.jsonc'
jobs:
markdown:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: npm run lint:md
+23
View File
@@ -1,4 +1,6 @@
node_modules/
.idea/
.planning/
dist/
.DS_Store
*.log
@@ -8,9 +10,30 @@ dist/
.vercel
.claude/
.cursor/
CLAUDE.md
.env.vercel-backup
.env.vercel-export
.agent/
.factory/
.windsurf/
skills/
ideas/
test-results/
src-tauri/sidecar/node/*
!src-tauri/sidecar/node/.gitkeep
# world-model runtime snapshot + history ring (regenerated on boot)
data/world-model.json
data/world-model.json.tmp
data/world-model-history.json.gz
data/world-model-history.json.gz.tmp
# embedded datastore lake + settings (SQLite, WAL mode) — regenerated on boot,
# lands here only when WORLD_DATA_DIR points at the repo (local dev)
world.db
world.db-wal
world.db-shm
data/world.db
data/world.db-wal
data/world.db-shm
/world
+10
View File
@@ -0,0 +1,10 @@
{
// Only enforce the 3 rules from PR #72. Everything else is off.
"config": {
"default": false,
"MD012": true,
"MD022": true,
"MD032": true
},
"ignores": ["node_modules/**", "dist/**", "src-tauri/target/**"]
}
+45
View File
@@ -0,0 +1,45 @@
# Hanzo World — agent guide
Vite + TypeScript SPA (`world-monitor`). Real-time global-intelligence dashboard
served at `world.hanzo.ai`, shipped by `hanzo.yml` CI/CD onto the `world`
operator Service CR. Same-origin data plane under `/v1/world/*`.
## Browser control — prefer the Hanzo MCP extension over Playwright
When the **Hanzo browser MCP** (the `mcp__hanzo__browser` / `mcp__claude-in-chrome__*`
tools, backed by the local extension in `~/work/hanzo/extension`) is available,
use it by default to drive a real browser for interactive UI work — inspecting
the globe, tweaking layers, checking live feeds, capturing screenshots against a
running dev server. It talks to a real Chrome/Firefox with the real WebGL/deck.gl
context, so what you see is what a user sees.
Fall back to **Playwright only when** the MCP extension is not connected, or for
the deterministic **e2e suite** (`e2e/*.spec.ts`) — those run headless in CI with
mocked `/v1/world/cloud/*` feeds and must stay reproducible offline. E2e is not
interactive editing; keep the two lanes separate.
Order of preference for "look at / poke the UI": Hanzo MCP browser → Playwright.
## The map / globe
- Basemaps: `dark` · `dot` · `satellite` · `terrain`. **`dot` is the default**
for every variant (`DEFAULT_BASEMAP_STYLE` in `src/config/variant.ts`) — the
Kaspersky-style cybermap: land drawn only as a glowing dot-lattice over a black
ocean sphere, no country fills/borders/imagery.
- The lattice is one pure value — `getLandDots()` in `src/services/land-dots.ts`
consumed by BOTH the 2D mercator map (`DeckGLMap`) and the 3D globe
(`GlobeNative`). One source, two projections. Don't fork it.
- Cloud view layers (default-on, `?variant=cloud`): live request-origin dots +
animated traffic arcs (`AnimatedArcLayer`, a travelling pulse advanced on RAF),
validator chain-nodes, BYO-GPU rings, datacenter clusters. Feeds are best-effort
and degrade to honest empty states — never fabricate volume.
- `satellite`/`terrain` need `VITE_MAPBOX_TOKEN` (from KMS `hanzo/deploy/`, never
in git); `dark`/`dot` are keyless CartoDB.
## Release
Bump `package.json` PATCH (x.y.z → x.y.z+1, never a lazy major), tag `v<version>`,
`workflow_dispatch` the `cicd` workflow. The image tag is the version WITHOUT the
`v`. CI's "Deploy" step can false-negative while the operator finishes rolling —
verify the live version, not just the CI square. Test/doc-only changes need no
release (the image is byte-identical).
+315
View File
@@ -0,0 +1,315 @@
# Changelog
All notable changes to World Monitor are documented here.
## [2.4.19]
### Fixed
- **Moving/resizing a panel no longer shifts the others** (the "when I shift the 3D map it shifts all other components" report). The dashboard now DEFAULTS to the free layout: every panel owns its own {x,y,w,h}, so a drag or resize leaves every sibling exactly where it was — no more grid reflow. The switch is invisible: the current grid arrangement is frozen as the starting geometry (`grid-config.applyDefaultLayout`), and Grid stays one dropdown-click away for anyone who wants snap-to-grid back.
- **Panels resize granularly** — pixel-exact width AND height from any edge or corner, instead of jumping between coarse column/row spans.
- **Panels can be made much narrower/smaller** ("constrained on min width"): the free-mode floors drop to 96×40px (map 140px), and the grid widget-size slider now reaches a 120px column track (was 140).
### Changed
- A panel shown from the Panels menu / "+ Add widget" while in free mode seeds a tidy default slot below the placed panels (never a full-width block under its absolute siblings). Hidden panels no longer persist 0×0 geometry.
## [2.4.13]
### Fixed
- **Globe overlays now sit ON the sphere with correct occlusion** (the "map spazzes" report). On the native deck.gl GlobeView, data layers (shared with the 2D map) carried no depth parameters, so the far hemisphere showed through and count badges floated above the limb. GlobeNative now seats every data layer against the depth-writing ocean sphere (test, don't write — no inter-marker z-fight), and the far-side billboard cull (`occludeFarSide`) faces the globe's OWN live camera instead of the parked (frozen) mapbox center — extended to `TextLayer` so a back-side "36" count badge disappears instead of hovering over the planet.
- **Terrain striping on the globe**: the draped ESRI imagery tiles each wrote depth, so coplanar neighbours z-fought at the tile seams (horizontal stripes / partial render). Tiles now depth-TEST only; the ocean sphere owns the depth buffer.
- **Live request-geo dots kept polling on the 3D globe**: the `/v1/world/cloud/*` poll was gated on `renderPaused`, which the native GlobeView sets on activation — so the realtime dots froze the moment you entered the default 3D view. It now gates on tab visibility, so the dots stay live (and fade) on the globe.
### Changed
- **Request-origin → serving-region arcs on by default** in the Cloud view: the `/cloud/traffic` arcs derive from the same real native request-geo points as the dots (origin country → nearest Hanzo region) and degrade to empty — never demo.
## [2.4.6]
### Added
- **Enso Flywheel panel (AI variant)**: the router self-improvement loop made visible. New `/v1/world/enso-training` folds the routing-decision ledger tail + reward tail (`export-routing-ledger` / `export-routing-rewards`, super-admin) into ledger growth, engine-vs-heuristic mix, a routing-confidence histogram, and task/model distributions, alongside the latest enso-bench eval scores (an embedded `results/summary.json` snapshot, overridable live via `ENSO_BENCH_URL`). The response is event-typed so future retrain/deploy milestones slot into the same timeline. Eval scores render even signed-out; `state` (live/partial/demo) says which live sources resolved.
## [2.4.5]
### Added
- **AI Compute panel (AI variant)**: live Hanzo inference-plane telemetry over SSE — tokens/s, requests/s, 24h spend, top models by real spend, and the serving fleet (GPUs, machines online, models served). New `/v1/world/ai-pulse` pushes typed `usage`/`fleet`/`status` frames (EventSource) and answers a plain GET with one JSON snapshot as the poll fallback. Honest "connecting"/"unavailable" states — never a zero dressed up as live; the service bearer stays server-side.
## [2.4.4]
### Changed
- **Cloud Pulse is real when a service token is wired**: `/v1/world/cloud-pulse` now folds MEASURED platform-wide 24h request/token volume from the ClickHouse-backed usage ledger (`get-cloud-usages ?org=all`, super-admin) on top of the live model/node/GPU/region counts — dropping both `demo:true` and `volumeModeled:true`. Top models come from real ledger spend. Without a token, or with a non-admin token, it stays honestly demo/modeled — platform numbers are never faked silently. The service bearer stays server-side (never sent to the browser).
## [2.4.2]
### Added
- **Streaming analyst**: answers flow in live over SSE — reasoning shows as dim thinking text, tool calls appear as chips the moment they run, the reply types itself in; the final render and command dispatch are unchanged (done event = the old JSON contract)
- **Model menu**: the composer pill opens a grouped popover (Auto / Zen / GPT / Llama / Claude / Agents) with per-family marks and an active check
### Fixed
- **Model identity**: the Zen ring appears only on zen* models — gpt-oss/llama/claude get their own marks on the pill, menu rows, avatars, and the thinking row
- **Dev server**: `npm run dev` proxies /v1 to production by default (VITE_DEV_API_PROXY overrides)
## [2.4.1]
### Added
- **Western Pacific cyclones**: cross-agency tropical-cyclone attribution (GDACS + HKO warnings via new `/v1/world/hko-warnings` proxy) with per-agency wind observations, canonical dedup, and map popup detail rows
- **China macro snapshot**: `/v1/world/china-macro` — CPI/CLI (OECD), policy rate, USD/CNY (FRED), HKMA context, NBS release calendar + PBoC LPR dates, surfaced with staleness-honest indicator tiles
- **Model roster**: `Best (auto)` leads the analyst model picker — the gateway routing alias that always resolves
### Changed
- **AI default model**: `zen5``best`; a pinned family id goes dark when the inference plane's claim catalog shifts, the routing alias never does
- **Server cache is now stale-while-revalidate**: `cachedJSON`/`passthrough` serve stale instantly and refresh in the background (single-flight); GDELT/theater-posture no longer stall requests ~10s on TTL expiry
- **GDELT cache warmers**: hot keys (analyst grounding, protests layer) refreshed every ~4min so no user ever eats a cold miss
- **Sparkline payloads**: close arrays rounded to 7 significant digits (float32-widening noise stripped, ~40% smaller; scalars untouched)
### Fixed
- **News first paint**: panels no longer gate their first DOM write on a 65MB ML sentiment model download — headlines paint immediately, sentiment refines in place
- **Analyst grounding snapshot**: context fetches bounded at 2.5s so a cold endpoint can't hold the chat send hostage
- **AI errors are honest**: upstream error codes (`insufficient_balance`, `spend_cap_exceeded`, …) surface in the chat instead of a bare `status 402`
## [2.4.0] - 2026-02-19
### Added
- **Live Webcams Panel**: 2x2 grid of live YouTube webcam feeds from global hotspots with region filters (Middle East, Europe, Asia-Pacific, Americas), grid/single view toggle, idle detection, and full i18n support (#111)
- **Linux download**: added `.AppImage` option to download banner
### Changed
- **Mobile detection**: use viewport width only for mobile detection; touch-capable notebooks (e.g. ROG Flow X13) now get desktop layout (#113)
- **Webcam feeds**: curated Tel Aviv, Mecca, LA, Miami; replaced dead Tokyo feed; diverse ALL grid with Jerusalem, Tehran, Kyiv, Washington
### Fixed
- **Le Monde RSS**: English feed URL updated (`/en/rss/full.xml``/en/rss/une.xml`) to fix 404
- **Workbox precache**: added `html` to `globPatterns` so `navigateFallback` works for offline PWA
- **Panel ordering**: one-time migration ensures Live Webcams follows Live News for existing users
- **Mobile popups**: improved sheet/touch/controls layout (#109)
- **Intelligence alerts**: disabled on mobile to reduce noise (#110)
- **RSS proxy**: added 8 missing domains to allowlist
- **HTML tags**: repaired malformed tags in panel template literals
- **ML worker**: wrapped `unloadModel()` in try/catch to prevent unhandled timeout rejections
- **YouTube player**: optional chaining on `playVideo?.()` / `pauseVideo?.()` for initialization race
- **Panel drag**: guarded `.closest()` on non-Element event targets
- **Beta mode**: resolved race condition and timeout failures
- **Sentry noise**: added filters for Firefox `too much recursion`, maplibre `_layers`/`id`/`type` null crashes
## [2.3.9] - 2026-02-18
### Added
- **Full internationalization (14 locales)**: English, French, German, Spanish, Italian, Polish, Portuguese, Dutch, Swedish, Russian, Arabic, Chinese Simplified, Japanese — each with 1100+ translated keys
- **RTL support**: Arabic locale with `dir="rtl"`, dedicated RTL CSS overrides, regional language code normalization (e.g. `ar-SA` correctly triggers RTL)
- **Language switcher**: in-app locale picker with flag icons, persists to localStorage
- **i18n infrastructure**: i18next with browser language detection and English fallback
- **Community discussion widget**: floating pill linking to GitHub Discussions with delayed appearance and permanent dismiss
- **Linux AppImage**: added `ubuntu-22.04` to CI build matrix with webkit2gtk/appindicator dependencies
- **NHK World and Nikkei Asia**: added RSS feeds for Japan news coverage
- **Intelligence Findings badge toggle**: option to disable the findings badge in the UI
### Changed
- **Zero hardcoded English**: all UI text routed through `t()` — panels, modals, tooltips, popups, map legends, alert templates, signal descriptions
- **Trending proper-noun detection**: improved mid-sentence capitalization heuristic with all-caps fallback when ML classifier is unavailable
- **Stopword suppression**: added missing English stopwords to trending keyword filter
### Fixed
- **Dead UTC clock**: removed `#timeDisplay` element that permanently displayed `--:--:-- UTC`
- **Community widget duplicates**: added DOM idempotency guard preventing duplicate widgets on repeated news refresh cycles
- **Settings help text**: suppressed raw i18n key paths rendering when translation is missing
- **Intelligence Findings badge**: fixed toggle state and listener lifecycle
- **Context menu styles**: restored intel-findings context menu styles
- **CSS theme variables**: defined missing `--panel-bg` and `--panel-border` variables
## [2.3.8] - 2026-02-17
### Added
- **Finance variant**: Added a dedicated market-first variant (`finance.worldmonitor.app`) with finance/trading-focused feeds, panels, and map defaults
- **Finance desktop profile**: Added finance-specific desktop config and build profile for Tauri packaging
### Changed
- **Variant feed loading**: `loadNews` now enumerates categories dynamically and stages category fetches with bounded concurrency across variants
- **Feed resilience**: Replaced direct MarketWatch RSS usage in finance/full/tech paths with Google News-backed fallback queries
- **Classification pressure controls**: Tightened AI classification budgets for tech/full and tuned per-feed caps to reduce startup burst pressure
- **Timeline behavior**: Wired timeline filtering consistently across map and news panels
- **AI summarization defaults**: Switched OpenRouter summarization to auto-routed free-tier model selection
### Fixed
- **Finance panel parity**: Kept data-rich panels while adding news panels for finance instead of removing core data surfaces
- **Desktop finance map parity**: Finance variant now runs first-class Deck.GL map/layer behavior on desktop runtime
- **Polymarket fallback**: Added one-time direct connectivity probe and memoized fallback to prevent repeated `ERR_CONNECTION_RESET` storms
- **FRED fallback behavior**: Missing `FRED_API_KEY` now returns graceful empty payloads instead of repeated hard 500s
- **Preview CSP tooling**: Allowed `https://vercel.live` script in CSP so Vercel preview feedback injection is not blocked
- **Trending quality**: Suppressed noisy generic finance terms in keyword spike detection
- **Mobile UX**: Hidden desktop download prompt on mobile devices
## [2.3.7] - 2026-02-16
### Added
- **Full light mode theme**: Complete light/dark theme system with CSS custom properties, ThemeManager module, FOUC prevention, and `getCSSColor()` utility for theme-aware inline styles
- **Theme-aware maps and charts**: Deck.GL basemap, overlay layers, and CountryTimeline charts respond to theme changes in real time
- **Dark/light mode header toggle**: Sun/moon icon in the header bar for quick theme switching, replacing the duplicate UTC clock
- **Desktop update checker**: Architecture-aware download links for macOS (ARM/Intel) and Windows
- **Node.js bundled in Tauri installer**: Sidecar no longer requires system Node.js
- **Markdown linting**: Added markdownlint config and CI workflow
### Changed
- **Panels modal**: Reverted from "Settings" back to "Panels" — removed redundant Appearance section now that header has theme toggle
- **Default panels**: Enabled UCDP Conflict Events, UNHCR Displacement, Climate Anomalies, and Population Exposure panels by default
### Fixed
- **CORS for Tauri desktop**: Fixed CORS issues for desktop app requests
- **Markets panel**: Keep Yahoo-backed data visible when Finnhub API key is skipped
- **Windows UNC paths**: Preserve extended-length path prefix when sanitizing sidecar script path
- **Light mode readability**: Darkened neon semantic colors and overlay backgrounds for light mode contrast
## [2.3.6] - 2026-02-16
### Fixed
- **Windows console window**: Hide the `node.exe` console window that appeared alongside the desktop app on Windows
## [2.3.5] - 2026-02-16
### Changed
- **Panel error messages**: Differentiated error messages per panel so users see context-specific guidance instead of generic failures
- **Desktop config auto-hide**: Desktop configuration panel automatically hides on web deployments where it is not relevant
## [2.3.4] - 2026-02-16
### Fixed
- **Windows sidecar crash**: Strip `\\?\` UNC extended-length prefix from paths before passing to Node.js — Tauri `resource_dir()` on Windows returns UNC-prefixed paths that cause `EISDIR: lstat 'C:'` in Node.js module resolution
- **Windows sidecar CWD**: Set explicit `current_dir` on the Node.js Command to prevent bare drive-letter working directory issues from NSIS shortcut launcher
- **Sidecar package scope**: Add `package.json` with `"type": "module"` to sidecar directory, preventing Node.js from walking up the entire directory tree during ESM scope resolution
## [2.3.3] - 2026-02-16
### Fixed
- **Keychain persistence**: Enable `apple-native` (macOS) and `windows-native` (Windows) features for the `keyring` crate — v3 ships with no default platform backends, so API keys were stored in-memory only and lost on restart
- **Settings key verification**: Soft-pass network errors during API key verification so transient sidecar failures don't block saving
- **Resilient keychain reads**: Use `Promise.allSettled` in `loadDesktopSecrets` so a single key failure doesn't discard all loaded secrets
- **Settings window capabilities**: Add `"settings"` to Tauri capabilities window list for core plugin permissions
- **Input preservation**: Capture unsaved input values before DOM re-render in settings panel
## [2.3.0] - 2026-02-15
### Security
- **CORS hardening**: Tighten Vercel preview deployment regex to block origin spoofing (`worldmonitorEVIL.vercel.app`)
- **Sidecar auth bypass**: Move `/api/local-env-update` behind `LOCAL_API_TOKEN` auth check
- **Env key allowlist**: Restrict sidecar env mutations to 18 known secret keys (matching `SUPPORTED_SECRET_KEYS`)
- **postMessage validation**: Add `origin` and `source` checks on incoming messages in LiveNewsPanel
- **postMessage targetOrigin**: Replace wildcard `'*'` with specific embed origin
- **CORS enforcement**: Add `isDisallowedOrigin()` check to 25+ API endpoints that were missing it
- **Custom CORS migration**: Migrate `gdelt-geo` and `eia` from custom CORS to shared `_cors.js` module
- **New CORS coverage**: Add CORS headers + origin check to `firms-fires`, `stock-index`, `youtube/live`
- **YouTube embed origins**: Tighten `ALLOWED_ORIGINS` regex in `youtube/embed.js`
- **CSP hardening**: Remove `'unsafe-inline'` from `script-src` in both `index.html` and `tauri.conf.json`
- **iframe sandbox**: Add `sandbox="allow-scripts allow-same-origin allow-presentation"` to YouTube embed iframe
- **Meta tag validation**: Validate URL query params with regex allowlist in `parseStoryParams()`
### Fixed
- **Service worker stale assets**: Add `skipWaiting`, `clientsClaim`, and `cleanupOutdatedCaches` to workbox config — fixes `NS_ERROR_CORRUPTED_CONTENT` / MIME type errors when users have a cached SW serving old HTML after redeployment
## [2.2.6] - 2026-02-14
### Fixed
- Filter trending noise and fix sidecar auth
- Restore tech variant panels
- Remove Market Radar and Economic Data panels from tech variant
### Docs
- Add developer X/Twitter link to Support section
- Add cyber threat API keys to `.env.example`
## [2.2.5] - 2026-02-13
### Security
- Migrate all Vercel edge functions to CORS allowlist
- Restrict Railway relay CORS to allowed origins only
### Fixed
- Hide desktop config panel on web
- Route World Bank & Polymarket via Railway relay
## [2.2.3] - 2026-02-12
### Added
- Cyber threat intelligence map layer (Feodo Tracker, URLhaus, C2IntelFeeds, OTX, AbuseIPDB)
- Trending keyword spike detection with end-to-end flow
- Download desktop app slide-in banner for web visitors
- Country briefs in Cmd+K search
### Changed
- Redesign 4 panels with table layouts and scoped styles
- Redesign population exposure panel and reorder UCDP columns
- Dramatically increase cyber threat map density
### Fixed
- Resolve z-index conflict between pinned map and panels grid
- Cap geo enrichment at 12s timeout, prevent duplicate download banners
- Replace ipwho.is/ipapi.co with ipinfo.io/freeipapi.com for geo enrichment
- Harden trending spike processing and optimize hot paths
- Improve cyber threat tooltip/popup UX and dot visibility
## [2.2.2] - 2026-02-10
### Added
- Full-page Country Brief Page replacing modal overlay
- Download redirect API for platform-specific installers
### Fixed
- Normalize country name from GeoJSON to canonical TIER1 name
- Tighten headline relevance, add Top News section, compact markets
- Hide desktop config panel on web, fix irrelevant prediction markets
- Tone down climate anomalies heatmap to stop obscuring other layers
- macOS: hide window on close instead of quitting
### Performance
- Reduce idle CPU from pulse animation loop
- Harden regression guardrails in CI, cache, and map clustering
## [2.2.1] - 2026-02-08
### Fixed
- Consolidate variant naming and fix PWA tile caching
- Windows settings window: async command, no menu bar, no white flash
- Constrain layers menu height in DeckGLMap
- Allow Cloudflare Insights script in CSP
- macOS build failures when Apple signing secrets are missing
## [2.2.0] - 2026-02-07
Initial v2.2 release with multi-variant support (World + Tech), desktop app (Tauri), and comprehensive geopolitical intelligence features.
-171
View File
@@ -1,171 +0,0 @@
# WorldMonitor Development Notes
## 🤖 Model Preferences (Jan 30, 2026)
**For ALL coding tasks in WorldMonitor, ALWAYS use:**
| Task | Model | Alias |
|------|-------|-------|
| **Coding** | `openrouter/anthropic/claude-sonnet-4-5` | `sonnet` |
| **Coding** | `openai/gpt-5-2` | `codex` |
**Never default to MiniMax for coding tasks.**
**How to run with preferred model:**
```bash
# Sonnet for coding
clawdbot --model openrouter/anthropic/claude-sonnet-4-5 "build me..."
# Codex for coding
clawdbot --model openai/gpt-5-2 "build me..."
```
**Set as default:**
```bash
export CLAUDE_MODEL=openrouter/anthropic/claude-sonnet-4-5
```
## CRITICAL: Git Branch Rules
**NEVER merge or push to a different branch without explicit user permission.**
- If on `beta`, only push to `beta` - never merge to `main` without asking
- If on `main`, stay on `main` - never switch branches and push without asking
- NEVER merge branches without explicit request
- Pushing to the CURRENT branch after commits is OK when continuing work
## Critical: RSS Proxy Allowlist
When adding new RSS feeds in `src/config/feeds.ts`, you **MUST** also add the feed domains to the allowlist in `api/rss-proxy.js`.
### Why
The RSS proxy has a security allowlist (`ALLOWED_DOMAINS`) that blocks requests to domains not explicitly listed. Feeds from unlisted domains will return HTTP 403 "Domain not allowed" errors.
### How to Add New Feeds
1. Add the feed to `src/config/feeds.ts`
2. Extract the domain from the feed URL (e.g., `https://www.ycombinator.com/blog/rss/``www.ycombinator.com`)
3. Add the domain to `ALLOWED_DOMAINS` array in `api/rss-proxy.js`
4. Deploy changes to Vercel
### Example
```javascript
// In api/rss-proxy.js
const ALLOWED_DOMAINS = [
// ... existing domains
'www.ycombinator.com', // Add new domain here
];
```
### Debugging Feed Issues
If a panel shows "No news available":
1. Open browser DevTools → Console
2. Look for `HTTP 403` or "Domain not allowed" errors
3. Check if the domain is in `api/rss-proxy.js` allowlist
## Site Variants
Two variants controlled by `VITE_VARIANT` environment variable:
- `full` (default): Geopolitical focus - worldmonitor.app
- `tech`: Tech/startup focus - startups.worldmonitor.app
### Running Locally
```bash
npm run dev # Full variant
npm run dev:tech # Tech variant
```
### Building
```bash
npm run build:full # Production build for worldmonitor.app
npm run build:tech # Production build for startups.worldmonitor.app
```
## Custom Feed Scrapers
Some sources don't provide RSS feeds. Custom scrapers are in `/api/`:
| Endpoint | Source | Notes |
|----------|--------|-------|
| `/api/fwdstart` | FwdStart Newsletter (Beehiiv) | Scrapes archive page, 30min cache |
### Adding New Scrapers
1. Create `/api/source-name.js` edge function
2. Scrape source, return RSS XML format
3. Add to feeds.ts: `{ name: 'Source', url: '/api/source-name' }`
4. No need to add to rss-proxy allowlist (direct API, not proxied)
## AI Summarization & Caching
The AI Insights panel uses a server-side Redis cache to deduplicate API calls across users.
### Required Environment Variables
```bash
# Groq API (primary summarization)
GROQ_API_KEY=gsk_xxx
# OpenRouter API (fallback)
OPENROUTER_API_KEY=sk-or-xxx
# Upstash Redis (cross-user caching)
UPSTASH_REDIS_REST_URL=https://xxx.upstash.io
UPSTASH_REDIS_REST_TOKEN=xxx
```
### How It Works
1. User visits → `/api/groq-summarize` receives headlines
2. Server hashes headlines → checks Redis cache
3. **Cache hit** → return immediately (no API call)
4. **Cache miss** → call Groq API → store in Redis (24h TTL) → return
### Model Selection
- **llama-3.1-8b-instant**: 14,400 req/day (used for summaries)
- **llama-3.3-70b-versatile**: 1,000 req/day (quality but limited)
### Fallback Chain
1. Groq (fast, 14.4K/day) → Redis cache
2. OpenRouter (50/day) → Redis cache
3. Browser T5 (unlimited, slower, no cache)
### Setup Upstash
1. Create free account at [upstash.com](https://upstash.com)
2. Create a new Redis database
3. Copy REST URL and Token to Vercel env vars
## Service Status Panel
Status page URLs in `api/service-status.js` must match the actual status page endpoint. Common formats:
- Statuspage.io: `https://status.example.com/api/v2/status.json`
- Atlassian: `https://example.status.atlassian.com/api/v2/status.json`
- incident.io: Same endpoint but returns HTML, handled by `incidentio` parser
Current known URLs:
- Anthropic: `https://status.claude.com/api/v2/status.json`
- Zoom: `https://www.zoomstatus.com/api/v2/status.json`
- Notion: `https://www.notion-status.com/api/v2/status.json`
## Allowed Bash Commands
The following additional bash commands are permitted without user approval:
- `Bash(ps aux:*)` - List running processes
- `Bash(grep:*)` - Search text patterns
- `Bash(ls:*)` - List directory contents
## Bash Guidelines
### IMPORTANT: Avoid commands that cause output buffering issues
- DO NOT pipe output through `head`, `tail`, `less`, or `more` when monitoring or checking command output
- DO NOT use `| head -n X` or `| tail -n X` to truncate output - these cause buffering problems
- Instead, let commands complete fully, or use `--max-lines` flags if the command supports them
- For log monitoring, prefer reading files directly rather than piping through filters
### When checking command output:
- Run commands directly without pipes when possible
- If you need to limit output, use command-specific flags (e.g., `git log -n 10` instead of `git log | head -10`)
- Avoid chained pipes that can cause output to buffer indefinitely
+73
View File
@@ -0,0 +1,73 @@
# world.hanzo.ai — Vite SPA + same-origin /api/* data backend, one Go binary.
#
# The SPA fetches SAME-ORIGIN /api/* (runtime.ts resolves to the current origin),
# so world.hanzo.ai (and every *.hanzo.app fork) must serve /api/* itself. The
# old static-only image (hanzoai/static) had no /api, so every data + live-video
# request fell through to the SPA index.html — the app showed no data and no
# video. This image fixes that: cmd/world serves BOTH the static build (with SPA
# fallback for client routes) AND the ~48 /api/* endpoints (internal/world),
# each a faithful Go port of the original edge function.
#
# Built on Hanzo's own hardware (platform.hanzo.ai -> arcd / in-cluster
# BuildKit), never on GitHub builders.
#
# Build (BuildKit, on-cluster):
# --opt=context=https://github.com/hanzoai/world.git#<sha>
# --opt=filename=Dockerfile
# --output=type=image,name=ghcr.io/hanzoai/world:<tag>,push=true
#
# Data-source API keys (all optional; a missing key degrades that endpoint to a
# clean empty payload, never a 5xx) are injected as env at deploy time from KMS:
# YOUTUBE_API_KEY (live-video reliability; scrape fallback needs no key),
# FRED_API_KEY, FINNHUB_API_KEY, NASA_FIRMS_API_KEY, EIA_API_KEY,
# ACLED_ACCESS_TOKEN, CLOUDFLARE_API_TOKEN, WINGBITS_API_KEY, WS_RELAY_URL,
# HANZO_AI_KEY (+ HANZO_AI_BASE / HANZO_AI_MODEL) for the AI endpoints.
# ---- web stage: Vite static build (-> /app/dist) -------------------------
FROM node:20-bookworm-slim AS web
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# vite.config.ts: default base '/', default outDir 'dist'. VITE_VARIANT defaults
# to the full layer set; no build-time secrets are required (the runtime API base
# is same-origin, resolved in the browser).
ARG VITE_MAPBOX_TOKEN
ENV VITE_MAPBOX_TOKEN=$VITE_MAPBOX_TOKEN
RUN npm run build
# ---- go stage: build the static server binary (CGO-free) -----------------
# go 1.26: go.mod requires >= 1.26.4 (github.com/hanzoai/sqlite drop-in). The
# binary stays CGO-free — with CGO_ENABLED=0, hanzoai/sqlite selects its vendored
# pure-Go engine (zero modernc.org/* in the module graph). That engine gates FTS5
# behind the `sqlite_fts5` build tag, which the store's items_fts virtual table
# needs — so the build below MUST carry `-tags sqlite_fts5` or Open degrades.
FROM golang:1.26-alpine AS gobuild
WORKDIR /src
# git: go resolves the PRIVATE indirect dep github.com/hanzoai/csqlite 'direct'
# (not via the module proxy), which needs the git binary + an https credential.
# alpine ships neither, so add git and mount the gh_token BuildKit secret that
# hanzoai/ci passes (--secret id=gh_token); the mount is a no-op for public builds.
RUN apk add --no-cache git
ENV GOPRIVATE=github.com/hanzoai,github.com/luxfi,github.com/zooai
# Deps: hanzo-kv client (go-redis) + embedded SQLite (modernc). Download once for
# a cached layer before the source is copied.
COPY go.mod go.sum ./
RUN --mount=type=secret,id=gh_token \
if [ -s /run/secrets/gh_token ]; then \
git config --global url."https://x-access-token:$(cat /run/secrets/gh_token)@github.com/".insteadOf "https://github.com/"; \
fi; \
go mod download
COPY cmd ./cmd
COPY internal ./internal
RUN CGO_ENABLED=0 GOOS=linux go build -tags sqlite_fts5 -trimpath -ldflags="-s -w" -o /out/world ./cmd/world
# ---- final stage: minimal image running the Go binary --------------------
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata \
&& adduser -D -H -u 10001 world
COPY --from=gobuild /out/world /usr/local/bin/world
COPY --from=web /app/dist /srv
USER world
EXPOSE 3000
ENTRYPOINT ["/usr/local/bin/world", "--root=/srv", "--addr=:3000"]
+4 -3
View File
@@ -1,13 +1,14 @@
MIT License
Copyright (c) 2025-2026 Elie Habib
Copyright (c) 2024-2026 Elie Habib
Copyright (c) 2026 Hanzo AI
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
+8
View File
@@ -0,0 +1,8 @@
World Monitor — Hanzo fork
Copyright (c) 2026 Hanzo AI. Licensed under the MIT License (see LICENSE).
Forked from koala73/worldmonitor (https://github.com/koala73/worldmonitor) at
v2.4.0 (commit 572f3808) — the last release under the MIT License. This fork
tracks only the MIT-era code and develops forward independently.
Original work © 2024-2026 Elie Habib, used under the MIT License.
+502 -70
View File
@@ -1,3 +1,5 @@
<p align="center"><img src=".github/hero.svg" alt="world" width="880"></p>
# World Monitor
**Real-time global intelligence dashboard** — AI-powered news aggregation, geopolitical monitoring, and infrastructure tracking in a unified situational awareness interface.
@@ -7,11 +9,23 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
[![TypeScript](https://img.shields.io/badge/TypeScript-007ACC?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![Last commit](https://img.shields.io/github/last-commit/koala73/worldmonitor)](https://github.com/koala73/worldmonitor/commits/main)
[![Latest release](https://img.shields.io/github/v/release/koala73/worldmonitor?style=flat)](https://github.com/koala73/worldmonitor/releases/latest)
<p align="center">
<a href="https://worldmonitor.app"><strong>Live Demo</strong></a> &nbsp;·&nbsp;
<a href="https://tech.worldmonitor.app"><strong>Tech Variant</strong></a> &nbsp;·&nbsp;
<a href="./docs/DOCUMENTATION.md"><strong>Full Documentation</strong></a>
<a href="https://worldmonitor.app"><img src="https://img.shields.io/badge/Web_App-worldmonitor.app-blue?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Web App"></a>&nbsp;
<a href="https://tech.worldmonitor.app"><img src="https://img.shields.io/badge/Tech_Variant-tech.worldmonitor.app-0891b2?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Tech Variant"></a>&nbsp;
<a href="https://finance.worldmonitor.app"><img src="https://img.shields.io/badge/Finance_Variant-finance.worldmonitor.app-059669?style=for-the-badge&logo=googlechrome&logoColor=white" alt="Finance Variant"></a>
</p>
<p align="center">
<a href="https://worldmonitor.app/api/download?platform=windows-exe"><img src="https://img.shields.io/badge/Download-Windows_(.exe)-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Download Windows"></a>&nbsp;
<a href="https://worldmonitor.app/api/download?platform=macos-arm64"><img src="https://img.shields.io/badge/Download-macOS_Apple_Silicon-000000?style=for-the-badge&logo=apple&logoColor=white" alt="Download macOS ARM"></a>&nbsp;
<a href="https://worldmonitor.app/api/download?platform=macos-x64"><img src="https://img.shields.io/badge/Download-macOS_Intel-555555?style=for-the-badge&logo=apple&logoColor=white" alt="Download macOS Intel"></a>
</p>
<p align="center">
<a href="./docs/DOCUMENTATION.md"><strong>Full Documentation</strong></a> &nbsp;·&nbsp;
<a href="https://github.com/koala73/worldmonitor/releases/latest"><strong>All Releases</strong></a>
</p>
![World Monitor Dashboard](new-world-monitor.png)
@@ -23,11 +37,14 @@
| Problem | Solution |
|---------|----------|
| News scattered across 100+ sources | **Single unified dashboard** with 100+ curated feeds |
| No geospatial context for events | **Interactive map** with 25+ toggleable data layers |
| No geospatial context for events | **Interactive map** with 35+ toggleable data layers |
| Information overload | **AI-synthesized briefs** with focal point detection |
| Crypto/macro signal noise | **7-signal market radar** with composite BUY/CASH verdict |
| Expensive OSINT tools ($$$) | **100% free & open source** |
| Static news feeds | **Real-time updates** with live video streams |
| Web-only dashboards | **Native desktop app** (Tauri) + installable PWA with offline map support |
| Flat 2D maps | **3D WebGL globe** with deck.gl rendering and 35+ toggleable data layers |
| Siloed financial data | **Finance variant** with 92 stock exchanges, 19 financial centers, 13 central banks, and Gulf FDI tracking |
---
@@ -37,37 +54,54 @@
|---------|-----|-------|
| **World Monitor** | [worldmonitor.app](https://worldmonitor.app) | Geopolitics, military, conflicts, infrastructure |
| **Tech Monitor** | [tech.worldmonitor.app](https://tech.worldmonitor.app) | Startups, AI/ML, cloud, cybersecurity |
| **Finance Monitor** | [finance.worldmonitor.app](https://finance.worldmonitor.app) | Global markets, trading, central banks, Gulf FDI |
Both variants run from a single codebase — switch between them with one click.
All three variants run from a single codebase — switch between them with one click via the header bar (🌍 WORLD | 💻 TECH | 📈 FINANCE).
---
## Key Features
### Interactive Global Map
- **25+ data layers** — conflicts, military bases, nuclear facilities, undersea cables, pipelines, satellite fire detection, protests, natural disasters, datacenters, and more
- **Smart clustering** — markers intelligently group at low zoom, expand on zoom in
- **Progressive disclosure** — detail layers (bases, nuclear, datacenters) appear only when zoomed in; zoom-adaptive opacity prevents clutter at world view
### Localization & Regional Support
- **Multilingual UI** — Fully localized interface supporting **English, French, Spanish, German, Italian, Portuguese, Dutch, Swedish, Russian, Arabic, Chinese, and Japanese**.
- **RTL Support** — Native right-to-left layout support for Arabic (`ar`) and Hebrew.
- **Localized News Feeds** — Region-specific RSS selection based on language preference (e.g., viewing the app in French loads Le Monde, Jeune Afrique, and France24).
- **AI Translation** — Integrated LLM translation for news headlines and summaries, enabling cross-language intelligence gathering.
- **Regional Intelligence** — Dedicated monitoring panels for Africa, Latin America, Middle East, and Asia with local sources.
### Interactive 3D Globe
- **WebGL-accelerated rendering** — deck.gl + MapLibre GL JS for smooth 60fps performance with thousands of concurrent markers. Switchable between **3D globe** (with pitch/rotation) and **flat map** mode via `VITE_MAP_INTERACTION_MODE`
- **35+ data layers** — conflicts, military bases, nuclear facilities, undersea cables, pipelines, satellite fire detection, protests, natural disasters, datacenters, displacement flows, climate anomalies, cyber threat IOCs, stock exchanges, financial centers, central banks, commodity hubs, Gulf investments, and more
- **Smart clustering** — Supercluster groups markers at low zoom, expands on zoom in. Cluster thresholds adapt to zoom level
- **Progressive disclosure** — detail layers (bases, nuclear, datacenters) appear only when zoomed in; zoom-adaptive opacity fades markers from 0.2 at world view to 1.0 at street level
- **Label deconfliction** — overlapping labels (e.g., multiple BREAKING badges) are automatically suppressed by priority, highest-severity first
- **8 regional presets** — Global, Americas, Europe, MENA, Asia, Africa, Oceania, Latin America
- **Time filtering** — 1h, 6h, 24h, 48h, 7d event windows
- **URL state sharing** — map center, zoom, active layers, and time range are encoded in the URL for shareable views (`?view=mena&zoom=4&layers=conflicts,bases`)
### AI-Powered Intelligence
- **World Brief** — LLM-synthesized summary of top global developments (Groq Llama 3.1, Redis-cached)
- **Hybrid Threat Classification** — instant keyword classifier with async LLM override for higher-confidence results
- **Focal Point Detection** — correlates entities across news, military activity, protests, outages, and markets to identify convergence
- **Country Instability Index** — real-time stability scores for 20 monitored nations using weighted multi-signal blend
- **Country Instability Index** — real-time stability scores for 22 monitored nations using weighted multi-signal blend
- **Trending Keyword Spike Detection** — 2-hour rolling window vs 7-day baseline flags surging terms across RSS feeds, with CVE/APT entity extraction and auto-summarization
- **Strategic Posture Assessment** — composite risk score combining all intelligence modules with trend detection
- **Country Brief Pages** — click any country for a full-page intelligence dossier with CII score ring, AI-generated analysis, top news with citation anchoring, prediction markets, 7-day event timeline, active signal chips, infrastructure exposure, and stock market index — exportable as JSON, CSV, or image
### Real-Time Data Layers
<details>
<summary><strong>Geopolitical</strong></summary>
- Active conflict zones with escalation tracking
- Active conflict zones with escalation tracking (UCDP + ACLED)
- Intelligence hotspots with news correlation
- Social unrest events (ACLED + GDELT)
- Social unrest events (dual-source: ACLED protests + GDELT geo-events, Haversine-deduplicated)
- Natural disasters from 3 sources (USGS earthquakes M4.5+, GDACS alerts, NASA EONET events)
- Sanctions regimes
- Cyber threat IOCs (C2 servers, malware hosts, phishing, malicious URLs) geo-located on the globe
- Weather alerts and severe conditions
</details>
@@ -90,6 +124,7 @@ Both variants run from a single codebase — switch between them with one click.
- Undersea cables with landing points
- Oil & gas pipelines
- AI datacenters (111 major clusters)
- 83 strategic ports across 6 types (container, oil, LNG, naval, mixed, bulk) with throughput rankings
- Internet outages (Cloudflare Radar)
- Critical mineral deposits
- NASA FIRMS satellite fire detection (VIIRS thermal hotspots)
@@ -120,44 +155,95 @@ Both variants run from a single codebase — switch between them with one click.
</details>
<details>
<summary><strong>Finance & Markets</strong> (Finance variant)</summary>
- 92 global stock exchanges — mega (NYSE, NASDAQ, Shanghai, Euronext, Tokyo), major (Hong Kong, London, NSE/BSE, Toronto, Korea, Saudi Tadawul), and emerging markets — with market caps and trading hours
- 19 financial centers — ranked by Global Financial Centres Index (New York #1 through offshore centers: Cayman Islands, Luxembourg, Bermuda, Channel Islands)
- 13 central banks — Federal Reserve, ECB, BoJ, BoE, PBoC, SNB, RBA, BoC, RBI, BoK, BCB, SAMA, plus supranational institutions (BIS, IMF)
- 10 commodity hubs — exchanges (CME Group, ICE, LME, SHFE, DCE, TOCOM, DGCX, MCX) and physical hubs (Rotterdam, Houston)
- Gulf FDI investment layer — 64 Saudi/UAE foreign direct investments plotted globally, color-coded by status (operational, under-construction, announced), sized by investment amount
</details>
### Live News & Video
- **100+ RSS feeds** across geopolitics, defense, energy, tech
- **Live video streams** — Bloomberg, Sky News, Al Jazeera, CNBC, and more
- **Custom monitors** — Create keyword-based alerts for any topic
- **150+ RSS feeds** across geopolitics, defense, energy, tech, and finance — domain-allowlisted proxy prevents CORS issues. Each variant loads its own curated feed set: ~25 categories for geopolitical, ~20 for tech, ~18 for finance
- **8 live video streams** — Bloomberg, Sky News, Al Jazeera, Euronews, DW, France24, CNBC, Al Arabiya — with automatic live detection that scrapes YouTube channel pages every 5 minutes to find active streams
- **Desktop embed bridge** — YouTube's IFrame API restricts playback in native webviews (error 153). The dashboard detects this and transparently routes through a cloud-hosted embed proxy with bidirectional message passing (play/pause/mute/unmute/loadVideo)
- **Idle-aware playback** — video players pause and are removed from the DOM after 5 minutes of inactivity, resuming when the user returns. Tab visibility changes also suspend/resume streams
- **Custom monitors** — Create keyword-based alerts for any topic, color-coded with persistent storage
- **Entity extraction** — Auto-links countries, leaders, organizations
- **Virtual scrolling** — news panels render only visible DOM elements, handling thousands of items without browser lag
### Signal Aggregation & Anomaly Detection
- **Multi-source signal fusion** — internet outages, military flights, naval vessels, protests, AIS disruptions, and satellite fires are aggregated into a unified intelligence picture with per-country and per-region clustering
- **Multi-source signal fusion** — internet outages, military flights, naval vessels, protests, AIS disruptions, satellite fires, and keyword spikes are aggregated into a unified intelligence picture with per-country and per-region clustering
- **Temporal baseline anomaly detection** — Welford's online algorithm computes streaming mean/variance per event type, region, weekday, and month over a 90-day window. Z-score thresholds (1.5/2.0/3.0) flag deviations like "Military flights 3.2x normal for Thursday (January)" — stored in Redis via Upstash
- **Regional convergence scoring** — when multiple signal types spike in the same geographic area, the system identifies convergence zones and escalates severity
### Story Sharing & Social Export
- **Shareable intelligence stories** — generate country-level intelligence briefs with CII scores, threat counts, theater posture, and related prediction markets
- **Multi-platform export** — custom-formatted sharing for Twitter/X, LinkedIn, WhatsApp, Telegram, Reddit, and Facebook with platform-appropriate formatting
- **Deep links** — every story generates a unique URL (`/story?c=<country>&t=<type>`) with dynamic Open Graph meta tags for rich social previews
- **Canvas-based image generation** — stories render as PNG images for visual sharing, with QR codes linking back to the live dashboard
### Desktop Application (Tauri)
- **Native desktop app** for macOS and Windows — packages the full dashboard with a local Node.js sidecar that runs all 60+ API handlers locally
- **OS keychain integration** — API keys stored in the system credential manager (macOS Keychain, Windows Credential Manager), never in plaintext files
- **Token-authenticated sidecar** — a unique session token prevents other local processes from accessing the sidecar on localhost. Generated per launch using randomized hashing
- **Cloud fallback** — when a local API handler fails or is missing, requests transparently fall through to the cloud deployment (worldmonitor.app) with origin headers stripped
- **Settings window** — dedicated configuration UI (Cmd+,) for managing 17 API keys with validation, signup links, and feature-availability indicators
- **Verbose debug mode** — toggle traffic logging with persistent state across restarts. View the last 200 requests with timing, status codes, and error details
- **DevTools toggle** — Cmd+Alt+I opens the embedded web inspector for debugging
### Progressive Web App
- **Installable** — the dashboard can be installed to the home screen on mobile or as a standalone desktop app via Chrome's install prompt. Full-screen `standalone` display mode with custom theme color
- **Offline map support** — MapTiler tiles are cached using a CacheFirst strategy (up to 500 tiles, 30-day TTL), enabling map browsing without a network connection
- **Smart caching strategies** — APIs and RSS feeds use NetworkOnly (real-time data must always be fresh), while fonts (1-year TTL), images (7-day StaleWhileRevalidate), and static assets (1-year immutable) are aggressively cached
- **Auto-updating service worker** — checks for new versions every 60 minutes. Tauri desktop builds skip service worker registration entirely (uses native APIs instead)
- **Offline fallback** — a branded fallback page with retry button is served when the network is unavailable
### Additional Capabilities
- Signal intelligence with "Why It Matters" context
- Infrastructure cascade analysis with proximity correlation
- Maritime & aviation tracking with surge detection
- Prediction market integration (Polymarket) as leading indicators
- Prediction market integration (Polymarket) with 3-tier JA3 bypass (browser-direct → Tauri native TLS → cloud proxy)
- Service status monitoring (cloud providers, AI services)
- Shareable map state via URL parameters (view, zoom, coordinates, time range, active layers)
- Data freshness monitoring across 14 data sources with explicit intelligence gap reporting
- Per-feed circuit breakers with 5-minute cooldowns to prevent cascading failures
- Browser-side ML worker (Transformers.js) for NER and sentiment analysis without server dependency
- **Cmd+K search** — fuzzy search across news headlines, countries, and entities
- **Virtual scrolling** — news panels render only visible DOM elements, handling thousands of items without browser lag
- **Cmd+K search** — fuzzy search across 20+ result types: news headlines, countries (with direct country brief navigation), hotspots, markets, military bases, cables, pipelines, datacenters, nuclear facilities, tech companies, and more
- **Historical playback** — dashboard snapshots are stored in IndexedDB. A time slider allows rewinding to any saved state, with live updates paused during playback
- **Mobile detection** — screens below 768px receive a warning modal since the dashboard is designed for multi-panel desktop use
- **UCDP conflict classification** — countries with active wars (1,000+ battle deaths/year) receive automatic CII floor scores, preventing optimistic drift
- **HAPI humanitarian data** — UN OCHA humanitarian access metrics feed into country-level instability scoring
- **HAPI humanitarian data** — UN OCHA humanitarian access metrics and displacement flows feed into country-level instability scoring with dual-perspective (origins vs. hosts) panel
- **Idle-aware resource management** — animations pause after 2 minutes of inactivity and when the tab is hidden, preventing battery drain. Video streams are destroyed from the DOM and recreated on return
- **Country-specific stock indices** — country briefs display the primary stock market index with 1-week change (S&P 500 for US, Shanghai Composite for China, etc.) via the `/api/stock-index` endpoint
- **Climate anomaly panel** — 15 conflict-prone zones monitored for temperature/precipitation deviations against 30-day ERA5 baselines, with severity classification feeding into CII
- **Country brief export** — every brief is downloadable as structured JSON, flattened CSV, or rendered PNG image, enabling offline analysis and reporting workflows
- **Print/PDF support** — country briefs include a print button that triggers the browser's native print dialog, producing clean PDF output
- **Oil & energy analytics** — WTI/Brent crude prices, US production (Mbbl/d), and inventory levels via the EIA API with weekly trend detection
- **Population exposure estimation** — WorldPop density data estimates civilian population within event-specific radii (50100km) for conflicts, earthquakes, floods, and wildfires
- **Trending keywords panel** — real-time display of surging terms across all RSS feeds with spike severity, source count, and AI-generated context summaries
- **Download banner** — persistent notification for web users linking to native desktop installers for their detected platform
- **Download API** — `/api/download?platform={windows-exe|windows-msi|macos-arm64|macos-x64}` redirects to the matching GitHub Release asset, with fallback to the releases page
- **Non-tier country support** — clicking countries outside the 22 tier-1 list opens a brief with available data (news, markets, infrastructure) and a "Limited coverage" badge; country names for non-tier countries resolve via `Intl.DisplayNames`
- **Feature toggles** — 14 runtime toggles (AI/Groq, AI/OpenRouter, FRED economic, EIA energy, internet outages, ACLED conflicts, threat intel feeds, AIS relay, OpenSky, Finnhub, NASA FIRMS) stored in `localStorage`, allowing administrators to enable/disable data sources without rebuilding
- **AIS chokepoint detection** — the relay server monitors 8 strategic maritime chokepoints (Strait of Hormuz, Suez Canal, Malacca Strait, Bab el-Mandeb, Panama Canal, Taiwan Strait, South China Sea, Turkish Straits) and classifies transiting vessels by naval candidacy using MMSI prefixes, ship type codes, and name patterns
- **AIS density grid** — vessel positions are aggregated into 2°×2° geographic cells over 30-minute windows, producing a heatmap of maritime traffic density that feeds into convergence detection
- **Panel resizing** — drag handles on panel edges allow height adjustment (span-1 through span-4 grid rows), persisted to localStorage. Double-click resets to default height
---
## Regression Testing
Map overlay behavior is validated in Playwright using the map harness (`/map-harness.html`).
Map overlay behavior is validated in Playwright using the map harness (`/tests/map-harness.html`).
- Cluster-state cache initialization guard:
- `updates protest marker click payload after data refresh`
@@ -170,6 +256,37 @@ Map overlay behavior is validated in Playwright using the map harness (`/map-har
## How It Works
### Country Brief Pages
Clicking any country on the map opens a full-page intelligence dossier — a single-screen synthesis of all intelligence modules for that country. The brief is organized into a two-column layout:
**Left column**:
- **Instability Index** — animated SVG score ring (0100) with four component breakdown bars (Unrest, Conflict, Security, Information), severity badge, and trend indicator
- **Intelligence Brief** — AI-generated analysis (Groq Llama 3.1) with inline citation anchors `[1]``[8]` that scroll to the corresponding news source when clicked
- **Top News** — 8 most relevant headlines for the country, threat-level color-coded, with source and time-ago metadata
**Right column**:
- **Active Signals** — real-time chip indicators for protests, military aircraft, naval vessels, internet outages, earthquakes, displacement flows, climate stress, conflict events, and the country's stock market index (1-week change)
- **7-Day Timeline** — D3.js-rendered event chart with 4 severity-coded lanes (protest, conflict, natural, military), interactive tooltips, and responsive resizing
- **Prediction Markets** — top 3 Polymarket contracts by volume with probability bars and external links
- **Infrastructure Exposure** — pipelines, undersea cables, datacenters, military bases, nuclear facilities, and ports within a 600km radius of the country centroid, ranked by distance
**Headline relevance filtering**: each country has an alias map (e.g., `US → ["united states", "american", "washington", "pentagon", "biden", "trump"]`). Headlines are filtered using a negative-match algorithm — if another country's alias appears earlier in the headline title than the target country's alias, the headline is excluded. This prevents cross-contamination (e.g., a headline about Venezuela mentioning "Washington sanctions" appearing in the US brief).
**Export options**: briefs are exportable as JSON (structured data with all scores, signals, and headlines), CSV (flattened tabular format), or PNG image. A print button triggers the browser's native print dialog for PDF export.
### Local-First Country Detection
Map clicks resolve to countries using a local geometry service rather than relying on network reverse-geocoding (Nominatim). The system loads a GeoJSON file containing polygon boundaries for ~200 countries and builds an indexed spatial lookup:
1. **Bounding box pre-filter** — each country's polygon(s) are wrapped in a bounding box (`[minLon, minLat, maxLon, maxLat]`). Points outside the bbox are rejected without polygon intersection testing.
2. **Ray-casting algorithm** — for points inside the bbox, a ray is cast from the point along the positive x-axis. The number of polygon edge intersections determines inside/outside status (odd = inside). Edge cases are handled: points on segment boundaries return `true`, and polygon holes are subtracted (a point inside an outer ring but also inside a hole is excluded).
3. **MultiPolygon support** — countries with non-contiguous territories (e.g., the US with Alaska and Hawaii, Indonesia with thousands of islands) use MultiPolygon geometries where each polygon is tested independently.
This approach provides sub-millisecond country detection entirely in the browser, with no network latency. The geometry data is preloaded at app startup and cached for the session. For countries not in the GeoJSON (rare), the system falls back to hardcoded rectangular bounding boxes, and finally to network reverse-geocoding as a last resort.
### Threat Classification Pipeline
Every news item passes through a two-stage classification pipeline:
@@ -181,7 +298,7 @@ This hybrid approach means the UI is never blocked waiting for AI — users see
### Country Instability Index (CII)
Each monitored country receives a real-time instability score (0100) computed from:
22 tier-1 countries receive continuous monitoring: US, Russia, China, Ukraine, Iran, Israel, Taiwan, North Korea, Saudi Arabia, Turkey, Poland, Germany, France, UK, India, Pakistan, Syria, Yemen, Myanmar, Venezuela, Brazil, and UAE. Each receives a real-time instability score (0100) computed from:
| Component | Weight | Details |
|-----------|--------|---------|
@@ -224,6 +341,7 @@ Nine operational theaters are continuously assessed for military posture escalat
| Black Sea | ISR flights, naval movements |
Posture levels escalate from NORMAL → ELEVATED → CRITICAL based on a composite of:
- **Aircraft count** in theater (both resident and transient)
- **Strike capability** — the presence of tankers + AWACS + fighters together indicates strike packaging, not routine training
- **Naval presence** — carrier groups and combatant formations
@@ -257,6 +375,7 @@ Disruption Event → Affected Node → Cascade Propagation (BFS, depth ≤ 3)
**Impact calculation**: `strength = edge_weight × disruption_level × (1 redundancy)`
Strategic chokepoint modeling captures real-world dependencies:
- **Strait of Hormuz** — 80% of Japan's oil, 70% of South Korea's, 60% of India's, 40% of China's
- **Suez Canal** — EU-Asia trade routes (Germany, Italy, UK, China)
- **Malacca Strait** — 80% of China's oil transit
@@ -271,6 +390,20 @@ When a news event is geo-located, the system automatically identifies critical i
A 74-hub strategic location database infers geography from headlines via keyword matching. Hubs span capitals, conflict zones, strategic chokepoints (Strait of Hormuz, Suez Canal, Malacca Strait), and international organizations. Confidence scoring is boosted for critical-tier hubs and active conflict zones, enabling map-driven news placement without requiring explicit location metadata from RSS feeds.
### Entity Index & Cross-Referencing
A structured entity registry catalogs countries, organizations, world leaders, and military entities with multiple lookup indices:
| Index Type | Purpose | Example |
|-----------|---------|---------|
| **ID index** | Direct entity lookup | `entity:us` → United States profile |
| **Alias index** | Name variant matching | "America", "USA", "United States" → same entity |
| **Keyword index** | Contextual detection | "Pentagon", "White House" → United States |
| **Sector index** | Domain grouping | "military", "energy", "tech" |
| **Type index** | Category filtering | "country", "organization", "leader" |
Entity matching uses word-boundary regex to prevent false positives (e.g., "Iran" matching "Ukraine"). Confidence scores are tiered by match quality: exact name matches score 1.0, aliases 0.850.95, and keyword matches 0.7. When the same entity surfaces across multiple independent data sources (news, military tracking, protest feeds, market signals), the system identifies it as a focal point and escalates its prominence in the intelligence picture.
### Temporal Baseline Anomaly Detection
Rather than relying on static thresholds, the system learns what "normal" looks like and flags deviations. Each event type (military flights, naval vessels, protests, news velocity, AIS gaps, satellite fires) is tracked per region with separate baselines for each weekday and month — because military activity patterns differ on Tuesdays vs. weekends, and January vs. July.
@@ -285,6 +418,115 @@ The algorithm uses **Welford's online method** for numerically stable streaming
A minimum of 10 historical samples is required before anomalies are reported, preventing false positives during the learning phase. Anomalies are ingested back into the signal aggregator, where they compound with other signals for convergence detection.
### Trending Keyword Spike Detection
Every RSS headline is tokenized into individual terms and tracked in per-term frequency maps. A 2-hour rolling window captures current activity while a 7-day baseline (refreshed hourly) establishes what "normal" looks like for each term. A spike fires when all conditions are met:
| Condition | Threshold |
|-----------|-----------|
| **Absolute count** | > `minSpikeCount` (5 mentions) |
| **Relative surge** | > baseline × `spikeMultiplier` (3×) |
| **Source diversity** | ≥ 2 unique RSS feed sources |
| **Cooldown** | 30 minutes since last spike for the same term |
The tokenizer extracts CVE identifiers (`CVE-2024-xxxxx`), APT/FIN threat actor designators, and 12 compound terms for world leaders (e.g., "Xi Jinping", "Kim Jong Un") that would be lost by naive whitespace splitting. A configurable blocklist suppresses common noise terms.
Detected spikes are auto-summarized via Groq (rate-limited to 5 summaries/hour) and emitted as `keyword_spike` signals into the correlation engine, where they compound with other signal types for convergence detection. The term registry is capped at 10,000 entries with LRU eviction to bound memory usage. All thresholds (spike multiplier, min count, cooldown, blocked terms) are configurable via the Settings panel.
### Cyber Threat Intelligence Layer
Five threat intelligence feeds provide indicators of compromise (IOCs) for active command-and-control servers, malware distribution hosts, phishing campaigns, and malicious URLs:
| Feed | IOC Type | Coverage |
|------|----------|----------|
| **Feodo Tracker** (abuse.ch) | C2 servers | Botnet C&C infrastructure |
| **URLhaus** (abuse.ch) | Malware hosts | Malware distribution URLs |
| **C2IntelFeeds** | C2 servers | Community-sourced C2 indicators |
| **AlienVault OTX** | Mixed | Open threat exchange pulse IOCs |
| **AbuseIPDB** | Malicious IPs | Crowd-sourced abuse reports |
Each IP-based IOC is geo-enriched using ipinfo.io with freeipapi.com as fallback. Geolocation results are Redis-cached for 24 hours. Enrichment runs concurrently — 16 parallel lookups with a 12-second timeout, processing up to 250 IPs per collection run.
IOCs are classified into four types (`c2_server`, `malware_host`, `phishing`, `malicious_url`) with four severity levels, rendered as color-coded scatter dots on the globe. The layer uses a 10-minute cache, a 14-day rolling window, and caps display at 500 IOCs to maintain rendering performance.
### Natural Disaster Monitoring
Three independent sources are merged into a unified disaster picture, then deduplicated on a 0.1° geographic grid:
| Source | Coverage | Types | Update Frequency |
|--------|----------|-------|------------------|
| **USGS** | Global earthquakes M4.5+ | Earthquakes | 5 minutes |
| **GDACS** | UN-coordinated disaster alerts | Earthquakes, floods, cyclones, volcanoes, wildfires, droughts | Real-time |
| **NASA EONET** | Earth observation events | 13 natural event categories (30-day open events) | Real-time |
GDACS events carry color-coded alert levels (Red = critical, Orange = high) and are filtered to exclude low-severity Green alerts. EONET wildfires are filtered to events within 48 hours to prevent stale data. Earthquakes from EONET are excluded since USGS provides higher-quality seismological data.
The merged output feeds into the signal aggregator for geographic convergence detection — e.g., an earthquake near a pipeline triggers an infrastructure cascade alert.
### Dual-Source Protest Tracking
Protest data is sourced from two independent providers to reduce single-source bias:
1. **ACLED** (Armed Conflict Location & Event Data) — 30-day window, tokenized API with Redis caching (10-minute TTL). Covers protests, riots, strikes, and demonstrations with actor attribution and fatality counts.
2. **GDELT** (Global Database of Events, Language, and Tone) — 7-day geospatial event feed filtered to protest keywords. Events with mention count ≥5 are included; those above 30 are marked as `validated`.
Events from both sources are **Haversine-deduplicated** on a 0.5° grid (~50km) with same-day matching. ACLED events take priority due to higher editorial confidence. Severity is classified as:
- **High** — fatalities present or riot/clash keywords
- **Medium** — standard protest/demonstration
- **Low** — default
Protest scoring is regime-aware: democratic countries use logarithmic scaling (routine protests don't trigger instability), while authoritarian states use linear scoring (every protest is significant). Fatalities and concurrent internet outages apply severity boosts.
### Climate Anomaly Detection
15 conflict-prone and disaster-prone zones are continuously monitored for temperature and precipitation anomalies using Open-Meteo ERA5 reanalysis data. A 30-day baseline is computed, and current conditions are compared against it to determine severity:
| Severity | Temperature Deviation | Precipitation Deviation |
|----------|----------------------|------------------------|
| **Extreme** | > 5°C above baseline | > 80mm/day above baseline |
| **Moderate** | > 3°C above baseline | > 40mm/day above baseline |
| **Normal** | Within expected range | Within expected range |
Anomalies feed into the signal aggregator, where they amplify CII scores for affected countries (climate stress is a recognized conflict accelerant). The Climate Anomaly panel surfaces these deviations in a severity-sorted list.
### Displacement Tracking
Refugee and displacement data is sourced from the UN OCHA Humanitarian API (HAPI), providing population-level counts for refugees, asylum seekers, and internally displaced persons (IDPs). The Displacement panel offers two perspectives:
- **Origins** — countries people are fleeing from, ranked by outflow volume
- **Hosts** — countries absorbing displaced populations, ranked by intake
Crisis badges flag countries with extreme displacement: > 1 million displaced (red), > 500,000 (orange). Displacement outflow feeds into the CII as a component signal — high displacement is a lagging indicator of instability that persists even when headlines move on.
### Population Exposure Estimation
Active events (conflicts, earthquakes, floods, wildfires) are cross-referenced against WorldPop population density data to estimate the number of civilians within the impact zone. Event-specific radii reflect typical impact footprints:
| Event Type | Radius | Rationale |
|-----------|--------|-----------|
| **Conflicts** | 50 km | Direct combat zone + displacement buffer |
| **Earthquakes** | 100 km | Shaking intensity propagation |
| **Floods** | 100 km | Watershed and drainage basin extent |
| **Wildfires** | 30 km | Smoke and evacuation perimeter |
API calls to WorldPop are batched concurrently (max 10 parallel requests) to handle multiple simultaneous events without sequential bottlenecks. The Population Exposure panel displays a summary header with total affected population and a per-event breakdown table.
### Strategic Port Infrastructure
83 strategic ports are cataloged across six types, reflecting their role in global trade and military posture:
| Type | Count | Examples |
|------|-------|---------|
| **Container** | 21 | Shanghai (#1, 47M+ TEU), Singapore, Ningbo, Shenzhen |
| **Oil/LNG** | 8 | Ras Tanura (Saudi), Sabine Pass (US), Fujairah (UAE) |
| **Chokepoint** | 8 | Suez Canal, Panama Canal, Strait of Malacca |
| **Naval** | 6 | Zhanjiang, Yulin (China), Vladivostok (Russia) |
| **Mixed** | 15+ | Ports serving multiple roles (trade + military) |
| **Bulk** | 20+ | Regional commodity ports |
Ports are ranked by throughput and weighted by strategic importance in the infrastructure cascade model: oil/LNG terminals carry 0.9 criticality, container ports 0.7, and naval bases 0.4. Port proximity appears in the Country Brief infrastructure exposure section.
### Browser-Side ML Pipeline
The dashboard runs a full ML pipeline in the browser via Transformers.js, with no server dependency for core intelligence. This is automatically disabled on mobile devices to conserve memory.
@@ -304,18 +546,30 @@ News velocity is tracked per cluster — when multiple Tier 12 sources conver
All real-time data sources feed into a central signal aggregator that builds a unified geospatial intelligence picture. Signals are clustered by country and region, with each signal carrying a severity (low/medium/high), geographic coordinates, and metadata. The aggregator:
1. **Clusters by country** — groups signals from diverse sources (flights, vessels, protests, fires, outages) into per-country profiles
1. **Clusters by country** — groups signals from diverse sources (flights, vessels, protests, fires, outages, `keyword_spike`) into per-country profiles
2. **Detects regional convergence** — identifies when multiple signal types spike in the same geographic corridor (e.g., military flights + protests + satellite fires in Eastern Mediterranean)
3. **Feeds downstream analysis** — the CII, hotspot escalation, focal point detection, and AI insights modules all consume the aggregated signal picture rather than raw data
### Data Freshness & Intelligence Gaps
A singleton tracker monitors 14 data sources (GDELT, RSS, AIS, military flights, earthquakes, weather, outages, ACLED, Polymarket, economic indicators, NASA FIRMS, and more) with status categorization: fresh (<15 min), stale (1h), very_stale (6h), no_data, error, disabled. It explicitly reports **intelligence gaps** — what analysts can't see — preventing false confidence when critical data sources are down or degraded.
A singleton tracker monitors 22 data sources (GDELT, RSS, AIS, military flights, earthquakes, weather, outages, ACLED, Polymarket, economic indicators, NASA FIRMS, cyber threat feeds, trending keywords, oil/energy, population exposure, and more) with status categorization: fresh (<15 min), stale (1h), very_stale (6h), no_data, error, disabled. It explicitly reports **intelligence gaps** — what analysts can't see — preventing false confidence when critical data sources are down or degraded.
### Prediction Markets as Leading Indicators
Polymarket geopolitical markets are queried using tag-based filters (Ukraine, Iran, China, Taiwan, etc.) with 5-minute caching. Market probability shifts are correlated with news volume: if a prediction market moves significantly before matching news arrives, this is flagged as a potential early-warning signal.
**Cloudflare JA3 bypass** — Polymarket's API is protected by Cloudflare TLS fingerprinting (JA3) that blocks all server-side requests. The system uses a 3-tier fallback:
| Tier | Method | When It Works |
|------|--------|---------------|
| **1** | Browser-direct fetch | Always (browser TLS passes Cloudflare) |
| **2** | Tauri native TLS (reqwest) | Desktop app (Rust TLS fingerprint differs from Node.js) |
| **3** | Vercel edge proxy | Rarely (edge runtime sometimes passes) |
Once browser-direct succeeds, the system caches this state and skips fallback tiers on subsequent requests. Country-specific markets are fetched by mapping countries to Polymarket tags with name-variant matching (e.g., "Russia" matches titles containing "Russian", "Moscow", "Kremlin", "Putin").
Markets are filtered to exclude sports and entertainment (100+ exclusion keywords), require meaningful price divergence from 50% or volume above $50K, and are ranked by trading volume. Each variant gets different tag sets — geopolitical focus queries politics/world/ukraine/middle-east tags, while tech focus queries ai/crypto/business tags.
### Macro Signal Analysis (Market Radar)
The Market Radar panel computes a composite BUY/CASH verdict from 7 independent signals sourced entirely from free APIs (Yahoo Finance, mempool.space, alternative.me):
@@ -340,6 +594,23 @@ VWAP = Σ(price × volume) / Σ(volume) for last 30 trading days
The **Mayer Multiple** (BTC price / SMA200) provides a long-term valuation context — historically, values above 2.4 indicate overheating, while values below 0.8 suggest deep undervaluation.
### Gulf FDI Investment Database
The Finance variant includes a curated database of 64 major foreign direct investments by Saudi Arabia and the UAE in global critical infrastructure. Investments are tracked across 12 sectors:
| Sector | Examples |
|--------|---------|
| **Ports** | DP World's 11 global container terminals, AD Ports (Khalifa, Al-Sokhna, Karachi), Saudi Mawani ports |
| **Energy** | ADNOC Ruwais LNG (9.6 mtpa), Aramco's Motiva Port Arthur refinery (630K bpd), ACWA Power renewables |
| **Manufacturing** | Mubadala's GlobalFoundries (82% stake, 3rd-largest chip foundry), Borealis (75%), SABIC (70%) |
| **Renewables** | Masdar wind/solar (UK Hornsea, Zarafshan 500MW, Gulf of Suez), NEOM Green Hydrogen (world's largest) |
| **Megaprojects** | NEOM THE LINE ($500B), Saudi National Cloud ($6B hyperscale datacenters) |
| **Telecoms** | STC's 9.9% stake in Telefónica, PIF's 20% of Telecom Italia NetCo |
Each investment records the investing entity (DP World, Mubadala, PIF, ADNOC, Masdar, Saudi Aramco, ACWA Power, etc.), target country, geographic coordinates, investment amount (USD), ownership stake, operational status, and year. The Investments Panel provides filterable views by country (SA/UAE), sector, entity, and status — clicking any row navigates the map to the investment location.
On the globe, investments appear as scaled bubbles: ≥$50B projects (NEOM) render at maximum size, while sub-$1B investments use smaller markers. Color encodes status: green for operational, amber for under-construction, blue for announced.
### Stablecoin Peg Monitoring
Five major stablecoins (USDT, USDC, DAI, FDUSD, USDe) are monitored via the CoinGecko API with 2-minute caching. Each coin's deviation from the $1.00 peg determines its health status:
@@ -352,6 +623,19 @@ Five major stablecoins (USDT, USDC, DAI, FDUSD, USDe) are monitored via the Coin
The panel aggregates total stablecoin market cap, 24h volume, and an overall health status (HEALTHY / CAUTION / WARNING). The `coins` query parameter accepts a comma-separated list of CoinGecko IDs, validated against a `[a-z0-9-]+` regex to prevent injection.
### Oil & Energy Analytics
The Oil & Energy panel tracks four key indicators from the U.S. Energy Information Administration (EIA) API:
| Indicator | Series | Update Cadence |
|-----------|--------|----------------|
| **WTI Crude** | Spot price ($/bbl) | Weekly |
| **Brent Crude** | Spot price ($/bbl) | Weekly |
| **US Production** | Crude oil output (Mbbl/d) | Weekly |
| **US Inventory** | Commercial crude stocks | Weekly |
Trend detection flags week-over-week changes exceeding ±0.5% as rising or falling, with flat readings within the threshold shown as stable. Results are cached client-side for 30 minutes. The panel provides energy market context for geopolitical analysis — price spikes often correlate with supply disruptions in monitored conflict zones and chokepoint closures.
### BTC ETF Flow Estimation
Ten spot Bitcoin ETFs are tracked via Yahoo Finance's 5-day chart API (IBIT, FBTC, ARKB, BITB, GBTC, HODL, BRRR, EZBC, BTCO, BTCW). Since ETF flow data requires expensive terminal subscriptions, the system estimates flow direction from publicly available signals:
@@ -364,6 +648,25 @@ This is an approximation, not a substitute for official flow data, but it captur
---
## Tri-Variant Architecture
A single codebase produces three specialized dashboards, each with distinct feeds, panels, map layers, and branding:
| Aspect | World Monitor | Tech Monitor | Finance Monitor |
|--------|--------------|--------------|-----------------|
| **Domain** | worldmonitor.app | tech.worldmonitor.app | finance.worldmonitor.app |
| **Focus** | Geopolitics, military, conflicts | AI/ML, startups, cybersecurity | Markets, trading, central banks |
| **RSS Feeds** | ~25 categories (politics, MENA, Africa, think tanks) | ~20 categories (AI, VC blogs, startups, GitHub) | ~18 categories (forex, bonds, commodities, IPOs) |
| **Panels** | 44 (strategic posture, CII, cascade) | 31 (AI labs, unicorns, accelerators) | 30 (forex, bonds, derivatives, institutional) |
| **Unique Map Layers** | Military bases, nuclear facilities, hotspots | Tech HQs, cloud regions, startup hubs | Stock exchanges, central banks, Gulf investments |
| **Desktop App** | World Monitor.app / .exe | Tech Monitor.app / .exe | Finance Monitor.app / .exe |
**Build-time selection** — the `VITE_VARIANT` environment variable controls which configuration is bundled. A Vite HTML plugin transforms meta tags, Open Graph data, PWA manifest, and JSON-LD structured data at build time. Each variant tree-shakes unused data files — the finance build excludes military base coordinates and APT group data, while the geopolitical build excludes stock exchange listings.
**Runtime switching** — a variant selector in the header bar (🌍 WORLD | 💻 TECH | 📈 FINANCE) navigates between deployed domains on the web, or sets `localStorage['worldmonitor-variant']` in the desktop app to switch without rebuilding.
---
## Architecture Principles
| Principle | Implementation |
@@ -372,10 +675,14 @@ This is an approximation, not a substitute for official flow data, but it captur
| **Assume failure** | Per-feed circuit breakers with 5-minute cooldowns. AI fallback chain: Groq → OpenRouter → browser-side T5. Redis cache failures degrade gracefully. Every edge function returns stale cached data when upstream APIs are down. |
| **Show what you can't see** | Intelligence gap tracker explicitly reports data source outages rather than silently hiding them. |
| **Browser-first compute** | Analysis (clustering, instability scoring, surge detection) runs client-side — no backend compute dependency for core intelligence. |
| **Local-first geolocation** | Country detection uses browser-side ray-casting against GeoJSON polygons rather than network reverse-geocoding. Sub-millisecond response, zero API dependency, works offline. Network geocoding is a fallback, not the primary path. |
| **Multi-signal correlation** | No single data source is trusted alone. Focal points require convergence across news + military + markets + protests before escalating to critical. |
| **Geopolitical grounding** | Hard-coded conflict zones, baseline country risk, and strategic chokepoints prevent statistical noise from generating false alerts in low-data regions. |
| **Defense in depth** | CORS origin allowlist, domain-allowlisted RSS proxy, server-side API key isolation, input sanitization with output encoding, IP rate limiting on AI endpoints. |
| **Cache everything, trust nothing** | Three-tier caching (in-memory → Redis → upstream) with versioned cache keys and stale-on-error fallback. Every API response includes `X-Cache` header for debugging. |
| **Defense in depth** | CORS origin allowlist, domain-allowlisted RSS proxy, server-side API key isolation, token-authenticated desktop sidecar, input sanitization with output encoding, IP rate limiting on AI endpoints. |
| **Cache everything, trust nothing** | Three-tier caching (in-memory → Redis → upstream) with versioned cache keys and stale-on-error fallback. Every API response includes `X-Cache` header for debugging. CDN layer (`s-maxage`) absorbs repeated requests before they reach edge functions. |
| **Bandwidth efficiency** | Gzip compression on all relay responses (80% reduction). Content-hash static assets with 1-year immutable cache. Staggered polling intervals prevent synchronized API storms. Animations and polling pause on hidden tabs. |
| **Baseline-aware alerting** | Trending keyword detection uses rolling 2-hour windows against 7-day baselines with per-term spike multipliers, cooldowns, and source diversity requirements — surfacing genuine surges while suppressing noise. |
| **Run anywhere** | Same codebase produces three specialized variants (geopolitical, tech, finance) and deploys to Vercel (web), Railway (relay), Tauri (desktop), and PWA (installable). Desktop sidecar mirrors all cloud API handlers locally. Service worker caches map tiles for offline use while keeping intelligence data always-fresh (NetworkOnly). |
---
@@ -396,7 +703,7 @@ Feeds also carry a **propaganda risk rating** and **state affiliation flag**. St
## Edge Function Architecture
World Monitor uses 45+ Vercel Edge Functions as a lightweight API layer. Each edge function handles a single data source concern — proxying, caching, or transforming external APIs. This architecture avoids a monolithic backend while keeping API keys server-side:
World Monitor uses 60+ Vercel Edge Functions as a lightweight API layer. Each edge function handles a single data source concern — proxying, caching, or transforming external APIs. This architecture avoids a monolithic backend while keeping API keys server-side:
- **RSS Proxy** — domain-allowlisted proxy for 100+ feeds, preventing CORS issues and hiding origin servers. Feeds from domains that block Vercel IPs are automatically routed through the Railway relay.
- **AI Pipeline** — Groq and OpenRouter edge functions with Redis deduplication, so identical headlines across concurrent users only trigger one LLM call. The classify-event endpoint pauses its queue on 500 errors to avoid wasting API quota.
@@ -404,30 +711,41 @@ World Monitor uses 45+ Vercel Edge Functions as a lightweight API layer. Each ed
- **Market Intelligence** — macro signals, ETF flows, and stablecoin monitors compute derived analytics server-side (VWAP, SMA, peg deviation, flow estimates) and cache results in Redis
- **Temporal Baseline** — Welford's algorithm state is persisted in Redis across requests, building statistical baselines without a traditional database
- **Custom Scrapers** — sources without RSS feeds (FwdStart, GitHub Trending, tech events) are scraped and transformed into RSS-compatible formats
- **Finance Geo Data** — stock exchanges (92), financial centers (19), central banks (13), and commodity hubs (10) are served as static typed datasets with market caps, GFCI rankings, trading hours, and commodity specializations
All edge functions include circuit breaker logic and return cached stale data when upstream APIs are unavailable, ensuring the dashboard never shows blank panels.
---
## Dual-Deployment Architecture
## Multi-Platform Architecture
World Monitor runs on two platforms that work together:
All three variants run on three platforms that work together:
```
┌─────────────────────────────────────┐
│ Vercel (Edge) │
45+ edge functions · static SPA │
60+ edge functions · static SPA │
│ CORS allowlist · Redis cache │
│ AI pipeline · market analytics │
└──────────────┬──────────────────────┘
│ https:// (server-side)
│ wss:// (client-side)
│ CDN caching (s-maxage) · PWA host │
└──────────┬─────────────┬────────────┘
│ │ fallback
│ ┌───────────────────────────────────┐
│ │ Tauri Desktop (Rust + Node) │
│ │ OS keychain · Token-auth sidecar │
│ │ 60+ local API handlers · gzip │
│ │ Cloud fallback · Traffic logging │
│ └───────────────────────────────────┘
│ https:// (server-side)
│ wss:// (client-side)
┌─────────────────────────────────────┐
│ Railway (Relay Server) │
│ WebSocket relay · OpenSky OAuth2 │
│ RSS proxy for blocked domains │
│ AIS vessel stream multiplexer
│ AIS vessel stream · gzip all resp
└─────────────────────────────────────┘
```
@@ -439,6 +757,102 @@ World Monitor runs on two platforms that work together:
The Vercel edge functions connect to Railway via `WS_RELAY_URL` (server-side, HTTPS) while browser clients connect via `VITE_WS_RELAY_URL` (client-side, WSS). This separation keeps the relay URL configurable per deployment without leaking server-side configuration to the browser.
All Railway relay responses are gzip-compressed (zlib `gzipSync`) when the client accepts it and the payload exceeds 1KB, reducing egress by ~80% for JSON and XML responses.
---
## Desktop Application Architecture
The Tauri desktop app wraps the dashboard in a native window with a local Node.js sidecar that runs all API handlers without cloud dependency:
```
┌─────────────────────────────────────────────────┐
│ Tauri (Rust) │
│ Window management · OS keychain · Menu bar │
│ Token generation · Log management │
│ Polymarket native TLS bridge │
└─────────────────────┬───────────────────────────┘
│ spawn + env vars
┌─────────────────────────────────────────────────┐
│ Node.js Sidecar (port 46123) │
│ 60+ API handlers · Gzip compression │
│ Cloud fallback · Traffic logging │
│ Verbose debug mode · Circuit breakers │
└─────────────────────┬───────────────────────────┘
│ fetch (on local failure)
┌─────────────────────────────────────────────────┐
│ Cloud (worldmonitor.app) │
│ Transparent fallback when local handlers fail │
└─────────────────────────────────────────────────┘
```
### Secret Management
API keys are stored in the operating system's credential manager (macOS Keychain, Windows Credential Manager) — never in plaintext config files. At sidecar launch, all 17 supported secrets are read from the keyring, trimmed, and injected as environment variables. Empty or whitespace-only values are skipped.
Secrets can also be updated at runtime without restarting the sidecar: saving a key in the Settings window triggers a `POST /api/local-env-update` call that hot-patches `process.env` and clears the module cache so handlers pick up the new value immediately.
### Sidecar Authentication
A unique 32-character hex token is generated per app launch using randomized hash state (`RandomState` from Rust's standard library). The token is:
1. Injected into the sidecar as `LOCAL_API_TOKEN`
2. Retrieved by the frontend via the `get_local_api_token` Tauri command (lazy-loaded on first API request)
3. Attached as `Authorization: Bearer <token>` to every local request
The `/api/service-status` health check endpoint is exempt from token validation to support monitoring tools.
### Cloud Fallback
When a local API handler is missing, throws an error, or returns a 5xx status, the sidecar transparently proxies the request to the cloud deployment. Endpoints that fail are marked as `cloudPreferred` — subsequent requests skip the local handler and go directly to the cloud until the sidecar is restarted. Origin and Referer headers are stripped before proxying to maintain server-to-server parity.
### Observability
- **Traffic log** — a ring buffer of the last 200 requests with method, path, status, and duration (ms), accessible via `GET /api/local-traffic-log`
- **Verbose mode** — togglable via `POST /api/local-debug-toggle`, persists across sidecar restarts in `verbose-mode.json`
- **Dual log files** — `desktop.log` captures Rust-side events (startup, secret injection counts, menu actions), while `local-api.log` captures Node.js stdout/stderr
- **DevTools** — `Cmd+Alt+I` toggles the embedded web inspector
---
## Bandwidth Optimization
The system minimizes egress costs through layered caching and compression across all three deployment targets:
### Vercel CDN Headers
Every API edge function includes `Cache-Control` headers that enable Vercel's CDN to serve cached responses without hitting the origin:
| Data Type | `s-maxage` | `stale-while-revalidate` | Rationale |
|-----------|-----------|--------------------------|-----------|
| Classification results | 3600s (1h) | 600s (10min) | Headlines don't reclassify often |
| Country intelligence | 3600s (1h) | 600s (10min) | Briefs change slowly |
| Risk scores | 300s (5min) | 60s (1min) | Near real-time, low latency |
| Market data | 3600s (1h) | 600s (10min) | Intraday granularity sufficient |
| Fire detection | 600s (10min) | 120s (2min) | VIIRS updates every ~12 hours |
| Economic indicators | 3600s (1h) | 600s (10min) | Monthly/quarterly releases |
Static assets use content-hash filenames with 1-year immutable cache headers. The service worker file (`sw.js`) is never cached (`max-age=0, must-revalidate`) to ensure update detection.
### Railway Relay Compression
All relay server responses pass through `gzipSync` when the client accepts gzip and the payload exceeds 1KB. This applies to OpenSky aircraft JSON, RSS XML feeds, UCDP event data, AIS snapshots, and health checks — reducing wire size by approximately 80%.
### Frontend Polling Intervals
Panels refresh at staggered intervals to avoid synchronized API storms:
| Panel | Interval | Rationale |
|-------|----------|-----------|
| AIS maritime snapshot | 10s | Real-time vessel positions |
| Service status | 60s | Health check cadence |
| Market signals / ETF / Stablecoins | 180s (3min) | Market hours granularity |
| Risk scores / Theater posture | 300s (5min) | Composite scores change slowly |
All animations and polling pause when the tab is hidden or after 2 minutes of inactivity, preventing wasted requests from background tabs.
---
## Caching Architecture
@@ -467,13 +881,15 @@ The AI summarization pipeline adds content-based deduplication: headlines are ha
| Layer | Mechanism |
|-------|-----------|
| **CORS origin allowlist** | Only `worldmonitor.app`, `startups.worldmonitor.app`, and `localhost:*` can call API endpoints. All others receive 403. Implemented in `api/_cors.js`. |
| **CORS origin allowlist** | Only `worldmonitor.app`, `tech.worldmonitor.app`, `finance.worldmonitor.app`, and `localhost:*` can call API endpoints. All others receive 403. Implemented in `api/_cors.js`. |
| **RSS domain allowlist** | The RSS proxy only fetches from explicitly listed domains (~90+). Requests for unlisted domains are rejected with 403. |
| **Railway domain allowlist** | The Railway relay has a separate, smaller domain allowlist for feeds that need the alternate origin. |
| **API key isolation** | All API keys live server-side in Vercel environment variables. The browser never sees Groq, OpenRouter, ACLED, Finnhub, or other credentials. |
| **Input sanitization** | User-facing content passes through `escapeHtml()` (prevents XSS) and `sanitizeUrl()` (blocks `javascript:` and `data:` URIs). URLs use `escapeAttr()` for attribute context encoding. |
| **Query parameter validation** | API endpoints validate input formats (e.g., stablecoin coin IDs must match `[a-z0-9-]+`, bounding box params are numeric). |
| **IP rate limiting** | AI endpoints use Upstash Redis-backed rate limiting to prevent abuse of Groq/OpenRouter quotas. |
| **Desktop sidecar auth** | The local API sidecar requires a per-session `Bearer` token generated at launch. The token is stored in Rust state and injected into the sidecar environment — only the Tauri frontend can retrieve it via IPC. Health check endpoints are exempt. |
| **OS keychain storage** | Desktop API keys are stored in the operating system's credential manager (macOS Keychain, Windows Credential Manager), never in plaintext files or environment variables on disk. |
| **No debug endpoints** | The `api/debug-env.js` endpoint returns 404 in production — it exists only as a disabled placeholder. |
---
@@ -485,7 +901,7 @@ The AI summarization pipeline adds content-based deduplication: headlines are ha
git clone https://github.com/koala73/worldmonitor.git
cd worldmonitor
npm install
vercel dev # Runs frontend + all 45+ API edge functions
vercel dev # Runs frontend + all 60+ API edge functions
```
Open [http://localhost:3000](http://localhost:3000)
@@ -518,7 +934,7 @@ See [`.env.example`](./.env.example) for the complete list with registration lin
## Self-Hosting
World Monitor relies on **45+ Vercel Edge Functions** in the `api/` directory for RSS proxying, data caching, and API key isolation. Running `npm run dev` alone starts only the Vite frontend — the edge functions won't execute, and most panels (news feeds, markets, AI summaries) will be empty.
World Monitor relies on **60+ Vercel Edge Functions** in the `api/` directory for RSS proxying, data caching, and API key isolation. Running `npm run dev` alone starts only the Vite frontend — the edge functions won't execute, and most panels (news feeds, markets, AI summaries) will be empty.
### Option 1: Deploy to Vercel (Recommended)
@@ -529,7 +945,7 @@ npm install -g vercel
vercel # Follow prompts to link/create project
```
Add your API keys in the Vercel dashboard under **Settings → Environment Variables**, then visit your deployment URL. The free Hobby plan supports all 45+ edge functions.
Add your API keys in the Vercel dashboard under **Settings → Environment Variables**, then visit your deployment URL. The free Hobby plan supports all 60+ edge functions.
### Option 2: Local Development with Vercel CLI
@@ -580,31 +996,20 @@ Set `WS_RELAY_URL` (server-side, HTTPS) and `VITE_WS_RELAY_URL` (client-side, WS
| Category | Technologies |
|----------|--------------|
| **Frontend** | TypeScript, Vite, deck.gl (WebGL), MapLibre GL |
| **Frontend** | TypeScript, Vite, deck.gl (WebGL 3D globe), MapLibre GL, vite-plugin-pwa (service worker + manifest) |
| **Desktop** | Tauri 2 (Rust) with Node.js sidecar, OS keychain integration (keyring crate), native TLS (reqwest) |
| **AI/ML** | Groq (Llama 3.1 8B), OpenRouter (fallback), Transformers.js (browser-side T5, NER, embeddings) |
| **Caching** | Redis (Upstash) — 3-tier cache with in-memory + Redis + upstream, cross-user AI deduplication |
| **Geopolitical APIs** | OpenSky, GDELT, ACLED, UCDP, HAPI, USGS, NASA FIRMS, Polymarket, Cloudflare Radar |
| **Caching** | Redis (Upstash) — 3-tier cache with in-memory + Redis + upstream, cross-user AI deduplication. Vercel CDN (s-maxage). Service worker (Workbox) |
| **Geopolitical APIs** | OpenSky, GDELT, ACLED, UCDP, HAPI, USGS, GDACS, NASA EONET, NASA FIRMS, Polymarket, Cloudflare Radar, WorldPop |
| **Market APIs** | Yahoo Finance (equities, forex, crypto), CoinGecko (stablecoins), mempool.space (BTC hashrate), alternative.me (Fear & Greed) |
| **Threat Intel APIs** | abuse.ch (Feodo Tracker, URLhaus), AlienVault OTX, AbuseIPDB, C2IntelFeeds |
| **Economic APIs** | FRED (Federal Reserve), EIA (Energy), Finnhub (stock quotes) |
| **Deployment** | Vercel Edge Functions (45+ endpoints) + Railway (WebSocket relay) |
| **Data** | 100+ RSS feeds, ADS-B transponders, AIS maritime data, VIIRS satellite imagery |
| **Deployment** | Vercel Edge Functions (60+ endpoints) + Railway (WebSocket relay) + Tauri (desktop) + PWA (installable) |
| **Finance Data** | 92 stock exchanges, 19 financial centers, 13 central banks, 10 commodity hubs, 64 Gulf FDI investments |
| **Data** | 150+ RSS feeds, ADS-B transponders, AIS maritime data, VIIRS satellite imagery, 8 live YouTube streams |
---
## Documentation
Full documentation including algorithms, data sources, and system architecture:
**[docs/DOCUMENTATION.md](./docs/DOCUMENTATION.md)**
Key sections:
- [Signal Intelligence](./docs/DOCUMENTATION.md#signal-intelligence)
- [Country Instability Index](./docs/DOCUMENTATION.md#country-instability-index-cii)
- [Military Tracking](./docs/DOCUMENTATION.md#military-tracking)
- [Infrastructure Analysis](./docs/DOCUMENTATION.md#infrastructure-cascade-analysis)
- [API Dependencies](./docs/DOCUMENTATION.md#api-dependencies)
- [System Architecture](./docs/DOCUMENTATION.md#system-architecture)
---
## Contributing
@@ -614,20 +1019,24 @@ Contributions welcome! See [CONTRIBUTING](./docs/DOCUMENTATION.md#contributing)
```bash
# Development
npm run dev # Full variant (worldmonitor.app)
npm run dev:tech # Tech variant (startups.worldmonitor.app)
npm run dev:tech # Tech variant (tech.worldmonitor.app)
npm run dev:finance # Finance variant (finance.worldmonitor.app)
# Production builds
npm run build:full # Build full variant
npm run build:tech # Build tech variant
npm run build:full # Build full variant
npm run build:tech # Build tech variant
npm run build:finance # Build finance variant
# Quality
npm run typecheck # TypeScript type checking
# Desktop packaging
npm run desktop:package:macos:full # .app + .dmg (World Monitor)
npm run desktop:package:macos:tech # .app + .dmg (Tech Monitor)
npm run desktop:package:windows:full # .exe + .msi (World Monitor)
npm run desktop:package:windows:tech # .exe + .msi (Tech Monitor)
npm run desktop:package:macos:full # .app + .dmg (World Monitor)
npm run desktop:package:macos:tech # .app + .dmg (Tech Monitor)
npm run desktop:package:macos:finance # .app + .dmg (Finance Monitor)
npm run desktop:package:windows:full # .exe + .msi (World Monitor)
npm run desktop:package:windows:tech # .exe + .msi (Tech Monitor)
npm run desktop:package:windows:finance # .exe + .msi (Finance Monitor)
# Generic packaging runner
npm run desktop:package -- --os macos --variant full
@@ -638,20 +1047,43 @@ npm run desktop:package:windows:full:sign
```
Desktop release details, signing hooks, variant outputs, and clean-machine validation checklist:
- [docs/RELEASE_PACKAGING.md](./docs/RELEASE_PACKAGING.md)
---
## Roadmap
- [x] 45+ API edge functions for programmatic access
- [x] Dual-site variant system (geopolitical + tech)
- [x] 60+ API edge functions for programmatic access
- [x] Tri-variant system (geopolitical + tech + finance)
- [x] Market intelligence (macro signals, ETF flows, stablecoin peg monitoring)
- [x] Railway relay for WebSocket and blocked-domain proxying
- [x] CORS origin allowlist and security hardening
- [x] Native desktop application (Tauri) with OS keychain + authenticated sidecar
- [x] Progressive Web App with offline map support and installability
- [x] Bandwidth optimization (CDN caching, gzip relay, staggered polling)
- [x] 3D WebGL globe visualization (deck.gl)
- [x] Natural disaster monitoring (USGS + GDACS + NASA EONET)
- [x] Historical playback via IndexedDB snapshots
- [x] Live YouTube stream detection with desktop embed bridge
- [x] Country brief pages with AI-generated intelligence dossiers
- [x] Local-first country detection (browser-side ray-casting, no network dependency)
- [x] Climate anomaly monitoring (15 conflict-prone zones)
- [x] Displacement tracking (UNHCR/HAPI origins & hosts)
- [x] Country brief export (JSON, CSV, PNG, PDF)
- [x] Cyber threat intelligence layer (Feodo Tracker, URLhaus, OTX, AbuseIPDB, C2IntelFeeds)
- [x] Trending keyword spike detection with baseline anomaly alerting
- [x] Oil & energy analytics (EIA: WTI, Brent, production, inventory)
- [x] Population exposure estimation (WorldPop density data)
- [x] Country search in Cmd+K with direct brief navigation
- [x] Entity index with cross-source correlation and confidence scoring
- [x] Finance variant with 92 stock exchanges, 19 financial centers, 13 central banks, and commodity hubs
- [x] Gulf FDI investment database (64 Saudi/UAE infrastructure investments mapped globally)
- [x] AIS maritime chokepoint detection and vessel density grid
- [x] Runtime feature toggles for 14 data sources
- [x] Panel height resizing with persistent layout state
- [ ] Mobile-optimized views
- [ ] Push notifications for critical alerts
- [ ] Historical data playback
- [ ] Self-hosted Docker image
See [full roadmap](./docs/DOCUMENTATION.md#roadmap).
@@ -683,10 +1115,10 @@ MIT License — see [LICENSE](LICENSE) for details.
<p align="center">
<a href="https://worldmonitor.app">worldmonitor.app</a> &nbsp;·&nbsp;
<a href="https://tech.worldmonitor.app">tech.worldmonitor.app</a>
<a href="https://tech.worldmonitor.app">tech.worldmonitor.app</a> &nbsp;·&nbsp;
<a href="https://finance.worldmonitor.app">finance.worldmonitor.app</a>
</p>
## Star History
<a href="https://api.star-history.com/svg?repos=koala73/worldmonitor&type=Date">
-408
View File
@@ -1,408 +0,0 @@
# World Monitor Roadmap: Intelligence Correlation Enhancements
This document outlines the top 5 features a geopolitical intelligence analyst would want, focusing on **correlation between existing data points** and leveraging **free APIs/RSS feeds only**.
---
## Current Correlation Capabilities
### What We Already Do Well
| Signal Type | Description | Data Sources |
|------------|-------------|--------------|
| **Convergence** | 3+ source types report same story within 30min | News feeds |
| **Triangulation** | Wire + Gov + Intel sources align on topic | News feeds |
| **Velocity Spike** | Topic mention rate doubles with 6+ sources/hr | News feeds |
| **Prediction Leading** | Polymarket moves 5%+ with low news coverage | Polymarket + News |
| **Silent Divergence** | Market moves 2%+ with minimal related news | Yahoo/Finnhub + News |
| **Flow/Price Divergence** | Energy price spike without pipeline news | Markets + News |
| **Related Assets** | News stories enriched with nearby infrastructure | Hotspots + All assets |
| **GDELT Tensions** | Country-pair tension scores with 7-day trends | GDELT GPR API |
### What's Missing
1. **No cross-layer correlation** - Protests, military movements, and economic data don't talk to each other
2. **No temporal pattern detection** - Can't detect "unusual for this time of year"
3. **No geographic clustering** - Multiple event types in same region not flagged
4. **No country-level aggregation** - No unified risk view per country
5. **No infrastructure dependency mapping** - Don't show cascade effects
---
## Top 5 Priority Features
### 1. Multi-Signal Geographic Convergence
**What:** When 3+ independent data types converge on the same geographic region within 24-48 hours, generate a high-priority alert.
**Why:** The most valuable I&W (Indications & Warning) signals come from multiple independent sources detecting activity in the same area. A protest + military flight activity + shipping disruption in the same region is far more significant than any single event.
**Data Sources (Already Have):**
- Protests (ACLED/GDELT) → lat/lon
- Military flights (OpenSky) → lat/lon
- Military vessels (AIS) → lat/lon
- Earthquakes/natural events → lat/lon
- News hotspots → lat/lon
- Chokepoint congestion → lat/lon
- Pipeline incidents → lat/lon (inferred)
**Implementation:**
```
1. Define 50km grid cells globally
2. Each refresh cycle, tag events to grid cells
3. Track event counts by type per cell over 24h window
4. Alert when: cell has events from 3+ distinct data types
5. Confidence = function(event_count, type_diversity, time_clustering)
```
**Example Alert:**
> ⚠️ **Geographic Convergence: Taiwan Strait**
> - Military flights: 12 (3x normal)
> - Naval vessels: 8 (2x normal)
> - News velocity: Spike (+340%)
> - Confidence: 87%
---
### 2. Country Instability Index
**What:** Real-time composite risk score for each country, aggregating all available signals into a single 0-100 index.
**Why:** Analysts need a quick way to answer "how stable is Country X right now?" without manually checking 10 different data sources.
**Components (Already Have Data):**
| Component | Source | Weight |
|-----------|--------|--------|
| Protest frequency | ACLED/GDELT | 20% |
| Protest severity | ACLED fatalities | 15% |
| Conflict proximity | Conflict zones | 15% |
| News sentiment | Clustered news | 10% |
| News velocity | RSS feeds | 10% |
| GDELT tension (as target) | GDELT GPR | 10% |
| Sanctions status | Static config | 10% |
| Infrastructure incidents | Cables/pipelines | 10% |
**Free Data to Add:**
- **World Bank Governance Indicators** (annual, free API)
- **UN Refugee Data** (UNHCR, RSS feeds)
- **Election proximity** (static calendar)
**Implementation:**
```
1. Map all events to ISO country codes
2. Maintain rolling 7-day and 30-day baselines per country
3. Calculate Z-scores for each component
4. Weight and sum to 0-100 index
5. Track index changes for trend detection
```
**UI:**
- Choropleth map layer showing index by color
- Sortable country list panel
- Click country → drill-down to component breakdown
- Alert when country moves 10+ points in 24h
---
### 3. Trade Route Risk Scoring
**What:** Real-time risk assessment for major shipping routes, showing which supply chains are most vulnerable right now.
**Why:** Supply chain disruptions are the primary economic consequence of geopolitical events. An analyst needs to quickly assess "if X happens, what trade is affected?"
**Major Routes to Score:**
| Route | Chokepoints | Commodities |
|-------|-------------|-------------|
| Asia → Europe (Suez) | Suez, Bab el-Mandeb, Malacca | Containers, oil |
| Asia → US West Coast | Malacca, Taiwan Strait, Panama | Containers, electronics |
| Middle East → Europe | Hormuz, Suez, Bosphorus | Oil, LNG |
| Russia → Europe | Baltic, Bosphorus | Oil, gas, grain |
| South America → Asia | Panama, Magellan | Commodities, grain |
**Risk Components:**
| Factor | Source | Notes |
|--------|--------|-------|
| Chokepoint congestion | AIS density | Real-time |
| Dark ship activity | AIS gaps | Real-time |
| Weather/storms | NASA EONET | Real-time |
| Conflict proximity | Conflict zones | Static + news |
| Piracy indicators | News keywords | Real-time |
| Sanctions impact | Config | Which ports blocked |
| Port delays | Inferred from AIS | Real-time |
**Implementation:**
```
1. Define route polylines with chokepoint waypoints
2. For each chokepoint, calculate: density_change + gap_rate + weather_alerts + conflict_distance
3. Weight by chokepoint criticality (Hormuz > Malacca > Panama for oil)
4. Sum to 0-100 risk score per route
5. Compare to 30-day baseline for trend
```
**UI:**
- Route lines on map colored by risk (green → yellow → red)
- Panel showing route rankings with trends
- Click route → show chokepoint breakdown
- Alert when route risk jumps 20+ points
---
### 4. Infrastructure Cascade Visualization
**What:** When you click any infrastructure asset, show what depends on it and what would be affected by its disruption.
**Why:** Critical infrastructure is interconnected. A submarine cable fault affects countries downstream. A pipeline disruption affects refineries and ports. Analysts need to see the "so what."
**Dependency Mappings:**
**Ports → dependent on:**
- Pipelines (oil/LNG terminals)
- Submarine cables (data for port operations)
- Nearby naval bases (protection)
- Chokepoints (access routes)
**Cables → serve:**
- Countries (list from cable data)
- Data centers (proximity)
- Financial centers (criticality)
**Pipelines → connect:**
- Origin countries
- Transit countries
- Destination ports/refineries
- Alternate routes
**Implementation:**
```
1. Build static dependency graph in config
2. For cables: map landing points to countries
3. For pipelines: map to origin/transit/destination
4. For ports: map to pipelines that terminate there
5. On asset click: traverse graph, highlight dependents on map
6. Show impact panel: "Disruption would affect: X countries, Y trade volume"
```
**Data Enhancement (Free):**
- **TeleGeography** submarine cable landing points (public)
- **Global Energy Monitor** pipeline database (public)
- **UN COMTRADE** for trade flow volumes (free API)
---
### 5. Temporal Anomaly Detection
**What:** Detect when current activity levels deviate significantly from historical norms for the same time period (day of week, month, season).
**Why:** "Unusual activity" only makes sense in context. Military flights on a Tuesday might be normal; the same level on a Sunday might be significant. Activity in December might be normal for end-of-year exercises but unusual in March.
**What to Track:**
| Data Type | Baseline Period | Anomaly Threshold |
|-----------|-----------------|-------------------|
| Military flights per region | Same weekday, 4-week rolling | Z > 2.0 |
| Naval vessels per chokepoint | Same weekday, 4-week rolling | Z > 2.0 |
| Protest count per country | Same month, 3-year average | Z > 1.5 |
| News velocity per topic | Same weekday, 4-week rolling | Z > 2.5 |
| AIS gaps per region | Same weekday, 4-week rolling | Z > 2.0 |
**Implementation:**
```
1. Store hourly/daily counts by category in IndexedDB
2. Maintain separate baselines by: weekday, month, region
3. On refresh: compare current to same-period baseline
4. Calculate Z-score accounting for seasonal patterns
5. Alert format: "Military flights in Baltic 3.2x normal for Tuesday"
```
**Example Alerts:**
> 📊 **Temporal Anomaly: Baltic Region**
> - Military flights: 47 (normal Tuesday avg: 15)
> - Z-score: 2.8 (highly unusual)
> - Last similar: March 2024 (NATO exercise)
> 📊 **Temporal Anomaly: Iran Protests**
> - Events this week: 23 (normal January avg: 8)
> - Z-score: 1.9 (elevated)
> - Note: Anniversary of 2023 protests approaching
---
## Additional Free Data Sources to Integrate
### Economic/Trade APIs (No Key Required)
| Source | Endpoint | Data | Rate Limit |
|--------|----------|------|------------|
| **World Bank API** | `api.worldbank.org/v2/` | 16,000+ indicators, GDP, trade, FDI | None |
| **IMF Data API** | `dataservices.imf.org/REST/SDMX_JSON.svc/` | IFS, trade flows, balance of payments | None |
| **UN Comtrade** | `comtradeapi.un.org/public/v1/` | Bilateral trade flows by HS code | 100/day free |
| **BIS Statistics** | `stats.bis.org/api/v1/` | Global liquidity, cross-border banking | None |
| **OECD Data** | `stats.oecd.org/SDMX-JSON/` | OECD country indicators | None |
### Food Security (Critical for Instability Correlation)
| Source | Endpoint | Data | Notes |
|--------|----------|------|-------|
| **FAO GIEWS RSS** | `fao.org/giews/english/shortnews/rss.xml` | Food price alerts, country briefs | Add to feeds.ts |
| **FAO Food Price Monitor** | `fpma.fao.org/giews/fpmat4/` | Real-time commodity prices | JSON API |
| **FAO STAT API** | `fenixservices.fao.org/faostat/api/v1/` | Food Price Index, production | REST |
### Sanctions Lists (Critical for Risk Scoring)
| Source | Endpoint | Data | Update Frequency |
|--------|----------|------|------------------|
| **OFAC SDN List** | `sanctionslistservice.ofac.treas.gov/api/` | US sanctions | Daily |
| **EU Sanctions** | `webgate.ec.europa.eu/fsd/fsf/public/files/` | EU restrictive measures | Weekly |
| **UN Sanctions** | `scsanctions.un.org/resources/xml/` | Al-Qaida, DPRK, Iran, etc. | Real-time |
| **OpenSanctions** | `api.opensanctions.org/` | Unified 100+ sources | Free tier: 1000/day |
### Migration/Humanitarian (Instability Indicators)
| Source | Endpoint | Data | Notes |
|--------|----------|------|-------|
| **UNHCR API** | `api.unhcr.org/` | Refugee populations, IDPs, asylum | No key |
| **IOM DTM** | `dtm.iom.int/` | Displacement tracking, migration flows | Free registration |
| **ReliefWeb API** | `api.reliefweb.int/v1/` | Humanitarian reports, disasters | No key |
| **INFORM Risk** | `drmkc.jrc.ec.europa.eu/inform-index/` | Hazard/vulnerability scores | CSV download |
### Think Tank RSS Feeds (Add to feeds.ts)
**Security/Defense:**
- RUSI: `rusi.org/rss.xml`
- Chatham House: `chathamhouse.org/rss.xml`
- ECFR: `ecfr.eu/feed/`
- CFR: `cfr.org/rss`
- Wilson Center: `wilsoncenter.org/rss.xml`
- GMF: `gmfus.org/feed`
- Stimson: `stimson.org/feed/`
- CNAS: `cnas.org/rss`
**Nuclear/Arms Control:**
- Arms Control Association: `armscontrol.org/rss/all`
- FAS: `fas.org/feed/`
- NTI: `nti.org/rss/`
- Bulletin of Atomic Scientists: `thebulletin.org/feed/`
**Regional:**
- Middle East Institute: `mei.edu/rss.xml`
- Lowy Institute (Asia-Pacific): `lowyinstitute.org/feed`
- EU ISS: `iss.europa.eu/rss.xml`
### Static Data (Annual/Quarterly Updates)
| Source | Data | Format | Use Case |
|--------|------|--------|----------|
| **SIPRI Arms Transfers** | Weapons exports by country | CSV | Military capability assessment |
| **SIPRI MILEX** | Military spending | CSV | Defense budget trends |
| **V-Dem** | 400+ democracy indicators | CSV | Governance quality |
| **Fragile States Index** | Country risk scores | CSV | Baseline instability |
| **Freedom House** | Democracy/freedom scores | CSV | Political environment |
| **Global Terrorism Database** | Historical incidents | Registration | Pattern analysis |
### Election Calendar (Static Config)
Maintain election calendar in `src/config/elections.ts`. When election date approaches:
- **30 days**: Add to "upcoming events" panel
- **7 days**: Boost country news correlation
- **1 day**: Increase instability index weighting
- **Election day**: Maximum alert sensitivity
```typescript
interface Election {
country: string;
countryCode: string;
type: 'presidential' | 'parliamentary' | 'referendum' | 'local';
date: Date;
significance: 'high' | 'medium' | 'low';
notes?: string;
}
```
---
## Implementation Priority
| Feature | Complexity | Impact | Priority |
|---------|------------|--------|----------|
| Multi-Signal Geographic Convergence | Medium | Very High | 1 |
| Country Instability Index | Medium | High | 2 |
| Temporal Anomaly Detection | Medium | High | 3 |
| Trade Route Risk Scoring | High | High | 4 |
| Infrastructure Cascade Viz | High | Medium | 5 |
**Recommended approach:** Implement features 1-3 first as they primarily leverage existing data with new correlation logic. Features 4-5 require additional data mapping and UI work.
---
## Technical Notes
### IndexedDB Schema Extensions
```typescript
interface TemporalBaseline {
type: 'military_flights' | 'vessels' | 'protests' | 'news' | 'ais_gaps';
region: string;
weekday: number; // 0-6
month: number; // 1-12
hourlyAvg: number[];
dailyAvg: number;
stdDev: number;
sampleCount: number;
lastUpdated: Date;
}
interface CountryRiskSnapshot {
countryCode: string;
timestamp: Date;
components: {
protests: number;
conflict: number;
sentiment: number;
velocity: number;
tension: number;
sanctions: number;
infrastructure: number;
};
index: number;
trend: 'rising' | 'stable' | 'falling';
}
interface GeographicCell {
lat: number;
lon: number;
eventTypes: Set<string>;
eventCount: number;
firstSeen: Date;
lastUpdated: Date;
}
```
### New Signal Types
```typescript
type SignalType =
// Existing
| 'prediction_leads_news'
| 'news_leads_markets'
| 'silent_divergence'
| 'velocity_spike'
| 'convergence'
| 'triangulation'
| 'flow_drop'
| 'flow_price_divergence'
// New
| 'geographic_convergence'
| 'country_risk_spike'
| 'trade_route_risk'
| 'temporal_anomaly'
| 'infrastructure_cascade';
```
---
## Conclusion
The most valuable enhancements for a geopolitical analyst focus on **correlation, not accumulation**. The dashboard already aggregates vast amounts of data; the next step is making that data talk to each other.
Priority 1 (Geographic Convergence) alone would significantly elevate the tool's I&W capability by detecting when multiple independent signals point to the same location—the hallmark of significant events.
All proposed features use **existing data sources** or **free APIs/RSS feeds**, keeping with the project's accessible, open-source philosophy.
+1
View File
@@ -0,0 +1 @@
2.4.36
+3 -4
View File
@@ -1,11 +1,10 @@
const ALLOWED_ORIGIN_PATTERNS = [
/^https:\/\/(.*\.)?worldmonitor\.app$/,
/^https:\/\/.*-elie-habib-projects\.vercel\.app$/,
/^https:\/\/worldmonitor.*\.vercel\.app$/,
/^https:\/\/worldmonitor-[a-z0-9-]+-elie-habib-projects\.vercel\.app$/,
/^https?:\/\/localhost(:\d+)?$/,
/^https?:\/\/127\.0\.0\.1(:\d+)?$/,
/^https:\/\/tauri\.localhost(:\d+)?$/,
/^https:\/\/[a-z0-9-]+\.tauri\.localhost(:\d+)?$/i,
/^https?:\/\/tauri\.localhost(:\d+)?$/,
/^https?:\/\/[a-z0-9-]+\.tauri\.localhost(:\d+)?$/i,
/^tauri:\/\/localhost$/,
/^asset:\/\/localhost$/,
];
+46 -9
View File
@@ -4,7 +4,10 @@ const isSidecar = (process.env.LOCAL_API_MODE || '').includes('sidecar');
const mem = new Map();
let persistPath = null;
let persistTimer = null;
let persistInFlight = false;
let persistQueued = false;
let loaded = false;
const MAX_PERSIST_ENTRIES = Math.max(100, Number(process.env.LOCAL_API_CACHE_PERSIST_MAX || 5000));
async function ensureDesktopCache() {
if (loaded) return;
@@ -31,18 +34,52 @@ async function ensureDesktopCache() {
}, 60_000).unref?.();
}
function buildPersistSnapshot() {
const now = Date.now();
const payload = Object.create(null);
let kept = 0;
for (const [key, entry] of mem) {
if (!entry || entry.expiresAt <= now) continue;
payload[key] = entry;
kept += 1;
if (kept >= MAX_PERSIST_ENTRIES) break;
}
return payload;
}
async function persistToDisk() {
if (!persistPath) return;
if (persistInFlight) {
persistQueued = true;
return;
}
persistInFlight = true;
try {
const snapshot = buildPersistSnapshot();
const json = JSON.stringify(snapshot);
const { writeFile, rename } = await import('node:fs/promises');
const tmp = persistPath + '.tmp';
await writeFile(tmp, json, 'utf8');
await rename(tmp, persistPath);
} catch (err) {
console.warn('[Cache] Persist error:', err.message);
} finally {
persistInFlight = false;
if (persistQueued) {
persistQueued = false;
void persistToDisk();
}
}
}
function debouncedPersist() {
if (!persistPath) return;
clearTimeout(persistTimer);
persistTimer = setTimeout(async () => {
try {
const { writeFileSync, renameSync } = await import('node:fs');
const tmp = persistPath + '.tmp';
writeFileSync(tmp, JSON.stringify(Object.fromEntries(mem)));
renameSync(tmp, persistPath);
} catch (err) {
console.warn('[Cache] Persist error:', err.message);
}
persistTimer = setTimeout(() => {
void persistToDisk();
}, 2000);
if (persistTimer?.unref) persistTimer.unref();
}
+7 -2
View File
@@ -1,8 +1,13 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
// Fetch AI/ML papers from ArXiv
// Categories: cs.AI, cs.LG (Machine Learning), cs.CL (Computation and Language)
export default async function handler(request) {
const cors = getCorsHeaders(request);
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
try {
const { searchParams } = new URL(request.url);
const category = searchParams.get('category') || 'cs.AI'; // cs.AI, cs.LG, cs.CL
@@ -32,7 +37,7 @@ export default async function handler(request) {
status: 200,
headers: {
'Content-Type': 'application/xml',
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': 'public, max-age=3600, s-maxage=3600, stale-while-revalidate=600', // 1 hour cache
},
});
@@ -46,7 +51,7 @@ export default async function handler(request) {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
...cors
},
}
);
+22
View File
@@ -1,4 +1,5 @@
import { getCachedJson, setCachedJson, mget, hashString } from './_upstash-cache.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = {
runtime: 'edge',
@@ -18,9 +19,22 @@ const VALID_CATEGORIES = [
];
export default async function handler(request) {
const corsHeaders = getCorsHeaders(request, 'POST, OPTIONS');
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (request.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
@@ -33,6 +47,14 @@ export default async function handler(request) {
});
}
const contentLength = parseInt(request.headers.get('content-length') || '0', 10);
if (contentLength > 51200) {
return new Response(JSON.stringify({ error: 'Payload too large' }), {
status: 413,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
let body;
try {
body = await request.json();
+14
View File
@@ -1,4 +1,5 @@
import { getCachedJson, setCachedJson, hashString } from './_upstash-cache.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = {
runtime: 'edge',
@@ -17,9 +18,22 @@ const VALID_CATEGORIES = [
];
export default async function handler(request) {
const corsHeaders = getCorsHeaders(request, 'GET, OPTIONS');
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (request.method !== 'GET') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
+13 -8
View File
@@ -2,6 +2,7 @@ export const config = { runtime: 'edge' };
import { getCachedJson, hashString, setCachedJson } from './_upstash-cache.js';
import { recordCacheTelemetry } from './_cache-telemetry.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
const ALLOWED_CURRENCIES = ['usd', 'eur', 'gbp', 'jpy', 'cny', 'btc', 'eth'];
const MAX_COIN_IDS = 20;
@@ -36,10 +37,10 @@ function validateBoolean(val, defaultVal) {
return defaultVal;
}
function getHeaders(xCache, cacheControl = RESPONSE_CACHE_CONTROL) {
function getHeaders(cors, xCache, cacheControl = RESPONSE_CACHE_CONTROL) {
return {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': cacheControl,
'X-Cache': xCache,
};
@@ -55,6 +56,10 @@ function isValidPayload(payload) {
}
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
const url = new URL(req.url);
const ids = validateCoinIds(url.searchParams.get('ids'));
@@ -70,7 +75,7 @@ export default async function handler(req) {
recordCacheTelemetry('/api/coingecko', 'REDIS-HIT');
return new Response(redisCached.body, {
status: redisCached.status,
headers: getHeaders('REDIS-HIT'),
headers: getHeaders(cors, 'REDIS-HIT'),
});
}
@@ -82,7 +87,7 @@ export default async function handler(req) {
recordCacheTelemetry('/api/coingecko', 'MEMORY-HIT');
return new Response(fallbackCache.payload.body, {
status: fallbackCache.payload.status,
headers: getHeaders('MEMORY-HIT'),
headers: getHeaders(cors, 'MEMORY-HIT'),
});
}
@@ -110,7 +115,7 @@ export default async function handler(req) {
recordCacheTelemetry('/api/coingecko', 'STALE');
return new Response(fallbackCache.payload.body, {
status: fallbackCache.payload.status,
headers: getHeaders('STALE'),
headers: getHeaders(cors, 'STALE'),
});
}
@@ -128,7 +133,7 @@ export default async function handler(req) {
return new Response(data, {
status: response.status,
headers: getHeaders('MISS'),
headers: getHeaders(cors, 'MISS'),
});
} catch (error) {
// Return cached data on error if available
@@ -136,14 +141,14 @@ export default async function handler(req) {
recordCacheTelemetry('/api/coingecko', 'ERROR-FALLBACK');
return new Response(fallbackCache.payload.body, {
status: fallbackCache.payload.status,
headers: getHeaders('ERROR-FALLBACK', 'public, max-age=120'),
headers: getHeaders(cors, 'ERROR-FALLBACK', 'public, max-age=120'),
});
}
recordCacheTelemetry('/api/coingecko', 'ERROR');
return new Response(JSON.stringify({ error: 'Failed to fetch data' }), {
status: 500,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...cors },
});
}
}
+24 -1
View File
@@ -5,6 +5,7 @@
*/
import { getCachedJson, setCachedJson, hashString } from './_upstash-cache.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = {
runtime: 'edge',
@@ -16,9 +17,22 @@ const CACHE_TTL_SECONDS = 7200; // 2 hours
const CACHE_VERSION = 'ci-v2';
export default async function handler(request) {
const corsHeaders = getCorsHeaders(request, 'POST, OPTIONS');
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (request.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
@@ -31,6 +45,14 @@ export default async function handler(request) {
});
}
const contentLength = parseInt(request.headers.get('content-length') || '0', 10);
if (contentLength > 51200) {
return new Response(JSON.stringify({ error: 'Payload too large' }), {
status: 413,
headers: { 'Content-Type': 'application/json' },
});
}
try {
const { country, code, context } = await request.json();
@@ -109,7 +131,8 @@ Rules:
- 5-6 paragraphs, 300-400 words.
- No speculation beyond what the data supports.
- Use plain language, not jargon.
- If military assets are 0, don't speculate about military presence — say monitoring shows no current military activity.`;
- If military assets are 0, don't speculate about military presence — say monitoring shows no current military activity.
- When referencing a specific headline from the numbered list, cite it as [N] where N is the headline number (e.g. "tensions escalated [3]"). Only cite headlines you directly reference.`;
const userPrompt = `Country: ${country} (${code})${dataSection}`;
+1061
View File
File diff suppressed because it is too large Load Diff
+371
View File
@@ -0,0 +1,371 @@
import { strict as assert } from 'node:assert';
import test from 'node:test';
import handler, {
__resetCyberThreatsState,
__testDedupeThreats,
__testParseFeodoRecords,
} from './cyber-threats.js';
const ORIGINAL_FETCH = globalThis.fetch;
const ORIGINAL_URLHAUS_KEY = process.env.URLHAUS_AUTH_KEY;
const ORIGINAL_OTX_KEY = process.env.OTX_API_KEY;
const ORIGINAL_ABUSEIPDB_KEY = process.env.ABUSEIPDB_API_KEY;
function makeRequest(path = '/api/cyber-threats', ip = '198.51.100.10') {
const headers = new Headers();
headers.set('x-forwarded-for', ip);
return new Request(`https://worldmonitor.app${path}`, { headers });
}
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
function textResponse(body, status = 200) {
return new Response(body, {
status,
headers: { 'content-type': 'text/plain' },
});
}
// Mock that handles all 5 source URLs + geo enrichment
function createMockFetch({ feodo, urlhaus, c2intel, otx, abuseipdb, geo } = {}) {
return async (url) => {
const target = String(url);
if (target.includes('feodotracker.abuse.ch') && feodo) return feodo(target);
if (target.includes('urlhaus-api.abuse.ch') && urlhaus) return urlhaus(target);
if (target.includes('raw.githubusercontent.com') && target.includes('C2IntelFeeds') && c2intel) return c2intel(target);
if (target.includes('otx.alienvault.com') && otx) return otx(target);
if (target.includes('api.abuseipdb.com') && abuseipdb) return abuseipdb(target);
if ((target.includes('ipwho.is') || target.includes('ipapi.co')) && geo) return geo(target);
// Default: return 404 for unconfigured sources
return new Response('not found', { status: 404 });
};
}
test.afterEach(() => {
globalThis.fetch = ORIGINAL_FETCH;
process.env.URLHAUS_AUTH_KEY = ORIGINAL_URLHAUS_KEY;
process.env.OTX_API_KEY = ORIGINAL_OTX_KEY;
process.env.ABUSEIPDB_API_KEY = ORIGINAL_ABUSEIPDB_KEY;
__resetCyberThreatsState();
});
test('Feodo parser accepts online and recent offline entries, filters stale', () => {
const nowMs = Date.parse('2026-02-15T12:00:00.000Z');
const records = [
{
ip_address: '1.2.3.4',
status: 'online',
first_seen: '2026-02-14 10:00:00 UTC',
last_online: '2026-02-15 10:00:00 UTC',
malware: 'QakBot',
},
{
ip_address: '5.6.7.8',
status: 'offline',
first_seen: '2026-02-14 10:00:00 UTC',
last_online: '2026-02-15 10:00:00 UTC',
malware: 'Emotet',
},
{
ip_address: '9.9.9.9',
status: 'online',
first_seen: '2025-10-01 10:00:00 UTC',
last_online: '2025-10-02 10:00:00 UTC',
malware: 'generic',
},
{
ip_address: '2.2.2.2',
first_seen: '2026-02-14 10:00:00 UTC',
last_online: '2026-02-15 10:00:00 UTC',
malware: 'generic',
},
];
const parsed = __testParseFeodoRecords(records, { nowMs, days: 14 });
// online + offline (recent) + no-status (recent) = 3; stale (9.9.9.9) filtered
assert.equal(parsed.length, 3);
assert.equal(parsed[0].indicator, '1.2.3.4');
assert.equal(parsed[0].severity, 'critical');
assert.equal(parsed[1].indicator, '5.6.7.8');
assert.equal(parsed[1].severity, 'medium');
assert.equal(parsed[0].firstSeen?.endsWith('Z'), true);
assert.equal(parsed[0].lastSeen?.endsWith('Z'), true);
});
test('dedupes by source + indicatorType + indicator', () => {
const deduped = __testDedupeThreats([
{
id: 'a',
source: 'feodo',
type: 'c2_server',
indicatorType: 'ip',
indicator: '1.2.3.4',
severity: 'high',
tags: ['a'],
firstSeen: '2026-02-10T00:00:00.000Z',
lastSeen: '2026-02-11T00:00:00.000Z',
},
{
id: 'b',
source: 'feodo',
type: 'c2_server',
indicatorType: 'ip',
indicator: '1.2.3.4',
severity: 'critical',
tags: ['b'],
firstSeen: '2026-02-12T00:00:00.000Z',
lastSeen: '2026-02-13T00:00:00.000Z',
},
{
id: 'c',
source: 'urlhaus',
type: 'malicious_url',
indicatorType: 'domain',
indicator: 'bad.example',
severity: 'medium',
tags: [],
firstSeen: '2026-02-11T00:00:00.000Z',
lastSeen: '2026-02-11T01:00:00.000Z',
},
]);
assert.equal(deduped.length, 2);
const feodo = deduped.find((item) => item.source === 'feodo');
assert.equal(feodo?.severity, 'critical');
assert.equal(feodo?.tags.includes('a'), true);
assert.equal(feodo?.tags.includes('b'), true);
});
test('API aggregates from all 5 sources', async () => {
process.env.URLHAUS_AUTH_KEY = 'test-key';
process.env.OTX_API_KEY = 'test-otx';
process.env.ABUSEIPDB_API_KEY = 'test-abuse';
globalThis.fetch = createMockFetch({
feodo: () => jsonResponse([
{
ip_address: '1.2.3.4',
status: 'online',
last_online: '2026-02-15T10:00:00.000Z',
first_seen: '2026-02-14T10:00:00.000Z',
malware: 'QakBot',
country: 'GB',
lat: 51.5,
lon: -0.12,
},
]),
urlhaus: () => jsonResponse({
urls: [{
url: 'http://5.5.5.5/malware.exe',
host: '5.5.5.5',
url_status: 'online',
threat: 'malware_download',
tags: ['malware'],
dateadded: '2026-02-14T08:00:00.000Z',
latitude: 48.86,
longitude: 2.35,
country: 'FR',
}],
}),
c2intel: () => textResponse(
'#ip,ioc\n10.10.10.10,Possible Cobaltstrike C2 IP\n10.10.10.11,Possible Metasploit C2 IP',
),
otx: () => jsonResponse({
results: [{
indicator: '20.20.20.20',
title: 'APT threat',
tags: ['apt', 'c2'],
created: '2026-02-13T00:00:00.000Z',
modified: '2026-02-14T00:00:00.000Z',
}],
}),
abuseipdb: () => jsonResponse({
data: [{
ipAddress: '30.30.30.30',
abuseConfidenceScore: 98,
lastReportedAt: '2026-02-15T06:00:00.000Z',
countryCode: 'CN',
latitude: 39.9,
longitude: 116.4,
}],
}),
geo: () => jsonResponse({ success: true, latitude: 40.0, longitude: -74.0, country_code: 'US' }),
});
const response = await handler(makeRequest('/api/cyber-threats?limit=100&days=14', '198.51.100.20'));
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.success, true);
assert.equal(body.sources.feodo.ok, true);
assert.equal(body.sources.urlhaus.ok, true);
assert.equal(body.sources.c2intel.ok, true);
assert.equal(body.sources.otx.ok, true);
assert.equal(body.sources.abuseipdb.ok, true);
// 5 sources, all with coords (3 native + 3 via geo enrichment mock)
assert.equal(body.data.length >= 5, true);
});
test('API works with only free sources when keys missing', async () => {
delete process.env.URLHAUS_AUTH_KEY;
delete process.env.OTX_API_KEY;
delete process.env.ABUSEIPDB_API_KEY;
globalThis.fetch = createMockFetch({
feodo: () => jsonResponse([
{
ip_address: '1.2.3.4',
status: 'online',
last_online: '2026-02-15T10:00:00.000Z',
first_seen: '2026-02-14T10:00:00.000Z',
malware: 'QakBot',
country: 'GB',
lat: 51.5,
lon: -0.12,
},
]),
c2intel: () => textResponse('#ip,ioc\n10.10.10.10,Possible Cobaltstrike C2 IP'),
});
const response = await handler(makeRequest('/api/cyber-threats?limit=100&days=14', '198.51.100.11'));
assert.equal(response.status, 200);
assert.equal(response.headers.get('X-Cache'), 'MISS');
const body = await response.json();
assert.equal(body.success, true);
assert.equal(body.partial, false);
assert.equal(body.sources.feodo.ok, true);
assert.equal(body.sources.c2intel.ok, true);
assert.equal(body.sources.urlhaus.ok, false);
assert.equal(body.sources.urlhaus.reason, 'missing_auth_key');
assert.equal(body.sources.otx.ok, false);
assert.equal(body.sources.otx.reason, 'missing_api_key');
assert.equal(body.sources.abuseipdb.ok, false);
assert.equal(body.sources.abuseipdb.reason, 'missing_api_key');
assert.equal(Array.isArray(body.data), true);
});
test('API marks partial=true when URLhaus is enabled but fails', async () => {
process.env.URLHAUS_AUTH_KEY = 'test-key';
delete process.env.OTX_API_KEY;
delete process.env.ABUSEIPDB_API_KEY;
globalThis.fetch = createMockFetch({
feodo: () => jsonResponse([
{
ip_address: '1.2.3.4',
status: 'online',
last_online: '2026-02-15T10:00:00.000Z',
first_seen: '2026-02-14T10:00:00.000Z',
malware: 'QakBot',
country: 'GB',
lat: 51.5,
lon: -0.12,
},
]),
urlhaus: () => new Response('boom', { status: 500 }),
c2intel: () => textResponse('#ip,ioc\n10.10.10.10,Possible Cobaltstrike C2 IP'),
});
const response = await handler(makeRequest('/api/cyber-threats?limit=100&days=14', '198.51.100.12'));
assert.equal(response.status, 200);
const body = await response.json();
assert.equal(body.success, true);
assert.equal(body.partial, true);
assert.equal(body.sources.urlhaus.ok, false);
assert.equal(body.sources.urlhaus.reason, 'urlhaus_http_500');
});
test('API returns memory cache hit on repeated request', async () => {
delete process.env.URLHAUS_AUTH_KEY;
delete process.env.OTX_API_KEY;
delete process.env.ABUSEIPDB_API_KEY;
let feodoCalls = 0;
globalThis.fetch = createMockFetch({
feodo: () => {
feodoCalls += 1;
return jsonResponse([
{
ip_address: '1.2.3.4',
status: 'online',
last_online: '2026-02-15T10:00:00.000Z',
first_seen: '2026-02-14T10:00:00.000Z',
malware: 'QakBot',
country: 'GB',
lat: 51.5,
lon: -0.12,
},
]);
},
c2intel: () => textResponse('#ip,ioc\n10.10.10.10,Possible Cobaltstrike C2 IP'),
});
const first = await handler(makeRequest('/api/cyber-threats?limit=100&days=14', '198.51.100.13'));
assert.equal(first.status, 200);
assert.equal(first.headers.get('X-Cache'), 'MISS');
assert.equal(feodoCalls, 1);
globalThis.fetch = async () => {
throw new Error('network should not be hit for memory cache');
};
const second = await handler(makeRequest('/api/cyber-threats?limit=100&days=14', '198.51.100.13'));
assert.equal(second.status, 200);
assert.equal(second.headers.get('X-Cache'), 'MEMORY-HIT');
assert.equal(feodoCalls, 1);
});
test('API returns stale fallback when upstream fails after fresh cache TTL', async () => {
delete process.env.URLHAUS_AUTH_KEY;
delete process.env.OTX_API_KEY;
delete process.env.ABUSEIPDB_API_KEY;
const baseNow = Date.parse('2026-02-15T12:00:00.000Z');
const originalDateNow = Date.now;
Date.now = () => baseNow;
try {
globalThis.fetch = createMockFetch({
feodo: () => jsonResponse([
{
ip_address: '1.2.3.4',
status: 'online',
last_online: '2026-02-15T10:00:00.000Z',
first_seen: '2026-02-14T10:00:00.000Z',
malware: 'QakBot',
country: 'GB',
lat: 51.5,
lon: -0.12,
},
]),
c2intel: () => textResponse('#ip,ioc\n10.10.10.10,Possible Cobaltstrike C2 IP'),
});
const first = await handler(makeRequest('/api/cyber-threats?limit=100&days=14', '198.51.100.14'));
assert.equal(first.status, 200);
assert.equal(first.headers.get('X-Cache'), 'MISS');
Date.now = () => baseNow + (11 * 60 * 1000);
globalThis.fetch = async () => {
throw new Error('forced upstream failure');
};
const stale = await handler(makeRequest('/api/cyber-threats?limit=100&days=14', '198.51.100.14'));
assert.equal(stale.status, 200);
assert.equal(stale.headers.get('X-Cache'), 'STALE');
const body = await stale.json();
assert.equal(body.success, true);
assert.equal(Array.isArray(body.data), true);
assert.equal(body.data.length >= 1, true);
} finally {
Date.now = originalDateNow;
}
});
+52
View File
@@ -0,0 +1,52 @@
export const config = { runtime: 'edge' };
const RELEASES_URL = 'https://api.github.com/repos/koala73/worldmonitor/releases/latest';
const RELEASES_PAGE = 'https://github.com/koala73/worldmonitor/releases/latest';
const PLATFORM_PATTERNS = {
'windows-exe': (name) => name.endsWith('_x64-setup.exe'),
'windows-msi': (name) => name.endsWith('_x64_en-US.msi'),
'macos-arm64': (name) => name.endsWith('_aarch64.dmg'),
'macos-x64': (name) => name.endsWith('_x64.dmg') && !name.includes('setup'),
'linux-appimage': (name) => name.endsWith('_amd64.AppImage'),
};
export default async function handler(req) {
const url = new URL(req.url);
const platform = url.searchParams.get('platform');
if (!platform || !PLATFORM_PATTERNS[platform]) {
return Response.redirect(RELEASES_PAGE, 302);
}
try {
const res = await fetch(RELEASES_URL, {
headers: {
'Accept': 'application/vnd.github+json',
'User-Agent': 'WorldMonitor-Download-Redirect',
},
});
if (!res.ok) {
return Response.redirect(RELEASES_PAGE, 302);
}
const release = await res.json();
const matcher = PLATFORM_PATTERNS[platform];
const asset = release.assets?.find((a) => matcher(a.name));
if (!asset) {
return Response.redirect(RELEASES_PAGE, 302);
}
return new Response(null, {
status: 302,
headers: {
'Location': asset.browser_download_url,
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60',
},
});
} catch {
return Response.redirect(RELEASES_PAGE, 302);
}
}
+9 -3
View File
@@ -1,6 +1,12 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
export default async function handler() {
export default async function handler(request) {
const cors = getCorsHeaders(request);
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
try {
const response = await fetch(
'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_day.geojson',
@@ -16,14 +22,14 @@ export default async function handler() {
status: response.status,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
},
});
} catch (error) {
return new Response(JSON.stringify({ error: 'Failed to fetch data' }), {
status: 500,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...cors },
});
}
}
+16 -34
View File
@@ -1,28 +1,22 @@
// EIA (Energy Information Administration) API proxy
// Keeps API key server-side
import { getCorsHeaders, isDisallowedOrigin } from '../_cors.js';
export const config = { runtime: 'edge' };
function getCorsOrigin(req) {
const origin = req.headers.get('origin') || '';
// Allow *.worldmonitor.app and localhost
if (
origin.endsWith('.worldmonitor.app') ||
origin === 'https://worldmonitor.app' ||
origin.startsWith('http://localhost:')
) {
return origin;
}
return 'https://worldmonitor.app';
}
export default async function handler(req) {
const corsOrigin = getCorsOrigin(req);
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
// Only allow GET and OPTIONS methods
if (req.method !== 'GET' && req.method !== 'OPTIONS') {
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: cors });
}
if (req.method !== 'GET') {
return Response.json({ error: 'Method not allowed' }, {
status: 405,
headers: { 'Access-Control-Allow-Origin': corsOrigin },
headers: cors,
});
}
@@ -38,26 +32,14 @@ export default async function handler(req) {
reason: 'EIA_API_KEY not configured',
}, {
status: 200,
headers: { 'Access-Control-Allow-Origin': corsOrigin },
});
}
// Handle CORS preflight
if (req.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': corsOrigin,
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
headers: cors,
});
}
// Health check
if (path === '/health' || path === '') {
return Response.json({ configured: true }, {
headers: { 'Access-Control-Allow-Origin': corsOrigin },
headers: cors,
});
}
@@ -113,8 +95,8 @@ export default async function handler(req) {
return Response.json(results, {
headers: {
'Access-Control-Allow-Origin': corsOrigin,
'Cache-Control': 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=300', // 30 min cache
...cors,
'Cache-Control': 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=300',
},
});
} catch (error) {
@@ -123,13 +105,13 @@ export default async function handler(req) {
error: 'Failed to fetch EIA data',
}, {
status: 500,
headers: { 'Access-Control-Allow-Origin': corsOrigin },
headers: cors,
});
}
}
return Response.json({ error: 'Not found' }, {
status: 404,
headers: { 'Access-Control-Allow-Origin': corsOrigin },
headers: cors,
});
}
+7 -1
View File
@@ -1,6 +1,12 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
try {
const response = await fetch('https://nasstatus.faa.gov/api/airport-status-information', {
headers: { 'Accept': 'application/xml' },
@@ -10,7 +16,7 @@ export default async function handler(req) {
status: response.status,
headers: {
'Content-Type': 'application/xml',
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=30',
},
});
+7
View File
@@ -7,6 +7,8 @@
* GET ?days=1 — fires for all monitored regions
*/
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = {
runtime: 'edge',
};
@@ -70,6 +72,11 @@ function parseCSV(csv) {
}
export default async function handler(request) {
const cors = getCorsHeaders(request);
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors });
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
if (!FIRMS_API_KEY) {
return json({ regions: {}, totalCount: 0, skipped: true, reason: 'NASA_FIRMS_API_KEY not configured', source: SOURCE, days: 0, timestamp: new Date().toISOString() });
}
+11 -3
View File
@@ -39,9 +39,17 @@ export default async function handler(req) {
const apiKey = process.env.FRED_API_KEY;
if (!apiKey) {
return new Response(JSON.stringify({ error: 'FRED_API_KEY not configured' }), {
status: 500,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
return new Response(JSON.stringify({
observations: [],
skipped: true,
reason: 'FRED_API_KEY not configured',
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
...corsHeaders,
},
});
}
+7 -2
View File
@@ -1,7 +1,12 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
// Scrape FwdStart newsletter archive and return as RSS
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
try {
const response = await fetch('https://www.fwdstart.me/archive', {
headers: {
@@ -84,7 +89,7 @@ export default async function handler(req) {
return new Response(rss, {
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=300',
},
});
@@ -97,7 +102,7 @@ export default async function handler(req) {
status: 502,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
...cors,
},
});
}
+6 -1
View File
@@ -1,9 +1,14 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
const MAX_RECORDS = 20;
const DEFAULT_RECORDS = 10;
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
const url = new URL(req.url);
const query = url.searchParams.get('query');
const maxrecords = Math.min(
@@ -50,7 +55,7 @@ export default async function handler(req) {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
},
});
+14 -33
View File
@@ -1,4 +1,5 @@
// GDELT Geo API proxy with security hardening
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
const ALLOWED_FORMATS = ['geojson', 'json', 'csv'];
@@ -6,19 +7,6 @@ const MAX_RECORDS = 500;
const MIN_RECORDS = 1;
const ALLOWED_TIMESPANS = ['1d', '7d', '14d', '30d', '60d', '90d'];
function getCorsOrigin(req) {
const origin = req.headers.get('origin') || '';
// Allow *.worldmonitor.app and localhost
if (
origin.endsWith('.worldmonitor.app') ||
origin === 'https://worldmonitor.app' ||
origin.startsWith('http://localhost:')
) {
return origin;
}
return 'https://worldmonitor.app';
}
function validateMaxRecords(val) {
const num = parseInt(val, 10);
if (isNaN(num)) return 250;
@@ -39,26 +27,19 @@ function sanitizeQuery(val) {
}
export default async function handler(req) {
const corsOrigin = getCorsOrigin(req);
if (req.method !== 'GET' && req.method !== 'OPTIONS') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': corsOrigin,
},
});
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
if (req.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': corsOrigin,
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
},
return new Response(null, { status: 204, headers: cors });
}
if (req.method !== 'GET') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json', ...cors },
});
}
@@ -78,7 +59,7 @@ export default async function handler(req) {
status: 502,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': corsOrigin,
...cors,
},
});
}
@@ -88,7 +69,7 @@ export default async function handler(req) {
status: 200,
headers: {
'Content-Type': format === 'csv' ? 'text/csv' : 'application/json',
'Access-Control-Allow-Origin': corsOrigin,
...cors,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
},
});
@@ -98,7 +79,7 @@ export default async function handler(req) {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': corsOrigin,
...cors,
},
});
}
+9 -3
View File
@@ -1,8 +1,14 @@
export const config = { runtime: 'edge' };
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
// Fetch trending GitHub repositories
// Uses unofficial GitHub trending scraper API
export default async function handler(request) {
const cors = getCorsHeaders(request);
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
try {
const { searchParams } = new URL(request.url);
const language = searchParams.get('language') || 'python'; // python, javascript, typescript, etc.
@@ -50,7 +56,7 @@ export default async function handler(request) {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=300', // 30 min cache
},
});
@@ -62,7 +68,7 @@ export default async function handler(request) {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=300', // 30 min cache
},
});
@@ -76,7 +82,7 @@ export default async function handler(request) {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
...cors,
},
}
);
+51 -9
View File
@@ -6,6 +6,7 @@
*/
import { getCachedJson, setCachedJson, hashString } from './_upstash-cache.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = {
runtime: 'edge',
@@ -17,11 +18,19 @@ const CACHE_TTL_SECONDS = 86400; // 24 hours
const CACHE_VERSION = 'v3';
function getCacheKey(headlines, mode, geoContext = '', variant = 'full') {
function getCacheKey(headlines, mode, geoContext = '', variant = 'full', lang = 'en') {
const sorted = headlines.slice(0, 8).sort().join('|');
const geoHash = geoContext ? ':g' + hashString(geoContext).slice(0, 6) : '';
const hash = hashString(`${mode}:${sorted}`);
return `summary:${CACHE_VERSION}:${variant}:${hash}${geoHash}`;
const normalizedVariant = typeof variant === 'string' && variant ? variant.toLowerCase() : 'full';
const normalizedLang = typeof lang === 'string' && lang ? lang.toLowerCase() : 'en';
if (mode === 'translate') {
const targetLang = normalizedVariant || normalizedLang;
return `summary:${CACHE_VERSION}:${mode}:${targetLang}:${hash}${geoHash}`;
}
return `summary:${CACHE_VERSION}:${mode}:${normalizedVariant}:${normalizedLang}:${hash}${geoHash}`;
}
// Deduplicate similar headlines (same story from different sources)
@@ -60,10 +69,22 @@ function deduplicateHeadlines(headlines) {
}
export default async function handler(request) {
// Only allow POST
const corsHeaders = getCorsHeaders(request, 'POST, OPTIONS');
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (request.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
@@ -76,8 +97,16 @@ export default async function handler(request) {
});
}
const contentLength = parseInt(request.headers.get('content-length') || '0', 10);
if (contentLength > 51200) {
return new Response(JSON.stringify({ error: 'Payload too large' }), {
status: 413,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
try {
const { headlines, mode = 'brief', geoContext = '', variant = 'full' } = await request.json();
const { headlines, mode = 'brief', geoContext = '', variant = 'full', lang = 'en' } = await request.json();
if (!headlines || !Array.isArray(headlines) || headlines.length === 0) {
return new Response(JSON.stringify({ error: 'Headlines array required' }), {
@@ -87,7 +116,7 @@ export default async function handler(request) {
}
// Check cache first
const cacheKey = getCacheKey(headlines, mode, geoContext, variant);
const cacheKey = getCacheKey(headlines, mode, geoContext, variant, lang);
const cached = await getCachedJson(cacheKey);
if (cached && typeof cached === 'object' && cached.summary) {
console.log('[Groq] Cache hit:', cacheKey);
@@ -115,6 +144,9 @@ export default async function handler(request) {
const isTechVariant = variant === 'tech';
const dateContext = `Current date: ${new Date().toISOString().split('T')[0]}.${isTechVariant ? '' : ' Donald Trump is the current US President (second term, inaugurated Jan 2025).'}`;
// Language instruction
const langInstruction = lang && lang !== 'en' ? `\nIMPORTANT: Output the summary in ${lang.toUpperCase()} language.` : '';
if (mode === 'brief') {
if (isTechVariant) {
// Tech variant: focus on startups, AI, funding, product launches
@@ -126,7 +158,7 @@ Rules:
- IGNORE political news, trade policy, tariffs, government actions unless directly about tech regulation
- Lead with the company/product/technology name
- Start directly: "OpenAI announced...", "A new $50M Series B...", "GitHub released..."
- No bullet points, no meta-commentary`;
- No bullet points, no meta-commentary${langInstruction}`;
} else {
// Full variant: geopolitical focus
systemPrompt = `${dateContext}
@@ -138,7 +170,7 @@ Rules:
- Start directly with the subject: "Iran's regime...", "The US Treasury...", "Protests in..."
- CRITICAL FOCAL POINTS are the main actors - mention them by name
- If focal points show news + signals convergence, that's the lead
- No bullet points, no meta-commentary`;
- No bullet points, no meta-commentary${langInstruction}`;
}
userPrompt = `Summarize the top story:\n${headlineText}${intelSection}`;
} else if (mode === 'analysis') {
@@ -166,10 +198,19 @@ Rules:
userPrompt = isTechVariant
? `What's the key tech trend or development?\n${headlineText}${intelSection}`
: `What's the key pattern or risk?\n${headlineText}${intelSection}`;
} else if (mode === 'translate') {
const targetLang = variant; // In translate mode, variant param holds the target language code (e.g., 'fr', 'es')
systemPrompt = `You are a professional news translator. Translate the following news headlines/summaries into ${targetLang}.
Rules:
- Maintain the original tone and journalistic style.
- Do NOT add any conversational filler (e.g., "Here is the translation").
- Output ONLY the translated text.
- If the text is already in ${targetLang}, return it as is.`;
userPrompt = `Translate to ${targetLang}:\n${headlines[0]}`;
} else {
systemPrompt = isTechVariant
? `${dateContext}\n\nSynthesize tech news in 2 sentences. Focus on startups, AI, funding, products. Ignore politics unless directly about tech regulation.`
: `${dateContext}\n\nSynthesize in 2 sentences max. Lead with substance. NEVER start with "Breaking news" or "Tonight" - just state the insight directly. CRITICAL focal points with news-signal convergence are significant.`;
? `${dateContext}\n\nSynthesize tech news in 2 sentences. Focus on startups, AI, funding, products. Ignore politics unless directly about tech regulation.${langInstruction}`
: `${dateContext}\n\nSynthesize in 2 sentences max. Lead with substance. NEVER start with "Breaking news" or "Tonight" - just state the insight directly. CRITICAL focal points with news-signal convergence are significant.${langInstruction}`;
userPrompt = `Key takeaway:\n${headlineText}${intelSection}`;
}
@@ -235,6 +276,7 @@ Rules:
}), {
status: 200,
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=300',
},
+8 -2
View File
@@ -1,5 +1,7 @@
export const config = { runtime: 'edge' };
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
// Fetch Hacker News front page stories
// Uses official HackerNews Firebase API
const ALLOWED_STORY_TYPES = new Set(['top', 'new', 'best', 'ask', 'show', 'job']);
@@ -14,6 +16,10 @@ function parseLimit(rawLimit) {
}
export default async function handler(request) {
const cors = getCorsHeaders(request);
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
try {
const { searchParams } = new URL(request.url);
const requestedType = searchParams.get('type') || 'top';
@@ -70,7 +76,7 @@ export default async function handler(request) {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60', // 5 min cache
},
});
@@ -84,7 +90,7 @@ export default async function handler(request) {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
...cors,
},
}
);
+10 -5
View File
@@ -5,6 +5,7 @@ export const config = { runtime: 'edge' };
import { getCachedJson, setCachedJson } from './_upstash-cache.js';
import { recordCacheTelemetry } from './_cache-telemetry.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
const CACHE_KEY = 'hapi:conflict-events:v2';
const CACHE_TTL_SECONDS = 6 * 60 * 60; // 6 hours
@@ -28,6 +29,10 @@ function toErrorMessage(error) {
}
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
const now = Date.now();
const cached = await getCachedJson(CACHE_KEY);
if (isValidResult(cached)) {
@@ -35,7 +40,7 @@ export default async function handler(req) {
return Response.json(cached, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': RESPONSE_CACHE_CONTROL,
'X-Cache': 'REDIS-HIT',
},
@@ -47,7 +52,7 @@ export default async function handler(req) {
return Response.json(fallbackCache.data, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': RESPONSE_CACHE_CONTROL,
'X-Cache': 'MEMORY-HIT',
},
@@ -116,7 +121,7 @@ export default async function handler(req) {
return Response.json(result, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': RESPONSE_CACHE_CONTROL,
'X-Cache': 'MISS',
},
@@ -127,7 +132,7 @@ export default async function handler(req) {
return Response.json(fallbackCache.data, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
'X-Cache': 'STALE',
},
@@ -137,7 +142,7 @@ export default async function handler(req) {
recordCacheTelemetry('/api/hapi', 'ERROR');
return Response.json({ error: `Fetch failed: ${toErrorMessage(error)}`, countries: [] }, {
status: 500,
headers: { 'Access-Control-Allow-Origin': '*' },
headers: { ...cors },
});
}
}
+7 -1
View File
@@ -1,6 +1,12 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
try {
const response = await fetch(
'https://msi.nga.mil/api/publications/broadcast-warn?output=json&status=A'
@@ -8,7 +14,7 @@ export default async function handler(req) {
const data = await response.text();
return new Response(data, {
status: response.status,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60' },
headers: { 'Content-Type': 'application/json', ...cors, 'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60' },
});
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
+52 -11
View File
@@ -1,28 +1,37 @@
/**
* OpenRouter API Summarization Endpoint with Redis Caching
* Fallback when Groq is rate-limited
* Uses Llama 3.3 70B free model
* Uses OpenRouter auto-routed free model
* Free tier: 50 requests/day (20/min)
* Server-side Redis cache for cross-user deduplication
*/
import { getCachedJson, setCachedJson, hashString } from './_upstash-cache.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = {
runtime: 'edge',
};
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
const MODEL = 'meta-llama/llama-3.3-70b-instruct:free';
const MODEL = 'openrouter/free';
const CACHE_TTL_SECONDS = 86400; // 24 hours
const CACHE_VERSION = 'v3';
function getCacheKey(headlines, mode, geoContext = '', variant = 'full') {
function getCacheKey(headlines, mode, geoContext = '', variant = 'full', lang = 'en') {
const sorted = headlines.slice(0, 8).sort().join('|');
const geoHash = geoContext ? ':g' + hashString(geoContext).slice(0, 6) : '';
const hash = hashString(`${mode}:${sorted}`);
return `summary:${CACHE_VERSION}:${variant}:${hash}${geoHash}`;
const normalizedVariant = typeof variant === 'string' && variant ? variant.toLowerCase() : 'full';
const normalizedLang = typeof lang === 'string' && lang ? lang.toLowerCase() : 'en';
if (mode === 'translate') {
const targetLang = normalizedVariant || normalizedLang;
return `summary:${CACHE_VERSION}:${mode}:${targetLang}:${hash}${geoHash}`;
}
return `summary:${CACHE_VERSION}:${mode}:${normalizedVariant}:${normalizedLang}:${hash}${geoHash}`;
}
// Deduplicate similar headlines (same story from different sources)
@@ -61,10 +70,22 @@ function deduplicateHeadlines(headlines) {
}
export default async function handler(request) {
// Only allow POST
const corsHeaders = getCorsHeaders(request, 'POST, OPTIONS');
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (request.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
@@ -77,8 +98,16 @@ export default async function handler(request) {
});
}
const contentLength = parseInt(request.headers.get('content-length') || '0', 10);
if (contentLength > 51200) {
return new Response(JSON.stringify({ error: 'Payload too large' }), {
status: 413,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
try {
const { headlines, mode = 'brief', geoContext = '', variant = 'full' } = await request.json();
const { headlines, mode = 'brief', geoContext = '', variant = 'full', lang = 'en' } = await request.json();
if (!headlines || !Array.isArray(headlines) || headlines.length === 0) {
return new Response(JSON.stringify({ error: 'Headlines array required' }), {
@@ -88,7 +117,7 @@ export default async function handler(request) {
}
// Check cache first (shared with Groq endpoint)
const cacheKey = getCacheKey(headlines, mode, geoContext, variant);
const cacheKey = getCacheKey(headlines, mode, geoContext, variant, lang);
const cached = await getCachedJson(cacheKey);
if (cached && typeof cached === 'object' && cached.summary) {
console.log('[OpenRouter] Cache hit:', cacheKey);
@@ -116,6 +145,9 @@ export default async function handler(request) {
const isTechVariant = variant === 'tech';
const dateContext = `Current date: ${new Date().toISOString().split('T')[0]}.${isTechVariant ? '' : ' Donald Trump is the current US President (second term, inaugurated Jan 2025).'}`;
// Language instruction
const langInstruction = lang && lang !== 'en' ? `\nIMPORTANT: Output the summary in ${lang.toUpperCase()} language.` : '';
if (mode === 'brief') {
if (isTechVariant) {
// Tech variant: focus on startups, AI, funding, product launches
@@ -127,7 +159,7 @@ Rules:
- IGNORE political news, trade policy, tariffs, government actions unless directly about tech regulation
- Lead with the company/product/technology name
- Start directly: "OpenAI announced...", "A new $50M Series B...", "GitHub released..."
- No bullet points, no meta-commentary`;
- No bullet points, no meta-commentary${langInstruction}`;
} else {
// Full variant: geopolitical focus
systemPrompt = `${dateContext}
@@ -139,7 +171,7 @@ Rules:
- Start directly with the subject: "Iran's regime...", "The US Treasury...", "Protests in..."
- CRITICAL FOCAL POINTS are the main actors - mention them by name
- If focal points show news + signals convergence, that's the lead
- No bullet points, no meta-commentary`;
- No bullet points, no meta-commentary${langInstruction}`;
}
userPrompt = `Summarize the top story:\n${headlineText}${intelSection}`;
} else if (mode === 'analysis') {
@@ -167,10 +199,18 @@ Rules:
userPrompt = isTechVariant
? `What's the key tech trend or development?\n${headlineText}${intelSection}`
: `What's the key pattern or risk?\n${headlineText}${intelSection}`;
} else if (mode === 'translate') {
const targetLang = variant; // In translate mode, variant param holds the target language code
systemPrompt = `You are a professional news translator. Translate the following news headlines/summaries into ${targetLang}.
Rules:
- Maintain the original tone and journalistic style.
- Do NOT add any conversational filler.
- Output ONLY the translated text.`;
userPrompt = `Translate to ${targetLang}:\n${headlines[0]}`;
} else {
systemPrompt = isTechVariant
? `${dateContext}\n\nSynthesize tech news in 2 sentences. Focus on startups, AI, funding, products. Ignore politics unless directly about tech regulation.`
: `${dateContext}\n\nSynthesize in 2 sentences max. Lead with substance. NEVER start with "Breaking news" or "Tonight" - just state the insight directly. CRITICAL focal points with news-signal convergence are significant.`;
? `${dateContext}\n\nSynthesize tech news in 2 sentences. Focus on startups, AI, funding, products. Ignore politics unless directly about tech regulation.${langInstruction}`
: `${dateContext}\n\nSynthesize in 2 sentences max. Lead with substance. NEVER start with "Breaking news" or "Tonight" - just state the insight directly. CRITICAL focal points with news-signal convergence are significant.${langInstruction}`;
userPrompt = `Key takeaway:\n${headlineText}${intelSection}`;
}
@@ -238,6 +278,7 @@ Rules:
}), {
status: 200,
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=300',
},
+10 -4
View File
@@ -1,8 +1,14 @@
// OpenSky Network API proxy - v3
// Note: OpenSky seems to block some cloud provider IPs
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
const url = new URL(req.url);
// Build OpenSky API URL with bounding box params
@@ -29,7 +35,7 @@ export default async function handler(req) {
if (response.status === 429) {
return Response.json({ error: 'Rate limited', time: Date.now(), states: null }, {
status: 429,
headers: { 'Access-Control-Allow-Origin': '*' },
headers: cors,
});
}
@@ -42,7 +48,7 @@ export default async function handler(req) {
states: null
}, {
status: response.status,
headers: { 'Access-Control-Allow-Origin': '*' },
headers: cors,
});
}
@@ -50,7 +56,7 @@ export default async function handler(req) {
return Response.json(data, {
status: response.status,
headers: {
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': 'public, max-age=30, s-maxage=30, stale-while-revalidate=15',
},
});
@@ -61,7 +67,7 @@ export default async function handler(req) {
states: null
}, {
status: 500,
headers: { 'Access-Control-Allow-Origin': '*' },
headers: cors,
});
}
}
+8 -3
View File
@@ -1,6 +1,11 @@
import { getCorsHeaders, isDisallowedOrigin } from '../_cors.js';
export const config = { runtime: 'edge' };
export default async function handler() {
export default async function handler(request) {
const cors = getCorsHeaders(request);
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
try {
const response = await fetch('https://www.pizzint.watch/api/dashboard-data', {
headers: {
@@ -18,14 +23,14 @@ export default async function handler() {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=30',
},
});
} catch (error) {
return new Response(JSON.stringify({ error: 'Failed to fetch PizzINT data', details: error.message }), {
status: 502,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...cors },
});
}
}
+7 -2
View File
@@ -1,6 +1,11 @@
import { getCorsHeaders, isDisallowedOrigin } from '../../_cors.js';
export const config = { runtime: 'edge' };
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
const url = new URL(req.url);
const pairs = url.searchParams.get('pairs') || 'usa_russia,russia_ukraine,usa_china,china_taiwan,usa_iran,usa_venezuela';
const dateStart = url.searchParams.get('dateStart');
@@ -28,14 +33,14 @@ export default async function handler(req) {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
},
});
} catch (error) {
return new Response(JSON.stringify({ error: 'Failed to fetch GDELT data', details: error.message }), {
status: 502,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...cors },
});
}
}
+8 -2
View File
@@ -1,3 +1,5 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
const GAMMA_BASE = 'https://gamma-api.polymarket.com';
@@ -53,6 +55,10 @@ function buildUrl(base, endpoint, params) {
}
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
const url = new URL(req.url);
const endpoint = url.searchParams.get('endpoint') || 'markets';
@@ -82,7 +88,7 @@ export default async function handler(req) {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': 'public, max-age=120, s-maxage=120, stale-while-revalidate=60',
'X-Polymarket-Source': 'gamma',
},
@@ -93,7 +99,7 @@ export default async function handler(req) {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
...cors,
'X-Polymarket-Error': err.message,
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
},
+14 -1
View File
@@ -5,6 +5,7 @@
*/
import { getCachedJson, setCachedJson } from './_upstash-cache.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = {
runtime: 'edge',
@@ -220,10 +221,22 @@ function computeStrategicRisk(ciiScores) {
}
export default async function handler(request) {
// Allow GET only
const corsHeaders = getCorsHeaders(request, 'GET, OPTIONS');
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (request.method !== 'GET') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
+60 -21
View File
@@ -1,3 +1,5 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
// Fetch with timeout
@@ -54,6 +56,7 @@ const ALLOWED_DOMAINS = [
'www.reutersagency.com',
'feeds.reuters.com',
'rsshub.app',
'asia.nikkei.com',
'www.cfr.org',
'www.csis.org',
'www.politico.com',
@@ -121,11 +124,21 @@ const ALLOWED_DOMAINS = [
'english.alarabiya.net',
'www.arabnews.com',
'www.timesofisrael.com',
'www.haaretz.com',
'www.scmp.com',
'kyivindependent.com',
'www.themoscowtimes.com',
'feeds.24.com',
'feeds.capi24.com', // News24 redirect destination
// International News Sources
'www.france24.com',
'www.euronews.com',
'www.lemonde.fr',
'rss.dw.com',
'www.africanews.com',
'www.lasillavacia.com',
'www.channelnewsasia.com',
'www.thehindu.com',
// International Organizations
'news.un.org',
'www.iaea.org',
@@ -134,6 +147,11 @@ const ALLOWED_DOMAINS = [
'www.crisisgroup.org',
// Think Tanks & Research (Added 2026-01-29)
'rusi.org',
'warontherocks.com',
'www.aei.org',
'responsiblestatecraft.org',
'www.fpri.org',
'jamestown.org',
'www.chathamhouse.org',
'ecfr.eu',
'www.gmfus.org',
@@ -155,30 +173,14 @@ const ALLOWED_DOMAINS = [
'www.imf.org',
// Additional
'news.ycombinator.com',
// Finance variant
'seekingalpha.com',
'www.coindesk.com',
'cointelegraph.com',
];
// CORS helper - allow worldmonitor.app and Vercel preview domains
function getCorsHeaders(req) {
const origin = req.headers.get('origin') || '*';
const allowedPatterns = [
/^https:\/\/(.*\.)?worldmonitor\.app$/, // Matches worldmonitor.app and *.worldmonitor.app
/^https:\/\/.*-elie-habib-projects\.vercel\.app$/,
/^https:\/\/worldmonitor.*\.vercel\.app$/,
/^http:\/\/localhost(:\d+)?$/,
];
const isAllowed = origin === '*' || allowedPatterns.some(p => p.test(origin));
return {
'Access-Control-Allow-Origin': isAllowed ? origin : 'https://worldmonitor.app',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Max-Age': '86400',
};
}
export default async function handler(req) {
const corsHeaders = getCorsHeaders(req);
const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
// Handle CORS preflight
if (req.method === 'OPTIONS') {
@@ -216,8 +218,45 @@ export default async function handler(req) {
'Accept': 'application/rss+xml, application/xml, text/xml, */*',
'Accept-Language': 'en-US,en;q=0.9',
},
redirect: 'manual',
}, timeout);
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (location) {
try {
const redirectUrl = new URL(location, feedUrl);
if (!ALLOWED_DOMAINS.includes(redirectUrl.hostname)) {
return new Response(JSON.stringify({ error: 'Redirect to disallowed domain' }), {
status: 403,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
const redirectResponse = await fetchWithTimeout(redirectUrl.href, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/rss+xml, application/xml, text/xml, */*',
'Accept-Language': 'en-US,en;q=0.9',
},
}, timeout);
const data = await redirectResponse.text();
return new Response(data, {
status: redirectResponse.status,
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, max-age=300, s-maxage=300, stale-while-revalidate=60',
...corsHeaders,
},
});
} catch {
return new Response(JSON.stringify({ error: 'Invalid redirect' }), {
status: 502,
headers: { 'Content-Type': 'application/json', ...corsHeaders },
});
}
}
}
const data = await response.text();
return new Response(data, {
status: response.status,
+6 -1
View File
@@ -1,3 +1,4 @@
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
// Major tech services and their status page endpoints
@@ -248,6 +249,10 @@ async function checkStatusPage(service) {
}
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
const url = new URL(req.url);
const category = url.searchParams.get('category'); // cloud, dev, comm, ai, saas, or all
@@ -284,7 +289,7 @@ export default async function handler(req) {
}), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=30', // 1 min cache
},
});
+6
View File
@@ -4,6 +4,7 @@
* Redis cached (1h TTL)
*/
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
import { getCachedJson, setCachedJson } from './_upstash-cache.js';
export const config = {
@@ -62,6 +63,11 @@ const COUNTRY_INDEX = {
};
export default async function handler(request) {
const cors = getCorsHeaders(request);
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors });
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
if (request.method !== 'GET') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
+7 -2
View File
@@ -1,4 +1,5 @@
// Tech Events API - Parses Techmeme ICS feed and dev.events RSS, returns structured events
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = { runtime: 'edge' };
const ICS_URL = 'https://www.techmeme.com/newsy_events.ics';
@@ -617,6 +618,10 @@ function parseDevEventsRSS(rssText) {
}
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
const url = new URL(req.url);
const type = url.searchParams.get('type'); // 'all', 'conferences', 'earnings', 'ipo'
const mappable = url.searchParams.get('mappable') === 'true'; // Only return events with coords
@@ -712,7 +717,7 @@ export default async function handler(req) {
status: 200,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=300',
},
});
@@ -725,7 +730,7 @@ export default async function handler(req) {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
...cors,
},
});
}
+20 -1
View File
@@ -8,6 +8,7 @@
*/
import { getCachedJson, setCachedJson, mget } from './_upstash-cache.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = {
runtime: 'edge',
@@ -33,6 +34,19 @@ function getSeverity(zScore) {
}
export default async function handler(request) {
const corsHeaders = getCorsHeaders(request, 'GET, POST, OPTIONS');
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
}
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
try {
if (request.method === 'GET') {
return await handleGet(request);
@@ -41,7 +55,7 @@ export default async function handler(request) {
}
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { 'Content-Type': 'application/json' },
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
} catch (err) {
console.error('[TemporalBaseline] Error:', err);
@@ -102,6 +116,11 @@ async function handleGet(request) {
}
async function handlePost(request) {
const contentLength = parseInt(request.headers.get('content-length') || '0', 10);
if (contentLength > 51200) {
return json({ error: 'Payload too large' }, 413);
}
const body = await request.json();
const updates = body?.updates;
+5 -6
View File
@@ -5,6 +5,7 @@
*/
import { getCachedJson, setCachedJson } from './_upstash-cache.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = {
runtime: 'edge',
@@ -492,12 +493,10 @@ function calculatePostures(flights) {
}
export default async function handler(req) {
// CORS headers
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
const corsHeaders = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: corsHeaders });
}
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders });
+10 -5
View File
@@ -5,6 +5,7 @@ export const config = { runtime: 'edge' };
import { getCachedJson, setCachedJson } from './_upstash-cache.js';
import { recordCacheTelemetry } from './_cache-telemetry.js';
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
const CACHE_KEY = 'ucdp:country-conflicts:v2';
const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours (annual data)
@@ -28,6 +29,10 @@ function toErrorMessage(error) {
}
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
const now = Date.now();
const cached = await getCachedJson(CACHE_KEY);
if (isValidResult(cached)) {
@@ -35,7 +40,7 @@ export default async function handler(req) {
return Response.json(cached, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': RESPONSE_CACHE_CONTROL,
'X-Cache': 'REDIS-HIT',
},
@@ -47,7 +52,7 @@ export default async function handler(req) {
return Response.json(fallbackCache.data, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': RESPONSE_CACHE_CONTROL,
'X-Cache': 'MEMORY-HIT',
},
@@ -118,7 +123,7 @@ export default async function handler(req) {
return Response.json(result, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': RESPONSE_CACHE_CONTROL,
'X-Cache': 'MISS',
},
@@ -129,7 +134,7 @@ export default async function handler(req) {
return Response.json(fallbackCache.data, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': 'public, max-age=600, s-maxage=600, stale-while-revalidate=120',
'X-Cache': 'STALE',
},
@@ -139,7 +144,7 @@ export default async function handler(req) {
recordCacheTelemetry('/api/ucdp', 'ERROR');
return Response.json({ error: `Fetch failed: ${toErrorMessage(error)}`, conflicts: [] }, {
status: 500,
headers: { 'Access-Control-Allow-Origin': '*' },
headers: { ...cors },
});
}
}
+44
View File
@@ -0,0 +1,44 @@
export const config = { runtime: 'edge' };
const RELEASES_URL = 'https://api.github.com/repos/koala73/worldmonitor/releases/latest';
export default async function handler() {
try {
const res = await fetch(RELEASES_URL, {
headers: {
'Accept': 'application/vnd.github+json',
'User-Agent': 'WorldMonitor-Version-Check',
},
});
if (!res.ok) {
return new Response(JSON.stringify({ error: 'upstream' }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
});
}
const release = await res.json();
const tag = release.tag_name ?? '';
const version = tag.replace(/^v/, '');
return new Response(JSON.stringify({
version,
tag,
url: release.html_url,
prerelease: release.prerelease ?? false,
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=60',
'Access-Control-Allow-Origin': '*',
},
});
} catch {
return new Response(JSON.stringify({ error: 'fetch_failed' }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
});
}
}
+13 -12
View File
@@ -1,4 +1,5 @@
// World Bank API proxy (Web API handler for Edge + sidecar compatibility)
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
export const config = {
runtime: 'edge',
@@ -33,19 +34,19 @@ const TECH_COUNTRIES = [
'ZAF', 'NGA', 'KEN',
];
const CORS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
};
function json(data, status = 200, extra = {}) {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json', ...CORS, ...extra },
});
}
export default async function handler(request) {
const CORS = getCorsHeaders(request);
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: CORS });
}
function json(data, status = 200, extra = {}) {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json', ...CORS, ...extra },
});
}
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: CORS });
}
+9 -3
View File
@@ -1,5 +1,7 @@
export const config = { runtime: 'edge' };
import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
const SYMBOL_PATTERN = /^[A-Za-z0-9.^=\-]+$/;
const MAX_SYMBOL_LENGTH = 20;
@@ -12,13 +14,17 @@ function validateSymbol(symbol) {
}
export default async function handler(req) {
const cors = getCorsHeaders(req);
if (isDisallowedOrigin(req)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
const url = new URL(req.url);
const symbol = validateSymbol(url.searchParams.get('symbol'));
if (!symbol) {
return new Response(JSON.stringify({ error: 'Invalid or missing symbol parameter' }), {
status: 400,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...cors },
});
}
@@ -35,14 +41,14 @@ export default async function handler(req) {
status: response.status,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
...cors,
'Cache-Control': 'public, max-age=60, s-maxage=60, stale-while-revalidate=30',
},
});
} catch (error) {
return new Response(JSON.stringify({ error: 'Failed to fetch data' }), {
status: 500,
headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' },
headers: { 'Content-Type': 'application/json', ...cors },
});
}
}
+24 -2
View File
@@ -10,6 +10,28 @@ function sanitizeVideoId(value) {
return /^[A-Za-z0-9_-]{11}$/.test(value) ? value : null;
}
const ALLOWED_ORIGINS = [
/^https:\/\/(.*\.)?worldmonitor\.app$/,
/^https:\/\/worldmonitor-[a-z0-9-]+-elie-habib-projects\.vercel\.app$/,
/^https:\/\/worldmonitor-[a-z0-9-]+\.vercel\.app$/,
/^https?:\/\/localhost(:\d+)?$/,
/^https?:\/\/127\.0\.0\.1(:\d+)?$/,
/^tauri:\/\/localhost$/,
];
function sanitizeOrigin(raw) {
if (!raw) return 'https://worldmonitor.app';
try {
const parsed = new URL(raw);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:' && parsed.protocol !== 'tauri:') {
return 'https://worldmonitor.app';
}
const origin = parsed.origin !== 'null' ? parsed.origin : raw;
if (ALLOWED_ORIGINS.some(p => p.test(origin))) return origin;
} catch { /* invalid URL */ }
return 'https://worldmonitor.app';
}
export default async function handler(request) {
const url = new URL(request.url);
const videoId = sanitizeVideoId(url.searchParams.get('videoId'));
@@ -24,7 +46,7 @@ export default async function handler(request) {
const autoplay = parseFlag(url.searchParams.get('autoplay'), '1');
const mute = parseFlag(url.searchParams.get('mute'), '1');
const origin = url.searchParams.get('origin') || 'https://worldmonitor.app';
const origin = sanitizeOrigin(url.searchParams.get('origin'));
const embedSrc = new URL(`https://www.youtube-nocookie.com/embed/${videoId}`);
embedSrc.searchParams.set('autoplay', autoplay);
@@ -63,7 +85,7 @@ export default async function handler(request) {
player=new YT.Player('player',{
videoId:'${videoId}',
host:'https://www.youtube-nocookie.com',
playerVars:{autoplay:${autoplay},mute:${mute},playsinline:1,rel:0,controls:1,modestbranding:1,enablejsapi:1,origin:'${origin}',widget_referrer:'${origin}'},
playerVars:{autoplay:${autoplay},mute:${mute},playsinline:1,rel:0,controls:1,modestbranding:1,enablejsapi:1,origin:${JSON.stringify(origin)},widget_referrer:${JSON.stringify(origin)}},
events:{
onReady:function(){
window.parent.postMessage({type:'yt-ready'},'*');
+2 -2
View File
@@ -24,12 +24,12 @@ test('returns embeddable html for valid video id', async () => {
assert.equal(html.includes("host:'https://www.youtube-nocookie.com'"), true);
assert.equal(html.includes('autoplay:0'), true);
assert.equal(html.includes('mute:1'), true);
assert.equal(html.includes("origin:'https://worldmonitor.app'"), true);
assert.equal(html.includes('origin:"https://worldmonitor.app"'), true);
assert.equal(html.includes('postMessage'), true);
});
test('accepts custom origin parameter', async () => {
const response = await handler(makeRequest('?videoId=iEpJwprxDdk&origin=http://127.0.0.1:46123'));
const html = await response.text();
assert.equal(html.includes("origin:'http://127.0.0.1:46123'"), true);
assert.equal(html.includes('origin:"http://127.0.0.1:46123"'), true);
});
+7
View File
@@ -1,11 +1,18 @@
// YouTube Live Stream Detection API
// Uses YouTube's oembed endpoint to check for live streams
import { getCorsHeaders, isDisallowedOrigin } from '../_cors.js';
export const config = {
runtime: 'edge',
};
export default async function handler(request) {
const cors = getCorsHeaders(request);
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors });
if (isDisallowedOrigin(request)) {
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
}
const url = new URL(request.url);
const channel = url.searchParams.get('channel');
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"compress/gzip"
"io"
"net/http"
"strings"
"sync"
)
// gzip on the fly for the static SPA. The hanzoai/static image this binary
// replaced compressed responses for us; serving the Vite bundle raw (main.js
// 1.5MB, map.js 2.7MB) is the whole reason a cold load felt slow. Restoring
// gzip here cuts the JS/CSS wire size ~75%. Paired with immutable cache headers
// on hashed assets (see setCacheHeaders), each client pays the transfer once.
//
// Scope: wraps ONLY the SPA handler, never /v1/world/* — those include SSE /
// streaming endpoints that must not be buffered through a gzip.Writer.
var gzipPool = sync.Pool{
New: func() any {
w, _ := gzip.NewWriterLevel(io.Discard, gzip.DefaultCompression)
return w
},
}
// gzipStatic compresses compressible responses when the client accepts gzip.
// Range requests pass through untouched (compressing a byte-range is invalid).
func gzipStatic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Range") != "" || !acceptsGzip(r) {
next.ServeHTTP(w, r)
return
}
gw := &gzipResponseWriter{ResponseWriter: w}
defer gw.close()
next.ServeHTTP(gw, r)
})
}
func acceptsGzip(r *http.Request) bool {
for _, enc := range strings.Split(r.Header.Get("Accept-Encoding"), ",") {
if strings.EqualFold(strings.TrimSpace(strings.SplitN(enc, ";", 2)[0]), "gzip") {
return true
}
}
return false
}
// compressible reports whether a content type shrinks under gzip. Already-
// compressed media (png/jpg/woff2/…) is skipped so we never waste CPU or grow
// the payload.
func compressible(ct string) bool {
ct = strings.ToLower(ct)
switch {
case strings.HasPrefix(ct, "text/"),
strings.Contains(ct, "javascript"),
strings.Contains(ct, "json"),
strings.Contains(ct, "svg"),
strings.Contains(ct, "xml"),
strings.Contains(ct, "wasm"),
strings.Contains(ct, "manifest"):
return true
}
return false
}
type gzipResponseWriter struct {
http.ResponseWriter
gz *gzip.Writer
decided bool
compress bool
}
func (g *gzipResponseWriter) WriteHeader(status int) {
g.decide(status)
g.ResponseWriter.WriteHeader(status)
}
func (g *gzipResponseWriter) Write(b []byte) (int, error) {
if !g.decided {
g.decide(http.StatusOK) // FileServer may Write without an explicit WriteHeader
}
if g.compress {
return g.gz.Write(b)
}
return g.ResponseWriter.Write(b)
}
// decide inspects the headers the wrapped handler set (Content-Type is already
// populated by http.ServeContent at this point) and commits to compress-or-not
// exactly once.
func (g *gzipResponseWriter) decide(status int) {
if g.decided {
return
}
g.decided = true
h := g.Header()
if status == http.StatusOK && h.Get("Content-Encoding") == "" && compressible(h.Get("Content-Type")) {
g.compress = true
h.Del("Content-Length") // length changes after compression
h.Set("Content-Encoding", "gzip")
h.Add("Vary", "Accept-Encoding")
g.gz = gzipPool.Get().(*gzip.Writer)
g.gz.Reset(g.ResponseWriter)
}
}
func (g *gzipResponseWriter) close() {
if g.gz != nil {
_ = g.gz.Close()
gzipPool.Put(g.gz)
g.gz = nil
}
}
+191
View File
@@ -0,0 +1,191 @@
// Command world is the single process baked into the world image. It serves
// BOTH the static Vite SPA (world.hanzo.ai and every *.hanzo.app fork) and the
// same-origin /v1/world/* data + live-video backend the SPA fetches.
//
// One binary, two responsibilities kept orthogonal:
// - /v1/world/* → the Go data backend (internal/world), each endpoint a
// faithful port of the original edge function.
// - everything → static files from --root, with SPA fallback to index.html
// else for client-routed paths (never for /api or asset misses).
//
// It listens on :3000 (the container/CR port); override with --addr or PORT.
package main
import (
"context"
"errors"
"flag"
"io/fs"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/hanzoai/world/internal/world"
)
func main() {
var (
addr = flag.String("addr", envOr("WORLD_ADDR", ":"+envOr("PORT", "3000")), "listen address")
root = flag.String("root", envOr("WORLD_STATIC_ROOT", "dist"), "static SPA root directory")
)
flag.Parse()
// Root context cancelled on SIGINT/SIGTERM: drives the world-model ingest
// loop and triggers graceful HTTP shutdown from one signal source.
rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Fetch secrets from KMS and inject them into the environment BEFORE the
// server reads any config. Fail-open: no creds / unreachable KMS logs one
// line and continues on plain env (see internal/world/kms.go).
world.LoadKMSSecrets(rootCtx)
srv := world.NewServer()
defer srv.Close() // release hanzo-kv + embedded datastore handles
srv.StartModel(rootCtx) // continuously-folded world-state engine
srv.StartDatastore(rootCtx) // shared feed warmer + lake write-behind/prune
mux := http.NewServeMux()
srv.Mount(mux) // /v1/world/* routes
// Static SPA + fallback handles everything not matched by an /api route.
// gzipStatic wraps ONLY this handler — /v1/world/* keeps its streaming
// endpoints unbuffered.
mux.Handle("/", gzipStatic(newSPAHandler(*root)))
httpSrv := &http.Server{
Addr: *addr,
Handler: logRequests(mux),
ReadHeaderTimeout: 15 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
log.Printf("world: serving SPA from %q and /v1/world/* on %s", *root, *addr)
if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("world: server error: %v", err)
}
}()
<-rootCtx.Done()
log.Printf("world: shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = httpSrv.Shutdown(ctx)
}
// spaHandler serves files from root and falls back to index.html for any GET
// that doesn't resolve to a real file (client-side routing). It never serves the
// SPA shell for /api paths (those are handled by the mux) and returns 404 for
// missing static assets so a broken asset URL is visible, not masked by HTML.
type spaHandler struct {
root string
indexHTML []byte
csp string
fileSrv http.Handler
}
func newSPAHandler(root string) http.Handler {
// Honor the same CSP env the prior hanzoai/static image used, so the world
// CR needs no change on cutover; WORLD_CSP is an explicit alias.
h := &spaHandler{
root: root,
fileSrv: http.FileServer(http.Dir(root)),
csp: envOr("HANZO_STATIC_CSP", envOr("WORLD_CSP", "")),
}
if b, err := os.ReadFile(filepath.Join(root, "index.html")); err == nil {
h.indexHTML = b
} else {
log.Printf("world: warning: no index.html under %q: %v", root, err)
}
return h
}
func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
clean := filepath.Clean(r.URL.Path)
// Resolve against root; reject traversal.
rel := strings.TrimPrefix(clean, "/")
full := filepath.Join(h.root, rel)
if !strings.HasPrefix(full, filepath.Clean(h.root)) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if rel == "" || rel == "." {
h.serveIndex(w, r)
return
}
info, err := os.Stat(full)
switch {
case err == nil && !info.IsDir():
setCacheHeaders(w, rel)
h.fileSrv.ServeHTTP(w, r) // real file
case errors.Is(err, fs.ErrNotExist) && !hasExt(rel):
h.serveIndex(w, r) // client-routed path → SPA shell
default:
http.NotFound(w, r) // missing asset (has extension) or dir
}
}
func (h *spaHandler) serveIndex(w http.ResponseWriter, r *http.Request) {
if h.indexHTML == nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
if h.csp != "" {
w.Header().Set("Content-Security-Policy", h.csp)
}
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, _ = w.Write(h.indexHTML)
}
}
// setCacheHeaders makes Vite's content-hashed bundles cacheable forever while
// keeping every unhashed file (favicons, manifest, service worker) revalidated
// so SW updates and icon swaps are never stuck behind a stale cache.
func setCacheHeaders(w http.ResponseWriter, rel string) {
if strings.HasPrefix(rel, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
return
}
w.Header().Set("Cache-Control", "no-cache")
}
// hasExt reports whether the last path segment has a file extension, used to
// distinguish an asset miss (foo.js → 404) from a client route (/country/US →
// SPA shell).
func hasExt(rel string) bool {
base := filepath.Base(rel)
return strings.Contains(base, ".")
}
func logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/v1/world/") {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("api %s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
return
}
next.ServeHTTP(w, r)
})
}
func envOr(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return def
}
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"bytes"
"compress/gzip"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// writeTree lays out a minimal Vite-style dist for the SPA handler tests.
func writeTree(t *testing.T) string {
t.Helper()
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "assets"), 0o755); err != nil {
t.Fatal(err)
}
// A hashed JS bundle large enough that gzip clearly wins.
js := strings.Repeat("export const answer = 42;\n", 4000)
files := map[string]string{
"index.html": "<!doctype html><title>world</title>",
"assets/main-abc123.js": js,
"assets/main-abc123.css": strings.Repeat(".panel{display:flex}\n", 2000),
"favicon.ico": "\x00\x00binary",
"manifest.webmanifest": `{"name":"world"}`,
}
for name, body := range files {
if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
return root
}
func TestStaticGzipAndCache(t *testing.T) {
root := writeTree(t)
srv := httptest.NewServer(gzipStatic(newSPAHandler(root)))
defer srv.Close()
get := func(path, ae string) *http.Response {
req, _ := http.NewRequest(http.MethodGet, srv.URL+path, nil)
req.Header.Set("Accept-Encoding", ae)
resp, err := http.DefaultTransport.RoundTrip(req) // no transparent gunzip
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
return resp
}
rawLen := func(root, name string) int {
b, _ := os.ReadFile(filepath.Join(root, name))
return len(b)
}
t.Run("hashed js is gzipped, immutable, and smaller on the wire", func(t *testing.T) {
resp := get("/assets/main-abc123.js", "gzip")
defer resp.Body.Close()
if got := resp.Header.Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
if got := resp.Header.Get("Cache-Control"); !strings.Contains(got, "immutable") {
t.Fatalf("Cache-Control = %q, want immutable", got)
}
if !strings.Contains(resp.Header.Get("Vary"), "Accept-Encoding") {
t.Fatalf("Vary = %q, want Accept-Encoding", resp.Header.Get("Vary"))
}
body, _ := io.ReadAll(resp.Body)
raw := rawLen(root, "assets/main-abc123.js")
if len(body) >= raw {
t.Fatalf("gzip wire size %d not smaller than raw %d", len(body), raw)
}
// And it must actually decode back to the original bytes.
zr, err := gzip.NewReader(bytes.NewReader(body))
if err != nil {
t.Fatalf("gzip.NewReader: %v", err)
}
dec, _ := io.ReadAll(zr)
if len(dec) != raw {
t.Fatalf("decoded %d bytes, want %d", len(dec), raw)
}
})
t.Run("no gzip when client does not accept it", func(t *testing.T) {
resp := get("/assets/main-abc123.js", "identity")
defer resp.Body.Close()
if resp.Header.Get("Content-Encoding") != "" {
t.Fatalf("unexpected Content-Encoding %q", resp.Header.Get("Content-Encoding"))
}
})
t.Run("unhashed files revalidate, never immutable", func(t *testing.T) {
for _, p := range []string{"/favicon.ico", "/manifest.webmanifest"} {
resp := get(p, "gzip")
resp.Body.Close()
if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
t.Fatalf("%s Cache-Control = %q, want no-cache", p, cc)
}
}
})
t.Run("already-compressed media is not re-gzipped", func(t *testing.T) {
resp := get("/favicon.ico", "gzip")
defer resp.Body.Close()
if resp.Header.Get("Content-Encoding") == "gzip" {
t.Fatal("favicon.ico should not be gzipped")
}
})
t.Run("index shell served with no-cache and gzip", func(t *testing.T) {
resp := get("/", "gzip")
defer resp.Body.Close()
if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
t.Fatalf("index Cache-Control = %q, want no-cache", cc)
}
if resp.Header.Get("Content-Encoding") != "gzip" {
t.Fatalf("index should be gzipped, got %q", resp.Header.Get("Content-Encoding"))
}
})
}
+9 -3
View File
@@ -4,16 +4,20 @@ World Monitor desktop now uses a runtime configuration schema with per-feature t
## Secret keys
The desktop vault schema supports the following required keys used by services and relays:
The desktop vault schema supports the following 17 keys used by services and relays:
- `GROQ_API_KEY`
- `OPENROUTER_API_KEY`
- `FRED_API_KEY`
- `EIA_API_KEY`
- `FINNHUB_API_KEY`
- `CLOUDFLARE_API_TOKEN`
- `ACLED_ACCESS_TOKEN`
- `URLHAUS_AUTH_KEY`
- `OTX_API_KEY`
- `ABUSEIPDB_API_KEY`
- `NASA_FIRMS_API_KEY`
- `WINGBITS_API_KEY`
- `WS_RELAY_URL`
- `VITE_OPENSKY_RELAY_URL`
- `OPENSKY_CLIENT_ID`
- `OPENSKY_CLIENT_SECRET`
@@ -41,7 +45,9 @@ Secrets are **not stored in plaintext files** by the frontend.
If required secrets are missing/disabled:
- Summarization: Groq/OpenRouter disabled, browser model fallback.
- FRED / EIA: economic and oil analytics return empty state.
- FRED / EIA / Finnhub: economic, oil analytics, and stock data return empty state.
- Cloudflare / ACLED: outages/conflicts return empty state.
- Cyber threat feeds (URLhaus, OTX, AbuseIPDB): cyber threat layer returns empty state.
- NASA FIRMS: satellite fire detection returns empty state.
- Wingbits: flight enrichment disabled, heuristic-only flight classification remains.
- AIS / OpenSky relay: live tracking features are disabled cleanly.
+134
View File
@@ -29,6 +29,7 @@ A compact **variant switcher** in the header allows seamless navigation between
The primary variant focuses on geopolitical intelligence, military tracking, and infrastructure security monitoring.
### Key Capabilities
- **Conflict Monitoring** - Active war zones, hotspots, and crisis areas with real-time escalation tracking
- **Military Intelligence** - 220+ military bases, flight tracking, naval vessel monitoring, surge detection
- **Infrastructure Security** - Undersea cables, pipelines, datacenters, internet outages
@@ -37,6 +38,7 @@ The primary variant focuses on geopolitical intelligence, military tracking, and
- **AI-Powered Analysis** - Focal point detection, country instability scoring, infrastructure cascade analysis
### Intelligence Panels
| Panel | Purpose |
|-------|---------|
| **AI Insights** | LLM-synthesized world brief with focal point detection |
@@ -47,6 +49,7 @@ The primary variant focuses on geopolitical intelligence, military tracking, and
| **Live Intelligence** | GDELT-powered topic feeds (Military, Cyber, Nuclear, Sanctions) |
### News Coverage
80+ curated sources across geopolitics, defense, energy, think tanks, and regional news (Middle East, Africa, Latin America, Asia-Pacific).
---
@@ -56,6 +59,7 @@ The primary variant focuses on geopolitical intelligence, military tracking, and
The tech variant ([tech.worldmonitor.app](https://tech.worldmonitor.app)) provides specialized layers for technology sector monitoring.
### Tech Ecosystem Layers
| Layer | Description |
|-------|-------------|
| **Tech HQs** | Headquarters of major tech companies (Big Tech, unicorns, public companies) |
@@ -65,6 +69,7 @@ The tech variant ([tech.worldmonitor.app](https://tech.worldmonitor.app)) provid
| **Tech Events** | Upcoming conferences and tech events with countdown timers |
### Tech Infrastructure Layers
| Layer | Description |
|-------|-------------|
| **AI Datacenters** | 111 major AI compute clusters (≥10,000 GPUs) |
@@ -72,6 +77,7 @@ The tech variant ([tech.worldmonitor.app](https://tech.worldmonitor.app)) provid
| **Internet Outages** | Network disruptions affecting tech operations |
### Tech News Categories
- **Startups & VC** - Funding rounds, acquisitions, startup news
- **Cybersecurity** - Security vulnerabilities, breaches, threat intelligence
- **Cloud & Infrastructure** - AWS, Azure, GCP announcements, outages
@@ -99,6 +105,7 @@ The tech variant ([tech.worldmonitor.app](https://tech.worldmonitor.app)) provid
## Features
### Interactive Global Map
- **Zoom & Pan** - Smooth navigation with mouse/trackpad gestures
- **Regional Focus** - 8 preset views for rapid navigation (Global, Americas, Europe, MENA, Asia, Latin America, Africa, Oceania)
- **Layer System** - Toggle visibility of 20+ data layers organized by category
@@ -111,12 +118,14 @@ The tech variant ([tech.worldmonitor.app](https://tech.worldmonitor.app)) provid
Dense regions with many data points use intelligent clustering to prevent visual clutter:
**How It Works**
- Markers within a pixel radius (adaptive to zoom level) merge into cluster badges
- Cluster badges show the count of grouped items
- Clicking a cluster opens a popup listing all grouped items
- Zooming in reduces cluster radius, eventually showing individual markers
**Grouping Logic**
- **Protests**: Cluster within same country only (riots sorted first, high severity prioritized)
- **Tech HQs**: Cluster within same city (Big Tech sorted before unicorns before public companies)
- **Tech Events**: Cluster within same location (sorted by date, soonest first)
@@ -192,6 +201,7 @@ These panels transform raw signals into actionable intelligence by applying scor
### News Aggregation
Multi-source RSS aggregation across categories:
- **World / Geopolitical** - BBC, Reuters, AP, Guardian, NPR, Politico, The Diplomat
- **Middle East / MENA** - Al Jazeera, BBC ME, Guardian ME, Al Arabiya, Times of Israel
- **Africa** - BBC Africa, News24, Google News aggregation (regional & Sahel coverage)
@@ -213,6 +223,7 @@ Multi-source RSS aggregation across categories:
The **📡 SOURCES** button in the header opens a global source management modal, enabling fine-grained control over which news sources appear in the dashboard.
**Capabilities:**
- **Search**: Filter the source list by name to quickly find specific outlets
- **Individual Toggle**: Click any source to enable/disable it
- **Bulk Actions**: "Select All" and "Select None" for quick adjustments
@@ -220,12 +231,14 @@ The **📡 SOURCES** button in the header opens a global source management modal
- **Persistence**: Settings are saved to localStorage and persist across sessions
**Use Cases:**
- **Noise Reduction**: Disable high-volume aggregators (Google News) to focus on primary sources
- **Regional Focus**: Enable only sources relevant to a specific geographic area
- **Source Quality**: Disable sources with poor signal-to-noise ratio
- **Bias Management**: Balance coverage by enabling/disabling sources with known editorial perspectives
**Technical Details:**
- Disabled sources are filtered at fetch time (not display time), reducing bandwidth and API calls
- Affects all news panels simultaneously—disable BBC once, it's gone everywhere
- Panels with all sources disabled show "All sources disabled" message
@@ -260,6 +273,7 @@ Embedded YouTube live streams from major news networks with channel switching:
| **Al Jazeera** | Middle East & international news |
**Core Features:**
- **Channel Switcher** - One-click switching between networks
- **Live Indicator** - Blinking dot shows stream status, click to pause/play
- **Mute Toggle** - Audio control (muted by default)
@@ -290,6 +304,7 @@ To conserve resources, the panel implements automatic idle pausing:
This prevents background tabs from consuming bandwidth while preserving user preference for manually-paused streams.
### Market Data
- **Stocks** - Major indices and tech stocks via Finnhub (Yahoo Finance backup)
- **Commodities** - Oil, gold, natural gas, copper, VIX
- **Crypto** - Bitcoin, Ethereum, Solana via CoinGecko
@@ -299,11 +314,14 @@ This prevents background tabs from consuming bandwidth while preserving user pre
- **Government Spending** - USASpending.gov: Recent federal contracts and awards
### Prediction Markets
- Polymarket integration for event probability tracking
- Correlation analysis with news events
### Search (⌘K)
Universal search across all data sources:
- News articles
- Geographic hotspots and conflicts
- Infrastructure (pipelines, cables, datacenters)
@@ -311,6 +329,7 @@ Universal search across all data sources:
- Markets and predictions
### Data Export
- CSV and JSON export of current dashboard state
- Historical playback from snapshots
@@ -330,6 +349,7 @@ The header displays an **Intelligence Findings** badge that consolidates two typ
| **Unified Alerts** | Module-generated alerts | CII spikes, geographic convergence, infrastructure cascades |
**Interaction**: Clicking the badge—or clicking an individual alert—opens a detail modal showing:
- Full alert description and context
- Component breakdown (for composite alerts)
- Affected countries or regions
@@ -378,6 +398,7 @@ The system detects 12 distinct signal types across news, markets, military, and
### How It Works
The correlation engine maintains rolling snapshots of:
- News topic frequency (by keyword extraction)
- Market price changes
- Prediction market probabilities
@@ -566,6 +587,7 @@ The dashboard visually flags sources with known state affiliations or propaganda
**Display Locations**
Propaganda risk badges appear in:
- **Cluster primary source**: Badge next to the main source name
- **Top sources list**: Small badge next to each flagged source
- **Cluster view**: Visible when expanding multi-source clusters
@@ -634,6 +656,7 @@ When a market symbol moves significantly, the system searches news clusters for
4. **Result** - "Market Move Explained" or "Silent Divergence" signal
This enables signals like:
- **Explained**: "AVGO +5.2% — Broadcom mentioned in 3 news clusters (AI chip demand)"
- **Silent**: "AVGO +5.2% — No correlated news after entity search"
@@ -677,6 +700,7 @@ similarity(A, B) = |A ∩ B| / |A B|
```
**Tokenization**:
- Headlines are lowercased and split on word boundaries
- Stop words removed: "the", "a", "an", "in", "on", "at", "to", "for", "of", "and", "or"
- Short tokens (<3 characters) filtered out
@@ -691,6 +715,7 @@ Rather than O(n²) pairwise comparison, the algorithm uses an inverted index:
4. This reduces comparisons from ~10,000 to ~500 for typical news loads
**Clustering Rules**:
- Articles with similarity ≥ 0.5 are grouped into clusters
- Clusters are sorted by source tier, then recency
- The most authoritative source becomes the "primary" headline
@@ -750,6 +775,7 @@ The entity index pre-builds five lookup maps for O(1) access:
- Example: AVGO move also searches for NVDA, INTC, AMD news
**Performance**:
- Index builds once on first access (cached singleton)
- Alias map has ~300 entries for 100+ entities
- Keyword map has ~400 entries
@@ -764,6 +790,7 @@ The system maintains rolling baselines for news volume per topic:
- **Z-score** = (current - mean) / stddev
Deviation levels:
- **Spike**: Z > 2.5 (statistically rare increase)
- **Elevated**: Z > 1.5
- **Normal**: -2 < Z < 1.5
@@ -830,6 +857,7 @@ final_score = (static_baseline × 0.30 + dynamic_score × 0.70) × proximity_boo
**Trend Detection**
The system maintains 48-point history (24 hours at 30-minute intervals) per hotspot:
- **Linear regression** calculates slope of recent scores
- **Rising**: Slope > +0.1 points per interval
- **Falling**: Slope < -0.1 points per interval
@@ -838,6 +866,7 @@ The system maintains 48-point history (24 hours at 30-minute intervals) per hots
**Signal Generation**
Escalation signals (`hotspot_escalation`) are emitted when:
1. Final score exceeds threshold (typically 60)
2. At least 2 hours since last signal for this hotspot (cooldown)
3. Trend is rising or score is critical (>80)
@@ -998,6 +1027,7 @@ The floor applies *after* the standard calculation—if the computed score excee
### Trend Detection
The CII tracks 24-hour changes to identify trajectory:
- **Rising**: Score increased by ≥5 points (escalating situation)
- **Stable**: Change within ±5 points (steady state)
- **Falling**: Score decreased by ≥5 points (de-escalation)
@@ -1013,15 +1043,18 @@ Beyond the base component scores, several contextual factors can boost a country
| **Focal Point** | 8 | AI focal point detection on country | Multi-source convergence indicator |
**Hotspot Boost Calculation**:
- Hotspot activity (0-100) scaled by 1.5× then capped at 10
- Zero boost for countries with no associated hotspot activity
**News Urgency Boost Tiers**:
- Information ≥70: +5 points
- Information ≥50: +3 points
- Information <50: +0 points
**Focal Point Boost Tiers**:
- Critical urgency: +8 points
- Elevated urgency: +4 points
- Normal urgency: +0 points
@@ -1039,6 +1072,7 @@ On dashboard startup, the CII system enters **Learning Mode**—a 15-minute cali
**Note**: Server-side pre-computation now provides immediate scores to new users—Learning Mode primarily affects client-side dynamic adjustments and alert generation rather than initial score display.
**Why 15 minutes?** Real-world testing showed that CII scores stabilize after approximately 10-20 minutes of data collection. The 15-minute window provides sufficient time for:
- Multiple refresh cycles across all data sources
- Trend detection to establish direction (rising/stable/falling)
- Cross-source correlation to normalize bias
@@ -1073,6 +1107,7 @@ This ensures users understand that early scores are provisional while preventing
### Keyword Attribution
Countries are matched to data via keyword lists:
- **Russia**: `russia`, `moscow`, `kremlin`, `putin`
- **China**: `china`, `beijing`, `xi jinping`, `prc`
- **Taiwan**: `taiwan`, `taipei`
@@ -1117,12 +1152,14 @@ convergence_score = min(100, type_score + count_boost)
### Example Scenarios
**Taiwan Strait Buildup**
- Cell: `25°N, 121°E`
- Events: Military flights (3), Naval vessels (2), Protests (1)
- Score: 75 + 12 = 87 (Critical)
- Signal: "Geographic Convergence (3 types) - military flights, naval vessels, protests"
**Middle East Flashpoint**
- Cell: `32°N, 35°E`
- Events: Military flights (5), Protests (8), Earthquake (1)
- Score: 75 + 25 = 100 (Critical)
@@ -1169,6 +1206,7 @@ When a user selects an infrastructure asset for analysis, a **breadth-first casc
### Redundancy Modeling
The system accounts for alternative routes:
- Cables with high redundancy show reduced impact
- Countries with multiple cable landings show lower vulnerability
- Alternative routes are displayed with capacity percentages
@@ -1234,6 +1272,7 @@ The system parses NGA (National Geospatial-Intelligence Agency) maritime warning
### Repair Ship Tracking
When a cableship is mentioned in warnings, the system extracts:
- **Vessel name**: CS Reliance, Cable Innovator, etc.
- **Status**: "En route" or "On station"
- **Location**: Current working area
@@ -1244,6 +1283,7 @@ This enables monitoring of ongoing repair operations before official carrier ann
### Why This Matters
Undersea cables carry 95% of intercontinental data traffic. A cable cut can:
- Cause regional internet outages
- Disrupt financial transactions
- Impact military communications
@@ -1308,6 +1348,7 @@ Priority: Critical
### Trend Detection
The system tracks the composite score over time:
- First measurement establishes baseline (shows "Stable")
- Subsequent changes of ±5 points trigger trend changes
- This prevents false "escalating" signals on initialization
@@ -1352,6 +1393,7 @@ The indicator also displays geopolitical tension scores from GDELT (Global Datab
| Russia ↔ Ukraine | Active conflict zone |
Each pair shows:
- **Current tension score** (GDELT's normalized metric)
- **7-day trend** (rising, falling, stable)
- **Percentage change** from previous period
@@ -1395,6 +1437,7 @@ Up to 3 assets per type are displayed, sorted by proximity.
### Example Context
A news cluster about "pipeline explosion in Germany" would show:
- **Pipelines**: Nord Stream (23km), Yamal-Europe (156km)
- **Cables**: TAT-14 landing (89km)
- **Bases**: Ramstein (234km)
@@ -1484,6 +1527,7 @@ The system monitors eight critical maritime chokepoints where disruptions could
### Density Analysis
Vessel positions are aggregated into a 2° grid to calculate traffic density. Each cell tracks:
- Current vessel count
- Historical baseline (30-minute rolling window)
- Change percentage from baseline
@@ -1493,6 +1537,7 @@ Density changes of ±30% trigger alerts, indicating potential congestion, divers
### Dark Ship Detection
The system monitors for AIS gaps—vessels that stop transmitting their position. An AIS gap exceeding 60 minutes in monitored regions may indicate:
- Sanctions evasion (ship-to-ship transfers)
- Illegal fishing
- Military activity
@@ -1530,11 +1575,13 @@ Browser → Railway Relay → External APIs
| `/health` | Status check | None |
**Environment Variables** (Railway):
- `AISSTREAM_API_KEY` - AIS data access
- `OPENSKY_CLIENT_ID` - OAuth2 client ID
- `OPENSKY_CLIENT_SECRET` - OAuth2 client secret
**Why Railway?**
- Residential IP ranges (not blocked like cloud providers)
- WebSocket support for persistent connections
- Global edge deployment for low latency
@@ -1614,22 +1661,26 @@ Vessels within 50km of these bases are flagged, enabling detection of unusual ac
Military aircraft are tracked via the OpenSky Network using ADS-B data. OpenSky blocks unauthenticated requests from cloud provider IPs (Vercel, Railway, AWS), so aircraft tracking requires a relay server with credentials.
**Authentication**:
- Register for a free account at [opensky-network.org](https://opensky-network.org)
- Create an API client in account settings to get `OPENSKY_CLIENT_ID` and `OPENSKY_CLIENT_SECRET`
- The relay uses **OAuth2 client credentials flow** to obtain Bearer tokens
- Tokens are cached (30-minute expiry) and automatically refreshed
**Identification Methods**:
- **Callsign matching**: Known military callsign patterns (RCH, REACH, DUKE, etc.)
- **ICAO hex ranges**: Military aircraft use assigned hex code blocks by country
- **Altitude/speed profiles**: Unusual flight characteristics
**Tracked Metrics**:
- Position history (20-point trails over 5-minute windows)
- Altitude and ground speed
- Heading and track
**Activity Detection**:
- Formations (multiple military aircraft in proximity)
- Unusual patterns (holding, reconnaissance orbits)
- Chokepoint transits
@@ -1673,6 +1724,7 @@ Aircraft are categorized by callsign pattern matching:
**Baseline Calculation**
The system maintains rolling 48-hour activity baselines per theater:
- Minimum 6 data samples required for reliable baseline
- Default baselines when data insufficient: 3 transport, 2 fighter, 1 reconnaissance
- Activity below 50% of baseline indicates stand-down
@@ -1721,6 +1773,7 @@ The system tracks 18 strategically significant geographic areas:
**Detection Logic**
For each sensitive region, the system:
1. Identifies all military aircraft within the region boundary
2. Groups aircraft by operating nation
3. Excludes "home region" operators (e.g., Russian VKS in Baltic excluded from alert)
@@ -1771,17 +1824,21 @@ When an aircraft is detected via OpenSky ADS-B, the system queries Wingbits for:
The enrichment service analyzes owner and operator fields against curated keyword lists:
**Confirmed Military** (owner/operator match):
- Government: "United States Air Force", "Department of Defense", "Royal Air Force"
- International: "NATO", "Ministry of Defence", "Bundeswehr"
**Likely Military** (operator ICAO codes):
- `AIO` (Air Mobility Command), `RRR` (Royal Air Force), `GAF` (German Air Force)
- `RCH` (REACH flights), `CNV` (Convoy flights), `DOD` (Department of Defense)
**Possible Military** (defense contractors):
- Northrop Grumman, Lockheed Martin, General Atomics, Raytheon, Boeing Defense, L3Harris
**Aircraft Type Matching**:
- Transport: C-17, C-130, C-5, KC-135, KC-46
- Reconnaissance: RC-135, U-2, RQ-4, E-3, E-8
- Combat: F-15, F-16, F-22, F-35, B-52, B-2
@@ -1834,6 +1891,7 @@ The Spaceports layer displays global launch facilities for monitoring space-rela
### Why This Matters
Space launches are geopolitically significant:
- **Military implications**: Many launches are dual-use (civilian/military)
- **Technology competition**: Launch cadence indicates space program advancement
- **Supply chain**: Satellite services affect communications, GPS, reconnaissance
@@ -1867,6 +1925,7 @@ The Minerals layer displays strategic mineral extraction sites essential for mod
### Supply Chain Risks
Critical minerals are geopolitically concentrated:
- **Cobalt**: 70% from DRC, significant artisanal mining concerns
- **Rare Earths**: 60% from China, processing nearly monopolized
- **Lithium**: Expanding production but demand outpacing supply
@@ -1895,6 +1954,7 @@ Cyber operations often correlate with geopolitical tensions. When news reports r
### Visual Indicators
APT markers appear as warning triangles (⚠) with distinct styling. Clicking a marker shows:
- **Official designation** and common aliases
- **State sponsor** and intelligence agency
- **Primary targeting sectors**
@@ -1908,6 +1968,7 @@ The Protests layer aggregates civil unrest data from two independent sources, pr
### ACLED (Armed Conflict Location & Event Data)
Academic-grade conflict data with human-verified events:
- **Coverage**: Global, 30-day rolling window
- **Event types**: Protests, riots, strikes, demonstrations
- **Metadata**: Actors involved, fatalities, detailed notes
@@ -1916,6 +1977,7 @@ Academic-grade conflict data with human-verified events:
### GDELT (Global Database of Events, Language, and Tone)
Real-time news-derived event data:
- **Coverage**: Global, 7-day rolling window
- **Event types**: Geocoded protest mentions from news
- **Volume**: Reports per location (signal strength)
@@ -1924,6 +1986,7 @@ Real-time news-derived event data:
### Multi-Source Corroboration
Events from both sources are deduplicated using a 0.5° spatial grid and date matching. When both ACLED and GDELT report events in the same area:
- Confidence is elevated to "high"
- ACLED data takes precedence (higher accuracy)
- Source list shows corroboration
@@ -1977,6 +2040,7 @@ The Flights layer tracks airport delays and ground stops at major US airports us
### Monitored Airports
The 30 largest US airports are tracked:
- Major hubs: JFK, LAX, ORD, ATL, DFW, DEN, SFO
- International gateways with high traffic volume
- Airports frequently affected by weather or congestion
@@ -1999,6 +2063,7 @@ sanitizeUrl(url) // Allows only http/https protocols
```
This applies to:
- News headlines and sources (RSS feeds)
- Search results and highlights
- Monitor keywords (user input)
@@ -2044,6 +2109,7 @@ Normal → Failure #1 → Failure #2 → OPEN (cooldown)
```
**Behavior during cooldown:**
- New requests return cached data (if available)
- UI shows "temporarily unavailable" status
- No API calls are made (prevents hammering)
@@ -2068,6 +2134,7 @@ RSS feeds use per-feed circuit breakers—one failing feed doesn't affect others
### Graceful Degradation
When a service enters cooldown:
1. Cached data continues to display (stale but available)
2. Status panel shows service health
3. Automatic recovery when cooldown expires
@@ -2105,6 +2172,7 @@ The status panel lists all data feeds with their current state:
### Per-Feed Information
Each feed entry shows:
- **Source name** - The data provider
- **Last update** - Time since last successful fetch
- **Next refresh** - Countdown to next scheduled fetch
@@ -2113,6 +2181,7 @@ Each feed entry shows:
### Why This Matters
External APIs are unreliable. The status panel helps you understand:
- **Data freshness** - Is the news feed current or stale?
- **Coverage gaps** - Which sources are currently unavailable?
- **Recovery timeline** - When will failed sources retry?
@@ -2151,6 +2220,7 @@ Each data source has calibrated freshness expectations:
### Visual Indicators
The status panel displays freshness for each source:
- **Colored dot** indicates freshness level
- **Time since update** shows exact staleness
- **Next refresh countdown** shows when data will update
@@ -2158,6 +2228,7 @@ The status panel displays freshness for each source:
### Why This Matters
Understanding data freshness is critical for decision-making:
- A "fresh" earthquake feed means recent events are displayed
- A "stale" news feed means you may be missing breaking stories
- A "critical" AIS stream means vessel positions are unreliable
@@ -2210,11 +2281,13 @@ API calls are expensive. The system only fetches data for **enabled layers**, re
### Layer-Aware Loading
When a layer is toggled OFF:
- No API calls for that data source
- No refresh interval scheduled
- WebSocket connections closed (for AIS)
When a layer is toggled ON:
- Data is fetched immediately
- Refresh interval begins
- Loading indicator shown on toggle button
@@ -2222,6 +2295,7 @@ When a layer is toggled ON:
### Unconfigured Services
Some data sources require API keys (AIS relay, Cloudflare Radar). If credentials are not configured:
- The layer toggle is hidden entirely
- No failed requests pollute the console
- Users see only functional layers
@@ -2245,6 +2319,7 @@ CPU-intensive operations run in a dedicated Web Worker to avoid blocking the mai
| DOM rendering | O(n) | ❌ Main thread |
The worker manager implements:
- **Lazy initialization**: Worker spawns on first use
- **10-second ready timeout**: Rejects if worker fails to initialize
- **30-second request timeout**: Prevents hanging on stuck operations
@@ -2255,11 +2330,13 @@ The worker manager implements:
Large lists (100+ news items) use virtualized rendering:
**Fixed-Height Mode** (VirtualList):
- Only renders items visible in viewport + 3-item overscan buffer
- Element pooling—reuses DOM nodes rather than creating new ones
- Invisible spacers maintain scroll position without rendering all items
**Variable-Height Mode** (WindowedList):
- Chunk-based rendering (10 items per chunk)
- Renders chunks on-scroll with 1-chunk buffer
- CSS containment for performance isolation
@@ -2269,6 +2346,7 @@ This reduces DOM node count from thousands to ~30, dramatically improving scroll
### Request Deduplication
Identical requests within a short window are deduplicated:
- Market quotes batch multiple symbols into single API call
- Concurrent layer toggles don't spawn duplicate fetches
- `Promise.allSettled` ensures one failing request doesn't block others
@@ -2276,6 +2354,7 @@ Identical requests within a short window are deduplicated:
### Efficient Data Updates
When refreshing data:
- **Incremental updates**: Only changed items trigger re-renders
- **Stale-while-revalidate**: Old data displays while fetch completes
- **Delta compression**: Baselines store 7-day/30-day deltas, not raw history
@@ -2289,6 +2368,7 @@ The Prediction Markets panel focuses on **geopolitically relevant** markets, fil
### Inclusion Keywords
Markets matching these topics are displayed:
- **Conflicts**: war, military, invasion, ceasefire, NATO, nuclear
- **Countries**: Russia, Ukraine, China, Taiwan, Iran, Israel, Gaza
- **Leaders**: Putin, Zelensky, Trump, Biden, Xi Jinping, Netanyahu
@@ -2298,6 +2378,7 @@ Markets matching these topics are displayed:
### Exclusion Keywords
Markets matching these are filtered out:
- **Sports**: NBA, NFL, FIFA, World Cup, championships, playoffs
- **Entertainment**: Oscars, movies, celebrities, TikTok, streaming
@@ -2391,6 +2472,7 @@ The mobile experience focuses on the most essential intelligence layers:
| **Weather** | Severe weather warnings |
Layers disabled by default on mobile (but available on desktop):
- Military bases, nuclear facilities, spaceports, minerals
- Undersea cables, pipelines, datacenters
- AIS vessels, military flights
@@ -2547,6 +2629,7 @@ User requests summary
### Lazy Loading
Models are loaded on-demand to minimize initial page load:
- Models download only when first needed
- Progress indicator shows download status
- Once cached, models load instantly from IndexedDB
@@ -2554,6 +2637,7 @@ Models are loaded on-demand to minimize initial page load:
### Worker Isolation
All ML inference runs in a dedicated Web Worker:
- Main thread remains responsive during inference
- 30-second timeout prevents hanging
- Automatic cleanup on errors
@@ -2653,15 +2737,18 @@ FOCAL POINTS (entities across news + signals):
Not all news is equally important. Headlines are scored to identify the most significant stories for the briefing:
**Score Boosters** (high weight):
- Military keywords: war, invasion, airstrike, missile, deployment, mobilization
- Violence indicators: killed, casualties, clashes, massacre, crackdown
- Civil unrest: protest, uprising, coup, riot, martial law
**Geopolitical Multipliers**:
- Flashpoint regions: Iran, Russia, China, Taiwan, Ukraine, North Korea, Gaza
- Critical actors: NATO, Pentagon, Kremlin, Hezbollah, Hamas, Wagner
**Score Reducers** (demoted):
- Business context: CEO, earnings, stock, revenue, startup, data center
- Entertainment: celebrity, movie, streaming
@@ -2696,6 +2783,7 @@ The Focal Point Detector is the intelligence synthesis layer that correlates new
### The Problem It Solves
Without synthesis, intelligence streams operate in silos:
- News feeds show 80+ sources with thousands of headlines
- Map layers display military flights, protests, outages independently
- No automated way to see that IRAN appears in news AND has military activity AND an internet outage
@@ -2754,6 +2842,7 @@ Focal points display icons indicating which signal types are active:
### Example Output
A focal point for IRAN might show:
- **Display**: "Iran [CRITICAL] ✈️📢🌐"
- **News**: 12 mentions, velocity 0.5/hour
- **Signals**: 5 military flights, 3 protests, 1 outage
@@ -2763,6 +2852,7 @@ A focal point for IRAN might show:
### Integration with CII
Focal point urgency levels feed into the Country Instability Index:
- **Critical** focal point → CII score boost for that country
- Ensures countries with multi-source convergence are properly flagged
- Prevents "silent" instability when news alone wouldn't trigger alerts
@@ -2809,6 +2899,7 @@ Near-real-time natural event detection from satellite observation:
### Multi-Source Deduplication
When both GDACS and EONET report the same event:
1. Events within 100km and 48 hours are considered duplicates
2. GDACS severity takes precedence (human-verified)
3. EONET geometry provides more precise coordinates
@@ -2817,6 +2908,7 @@ When both GDACS and EONET report the same event:
### Filtering Logic
To prevent map clutter, natural events are filtered:
- **Wildfires**: Only events < 48 hours old (older fires are either contained or well-known)
- **Earthquakes**: M4.5+ globally, lower threshold for populated areas
- **Storms**: Only named storms or those with warnings
@@ -2928,6 +3020,7 @@ Nine geographic theaters are monitored continuously, each with custom thresholds
Beyond raw counts, the system assesses whether forces in a theater constitute an **offensive strike package**—the combination of assets required for sustained combat operations.
**Strike-Capable Criteria**:
- Aerial refueling tankers (KC-135, KC-10, A330 MRTT)
- Airborne command and control (E-3 AWACS, E-7 Wedgetail)
- Combat aircraft (fighters, strike aircraft)
@@ -3083,6 +3176,7 @@ The Service Status panel tracks the operational health of external services that
### Why This Matters
External service outages can affect:
- AI summarization (Groq, OpenRouter outages)
- Deployment pipelines (Vercel, GitHub outages)
- API availability (Cloudflare, AWS outages)
@@ -3154,12 +3248,14 @@ Historical filtering is client-side—all data is fetched but filtered for displ
The map uses a hybrid rendering strategy optimized for each platform:
**Desktop (deck.gl + MapLibre GL)**:
- WebGL-accelerated layers handle thousands of markers smoothly
- MapLibre GL provides base map tiles (OpenStreetMap)
- GeoJSON, Scatterplot, Path, and Icon layers for different data types
- GPU-based clustering and picking for responsive interaction
**Mobile (D3.js + TopoJSON)**:
- SVG rendering for battery efficiency
- Reduced marker count and simplified layers
- Touch-optimized interaction with larger hit targets
@@ -3375,12 +3471,14 @@ api/ # Vercel Edge serverless proxies
## Usage
### Keyboard Shortcuts
- `⌘K` / `Ctrl+K` - Open search
- `↑↓` - Navigate search results
- `Enter` - Select result
- `Esc` - Close modals
### Map Controls
- **Scroll** - Zoom in/out
- **Drag** - Pan the map
- **Click markers** - Show detailed popup with full context
@@ -3403,12 +3501,14 @@ Infrastructure markers (nuclear facilities, economic centers, ports) display wit
This design prioritizes geographic awareness over label density—users can quickly scan for markers and then interact for context.
### Panel Management
- **Drag panels** - Reorder layout
- **Settings (⚙)** - Toggle panel visibility
### Shareable Links
The current view state is encoded in the URL, enabling:
- **Bookmarking**: Save specific views for quick access
- **Sharing**: Send colleagues a link to your exact map position and layer configuration
- **Deep linking**: Link directly to a specific region or feature
@@ -3429,9 +3529,11 @@ Values are validated and clamped to prevent invalid states.
## Data Sources
### News Feeds
Aggregates **70+ RSS feeds** from major news outlets, government sources, and specialty publications with source-tier prioritization. Categories include world news, MENA, Africa, Latin America, Asia-Pacific, energy, technology, AI/ML, finance, government releases, defense/intel, think tanks, and international crisis organizations.
### Geospatial Data
- **Hotspots**: 30+ global intelligence hotspots with keyword correlation (including Sahel, Haiti, Horn of Africa)
- **Conflicts**: 10+ active conflict zones with involved parties
- **Military Bases**: 220+ installations from US, NATO, Russia, China, and allies
@@ -3443,6 +3545,7 @@ Aggregates **70+ RSS feeds** from major news outlets, government sources, and sp
- **Ports**: 61 strategic ports (container, oil/LNG, naval, chokepoint)
### Live APIs
- **USGS**: Earthquake feed (M4.5+ global)
- **NASA EONET**: Natural events (storms, wildfires, volcanoes, floods)
- **NWS**: Severe weather alerts (US)
@@ -3460,30 +3563,36 @@ Aggregates **70+ RSS feeds** from major news outlets, government sources, and sp
This project uses data from the following sources. Please respect their terms of use.
### Aircraft Tracking
Data provided by [The OpenSky Network](https://opensky-network.org). If you use this data in publications, please cite:
> Matthias Schäfer, Martin Strohmeier, Vincent Lenders, Ivan Martinovic and Matthias Wilhelm. "Bringing Up OpenSky: A Large-scale ADS-B Sensor Network for Research". In *Proceedings of the 13th IEEE/ACM International Symposium on Information Processing in Sensor Networks (IPSN)*, pages 83-94, April 2014.
### Conflict & Protest Data
- **ACLED**: Armed Conflict Location & Event Data. Source: [ACLED](https://acleddata.com). Data must be attributed per their [Attribution Policy](https://acleddata.com/attributionpolicy/).
- **GDELT**: Global Database of Events, Language, and Tone. Source: [The GDELT Project](https://www.gdeltproject.org/).
### Financial Data
- **Stock Quotes**: Powered by [Finnhub](https://finnhub.io/) (primary), with [Yahoo Finance](https://finance.yahoo.com/) as backup for indices and commodities
- **Cryptocurrency**: Powered by [CoinGecko API](https://www.coingecko.com/en/api)
- **Economic Indicators**: Data from [FRED](https://fred.stlouisfed.org/), Federal Reserve Bank of St. Louis
### Geophysical Data
- **Earthquakes**: [U.S. Geological Survey](https://earthquake.usgs.gov/), ANSS Comprehensive Catalog
- **Natural Events**: [NASA EONET](https://eonet.gsfc.nasa.gov/) - Earth Observatory Natural Event Tracker (storms, wildfires, volcanoes, floods)
- **Weather Alerts**: [National Weather Service](https://www.weather.gov/) - Open data, free to use
### Infrastructure & Transport
- **Airport Delays**: [FAA Air Traffic Control System Command Center](https://www.fly.faa.gov/)
- **Vessel Tracking**: [AISstream](https://aisstream.io/) real-time AIS data
- **Internet Outages**: [Cloudflare Radar](https://radar.cloudflare.com/) (CC BY-NC 4.0)
### Other Sources
- **Prediction Markets**: [Polymarket](https://polymarket.com/)
## Acknowledgments
@@ -3503,6 +3612,7 @@ This project is a **proof of concept** demonstrating what's possible with public
### Data Completeness
Some data sources require paid accounts for full access:
- **ACLED**: Free tier has API restrictions; Research tier required for programmatic access
- **OpenSky Network**: Rate-limited; commercial tiers offer higher quotas
- **Satellite AIS**: Global coverage requires commercial providers (Spire, Kpler, etc.)
@@ -3512,6 +3622,7 @@ The dashboard works with free tiers but may have gaps in coverage or update freq
### AIS Coverage Bias
The Ships layer uses terrestrial AIS receivers via [AISStream.io](https://aisstream.io). This creates a **geographic bias**:
- **Strong coverage**: European waters, Atlantic, major ports
- **Weak coverage**: Middle East, open ocean, remote regions
@@ -3520,6 +3631,7 @@ Terrestrial receivers only detect vessels within ~50km of shore. Satellite AIS (
### Blocked Data Sources
Some publishers block requests from cloud providers (Vercel, Railway, AWS):
- RSS feeds from certain outlets may fail with 403 errors
- This is a common anti-bot measure, not a bug in the dashboard
- Affected feeds are automatically disabled via circuit breakers
@@ -3569,15 +3681,18 @@ See [ROADMAP.md](ROADMAP.md) for detailed planning. Recent intelligence enhancem
### Planned
**High Priority:**
- **Temporal Anomaly Detection** - Flag activity unusual for time of day/week/year (e.g., "military flights 3x normal for Tuesday")
- **Trade Route Risk Scoring** - Real-time supply chain vulnerability for major shipping routes (Asia→Europe, Middle East→Europe, etc.)
**Medium Priority:**
- **Historical Playback** - Review past dashboard states with timeline scrubbing
- **Election Calendar Integration** - Auto-boost sensitivity 30 days before major elections
- **Choropleth CII Map Layer** - Country-colored overlay showing instability scores
**Future Enhancements:**
- **Alert Webhooks** - Push critical alerts to Slack, Discord, email
- **Custom Country Watchlists** - User-defined Tier-2 country monitoring
- **Additional Data Sources** - World Bank, IMF, OFAC sanctions, UNHCR refugee data, FAO food security
@@ -3675,17 +3790,20 @@ Different data types refresh at different intervals based on volatility and API
The system implements defense-in-depth for external service failures:
**Circuit Breakers**
- Each external service has an independent circuit breaker
- After 3 consecutive failures, the circuit opens for 60 seconds
- Partial failures don't cascade to other services
- Status panel shows exact failure states
**Graceful Degradation**
- Stale cached data displays during outages (with timestamp warning)
- Failed services are automatically retried on next cycle
- Critical data (news, markets) has backup sources
**User Feedback**
- Real-time status indicators in the header
- Specific error messages in the status panel
- No silent failures—every data source state is visible
@@ -3695,16 +3813,19 @@ The system implements defense-in-depth for external service failures:
The project uses Vite for optimal production builds:
**Code Splitting**
- Web Worker code is bundled separately
- Config files (tech-geo.ts, pipelines.ts) are tree-shaken
- Lazy-loaded panels reduce initial bundle size
**Variant Builds**
- `npm run build` - Standard geopolitical dashboard
- `npm run build:tech` - Tech sector variant with different defaults
- Both share the same codebase, configured via environment variables
**Asset Optimization**
- TopoJSON geography data is pre-compressed
- Static config data is inlined at build time
- CSS is minified and autoprefixed
@@ -3712,16 +3833,19 @@ The project uses Vite for optimal production builds:
### Security Considerations
**Client-Side Security**
- All user input is sanitized via `escapeHtml()` before rendering
- URLs are validated via `sanitizeUrl()` before href assignment
- No `innerHTML` with user-controllable content
**API Security**
- Sensitive API keys are stored server-side only
- Proxy functions validate and sanitize parameters
- Geographic coordinates are clamped to valid ranges
**Privacy**
- No user accounts or cloud storage
- All preferences stored in localStorage
- No telemetry beyond basic Vercel analytics (page views only)
@@ -3758,27 +3882,32 @@ Contributions are welcome! Whether you're fixing bugs, adding features, improvin
This project follows specific patterns to maintain consistency:
**TypeScript**
- Strict type checking enabled—avoid `any` where possible
- Use interfaces for data structures, types for unions
- Prefer `const` over `let`, never use `var`
**Architecture**
- Services (`src/services/`) handle data fetching and business logic
- Components (`src/components/`) handle UI rendering
- Config (`src/config/`) contains static data and constants
- Utils (`src/utils/`) contain shared helper functions
**Security**
- Always use `escapeHtml()` when rendering user-controlled or external data
- Use `sanitizeUrl()` for any URLs from external sources
- Validate and clamp parameters in API proxy endpoints
**Performance**
- Expensive computations should run in the Web Worker
- Use virtual scrolling for lists with 50+ items
- Implement circuit breakers for external API calls
**No Comments Policy**
- Code should be self-documenting through clear naming
- Only add comments for non-obvious algorithms or workarounds
- Never commit commented-out code
@@ -3825,26 +3954,31 @@ This project follows specific patterns to maintain consistency:
### Types of Contributions
**🐛 Bug Fixes**
- Found something broken? Fix it and submit a PR
- Include steps to reproduce in the PR description
**✨ New Features**
- New data layers (with public API sources)
- UI/UX improvements
- Performance optimizations
- New signal detection algorithms
**📊 Data Sources**
- Additional RSS feeds for news aggregation
- New geospatial datasets (bases, infrastructure, etc.)
- Alternative APIs for existing data
**📝 Documentation**
- Clarify existing documentation
- Add examples and use cases
- Fix typos and improve readability
**🔒 Security**
- Report vulnerabilities via GitHub Issues (non-critical) or email (critical)
- XSS prevention improvements
- Input validation enhancements
+47
View File
@@ -0,0 +1,47 @@
# News Translation Analysis
## Current Architecture
The application fetches news via `src/services/rss.ts`.
- **Mechanism**: Direct HTTP requests (via proxy) to RSS/Atom XML feeds.
- **Processing**: `DOMParser` parses XML client-side.
- **Storage**: Items are stored in-memory in `App.ts` (`allNews`, `newsByCategory`).
## The Challenge
Legacy RSS feeds are static XML files in their original language. There is no built-in "negotiation" for language. To display French news, we must either:
1. Fetch French feeds.
2. Translate English feeds on the fly.
## Proposed Solutions
### Option 1: Localized Feed Discovery (Recommended for "Major" Support)
Instead of forcing translation, we switch the *source* based on the selected language.
- **Implementation**:
- In `src/config/feeds.ts`, change the simple URL string to an object: `url: { en: '...', fr: '...' }` or separate constant lists `FEEDS_EN`, `FEEDS_FR`.
- **Pros**: Zero latency, native content quality, no API costs.
- **Cons**: Hard to find equivalent feeds for niche topics (e.g., specific mil-tech blogs) in all languages.
- **Strategy**: Creating a curated list of international feeds for major categories (World, Politics, Finance) is the most robust & scalable approach.
### Option 2: On-Demand Client-Side Translation
Add a "Translate" button to each news card.
- **Implementation**:
- Click triggers a call to a translation API (Google/DeepL/LLM).
- Store result in a local cache (Map).
- **Pros**: Low cost (only used when needed), preserves original context.
- **Cons**: User friction (click to read).
### Option 3: Automatic Auto-Translation (Not Recommended)
Translating 500+ headlines on every load.
- **Cons**:
- **Cost**: Prohibitive for free/low-cost APIs.
- **Latency**: Massive slowdown on startup.
- **Quality**: Short headlines often translate poorly without context.
## Recommendation
**Hybrid Approach**:
1. **Primary**: Source localized feeds where possible (e.g., Le Monde for FR, Spiegel for DE). This requires a community effort to curate `feeds.json` for each locale.
2. **Fallback**: Keep English feeds for niche tech/intel sources where no alternative exists.
3. **Feature**: Add a "Summarize & Translate" button using the existing LLM worker. The prompt to the LLM (currently used for summaries) can be adjusted to "Summarize this in [Current Language]".
## Next Steps
1. Audit `src/config/feeds.ts` to structure it for multi-language support.
2. Update `rss.ts` to select the correct URL based on `i18n.language`.
-1
View File
@@ -77,7 +77,6 @@ Bundler targets are pinned in both Tauri configs and enforced by packaging scrip
- macOS: `app,dmg`
- Windows: `nsis,msi`
## Rust dependency modes (online vs restricted network)
From `src-tauri/`, the project supports two packaging paths:
+3
View File
@@ -1,6 +1,7 @@
# Tauri Validation Report
## Scope
Validated desktop build readiness for the World Monitor Tauri app by checking frontend compilation, TypeScript integrity, and Tauri/Rust build execution.
## Preflight checks before desktop validation
@@ -25,6 +26,7 @@ If any of these checks fail, treat downstream desktop build failures as environm
5. `cargo check` (from `src-tauri/`) — failed because the environment blocks downloading crates from `https://index.crates.io` (`403 CONNECT tunnel failed`).
## Assessment
- The web app portion compiles successfully.
- Full Tauri desktop validation in this run is blocked by an **external environment outage/restriction** (registry access denied with HTTP 403).
- No code/runtime defects were observed in project sources during this validation pass.
@@ -42,6 +44,7 @@ Use these labels in future reports so outcomes are actionable:
- Action: provision offline artifacts/mirror config first, enable offline override (`config.local.toml` or CLI `--config`), then rerun.
## Next action to validate desktop end-to-end
Choose one supported path:
- Online path:
+125
View File
@@ -0,0 +1,125 @@
import { expect, test, type Page, type Route } from '@playwright/test';
/**
* Analyst control-surface e2e: signed-out shows the sign-in prompt (unchanged
* behaviour), and a signed-in command round-trip a stubbed AI response carrying
* a {type:"move_panel"} command flows transport dispatcher AppHost and the
* panel visibly moves, with a action-log entry in the chat. No real inference:
* the analyst + models routes are stubbed so the test is deterministic offline.
*/
const SCREENS = 'e2e/screens';
// Stub every /v1/world/* call the chat touches so the flow is deterministic.
async function stubWorldApi(page: Page): Promise<void> {
await page.route('**/v1/world/**', (route: Route) => {
const url = route.request().url();
if (url.includes('/v1/world/analyst')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
reply: 'Done — moved Markets to the top and switched to the light theme.',
actions: [
{ type: 'move_panel', key: 'markets', position: 'top' },
{ type: 'set_theme', theme: 'light' },
],
model: 'zen5',
tokens: 42,
}),
});
}
if (url.includes('/v1/world/models')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [
{ id: 'zen5', label: 'Zen 5', group: 'Zen' },
{ id: 'zen5-flash', label: 'Zen 5 Flash', group: 'Zen' },
{ id: 'zen3-omni', label: 'Zen 3 Omni', group: 'Zen' },
],
default: 'zen5',
}),
});
}
// Everything else (context feeds) → empty, so collectContext degrades quietly.
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
});
}
async function signIn(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('hanzo_iam_access_token', 'e2e-fake-token');
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3_600_000));
localStorage.setItem('hanzo_iam_owner', 'e2e-org');
});
}
async function appReady(page: Page): Promise<void> {
await page.goto('/');
await page.waitForSelector('#panelsGrid', { timeout: 60_000 });
await page.waitForSelector('[data-panel="markets"]', { timeout: 60_000 });
}
async function panelOrder(page: Page): Promise<string[]> {
return page.$$eval('#panelsGrid [data-panel]', (els) =>
els.map((e) => (e as HTMLElement).dataset.panel || ''),
);
}
test.describe('AI analyst control surface', () => {
test('signed-out: opening the dock shows the sign-in prompt (unchanged)', async ({ page }) => {
await stubWorldApi(page);
await appReady(page);
await page.click('.ai-dock-fab');
const prompt = page.locator('.ai-dock-body .ai-analyst-signedout');
await expect(prompt).toBeVisible();
await expect(prompt.locator('.ai-analyst-signin')).toHaveText(/sign in/i);
// No chat composer for anonymous users.
await expect(page.locator('.ai-dock-body .ai-analyst-input')).toHaveCount(0);
await page.screenshot({ path: `${SCREENS}/analyst-signedout.png` });
});
test('signed-in: a stubbed command round-trip moves a panel + logs the action', async ({ page }) => {
await signIn(page);
await stubWorldApi(page);
await appReady(page);
const before = await panelOrder(page);
const beforeIdx = before.indexOf('markets');
expect(beforeIdx).toBeGreaterThan(1); // markets starts well down the grid
await page.screenshot({ path: `${SCREENS}/round-trip-before.png` });
// Open the analyst dock and confirm the model dropdown populated.
await page.click('.ai-dock-fab');
const composer = page.locator('.ai-dock-body .ai-analyst-input');
await expect(composer).toBeVisible();
const modelSelect = page.locator('.ai-dock-body .ai-analyst-model');
await expect(modelSelect).toBeVisible();
await expect(modelSelect.locator('option')).toHaveCount(3);
await expect(modelSelect).toHaveValue('zen5');
// Send a command-style message; the stub returns the move+theme commands.
await composer.fill('Move markets to the top and go light');
await page.click('.ai-dock-body .ai-analyst-send');
// The prose reply renders…
await expect(page.locator('.ai-dock-body .ai-analyst-msg.assistant')).toContainText('moved Markets');
// …and the per-command action log shows a ✓ for the executed move.
const log = page.locator('.ai-dock-body .ai-analyst-actionlog .ai-analyst-action.ok');
await expect(log.first()).toBeVisible();
await expect(log.first()).toContainText(/moved markets/i);
// The panel VISIBLY moved: markets is now near the top of the grid.
await expect
.poll(async () => (await panelOrder(page)).indexOf('markets'), { timeout: 15_000 })
.toBeLessThan(beforeIdx);
// And the theme command took effect (whole-app visible change).
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await page.screenshot({ path: `${SCREENS}/round-trip-after.png` });
});
});
+147
View File
@@ -0,0 +1,147 @@
import { expect, test, type Page, type Route } from '@playwright/test';
/**
* Full app control from the chat widget.
*
* The analyst's control surface is the app-command registry (services/app-commands.ts)
* driven through the AppHost port. Until now it could show/hide/move panels and
* touch the map, but it could NOT change the layout mode, add a topic to monitor,
* or drive the Watch Queue so "reconfigure the layout and all widgets from the
* chat" was not actually true.
*
* Each test stubs the analyst to return ONE command and asserts the app really
* changed the command is dispatched through the same path a real model reply
* takes. No mocked host, no unit-test stand-in for the DOM.
*/
const SCREENS = 'e2e/screens';
async function stubAnalyst(page: Page, actions: unknown[], reply = 'Done.'): Promise<void> {
await page.route('**/v1/world/**', (route: Route) => {
const url = route.request().url();
if (url.includes('/v1/world/analyst')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ reply, actions, model: 'zen5', tokens: 0, traces: [] }),
});
}
if (url.includes('/v1/world/models')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ models: [{ id: 'zen5', name: 'Zen 5' }], default: 'zen5' }),
});
}
// Monitors: signed-in sync endpoints. Keep them empty + accepting.
if (url.includes('/v1/world/monitors')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ monitors: [], matches: [], ok: true }),
});
}
return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' });
});
}
async function signIn(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('hanzo_iam_access_token', 'e2e-fake-token');
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3_600_000));
localStorage.setItem('hanzo_iam_owner', 'e2e-org');
});
}
async function appReady(page: Page): Promise<void> {
await page.goto('/');
await page.waitForSelector('.panel', { timeout: 30_000 });
}
/** Send any message; the stub decides which command comes back. */
// Two AnalystChats exist (the in-grid panel and the dock) — they share the SAME
// code path, so drive the dock's explicitly rather than matching both.
async function ask(page: Page, text: string): Promise<void> {
await page.click('.hzc-fab');
const dock = page.locator('.hzc-panel');
await expect(dock).toBeVisible();
const composer = dock.locator('.hzc-input');
await expect(composer).toBeVisible();
await composer.fill(text);
await dock.locator('.hzc-send').click();
}
test.describe('analyst full app control', () => {
test('set_layout_mode switches the app into immersive', async ({ page }) => {
await signIn(page);
await stubAnalyst(page, [{ type: 'set_layout_mode', mode: 'immersive' }]);
await appReady(page);
await expect(page.locator('body')).not.toHaveClass(/immersive/);
await ask(page, 'go immersive');
// The app really entered immersive (body class is the mode's source of truth)…
await expect(page.locator('body')).toHaveClass(/immersive/);
// …and the dock select agrees — the AI and the UI cannot disagree about mode.
await expect(page.locator('#dockModeSelect')).toHaveValue('immersive');
await page.screenshot({ path: `${SCREENS}/analyst-immersive.png` });
});
test('add_monitor makes the analyst able to watch a new topic', async ({ page }) => {
await signIn(page);
await stubAnalyst(page, [{ type: 'add_monitor', keywords: 'nvidia, gpu' }]);
await appReady(page);
await ask(page, 'watch for nvidia and gpu');
// The monitor list really gained the topic (the panel renders it).
const monitors = page.locator('#monitorsList');
await expect(monitors).toContainText(/nvidia/i);
await expect(monitors).toContainText(/gpu/i);
// And it persisted through the ONE monitor path (localStorage mirror).
const stored = await page.evaluate(() =>
JSON.parse(localStorage.getItem('worldmonitor-monitors') || '[]'),
);
expect(JSON.stringify(stored)).toContain('nvidia');
});
test('queue_next advances the Watch Queue', async ({ page }) => {
await signIn(page);
await stubAnalyst(page, [{ type: 'queue_next' }]);
await appReady(page);
// Seed two items straight into the queue's own store, then let the analyst advance it.
await page.evaluate(() => {
localStorage.setItem(
'hanzo-world-watch-queue',
JSON.stringify({
items: [
{ id: 'a', kind: 'video', title: 'First talk', source: 'X', ref: 'aaaaaaaaaaa', addedAt: 1, status: 'queued' },
{ id: 'b', kind: 'video', title: 'Second talk', source: 'X', ref: 'bbbbbbbbbbb', addedAt: 2, status: 'queued' },
],
currentId: 'a',
}),
);
});
await page.reload();
await page.waitForSelector('.panel', { timeout: 30_000 });
await ask(page, 'next video');
// 'a' is finished and 'b' is now current — tracked consumption, driven by the AI.
await expect
.poll(async () =>
page.evaluate(() => {
const q = JSON.parse(localStorage.getItem('hanzo-world-watch-queue') || '{}');
return q.currentId;
}),
)
.toBe('b');
const statusOfA = await page.evaluate(() => {
const q = JSON.parse(localStorage.getItem('hanzo-world-watch-queue') || '{}');
return (q.items || []).find((i: { id: string }) => i.id === 'a')?.status;
});
expect(statusOfA).toBe('watched');
});
});
+155
View File
@@ -0,0 +1,155 @@
import { expect, test } from '@playwright/test';
import { clickMapControl } from './helpers/map-controls';
// Cloud mode: on world.hanzo.ai (and local dev — a hanzo brand host) the H logo is a
// toggle that reveals the [Hanzo | World | AI | Crypto | Finance | Tech] switcher,
// and the flagship `hanzo` view ships the live-traffic globe layer. This spec drives
// the real header + map on the main app, plus the TrafficGlobePanel in isolation.
const TRAFFIC = {
updatedAt: '2026-07-16T12:00:00Z',
live: true,
window: { minutes: 60, since: '2026-07-16T11:00:00Z', until: '2026-07-16T12:00:00Z' },
points: [
{ country: 'US', region: 'CA', lat: 36.12, lon: -119.68, count: 42, byService: { chat: 40, media: 2 } },
{ country: 'GB', lat: 55.38, lon: -3.44, count: 12, byService: { models: 12 } },
{ country: 'DE', lat: 51.17, lon: 10.45, count: 7, byService: { embeddings: 7 } },
],
totals: { rps_1m: 0.7, rpm_60m: 0.35, top_countries: [{ country: 'US', count: 42 }, { country: 'GB', count: 12 }, { country: 'DE', count: 7 }] },
};
test.describe('Cloud mode', () => {
test('H logo reveals the switcher; cloud view ships the live-traffic globe layer', async ({ page }) => {
// Feed the globe layer real points so its poll resolves (avoids a 404 empty state).
await page.route('**/v1/world/cloud/traffic-globe', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(TRAFFIC) }),
);
await page.goto('/?variant=cloud');
// The H logo renders as the Hanzo-mode toggle on this (hanzo brand) host.
const hLogo = page.locator('[data-hanzo-toggle]');
await expect(hLogo).toBeVisible();
// Flagship default: the switcher starts collapsed (revealed by the H, not shown
// by default), so the clean globe view leads.
const switcher = page.locator('.variant-switcher');
await expect(switcher).toBeHidden();
// Click the H → Cloud mode reveals the switcher.
await hLogo.click();
await expect(page.locator('.header.hanzo-mode')).toHaveCount(1);
await expect(switcher).toBeVisible();
await expect(hLogo).toHaveAttribute('aria-expanded', 'true');
// Exactly the [Cloud | World | AI | Crypto | Finance | Tech] tabs, Cloud first.
await expect(switcher.locator('.variant-option')).toHaveCount(6);
await expect(switcher.locator('.variant-option').first()).toHaveAttribute('data-variant', 'cloud');
await expect(page.locator('.variant-option[data-variant="cloud"].active')).toHaveCount(1);
// Click the H again → collapses.
await hLogo.click();
await expect(switcher).toBeHidden();
// The globe's native traffic layer is wired into the layer-toggle system and ON
// by default in the hanzo view — i.e. the globe layer is toggleable.
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
const trafficToggle = page.locator('.layer-toggle[data-layer="traffic"]');
await expect(trafficToggle).toHaveCount(1);
await expect(trafficToggle.locator('input[type="checkbox"]')).toBeChecked();
});
test('TrafficGlobePanel renders live throughput + top origins, and an honest empty state', async ({ page }) => {
await page.goto('/tests/runtime-harness.html');
// Live payload → throughput tiles + ranked origin countries.
const live = await page.evaluate(async (payload) => {
const orig = window.fetch;
window.fetch = (async () => ({ ok: true, status: 200, json: async () => payload })) as typeof window.fetch;
const { TrafficGlobePanel } = await import('/src/components/TrafficGlobePanel.ts');
const panel = new TrafficGlobePanel();
const root = panel.getElement();
document.body.appendChild(root);
const deadline = Date.now() + 3000;
while (Date.now() < deadline && root.querySelectorAll('.traffic-row').length === 0) {
await new Promise((r) => setTimeout(r, 30));
}
const text = root.textContent ?? '';
const rows = root.querySelectorAll('.traffic-row').length;
panel.destroy(); root.remove(); window.fetch = orig;
return { text, rows };
}, TRAFFIC);
expect(live.rows).toBe(3);
expect(live.text).toContain('US');
expect(live.text).toContain('requests / sec');
expect(live.text.toLowerCase()).toContain('active regions');
// Empty payload → honest zero state, never fabricated numbers.
const empty = await page.evaluate(async () => {
const orig = window.fetch;
const EMPTY = { updatedAt: '', live: false, window: { minutes: 60, since: '', until: '' }, points: [], totals: { rps_1m: 0, rpm_60m: 0, top_countries: [] } };
window.fetch = (async () => ({ ok: true, status: 200, json: async () => EMPTY })) as typeof window.fetch;
const { TrafficGlobePanel } = await import('/src/components/TrafficGlobePanel.ts');
const panel = new TrafficGlobePanel();
const root = panel.getElement();
document.body.appendChild(root);
const deadline = Date.now() + 3000;
while (Date.now() < deadline && !(root.textContent ?? '').includes('No live traffic')) {
await new Promise((r) => setTimeout(r, 30));
}
const text = root.textContent ?? '';
panel.destroy(); root.remove(); window.fetch = orig;
return text;
});
expect(empty).toContain('No live traffic yet');
});
test('3D globe settles (no idle-spin flicker) + Cloud-accurate legend, no cables assertion', async ({ page }) => {
await page.route('**/v1/world/cloud/traffic-globe', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(TRAFFIC) }),
);
const errors: string[] = [];
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
await page.goto('/?variant=cloud');
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
// Legend must MATCH what the globe plots — the Cloud data classes, not the
// geopolitical default (which would mislabel a traffic dot as "high alert").
const legend = page.locator('.deckgl-legend');
await expect(legend).toBeVisible();
const legendText = (await legend.innerText()).toLowerCase();
for (const cls of ['request origin', 'validator node', 'gpu fleet', 'cloud region', 'datacenter']) {
expect(legendText).toContain(cls);
}
for (const geo of ['high alert', 'nuclear', 'elevated']) {
expect(legendText).not.toContain(geo);
}
// Switch to the 3D globe (GlobeNative is exposed on window in dev/e2e).
await clickMapControl(page, '.deckgl-projection-toggle .proj-btn[data-mode="3d"]');
await expect
.poll(() => page.evaluate(() => Boolean((window as unknown as { __globeNative?: unknown }).__globeNative)), { timeout: 20000 })
.toBe(true);
// Idle auto-rotate is OFF, so the globe SETTLES: the camera longitude does not
// drift and the deck render loop idles (a few data-sync renders, not ~40/s). A
// perpetually-spinning globe re-rasterizes thin vector lines every frame — that is
// the shimmer; a settled globe does not.
const settled = await page.evaluate(async () => {
const g = (window as unknown as {
__globeNative: { getCenter: () => { lon: number }; getDeck: () => { setProps: (p: unknown) => void } };
}).__globeNative;
const c0 = g.getCenter();
let renders = 0;
g.getDeck().setProps({ onAfterRender: () => { renders++; } });
await new Promise((r) => setTimeout(r, 1300));
return { lonDelta: Math.abs(g.getCenter().lon - c0.lon), renders };
});
expect(settled.lonDelta).toBeLessThan(0.001); // no idle auto-rotate drift
expect(settled.renders).toBeLessThan(12); // render loop idles (vs ~40/s spinning)
// The broken cables PathLayer must not assert (cables off in Cloud + path guarded).
expect(errors.filter((e) => /cables-layer|assertion failed/i.test(e))).toEqual([]);
});
});
+174
View File
@@ -0,0 +1,174 @@
import { expect, test, type Page, type Route } from '@playwright/test';
/**
* Context-menu + analyst-data-tool e2e.
*
* 1. Right-click a news-shaped item (the exact data-ctx-* convention NewsPanel
* emits) the custom menu shows Open link / Copy link / Copy headline plus
* the panel baseline; Copy actually writes to the clipboard.
* 2. Right-click the live map the map menu shows Copy coordinates / Fly here /
* the 2D-3D toggle (via the MapContainer capability port).
* 3. A stubbed analyst response carrying a data-tool trace renders the collapsed
* "🔧 world_brief(...)" line before the grounded reply.
*
* No real inference or feeds: the analyst/models routes are stubbed and the news
* item is injected into a live panel so the menu code runs against production DOM.
*/
const SCREENS = 'e2e/screens';
async function stubWorldApi(page: Page, analystBody?: unknown): Promise<void> {
await page.route('**/v1/world/**', (route: Route) => {
const url = route.request().url();
if (url.includes('/v1/world/analyst')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
analystBody ?? { reply: '', actions: [], model: 'zen5', tokens: 0, traces: [] },
),
});
}
if (url.includes('/v1/world/models')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [{ id: 'zen5', label: 'Zen 5', group: 'Zen' }],
default: 'zen5',
}),
});
}
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
});
}
async function signIn(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('hanzo_iam_access_token', 'e2e-fake-token');
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3_600_000));
localStorage.setItem('hanzo_iam_owner', 'e2e-org');
});
}
async function appReady(page: Page): Promise<void> {
await page.goto('/');
await page.waitForSelector('#panelsGrid', { timeout: 60_000 });
}
// Read the current custom menu's item labels (buttons only, not separators/labels).
async function menuItems(page: Page): Promise<string[]> {
return page.$$eval('#panelContextMenu .panel-context-menu-item', (els) =>
els.map((e) => (e.textContent || '').trim()),
);
}
test.describe('right-click context menus', () => {
test('news item → Open link / Copy link / Copy headline (+ clipboard)', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await stubWorldApi(page);
await appReady(page);
// Add a faithful news item (exactly the data-ctx-* attributes NewsPanel
// emits) inside its own panel appended to the grid. A synthetic panel is used
// so a live panel's periodic re-render can't wipe the item mid-test, and so it
// is not under the map canvas — the menu code path is identical either way.
await page.evaluate(() => {
const grid = document.querySelector('#panelsGrid')!;
const panel = document.createElement('div');
panel.className = 'panel';
panel.dataset.panel = 'e2e-test-panel';
panel.innerHTML =
'<div class="panel-content"><div class="item" id="e2e-news-item" ' +
'data-ctx-url="https://example.com/story" data-ctx-headline="Breaking test headline" ' +
'style="padding:12px;min-height:24px;">Breaking test headline</div></div>';
grid.appendChild(panel);
});
await page.locator('#e2e-news-item').scrollIntoViewIfNeeded();
await page.locator('#e2e-news-item').click({ button: 'right' });
await expect(page.locator('#panelContextMenu')).toBeVisible();
const items = await menuItems(page);
expect(items).toEqual(expect.arrayContaining(['Open link', 'Copy link', 'Copy headline']));
// Baseline panel actions still ride below the component items.
expect(items).toEqual(expect.arrayContaining(['Hide panel', 'Reset layout', 'Full']));
await page.screenshot({ path: `${SCREENS}/ctxmenu-news.png` });
// Copy headline actually writes to the clipboard.
await page.locator('#panelContextMenu .panel-context-menu-item', { hasText: 'Copy headline' }).click();
await expect
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
.toBe('Breaking test headline');
// Menu dismisses after an action.
await expect(page.locator('#panelContextMenu')).toHaveCount(0);
});
test('map → Copy coordinates / Fly here / 2D-3D toggle', async ({ page }) => {
await stubWorldApi(page);
await appReady(page);
// The map is a first-class grid citizen in the full app; wait for its canvas.
await page.waitForSelector('.map-container canvas', { timeout: 60_000 });
// A stationary right-click on the map surface opens the map menu (a rotate
// drag would instead keep the maplibre gesture — not exercised here).
await expect
.poll(
async () => {
await page.locator('.map-container canvas').first().click({ button: 'right', position: { x: 300, y: 180 } });
if (!(await page.locator('#panelContextMenu').count())) return [];
return menuItems(page);
},
{ timeout: 30_000 },
)
.toEqual(expect.arrayContaining(['Copy coordinates', 'Fly here']));
const items = await menuItems(page);
// The 2D-3D toggle is present (label depends on current projection).
expect(items.some((l) => /Switch to (2D map|3D globe)/.test(l))).toBe(true);
await page.screenshot({ path: `${SCREENS}/ctxmenu-map.png` });
});
});
test.describe('analyst data-tool traces', () => {
test('a stubbed tool round-trip renders the 🔧 trace + grounded reply', async ({ page }) => {
await signIn(page);
await stubWorldApi(page, {
reply: 'Composite instability is steady; Sudan and Ukraine lead the movers.',
actions: [],
model: 'zen5',
tokens: 128,
traces: [
{
label: 'world_brief({"n":5})',
ok: true,
result: '{"asOf":"2026-07-09T00:00:00Z","kind":"country","top":[{"iso":"SD","instability":0.82}]}',
},
],
});
await appReady(page);
await page.click('.ai-dock-fab');
const composer = page.locator('.ai-dock-body .ai-analyst-input');
await expect(composer).toBeVisible();
await composer.fill('What is the state of global instability?');
await page.click('.ai-dock-body .ai-analyst-send');
// The collapsed tool trace renders before the reply that cites it.
const trace = page.locator('.ai-dock-body .ai-analyst-tool .ai-analyst-tool-summary');
await expect(trace).toBeVisible();
await expect(trace).toContainText('🔧 world_brief(');
// Expanding it reveals the raw result body.
await trace.click();
await expect(page.locator('.ai-dock-body .ai-analyst-tool-result')).toContainText('instability');
// …and the grounded prose reply is shown.
await expect(page.locator('.ai-dock-body .ai-analyst-msg.assistant')).toContainText('instability is steady');
await page.screenshot({ path: `${SCREENS}/analyst-tool-trace.png` });
});
});
+203
View File
@@ -0,0 +1,203 @@
import { expect, test, type Page, type Route } from '@playwright/test';
/**
* Country application-view e2e: clicking a country opens a FULLSCREEN view with
* the intel content on the left and a docked AI analyst chat on the right
* (desktop), a bottom-sheet on mobile. Escape restores the dashboard; the URL
* reflects the view (?country=FR) via pushState so browser Back closes it.
*
* The open is driven through window.__app.openCountryBriefByCode the EXACT call
* the map click handler makes (map.onCountryClicked openCountryBriefByCode)
* so the test exercises the real click code path deterministically, the same
* window.__app hook the repo's other e2e specs use.
*/
const SCREENS = 'e2e/screens';
async function stubViewApi(page: Page): Promise<void> {
await page.route('**/v1/world/**', (route: Route) => {
const url = route.request().url();
if (url.includes('/v1/world/models')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: [
{ id: 'zen5', label: 'Zen 5', group: 'Zen' },
{ id: 'zen5-flash', label: 'Zen 5 Flash', group: 'Zen' },
],
default: 'zen5',
}),
});
}
if (url.includes('/v1/world/country-intel')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
brief: 'France remains stable. Monitoring energy policy and labour actions across major cities.',
country: 'France',
code: 'FR',
cached: false,
generatedAt: new Date().toISOString(),
}),
});
}
if (url.includes('/v1/world/stock-index')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ available: true, indexName: 'CAC 40', price: '8000', weekChangePercent: '1.2' }),
});
}
if (url.includes('/v1/world/analyst')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ reply: 'Energy policy and labour actions are the current drivers.', actions: [], model: 'zen5', tokens: 12 }),
});
}
// Seed the two risk-required feeds (rss + gdelt) so the deep-link handler's
// dataFreshness.hasSufficientData() gate flips to 'sufficient' offline.
if (url.includes('/v1/world/feeds-batch')) {
const body = route.request().postDataJSON() as { urls?: string[] } | null;
const urls = body?.urls ?? [];
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
feeds: urls.map((u) => ({
url: u,
ok: true,
items: [{ title: 'Deep-link seed headline', link: 'https://example.com/news', pubDate: new Date().toISOString() }],
})),
}),
});
}
if (url.includes('/v1/world/gdelt-geo')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
type: 'FeatureCollection',
features: [
{ type: 'Feature', properties: { name: 'Paris, France', count: 60 }, geometry: { type: 'Point', coordinates: [2.3522, 48.8566] } },
{ type: 'Feature', properties: { name: 'Lyon, France', count: 40 }, geometry: { type: 'Point', coordinates: [4.8357, 45.764] } },
],
}),
});
}
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
});
}
async function signIn(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('hanzo_iam_access_token', 'e2e-fake-token');
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3_600_000));
localStorage.setItem('hanzo_iam_owner', 'e2e-org');
});
}
async function appReady(page: Page): Promise<void> {
await page.goto('/');
await page.waitForSelector('#panelsGrid', { timeout: 60_000 });
}
async function openCountry(page: Page, code = 'FR', name = 'France'): Promise<void> {
await page.evaluate(
([c, n]) => (window as unknown as { __app: { openCountryBriefByCode(c: string, n: string): Promise<void> } }).__app.openCountryBriefByCode(c, n),
[code, name],
);
await page.waitForSelector('.country-brief-overlay.active', { timeout: 30_000 });
}
test.describe('Country application view', () => {
test('desktop: fullscreen intel + docked analyst sidebar, URL + Escape', async ({ page }) => {
await signIn(page);
await stubViewApi(page);
await appReady(page);
await openCountry(page);
// Fullscreen, not the old centered card: page fills the viewport width.
const page_ = page.locator('.country-brief-page');
await expect(page_).toBeVisible();
const box = await page_.boundingBox();
const vp = page.viewportSize()!;
expect(box!.width).toBeGreaterThan(vp.width - 4); // no horizontal straitjacket
// Intel content on the left…
await expect(page.locator('.cb-main .cb-body')).toBeVisible();
// …docked analyst chat on the right (signed-in → composer, model picker present).
const sidebar = page.locator('.cb-analyst-sidebar');
await expect(sidebar).toBeVisible();
await expect(sidebar.locator('.ai-analyst-input')).toBeVisible();
await expect(sidebar.locator('.ai-analyst-model option')).toHaveCount(2);
// Sidebar is on the right of the intel content.
const mainBox = await page.locator('.cb-main').boundingBox();
const sideBox = await sidebar.boundingBox();
expect(sideBox!.x).toBeGreaterThan(mainBox!.x);
// URL reflects the view (pushState).
expect(page.url()).toContain('country=FR');
await page.screenshot({ path: `${SCREENS}/country-view-desktop.png` });
// Escape restores the dashboard and drops ?country=.
await page.keyboard.press('Escape');
await expect(page.locator('.country-brief-overlay.active')).toHaveCount(0);
expect(page.url()).not.toContain('country=');
});
test('desktop: analyst sidebar collapses and reopens via the pill', async ({ page }) => {
await signIn(page);
await stubViewApi(page);
await appReady(page);
await openCountry(page);
const brief = page.locator('.country-brief-page');
await expect(brief).not.toHaveClass(/cb-analyst-collapsed/);
await page.click('.cb-analyst-collapse');
await expect(brief).toHaveClass(/cb-analyst-collapsed/);
await expect(page.locator('.cb-analyst-fab')).toBeVisible();
await page.click('.cb-analyst-fab');
await expect(brief).not.toHaveClass(/cb-analyst-collapsed/);
});
test('mobile 390px: analyst is a bottom sheet, no horizontal scroll', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await signIn(page);
await stubViewApi(page);
await appReady(page);
await openCountry(page);
const brief = page.locator('.country-brief-page');
// On mobile the intel leads; the analyst starts collapsed with a reopen pill.
await expect(brief).toHaveClass(/cb-analyst-collapsed/);
await expect(page.locator('.cb-analyst-fab')).toBeVisible();
// No horizontal overflow.
const scrollW = await page.evaluate(() => document.documentElement.scrollWidth);
expect(scrollW).toBeLessThanOrEqual(390 + 1);
await page.screenshot({ path: `${SCREENS}/country-view-mobile-closed.png` });
// Open the bottom sheet.
await page.click('.cb-analyst-fab');
await expect(brief).not.toHaveClass(/cb-analyst-collapsed/);
await expect(page.locator('.cb-analyst-sidebar .ai-analyst-input')).toBeVisible();
await page.screenshot({ path: `${SCREENS}/country-view-mobile-open.png` });
});
test('deep link: ?country=FR opens the view directly', async ({ page }) => {
await signIn(page);
await stubViewApi(page);
// The deep-link handler waits for sufficient live data before opening; let the
// real proxied feeds load (news + gdelt), then assert the view appears.
await page.goto('/?country=FR');
await page.waitForSelector('#panelsGrid', { timeout: 60_000 });
await expect(page.locator('.country-brief-overlay.active')).toBeVisible({ timeout: 80_000 });
await expect(page.locator('.cb-analyst-sidebar')).toBeVisible();
expect(page.url()).toContain('country=FR');
});
});
+128
View File
@@ -0,0 +1,128 @@
import { expect, test, type Page } from '@playwright/test';
// The signed-in dashboard-sync contract, verified end-to-end against the REAL app:
// 1. on boot, this identity's server dashboard is written into localStorage
// BEFORE the app reads it (server precedence, cross-device),
// 2. a change to a dashboard key is synced to the server (debounced PUT of the
// full snapshot),
// 3. signed out, nothing ever touches the server (localStorage only).
// IAM is faked (a non-expired token + a stubbed userinfo) and /v1/world/dashboard
// is stubbed, so no real backend or identity is needed.
const DASH = '**/v1/world/dashboard';
const HIST = '**/v1/world/history';
const LN = '[data-panel="live-news"]';
async function fakeAuth(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem('hanzo_iam_access_token', 'faketoken');
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3_600_000));
localStorage.setItem('hanzo_iam_owner', 'acme');
});
// Keep the fake session valid: a userinfo 401 would let the app drop the token,
// and the sync (correctly) stops when signed out.
await page.route('**/v1/iam/oauth/userinfo', (r) =>
r.fulfill({ json: { sub: 'u1', owner: 'acme', email: 'u@acme.test' } }),
);
// Default empty history so boot's history hydrate never hits the real backend;
// tests that assert on history override this with a later page.route (last wins).
await page.route(HIST, (r) => r.fulfill({ json: { config: {} } }));
}
test.describe('dashboard sync (real app)', () => {
test.use({ viewport: { width: 1440, height: 900 } });
test('boot hydrates localStorage from the server (server precedence)', async ({ page }) => {
await fakeAuth(page);
await page.route(DASH, async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { config: { 'hanzo-world-map-mode': '3d' } } });
} else {
await route.fulfill({ json: { ok: true } });
}
});
await page.goto('/');
await page.waitForSelector(LN, { timeout: 45000 });
// The server value was applied to localStorage at boot, before the app read it.
await expect
.poll(() => page.evaluate(() => localStorage.getItem('hanzo-world-map-mode')), { timeout: 8000 })
.toBe('3d');
});
test('a dashboard change is synced to the server (debounced PUT of the snapshot)', async ({ page }) => {
await fakeAuth(page);
const puts: Array<Record<string, string>> = [];
await page.route(DASH, async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { config: {} } }); // empty server → first run
} else {
puts.push(route.request().postDataJSON() as Record<string, string>);
await route.fulfill({ json: { ok: true } });
}
});
await page.goto('/');
await page.waitForSelector(LN, { timeout: 45000 });
await page.waitForTimeout(2000); // let the boot writes flush their debounced PUT first
// Change an observed dashboard key; a debounced PUT carries a snapshot of ALL
// dashboard keys, including our change.
await page.evaluate(() => localStorage.setItem('worldmonitor-layers', '{"quakes":true}'));
await expect
.poll(() => puts.some((p) => p && p['worldmonitor-layers'] === '{"quakes":true}'), { timeout: 8000 })
.toBe(true);
});
test('signed out: never touches the server (localStorage only)', async ({ page }) => {
let hit = false;
await page.route(DASH, async (route) => {
hit = true;
await route.fulfill({ json: { config: {} } });
});
await page.goto('/'); // no fakeAuth → anonymous
await page.waitForSelector(LN, { timeout: 45000 });
await page.evaluate(() => localStorage.setItem('worldmonitor-layers', '{"a":1}'));
await page.waitForTimeout(1200);
expect(hit).toBe(false);
});
test('history keys sync to /v1/world/history, not /dashboard', async ({ page }) => {
await fakeAuth(page);
const dashPuts: string[] = [];
const histPuts: Array<Record<string, string>> = [];
await page.route(DASH, async (route) => {
if (route.request().method() === 'GET') await route.fulfill({ json: { config: {} } });
else {
dashPuts.push(route.request().postData() || '');
await route.fulfill({ json: { ok: true } });
}
});
await page.route(HIST, async (route) => {
if (route.request().method() === 'GET') await route.fulfill({ json: { config: {} } });
else {
histPuts.push(route.request().postDataJSON() as Record<string, string>);
await route.fulfill({ json: { ok: true } });
}
});
await page.goto('/');
await page.waitForSelector(LN, { timeout: 45000 });
await page.waitForTimeout(2000); // flush boot writes first
// A real user action (a recent search) is a HISTORY key → PUT to /history.
await page.evaluate(() => localStorage.setItem('worldmonitor_recent_searches', '["nvidia"]'));
await expect
.poll(() => histPuts.some((p) => p && p['worldmonitor_recent_searches'] === '["nvidia"]'), { timeout: 8000 })
.toBe(true);
// …and it was NOT mixed into a dashboard PUT (clean namespace separation).
expect(dashPuts.some((b) => b.includes('worldmonitor_recent_searches'))).toBe(false);
});
test('boot hydrates history from /v1/world/history (server precedence)', async ({ page }) => {
await fakeAuth(page);
await page.route(DASH, (r) => r.fulfill({ json: { config: {} } }));
await page.route(HIST, (r) => r.fulfill({ json: { config: { 'hanzo-world-watch-queue': '{"items":[7]}' } } }));
await page.goto('/');
await page.waitForSelector(LN, { timeout: 45000 });
await expect
.poll(() => page.evaluate(() => localStorage.getItem('hanzo-world-watch-queue')), { timeout: 8000 })
.toBe('{"items":[7]}');
});
});
+59
View File
@@ -0,0 +1,59 @@
import { expect, test } from '@playwright/test';
// Enso Live Training must reflect REAL models (the served catalog), never the opaque
// upstream "arm-N" routing mix that read as random/meaningless amounts. Both feeds are
// stubbed with real-shaped payloads; the router-stats stub deliberately includes an
// arm-N by_model map to prove it is NOT rendered anymore.
const ENSO = '.panel[data-panel="enso-training"]';
const ROUTER_STATS = {
scope: 'platform',
window: { since: '2026-07-17T00:00:00Z', until: '2026-07-18T00:00:00Z', events: 12345 },
cost: { saved_pct: 0.23, cumulative_saved_index: 98765, baseline_model: 'x', priced_events: 100 },
quality: { reward_rate: 0.5, rewarded_events: 10, engine_share: 0.62, avg_confidence: 0.8, shadow_agreement: 0.91 },
by_task: {},
by_model: { 'arm-1': 100, 'arm-2': 50, 'arm-3': 25 }, // opaque arms — must NOT render
throughput: { per_hour: [1, 2, 3, 4, 5, 6], total_window: 21 },
retrain: {
version: 'v9', trained_time: '2026-07-17T12:00:00Z', events: 100, gate_passed: true,
published: true, gate_kind: 'reward', gate_metric: 'rate', gate_value: 0.5, gate_base: 0.4, note: '',
},
};
const CLOUD_MODELS = {
updatedAt: '2026-07-18T00:00:00Z',
totalModels: 42,
zenModels: 8,
cloudRegions: 5,
cloudPlans: 3,
families: ['Zen', 'Fable'],
models: [
{ id: 'zen5', name: 'Zen 5', provider: 'hanzo', tier: 'flagship', context: 200000, inPrice: 3, outPrice: 15 },
{ id: 'zen5-flash', name: 'Zen 5 Flash', provider: 'hanzo', tier: 'fast', context: 128000, inPrice: 0.5, outPrice: 1.5 },
{ id: 'fable5', name: 'Fable 5', provider: 'hanzo', tier: 'premium', context: 400000, inPrice: 5, outPrice: 20 },
],
};
test.describe('Enso Live Training — real models only', () => {
test.use({ viewport: { width: 1440, height: 900 } });
test('renders the real served-model catalog, never opaque arm-N amounts', async ({ page }) => {
await page.route('**/v1/world/cloud/router-stats*', (r) => r.fulfill({ json: ROUTER_STATS }));
await page.route('**/v1/world/cloud/models', (r) => r.fulfill({ json: CLOUD_MODELS }));
await page.goto('/');
await page.waitForSelector(ENSO, { timeout: 45000 });
const panel = page.locator(ENSO);
// Real model names from the catalog appear.
await expect(panel).toContainText('Zen 5 Flash', { timeout: 15000 });
await expect(panel).toContainText('Fable 5');
await expect(panel).toContainText('models served');
// Real aggregates still render (unchanged, real telemetry).
await expect(panel).toContainText('cost saved');
// The opaque per-arm amounts are GONE — no meaningless "Enso arm N" rows.
await expect(panel).not.toContainText('Enso arm');
await expect(panel).not.toContainText('arm-1');
});
});
+69
View File
@@ -0,0 +1,69 @@
import { expect, test } from '@playwright/test';
// Mounts the real EnsoTrainingPanel against a stubbed router-stats payload and
// asserts the rendered DOM: opaque "Enso arm N" labels (NEVER a vendor name),
// the cost-saved headline, the retrain gate verdict, and the honest "—" shadow
// state. Mirrors e2e/investments-panel.spec.ts (runtime-harness).
test.describe('Enso Live Training panel', () => {
test('renders opaque arms, cost-saved headline and retrain gate — no vendor names', async ({ page }) => {
await page.goto('/tests/runtime-harness.html');
const result = await page.evaluate(async () => {
const CANNED = {
scope: 'platform',
window: { since: '2026-07-15T00:00:00Z', until: '2026-07-16T00:00:00Z', events: 48210 },
cost: { saved_pct: 21.5, cumulative_saved_index: 1372, baseline_model: 'arm-1', priced_events: 41000 },
quality: { reward_rate: 0.31, rewarded_events: 1200, engine_share: 0.62, avg_confidence: 0.74, shadow_agreement: null },
by_task: { chat: { events: 30000, models: { 'arm-1': 18000, 'arm-2': 12000 } } },
by_model: { 'arm-1': 30000, 'arm-2': 14000, 'arm-3': 4210 },
throughput: { per_hour: Array.from({ length: 24 }, (_, i) => 1500 + i * 40), total_window: 48210 },
retrain: {
version: 'router-2026.07.16', trained_time: '2026-07-16T00:00:00Z', events: 48210,
gate_passed: true, published: true, gate_kind: 'holdout', gate_metric: 'reward',
gate_value: 0.312, gate_base: 0.298, note: 'shipped',
},
};
// Stub the same-origin proxy the service calls.
const origFetch = window.fetch;
window.fetch = (async () =>
({ ok: true, status: 200, json: async () => CANNED })) as typeof window.fetch;
const { EnsoTrainingPanel } = await import('/src/components/EnsoTrainingPanel.ts');
const panel = new EnsoTrainingPanel();
const root = panel.getElement();
document.body.appendChild(root);
// Wait for the async fetch → render to land.
const deadline = Date.now() + 3000;
while (Date.now() < deadline && root.querySelectorAll('.cloud-model-row').length === 0) {
await new Promise((r) => setTimeout(r, 30));
}
const armLabels = Array.from(root.querySelectorAll('.cloud-model-name')).map(
(n) => (n.textContent ?? '').trim(),
);
const text = root.textContent ?? '';
panel.destroy();
root.remove();
window.fetch = origFetch;
return { armLabels, text };
});
// Three opaque arms, labeled by tier — never a vendor name.
expect(result.armLabels.length).toBe(3);
expect(result.armLabels[0]).toContain('Enso arm 1');
expect(result.armLabels[1]).toContain('Enso arm 2');
expect(result.armLabels[2]).toContain('Enso arm 3');
for (const vendor of ['claude', 'anthropic', 'gpt', 'openai', 'deepseek', 'qwen', 'gemini', 'llama']) {
expect(result.text.toLowerCase()).not.toContain(vendor);
}
// Headline cost-saved + retrain gate verdict + honest empty shadow state.
expect(result.text).toContain('21.5%');
expect(result.text).toContain('passed');
expect(result.text).toContain('Shadow-vs-served agreement: —');
});
});
+136
View File
@@ -0,0 +1,136 @@
import { expect, test } from '@playwright/test';
// Native deck.gl GlobeView (GlobeNative) — the pure-deck 3D globe that replaces the
// mapbox globe behind the ?globe=native flag.
//
// This guards the structural perf + correctness contract that a headless GPU CAN
// verify:
// - the mode activates from the ?globe=native flag,
// - the whole page holds exactly ONE WebGL context (no mapbox, no overlay) — the
// single-context perf target,
// - deck's active viewport is a GlobeViewport (proves it's reprojecting onto the
// sphere, not a flat map),
// - a seeded hotspot picks at its globe-projected screen point (layers render AND
// reproject on the sphere).
type PickResult = { found: boolean; layerId: string | null };
type GlobeHarness = {
ready: boolean;
nativeEnabled: boolean;
getCanvasCount: () => number;
getViewportType: () => string | null;
getFirstHotspotLngLat: () => { lon: number; lat: number } | null;
setCamera: (lon: number, lat: number, zoom: number) => void;
stopSpin: () => void;
pickAtLonLat: (lon: number, lat: number, radius?: number) => PickResult;
setBasemapStyle: (style: 'dark' | 'satellite' | 'terrain') => void;
getBasemapStyle: () => string;
destroy: () => void;
};
type HarnessWindow = Window & { __globeHarness?: GlobeHarness };
type Page = import('@playwright/test').Page;
const ready = async (page: Page, query: string): Promise<void> => {
await page.goto(`/tests/globe-native-harness.html${query}`);
await expect(page.locator('.globe-native-wrapper')).toBeVisible();
await expect
.poll(() => page.evaluate(() => Boolean((window as HarnessWindow).__globeHarness?.ready)), {
timeout: 45000,
})
.toBe(true);
};
const pickHotspot = async (page: Page): Promise<PickResult> =>
page.evaluate(() => {
const w = (window as HarnessWindow).__globeHarness!;
const hs = w.getFirstHotspotLngLat();
if (!hs) return { found: false, layerId: null };
w.setCamera(hs.lon, hs.lat, 3);
w.stopSpin();
return w.pickAtLonLat(hs.lon, hs.lat, 12);
});
test.describe('native deck.gl GlobeView', () => {
test.describe.configure({ retries: 1 });
test.use({ reducedMotion: 'reduce' });
test('flag activates; single WebGL context; GlobeViewport; hotspot picks on the sphere', async ({
page,
}, testInfo) => {
const pageErrors: string[] = [];
const ignorable = [/could not compile fragment shader/i, /image.*could not be decoded/i];
page.on('pageerror', (e) => pageErrors.push(e.message));
await ready(page, '?globe=native');
// The flag helper the app routes on reads native from ?globe=native.
expect(
await page.evaluate(() => (window as HarnessWindow).__globeHarness!.nativeEnabled),
).toBe(true);
// Single WebGL context: exactly one <canvas> on the whole page (no mapbox basemap
// canvas, no deck overlay canvas — just GlobeNative's).
expect(await page.evaluate(() => document.querySelectorAll('canvas').length)).toBe(1);
expect(
await page.evaluate(() => (window as HarnessWindow).__globeHarness!.getCanvasCount()),
).toBe(1);
// Active viewport is a GlobeViewport (deck is reprojecting onto the sphere).
await expect.poll(() =>
page.evaluate(() => (window as HarnessWindow).__globeHarness!.getViewportType()),
).toMatch(/Globe/i);
// A seeded hotspot picks at its globe-projected screen point.
await expect.poll(async () => (await pickHotspot(page)).found, { timeout: 20000 }).toBe(true);
// CTO-facing proof: dots + monochrome basemap on the sphere.
await page.evaluate(() => {
const w = (window as HarnessWindow).__globeHarness!;
const hs = w.getFirstHotspotLngLat();
if (hs) w.setCamera(hs.lon, hs.lat, 2.2);
w.stopSpin();
});
await page.waitForTimeout(600);
const shot = await page.locator('.globe-native-wrapper').screenshot();
await testInfo.attach('globe-native-dots', { body: shot, contentType: 'image/png' });
expect(pageErrors.filter((e) => !ignorable.some((p) => p.test(e)))).toEqual([]);
});
test('native is the default; ?globe=mapbox is the escape hatch', async ({ page }) => {
// Default (no query param) → native is on.
await ready(page, '');
expect(
await page.evaluate(() => (window as HarnessWindow).__globeHarness!.nativeEnabled),
).toBe(true);
expect(await page.evaluate(() => document.querySelectorAll('canvas').length)).toBe(1);
// Escape hatch → native off (the app would render the mapbox globe instead).
await ready(page, '?globe=mapbox');
expect(
await page.evaluate(() => (window as HarnessWindow).__globeHarness!.nativeEnabled),
).toBe(false);
});
test('basemap style switches dark/satellite/terrain, stays single-context, data still picks', async ({
page,
}) => {
await ready(page, '?globe=native');
const setStyle = (s: 'dark' | 'satellite' | 'terrain') =>
page.evaluate((style) => (window as HarnessWindow).__globeHarness!.setBasemapStyle(style), s);
const getStyle = () =>
page.evaluate(() => (window as HarnessWindow).__globeHarness!.getBasemapStyle());
for (const style of ['satellite', 'terrain', 'dark'] as const) {
await setStyle(style);
expect(await getStyle()).toBe(style);
// Draping imagery must not spawn a second WebGL context...
expect(await page.evaluate(() => document.querySelectorAll('canvas').length)).toBe(1);
// ...and the data layers keep rendering ON TOP (still pickable on the sphere).
await expect.poll(async () => (await pickHotspot(page)).found, { timeout: 15000 }).toBe(true);
}
});
});
+152
View File
@@ -0,0 +1,152 @@
import { expect, test } from '@playwright/test';
import { clickMapControl } from './helpers/map-controls';
// P0-1: deck.gl overlays must actually RENDER on the mapbox-gl v3 globe.
//
// The regression this guards: with interleaved:true, MapboxOverlay drew deck
// layers inside mapbox's projection pass, which does NOT reproject onto the
// globe — every overlay silently vanished in 3D (2D was fine). The fix flips the
// overlay to interleaved:false (overlaid), giving deck its own canvas + its own
// _GlobeView derived from the live map projection each frame.
//
// getDeckLayerSnapshot() (used by globe.spec) reads buildLayers() output and so
// passed EVEN WITH THE BUG. This spec instead asserts the renderer mechanism and
// a real on-globe pick — signals that are only true when layers reproject:
// - the overlay is overlaid (interleaved === false) and owns its own canvas,
// - deck's active viewport is a GlobeViewport in 3D (mercator in 2D),
// - picking a seeded feature at its globe-projected screen point hits it.
// It exercises 2D→3D→2D round-trips and a live setStyle (theme swap).
type PickResult = { found: boolean; layerId: string | null };
type Harness = {
ready: boolean;
seedAllDynamicData: () => void;
setZoom: (zoom: number) => void;
setCamera: (camera: { lon: number; lat: number; zoom: number }) => void;
setLayersForSnapshot: (layers: string[]) => void;
setProjectionMode: (mode: '2d' | '3d') => void;
getProjectionType: () => string;
stopIdleSpin: () => void;
getDeckInterleaved: () => boolean | null;
getDeckCanvasCount: () => number;
getDeckViewportType: () => string | null;
getFirstHotspotLngLat: () => { lon: number; lat: number } | null;
pickAtLonLat: (lon: number, lat: number, radius?: number) => PickResult;
};
type HarnessWindow = Window & {
__mapHarness?: Harness;
__mapboxMap?: unknown;
};
type Page = import('@playwright/test').Page;
const H = 'window.__mapHarness';
const ready = async (page: Page): Promise<void> => {
await page.goto('/tests/map-harness.html');
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible();
await expect
.poll(() => page.evaluate(() => Boolean((window as HarnessWindow).__mapHarness?.ready)), {
timeout: 45000,
})
.toBe(true);
// Only the hotspots layer needs to be on; it is fed by the static INTEL_HOTSPOTS
// config, so it is present regardless of any live data.
await page.evaluate(() => {
const w = (window as HarnessWindow).__mapHarness!;
w.seedAllDynamicData();
w.setLayersForSnapshot(['hotspots']);
w.stopIdleSpin();
});
};
const projection = (page: Page): Promise<string> =>
page.evaluate(() => (window as HarnessWindow).__mapHarness?.getProjectionType() ?? 'mercator');
const viewportType = (page: Page): Promise<string | null> =>
page.evaluate(() => (window as HarnessWindow).__mapHarness?.getDeckViewportType() ?? null);
// Centre on the seeded hotspot, freeze the spin, then pick it. Picking succeeds
// only if the layer is actually rendered AND projected at that screen location.
const pickHotspot = async (page: Page): Promise<PickResult> => {
return page.evaluate(() => {
const w = (window as HarnessWindow).__mapHarness!;
const hs = w.getFirstHotspotLngLat();
if (!hs) return { found: false, layerId: null };
w.setCamera({ lon: hs.lon, lat: hs.lat, zoom: 3 });
w.stopIdleSpin();
return w.pickAtLonLat(hs.lon, hs.lat, 12);
});
};
const pollPickFound = async (page: Page): Promise<void> => {
await expect
.poll(async () => (await pickHotspot(page)).found, { timeout: 20000 })
.toBe(true);
};
test.describe('P0-1 deck overlay renders on the globe', () => {
test.describe.configure({ retries: 1 });
test.use({ reducedMotion: 'reduce' });
test('overlaid mode; GlobeViewport in 3D; feature picks on the sphere; survives round-trips + setStyle', async ({
page,
}, testInfo) => {
const pageErrors: string[] = [];
const ignorable = [/could not compile fragment shader/i, /image.*could not be decoded/i];
page.on('pageerror', (e) => pageErrors.push(e.message));
await ready(page);
// The overlay is OVERLAID (the fix), not interleaved, and owns its own canvas.
expect(await page.evaluate(() => (window as HarnessWindow).__mapHarness!.getDeckInterleaved())).toBe(false);
expect(await page.evaluate(() => (window as HarnessWindow).__mapHarness!.getDeckCanvasCount())).toBeGreaterThanOrEqual(2);
// 2D baseline: mercator viewport + pickable dot.
expect(await projection(page)).toBe('mercator');
await expect.poll(() => viewportType(page)).toMatch(/Mercator|Web/i);
await pollPickFound(page);
// → 3D globe. Deck's active viewport must be a GlobeViewport (proves deck is
// reprojecting onto the sphere) and the dot must still pick on the globe.
await clickMapControl(page, '.deckgl-projection-toggle .proj-btn[data-mode="3d"]');
await expect.poll(() => projection(page), { timeout: 30000 }).toBe('globe');
await page.evaluate(() => (window as HarnessWindow).__mapHarness!.stopIdleSpin());
await expect.poll(() => viewportType(page), { timeout: 20000 }).toMatch(/Globe/i);
await pollPickFound(page);
// Screenshot the dots on the sphere (the CTO-facing proof).
await page.evaluate(() => {
const w = (window as HarnessWindow).__mapHarness!;
const hs = w.getFirstHotspotLngLat();
if (hs) w.setCamera({ lon: hs.lon, lat: hs.lat, zoom: 2.2 });
w.stopIdleSpin();
});
await page.waitForTimeout(600);
const shot = await page.locator('.deckgl-map-wrapper').screenshot();
await testInfo.attach('globe-3d-dots', { body: shot, contentType: 'image/png' });
// Round-trip 2D → 3D again; picks must survive each transition.
await clickMapControl(page, '.deckgl-projection-toggle .proj-btn[data-mode="2d"]');
await expect.poll(() => projection(page), { timeout: 20000 }).toBe('mercator');
await pollPickFound(page);
await clickMapControl(page, '.deckgl-projection-toggle .proj-btn[data-mode="3d"]');
await expect.poll(() => projection(page), { timeout: 20000 }).toBe('globe');
await page.evaluate(() => (window as HarnessWindow).__mapHarness!.stopIdleSpin());
await pollPickFound(page);
// setStyle: a full basemap swap (theme change) clears mapbox sources but the
// overlaid deck lives outside the style, so its layers must survive.
await page.evaluate(() =>
window.dispatchEvent(new CustomEvent('theme-changed', { detail: { theme: 'light' } })),
);
await page.waitForTimeout(1500); // style.load + re-applied projection/atmosphere
await page.evaluate(() => (window as HarnessWindow).__mapHarness!.stopIdleSpin());
expect(await page.evaluate(() => (window as HarnessWindow).__mapHarness!.getDeckInterleaved())).toBe(false);
await pollPickFound(page);
expect(pageErrors.filter((e) => !ignorable.some((p) => p.test(e)))).toEqual([]);
});
});
+223
View File
@@ -0,0 +1,223 @@
import { expect, test } from '@playwright/test';
// Globe render-fix acceptance suite (world.hanzo.ai Hanzo-Cloud view).
//
// Guards the three "spazz" bugs the CTO reported and the 2D/3D parity + live-analytics
// contract:
// 1. Overlays must sit ON the globe surface with correct occlusion — a back-hemisphere
// dot/badge is HIDDEN, a front one is drawn (no floating layers above the sphere).
// 2. Terrain drapes on the globe in a single WebGL context (no second canvas, no
// striping regression — depth-write disabled on the coplanar imagery tiles).
// 3. Every cloud data layer mounts in BOTH 2D (mercator) and 3D (globe) — parity.
// Plus: the REAL live request-geo dots appear and update.
//
// Data is mocked at the same-origin /v1/world/cloud/* contracts so the run is
// deterministic and offline (production serves the real, live payloads).
type DeckLayer = { id: string; props: Record<string, unknown> };
type Rgba = [number, number, number, number];
const cloudMap = {
// Two request-origin points on OPPOSITE hemispheres so occlusion is testable:
// FRONT (lon 0) faces a camera centred at lon 0; BACK (lon 180) is behind the globe.
trafficGlobe: {
updatedAt: '2026-07-18T12:00:00Z',
live: true,
window: { minutes: 60, since: '2026-07-18T11:00:00Z', until: '2026-07-18T12:00:00Z' },
points: [
{ country: 'FR', lat: 20, lon: 0, count: 90, byService: { models: 90 } },
{ country: 'US', lat: 37.09, lon: -95.71, count: 42, byService: { models: 42 } },
{ country: 'JP', lat: 20, lon: 180, count: 30, byService: { models: 30 } },
],
totals: { rps_1m: 1.6, rpm_60m: 96, top_countries: [{ country: 'FR', count: 90 }, { country: 'US', count: 42 }, { country: 'JP', count: 30 }] },
},
traffic: {
updatedAt: '2026-07-18T12:00:00Z', demo: false,
arcs: [
{ fromLat: 20, fromLon: 0, toLat: 51.5, toLon: -0.12, weight: 1, label: 'FR → lon' },
{ fromLat: 37.09, fromLon: -95.71, toLat: 40.71, toLon: -74.0, weight: 0.6, label: 'US → nyc' },
],
},
chainNodes: {
updatedAt: '2026-07-18T12:00:00Z', positionsModeled: true,
networks: [{ id: 'lux', name: 'Lux Network', chainId: 96369, blockHeight: 1096461, peers: 3, live: true,
nodes: [{ lat: 40.71, lon: -74.0, city: 'New York', kind: 'validator' }, { lat: 37.77, lon: -122.42, city: 'San Francisco', kind: 'validator' }] }],
},
byoGpu: { updatedAt: '2026-07-18T12:00:00Z', demo: false, gpus: [] },
};
// The flagship Cloud variant now OPENS on the native 3D globe (its immersive default),
// which parks the mapbox 2D map — `.deckgl-map-wrapper` mounts hidden. These specs
// exercise the 2D→3D toggle path (2D layer parity, then go3D), so they pin the start
// to 2D with `?mode=2d` (resolveInitialMapMode: the URL wins over the variant default).
// The globe-as-default render is proven separately (direct visual + the live prod shot).
const CLOUD_2D = '/?variant=cloud&mode=2d';
async function mockCloud(page: import('@playwright/test').Page, traffic = cloudMap.trafficGlobe): Promise<void> {
const json = (body: unknown) => ({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
await page.route('**/v1/world/cloud/traffic-globe', (r) => r.fulfill(json(traffic)));
await page.route('**/v1/world/cloud/traffic', (r) => r.fulfill(json(cloudMap.traffic)));
await page.route('**/v1/world/cloud/chain-nodes', (r) => r.fulfill(json(cloudMap.chainNodes)));
await page.route('**/v1/world/cloud/byo-gpu', (r) => r.fulfill(json(cloudMap.byoGpu)));
}
const layerIds = (page: import('@playwright/test').Page): Promise<string[]> =>
page.evaluate(() => {
const m = (window as unknown as { __deckMap?: { asGlobeSource: () => { buildLayers: () => DeckLayer[] } } }).__deckMap;
return (m?.asGlobeSource().buildLayers() ?? []).flat(Infinity).filter(Boolean).map((l: DeckLayer) => l.id);
});
const go3D = async (page: import('@playwright/test').Page): Promise<void> => {
// Enter the 3D globe through the projection toggle's REAL click handler
// (proj-btn → setProjectionMode('3d') → activateNativeGlobe). The flagship Cloud
// view floats its control dock over the full-viewport globe canvas, so under
// headless swiftshader a synthesized mouse click's hit-test lands on the canvas,
// not the collapsed-dropdown button — dispatch the click on the element itself.
// This spec verifies the GLOBE render (occlusion/terrain/parity/live dots); the
// dock's pointer reachability is a separate controls concern, out of scope here.
await page.evaluate(() => {
const btn = document.querySelector('.deckgl-projection-toggle .proj-btn[data-mode="3d"]') as HTMLElement | null;
btn?.click();
});
await expect
.poll(() => page.evaluate(() => Boolean((window as unknown as { __globeNative?: unknown }).__globeNative)), { timeout: 25000 })
.toBe(true);
await page.waitForTimeout(1200); // let the first data-sync pull + push layers
};
test.describe('Globe render fixes — occlusion, terrain, 2D/3D parity, live dots', () => {
test.describe.configure({ retries: 1 });
test.use({ reducedMotion: 'reduce' });
// The cloud data layers that must exist on the Hanzo-Cloud globe once feeds resolve.
const CLOUD_LAYERS = ['traffic', 'trafficArcs', 'chainNodes', 'datacenter-clusters-layer'];
test('3D: cloud layers mount, live dots render on the sphere, no WebGL errors', async ({ page }, testInfo) => {
const errors: string[] = [];
const ignorable = [/could not compile fragment shader/i, /image.*could not be decoded/i, /the layer 'background'/i, /status of 40[13]/i];
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', (e) => errors.push(e.message));
await mockCloud(page);
await page.goto(CLOUD_2D);
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
await go3D(page);
// Single WebGL context for the globe (deck GlobeView, not a 2nd overlay canvas).
expect(await page.evaluate(() => document.querySelectorAll('.globe-native-canvas').length)).toBe(1);
await expect.poll(() => page.evaluate(() => (window as unknown as { __globeNative: { getViewportType: () => string | null } }).__globeNative.getViewportType())).toMatch(/Globe/i);
// Every cloud data layer is mounted.
const ids = await layerIds(page);
for (const id of CLOUD_LAYERS) expect(ids).toContain(id);
// The live request-geo dots are present with the real (mocked) point count.
const trafficCount = await page.evaluate(() => {
const m = (window as unknown as { __deckMap: { asGlobeSource: () => { buildLayers: () => DeckLayer[] } } }).__deckMap;
const t = m.asGlobeSource().buildLayers().flat(Infinity).find((l: DeckLayer) => l?.id === 'traffic');
return (t?.props?.data as unknown[])?.length ?? 0;
});
expect(trafficCount).toBe(cloudMap.trafficGlobe.points.length);
const shot = await page.locator('.globe-native-wrapper').screenshot();
await testInfo.attach('3d-globe-cloud-layers', { body: shot, contentType: 'image/png' });
expect(errors.filter((e) => !ignorable.some((p) => p.test(e)))).toEqual([]);
});
test('occlusion: a back-hemisphere dot is culled to transparent, a front dot is opaque', async ({ page }) => {
await mockCloud(page);
await page.goto(CLOUD_2D);
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
await go3D(page);
// Face the camera at lon 0 (the FRONT point). Read the REAL traffic layer's fill
// accessor for each point: front → opaque (alpha>0), back (lon 180) → alpha 0.
const alphas = await page.evaluate(() => {
const m = (window as unknown as { __deckMap: { setOcclusionCenter: (lng: number, lat: number) => void; asGlobeSource: () => { buildLayers: () => DeckLayer[] } } }).__deckMap;
m.setOcclusionCenter(0, 20);
const t = m.asGlobeSource().buildLayers().flat(Infinity).find((l: DeckLayer) => l?.id === 'traffic') as DeckLayer;
const data = t.props.data as Array<{ lon: number }>;
const getFill = t.props.getFillColor as (d: unknown) => Rgba;
const front = getFill(data.find((d) => d.lon === 0));
const back = getFill(data.find((d) => d.lon === 180));
return { frontAlpha: front[3], backAlpha: back[3] };
});
expect(alphas.frontAlpha).toBeGreaterThan(0);
expect(alphas.backAlpha).toBe(0);
});
test('occlusion: a back-hemisphere count badge is culled (no floating badge over the globe)', async ({ page }) => {
await mockCloud(page);
await page.goto(CLOUD_2D);
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
await go3D(page);
// datacenter count badges are a TextLayer; a back-side badge's glyph AND pill must
// both go transparent. Probe both hemispheres against a fixed camera centre.
const badge = await page.evaluate(() => {
const m = (window as unknown as { __deckMap: { setOcclusionCenter: (lng: number, lat: number) => void; asGlobeSource: () => { buildLayers: () => DeckLayer[] } } }).__deckMap;
m.setOcclusionCenter(0, 20);
const b = m.asGlobeSource().buildLayers().flat(Infinity).find((l: DeckLayer) => l?.id === 'datacenter-clusters-badge') as DeckLayer | undefined;
if (!b) return { skip: true };
const getColor = b.props.getColor as (d: unknown) => Rgba;
const getBg = b.props.getBackgroundColor as (d: unknown) => Rgba;
const front = { lon: 0, lat: 20, count: 5 };
const back = { lon: 180, lat: 20, count: 5 };
return { skip: false, frontText: getColor(front)[3], backText: getColor(back)[3], backBg: getBg(back)[3] };
});
if (badge.skip) test.skip(true, 'no datacenter badge in this build');
expect(badge.frontText).toBeGreaterThan(0);
expect(badge.backText).toBe(0);
expect(badge.backBg).toBe(0);
});
test('2D/3D parity: every cloud layer that mounts in 3D also mounts in 2D', async ({ page }, testInfo) => {
await mockCloud(page);
await page.goto(CLOUD_2D);
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
await page.waitForTimeout(1500); // 2D feeds settle
const ids2d = await layerIds(page);
for (const id of CLOUD_LAYERS) expect(ids2d, `2D missing ${id}`).toContain(id);
await testInfo.attach('2d-map-cloud-layers', { body: await page.locator('.deckgl-map-wrapper').screenshot(), contentType: 'image/png' });
await go3D(page);
const ids3d = await layerIds(page);
for (const id of CLOUD_LAYERS) expect(ids3d, `3D missing ${id}`).toContain(id);
await testInfo.attach('3d-globe-cloud-layers-parity', { body: await page.locator('.globe-native-wrapper').screenshot(), contentType: 'image/png' });
});
test('terrain drapes on the globe in a single context (no second canvas)', async ({ page }, testInfo) => {
await mockCloud(page);
await page.goto(CLOUD_2D);
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
await go3D(page);
await page.evaluate(() => (window as unknown as { __globeNative: { setBasemapStyle: (s: string) => void } }).__globeNative.setBasemapStyle('terrain'));
await expect
.poll(() => page.evaluate(() => (window as unknown as { __globeNative: { getDeck: () => { props: { layers: DeckLayer[] } } } }).__globeNative.getDeck().props.layers.flat(Infinity).some((l: DeckLayer) => l?.id?.startsWith('globe-imagery-terrain'))), { timeout: 20000 })
.toBe(true);
// Still exactly one canvas — imagery drapes as deck tiles, not a 2nd WebGL context.
expect(await page.evaluate(() => document.querySelectorAll('.globe-native-wrapper canvas').length)).toBe(1);
await page.waitForTimeout(2500);
await testInfo.attach('3d-terrain-globe', { body: await page.locator('.globe-native-wrapper').screenshot(), contentType: 'image/png' });
});
test('live dots update when the feed changes', async ({ page }) => {
await mockCloud(page, { ...cloudMap.trafficGlobe, points: cloudMap.trafficGlobe.points.slice(0, 1) });
await page.goto(CLOUD_2D);
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible({ timeout: 45000 });
await go3D(page);
const count = () => page.evaluate(() => {
const m = (window as unknown as { __deckMap: { asGlobeSource: () => { buildLayers: () => DeckLayer[] } } }).__deckMap;
const t = m.asGlobeSource().buildLayers().flat(Infinity).find((l: DeckLayer) => l?.id === 'traffic');
return (t?.props?.data as unknown[])?.length ?? 0;
});
await expect.poll(count, { timeout: 15000 }).toBe(1);
// Swap the feed to the full 3-point payload; the DeckGLMap poll picks it up.
await mockCloud(page, cloudMap.trafficGlobe);
await expect.poll(count, { timeout: 20000 }).toBe(cloudMap.trafficGlobe.points.length);
});
});
+197
View File
@@ -0,0 +1,197 @@
import { expect, test } from '@playwright/test';
import { clickMapControl } from './helpers/map-controls';
type LayerSnapshot = { id: string; dataCount: number };
type Harness = {
ready: boolean;
seedAllDynamicData: () => void;
seedClimateAnomalies: () => void;
setZoom: (zoom: number) => void;
setProjectionMode: (mode: '2d' | '3d') => void;
getProjectionType: () => string;
getCenterLng: () => number | undefined;
isAutoRotateActive: () => boolean;
isUserInteracting: () => boolean;
autoRotateGateOpen: () => boolean;
rotateOneStep: (dtSec: number) => void;
stopIdleSpin: () => void;
getDeckLayerSnapshot: () => LayerSnapshot[];
};
type HarnessWindow = Window & { __mapHarness?: Harness };
// One representative of every deck.gl layer type in use — each must still build
// on the globe: PathLayer, GeoJsonLayer, IconLayer, ScatterplotLayer, Text.
const GLOBE_SAFE_LAYERS = [
'cables-layer', // PathLayer
'pipelines-layer', // PathLayer
'conflict-zones-layer', // GeoJsonLayer
'bases-layer', // IconLayer
'nuclear-layer', // IconLayer
'hotspots-layer', // ScatterplotLayer
'datacenters-layer', // IconLayer
'earthquakes-layer', // ScatterplotLayer
'weather-layer', // ScatterplotLayer
'military-flights-layer', // ScatterplotLayer
'ports-layer', // ScatterplotLayer
'news-locations-layer', // Text/Scatterplot
];
const waitForHarnessReady = async (
page: import('@playwright/test').Page
): Promise<void> => {
await page.goto('/tests/map-harness.html');
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible();
await expect
.poll(() => page.evaluate(() => Boolean((window as HarnessWindow).__mapHarness?.ready)), {
timeout: 45000,
})
.toBe(true);
};
const projectionType = (page: import('@playwright/test').Page): Promise<string> =>
page.evaluate(() => (window as HarnessWindow).__mapHarness?.getProjectionType() ?? 'mercator');
const layerIds = async (page: import('@playwright/test').Page): Promise<Set<string>> => {
const snapshot = await page.evaluate(
() => (window as HarnessWindow).__mapHarness?.getDeckLayerSnapshot() ?? []
);
return new Set(snapshot.filter((l) => l.dataCount > 0).map((l) => l.id));
};
test.describe('3D globe', () => {
test.describe.configure({ retries: 1 });
// Runs FIRST and is deliberately light: the idle-spin rAF loop is engaged then
// cancelled in the same tick, so no sustained globe repaint occurs (a
// continuously repainting software-GL globe starves page.evaluate in CI). The
// rotation math and idle/interaction gate are exercised via discrete calls —
// setCenter mutates map state synchronously, independent of GL throughput.
test.describe('idle auto-rotate', () => {
test.use({ reducedMotion: 'no-preference' });
test('engages in 3D, rotates the globe, and its gate closes on interaction / in 2D', async ({
page,
}) => {
await waitForHarnessReady(page);
const engaged = await page.evaluate(() => {
const w = (window as HarnessWindow).__mapHarness!;
w.setZoom(1.6);
w.setProjectionMode('3d');
const active = w.isAutoRotateActive(); // rafId set synchronously
w.stopIdleSpin(); // cancel before any sustained repaint
return { active, gate: w.autoRotateGateOpen() };
});
expect(engaged.active).toBe(true); // loop engaged in 3D
expect(engaged.gate).toBe(true); // idle gate open
// One step (1s worth) rotates the globe eastward ~2 degrees (the calmer
// background drift — AUTO_ROTATE_DEG_PER_SEC was halved from 4 to 2).
const moved = await page.evaluate(() => {
const w = (window as HarnessWindow).__mapHarness!;
const before = w.getCenterLng() ?? 0;
w.rotateOneStep(1);
return Math.abs((w.getCenterLng() ?? 0) - before);
});
expect(moved).toBeGreaterThan(1);
expect(moved).toBeLessThan(3);
// A real pointer interaction closes the gate (spin pauses).
await page.evaluate(() =>
document.querySelector('.mapboxgl-canvas')?.dispatchEvent(
new MouseEvent('mousedown', { bubbles: true })
)
);
const afterInteract = await page.evaluate(() => {
const w = (window as HarnessWindow).__mapHarness!;
return { interacting: w.isUserInteracting(), gate: w.autoRotateGateOpen() };
});
expect(afterInteract.interacting).toBe(true);
expect(afterInteract.gate).toBe(false);
// Flat map never auto-rotates.
const flat = await page.evaluate(() => {
const w = (window as HarnessWindow).__mapHarness!;
w.setProjectionMode('2d');
return { active: w.isAutoRotateActive(), gate: w.autoRotateGateOpen() };
});
expect(flat.active).toBe(false);
expect(flat.gate).toBe(false);
});
});
// Runs LAST because rendering a full-layer globe under CI's software GL is the
// heaviest step. reducedMotion keeps auto-rotate off so there is no sustained
// repaint; every assertion is a pure buildLayers/getProjection state read plus
// real pill clicks. All static checks live in ONE page load to avoid repeated
// heavy globe initialisations.
test.describe('projection + layers (static)', () => {
test.use({ reducedMotion: 'reduce' });
test('2D<->3D toggle switches projection; every layer renders on the globe; heatmap is substituted; no deck errors', async ({
page,
}) => {
const pageErrors: string[] = [];
const deckAssertionErrors: string[] = [];
const ignorable = [/could not compile fragment shader/i];
page.on('pageerror', (e) => pageErrors.push(e.message));
page.on('console', (msg) => {
if (msg.type() === 'error' && msg.text().includes('deck.gl: assertion failed')) {
deckAssertionErrors.push(msg.text());
}
});
await waitForHarnessReady(page);
await page.evaluate(() => {
const w = (window as HarnessWindow).__mapHarness!;
w.seedAllDynamicData();
w.seedClimateAnomalies();
w.setZoom(5); // clears per-layer minZoom gates (bases/nuclear/datacenters)
});
// Flat map by default; climate uses the screen-space HeatmapLayer.
expect(await projectionType(page)).toBe('mercator');
await expect(page.locator('.deckgl-projection-toggle .proj-btn[data-mode="2d"]')).toHaveClass(/active/);
{
const ids = await layerIds(page);
expect(ids.has('climate-heatmap-layer')).toBe(true);
expect(ids.has('climate-anomaly-points-layer')).toBe(false);
}
// Click the real 3D pill in the map header.
await clickMapControl(page, '.deckgl-projection-toggle .proj-btn[data-mode="3d"]');
await expect.poll(() => projectionType(page), { timeout: 30000 }).toBe('globe');
await expect(page.locator('.deckgl-projection-toggle .proj-btn[data-mode="3d"]')).toHaveClass(/active/);
// Freeze the idle spin so the static layer checks don't run against a
// continuously repainting globe (a repainting software-GL globe starves
// page.evaluate in CI). reducedMotion disables it where the emulation is
// honored; this makes it deterministic everywhere.
await page.evaluate(() => (window as HarnessWindow).__mapHarness?.stopIdleSpin());
// Every representative deck.gl layer type still builds on the globe...
await expect
.poll(async () => {
const ids = await layerIds(page);
return GLOBE_SAFE_LAYERS.filter((id) => !ids.has(id)).length;
}, { timeout: 20000 })
.toBe(0);
// ...and the screen-space climate heatmap is swapped for a globe-safe scatter.
{
const ids = await layerIds(page);
expect(ids.has('climate-anomaly-points-layer')).toBe(true);
expect(ids.has('climate-heatmap-layer')).toBe(false);
}
// Flip back to the flat map.
await clickMapControl(page, '.deckgl-projection-toggle .proj-btn[data-mode="2d"]');
await expect.poll(() => projectionType(page), { timeout: 20000 }).toBe('mercator');
expect(pageErrors.filter((e) => !ignorable.some((p) => p.test(e)))).toEqual([]);
expect(deckAssertionErrors).toEqual([]);
});
});
});
+18
View File
@@ -0,0 +1,18 @@
import type { Page, Locator } from '@playwright/test';
// The map's projection / basemap / time-range controls collapsed into dropdowns:
// each option button (.proj-btn / .style-btn / .time-btn) lives in a popover that a
// trigger opens. Tests that click an option must open its dropdown first. This helper
// opens the button's parent .deckgl-dd (if it's in one) and clicks the option — a
// no-op open when the button isn't inside a dropdown, so it's safe everywhere.
export async function clickMapControl(page: Page, buttonSelector: string): Promise<void> {
const btn: Locator = page.locator(buttonSelector).first();
const dd = btn.locator('xpath=ancestor::div[contains(concat(" ", normalize-space(@class), " "), " deckgl-dd ")][1]');
if (await dd.count()) {
const trigger = dd.locator('.dd-trigger').first();
if (!(await dd.evaluate((el) => el.classList.contains('open')).catch(() => false))) {
await trigger.click();
}
}
await btn.click();
}
+111
View File
@@ -0,0 +1,111 @@
import { expect, test, type Page } from '@playwright/test';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { clickMapControl } from './helpers/map-controls';
// Capture the CTO-facing proof screenshots for the layout/style batch. These drive
// the REAL app (not a harness) so they show the shipped chrome + layout. They are
// deliverables, not assertions — kept lenient so a flaky data feed never fails them.
const OUT = join(process.cwd(), 'e2e', 'screens');
mkdirSync(OUT, { recursive: true });
const shot = (page: Page, name: string) => page.screenshot({ path: join(OUT, name), fullPage: false });
async function boot(page: Page): Promise<void> {
await page.goto('/');
await expect(page.locator('#panelsGrid')).toBeVisible({ timeout: 60000 });
// The map + its chrome mount asynchronously; wait for the projection toggle.
await expect(page.locator('.deckgl-projection-toggle')).toBeVisible({ timeout: 60000 });
await page.waitForTimeout(1500); // let the first paint + a few panels settle
}
test.describe('layout/style batch — deliverable screenshots', () => {
test.describe.configure({ timeout: 120000 });
test('default panel sizes at 1440px', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await boot(page);
// Panels should be ≥ 2 tracks wide (comfortable), not 160px slivers.
await shot(page, 'default-sizes-1440.png');
});
test('immersive 3D background + floating panels, then video background', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await boot(page);
// Globe on.
await clickMapControl(page, '.deckgl-projection-toggle .proj-btn[data-mode="3d"]').catch(() => {});
await page.waitForTimeout(1200);
// Enter immersive.
await page.locator('#immersiveToggle').click();
await expect(page.locator('body')).toHaveClass(/immersive/);
await page.waitForTimeout(2000); // globe reprojects + panels restyle
await shot(page, 'immersive-3d-panels.png');
// Switch the background slot to the live video.
await page.locator('#immersiveBgSelect .ibg-btn[data-bg="video"]').click();
await expect(page.locator('body')).toHaveAttribute('data-immersive-bg', 'video');
await page.waitForTimeout(2500); // give the YouTube embed a chance to paint
await shot(page, 'immersive-video-bg.png');
// Collapse-to-edge affordance — the globe/video breathes.
await page.locator('#immersiveBgSelect .ibg-btn[data-bg="map"]').click();
await page.locator('#immersiveCollapse').click();
await expect(page.locator('body')).toHaveClass(/immersive-collapsed/);
await page.waitForTimeout(900);
await shot(page, 'immersive-collapsed.png');
});
test('basemap style switcher (dark / satellite / terrain)', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await boot(page);
// The basemap control collapsed into a dropdown: assert its trigger, then open it.
await expect(page.locator('.deckgl-dd[data-dd="basemap"] .dd-trigger')).toBeVisible();
await page.locator('.deckgl-dd[data-dd="basemap"] .dd-trigger').click();
await expect(page.locator('.deckgl-style-switcher')).toBeVisible();
await shot(page, 'style-switcher-dark.png');
// Satellite + terrain need a Mapbox token (VITE_MAPBOX_TOKEN); the buttons are
// disabled without one. When a token is configured, actually switch and let the
// relief render before capturing. clickMapControl reopens the dropdown each time
// (picking an option closes it).
const sat = page.locator('.deckgl-style-switcher .style-btn[data-style="satellite"]');
if (!(await sat.isDisabled())) {
await clickMapControl(page, '.deckgl-style-switcher .style-btn[data-style="satellite"]');
await page.waitForTimeout(4000);
await shot(page, 'style-switcher-satellite.png');
await clickMapControl(page, '.deckgl-style-switcher .style-btn[data-style="terrain"]');
await page.waitForTimeout(4000);
await shot(page, 'style-switcher-terrain.png');
}
});
test('layers panel dragged + entries reordered', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await boot(page);
const panel = page.locator('.deckgl-layer-toggles');
await expect(panel).toBeVisible();
// Drag the panel by its header grip to a new spot over the map.
const grip = panel.locator('.toggle-drag-grip');
const g = (await grip.boundingBox())!;
await page.mouse.move(g.x + g.width / 2, g.y + g.height / 2);
await page.mouse.down();
await page.mouse.move(g.x + 260, g.y + 120, { steps: 12 });
await page.mouse.up();
// Reorder: drag the first row's grip below the third row.
const rows = panel.locator('.layer-toggle');
const firstGrip = rows.nth(0).locator('.layer-reorder-grip');
const fg = (await firstGrip.boundingBox())!;
const third = (await rows.nth(2).boundingBox())!;
await page.mouse.move(fg.x + fg.width / 2, fg.y + fg.height / 2);
await page.mouse.down();
await page.mouse.move(fg.x, third.y + third.height, { steps: 14 });
await page.mouse.up();
await page.waitForTimeout(400);
await shot(page, 'layers-panel-dragged-reordered.png');
});
});
+119
View File
@@ -0,0 +1,119 @@
import { expect, test } from '@playwright/test';
test.describe('GCC investments coverage', () => {
test('focusInvestmentOnMap enables layer and recenters map', async ({ page }) => {
await page.goto('/tests/runtime-harness.html');
const result = await page.evaluate(async () => {
const { focusInvestmentOnMap } = await import('/src/services/investments-focus.ts');
const calls: { layers: string[]; center: { lat: number; lon: number; zoom: number } | null } = {
layers: [],
center: null,
};
const map = {
enableLayer: (layer: string) => {
calls.layers.push(layer);
},
setCenter: (lat: number, lon: number, zoom: number) => {
calls.center = { lat, lon, zoom };
},
};
const mapLayers = { gulfInvestments: false };
focusInvestmentOnMap(
map as unknown as {
enableLayer: (layer: 'gulfInvestments') => void;
setCenter: (lat: number, lon: number, zoom: number) => void;
},
mapLayers as unknown as { gulfInvestments: boolean } & Record<string, boolean>,
24.4667,
54.3667
);
return {
layers: calls.layers,
center: calls.center,
gulfInvestmentsEnabled: mapLayers.gulfInvestments,
};
});
expect(result.layers).toEqual(['gulfInvestments']);
expect(result.center).toEqual({ lat: 24.4667, lon: 54.3667, zoom: 6 });
expect(result.gulfInvestmentsEnabled).toBe(true);
});
test('InvestmentsPanel supports search/filter/sort and row click callbacks', async ({ page }) => {
await page.goto('/tests/runtime-harness.html');
const result = await page.evaluate(async () => {
const { InvestmentsPanel } = await import('/src/components/InvestmentsPanel.ts');
const { GULF_INVESTMENTS } = await import('/src/config/gulf-fdi.ts');
const clickedIds: string[] = [];
const panel = new InvestmentsPanel((inv) => {
clickedIds.push(inv.id);
});
document.body.appendChild(panel.getElement());
const root = panel.getElement();
const totalRows = root.querySelectorAll('.fdi-row').length;
const firstInvestment = GULF_INVESTMENTS[0];
const searchToken = firstInvestment?.assetName.split(/\s+/)[0]?.toLowerCase() ?? '';
const searchInput = root.querySelector<HTMLInputElement>('.fdi-search');
searchInput!.value = searchToken;
searchInput!.dispatchEvent(new Event('input', { bubbles: true }));
const searchRows = root.querySelectorAll('.fdi-row').length;
searchInput!.value = '';
searchInput!.dispatchEvent(new Event('input', { bubbles: true }));
const countrySelect = root.querySelector<HTMLSelectElement>(
'.fdi-filter[data-filter="investingCountry"]'
);
countrySelect!.value = 'SA';
countrySelect!.dispatchEvent(new Event('change', { bubbles: true }));
const saRows = root.querySelectorAll('.fdi-row').length;
const expectedSaRows = GULF_INVESTMENTS.filter((inv) => inv.investingCountry === 'SA').length;
const investmentSort = root.querySelector<HTMLElement>('.fdi-sort[data-sort="investmentUSD"]');
investmentSort!.click(); // asc
investmentSort!.click(); // desc
const firstRow = root.querySelector<HTMLElement>('.fdi-row');
const firstRowId = firstRow?.dataset.id ?? null;
const expectedTopSaId = GULF_INVESTMENTS
.filter((inv) => inv.investingCountry === 'SA')
.slice()
.sort((a, b) => (b.investmentUSD ?? -1) - (a.investmentUSD ?? -1))[0]?.id ?? null;
firstRow?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
panel.destroy();
root.remove();
return {
totalRows,
datasetSize: GULF_INVESTMENTS.length,
searchRows,
saRows,
expectedSaRows,
firstRowId,
expectedTopSaId,
clickedId: clickedIds[0] ?? null,
};
});
expect(result.totalRows).toBe(result.datasetSize);
expect(result.searchRows).toBeGreaterThan(0);
expect(result.searchRows).toBeLessThanOrEqual(result.totalRows);
expect(result.saRows).toBe(result.expectedSaRows);
expect(result.firstRowId).toBe(result.expectedTopSaId);
expect(result.clickedId).toBe(result.firstRowId);
});
});
+197
View File
@@ -0,0 +1,197 @@
import { expect, test } from '@playwright/test';
test.describe('keyword spike modal/badge flow', () => {
test('injects synthetic headlines and renders keyword_spike end-to-end', async ({ page }) => {
await page.goto('/tests/runtime-harness.html');
const setup = await page.evaluate(async () => {
const { SignalModal } = await import('/src/components/SignalModal.ts');
const { IntelligenceGapBadge } = await import('/src/components/IntelligenceGapBadge.ts');
const trending = await import('/src/services/trending-keywords.ts');
const correlation = await import('/src/services/correlation.ts');
const previousConfig = trending.getTrendingConfig();
const headerRight = document.createElement('div');
headerRight.className = 'header-right';
document.body.appendChild(headerRight);
const modal = new SignalModal();
const badge = new IntelligenceGapBadge();
badge.setOnSignalClick((signal) => modal.showSignal(signal));
trending.updateTrendingConfig({
blockedTerms: [],
minSpikeCount: 5,
spikeMultiplier: 3,
autoSummarize: false,
});
const now = new Date();
const headlines = [
{ source: 'Reuters', title: 'Iran sanctions pressure rises amid talks', link: 'https://example.com/reuters/1' },
{ source: 'AP', title: 'Iran sanctions debate intensifies in Washington', link: 'https://example.com/ap/1' },
{ source: 'BBC', title: 'Iran sanctions trigger fresh market concerns', link: 'https://example.com/bbc/1' },
{ source: 'Reuters', title: 'Iran sanctions package draws regional response', link: 'https://example.com/reuters/2' },
{ source: 'AP', title: 'Iran sanctions proposal gains momentum', link: 'https://example.com/ap/2' },
{ source: 'BBC', title: 'Iran sanctions timeline shortens after warnings', link: 'https://example.com/bbc/2' },
].map(item => ({
...item,
pubDate: now,
}));
trending.ingestHeadlines(headlines);
let spikes = trending.drainTrendingSignals();
for (let i = 0; i < 20 && spikes.length === 0; i += 1) {
await new Promise(resolve => setTimeout(resolve, 50));
spikes = trending.drainTrendingSignals();
}
if (spikes.length === 0) {
badge.destroy();
modal.getElement().remove();
trending.updateTrendingConfig(previousConfig);
return { ok: false, reason: 'No keyword spikes emitted from synthetic data' };
}
correlation.addToSignalHistory(spikes);
badge.update();
// Keep refs alive for user interactions in the test.
(window as unknown as Record<string, unknown>).__keywordSpikeTest = {
badge,
modal,
previousConfig,
};
return {
ok: true,
spikeType: spikes[0]?.type,
title: spikes[0]?.title ?? '',
badgeCount: (document.querySelector('.findings-count') as HTMLElement | null)?.textContent ?? '0',
};
});
expect(setup.ok).toBe(true);
expect(setup.spikeType).toBe('keyword_spike');
expect(Number(setup.badgeCount)).toBeGreaterThan(0);
await page.click('.intel-findings-badge');
const finding = page.locator('.finding-item').first();
await expect(finding).toBeVisible();
await expect(finding).toContainText('Trending');
await finding.click();
await expect(page.locator('.signal-modal-overlay.active')).toBeVisible();
await expect(page.locator('.signal-item .signal-type').first()).toContainText('Keyword Spike');
await expect(page.locator('.suppress-keyword-btn').first()).toBeVisible();
await page.evaluate(async () => {
const trending = await import('/src/services/trending-keywords.ts');
const store = (window as unknown as Record<string, unknown>).__keywordSpikeTest as
| {
badge?: { destroy?: () => void };
modal?: { getElement?: () => HTMLElement };
previousConfig?: Parameters<typeof trending.updateTrendingConfig>[0];
}
| undefined;
store?.badge?.destroy?.();
store?.modal?.getElement?.()?.remove();
if (store?.previousConfig) {
trending.updateTrendingConfig(store.previousConfig);
}
delete (window as unknown as Record<string, unknown>).__keywordSpikeTest;
});
});
test('does not emit spikes from source-attribution suffixes', async ({ page }) => {
await page.goto('/tests/runtime-harness.html');
const result = await page.evaluate(async () => {
const trending = await import('/src/services/trending-keywords.ts');
const previousConfig = trending.getTrendingConfig();
try {
trending.updateTrendingConfig({
blockedTerms: [],
minSpikeCount: 4,
spikeMultiplier: 3,
autoSummarize: false,
});
const now = new Date();
const headlines = [
{ source: 'Reuters', title: 'Qzxalpha ventures stabilize - WireDesk' },
{ source: 'AP', title: 'Bravotango liquidity trims - WireDesk' },
{ source: 'BBC', title: 'Cindelta refinery expands - WireDesk' },
{ source: 'Bloomberg', title: 'Dorion transit reroutes - WireDesk' },
{ source: 'WSJ', title: 'Epsiluna lending reprices - WireDesk' },
].map((item) => ({ ...item, pubDate: now }));
trending.ingestHeadlines(headlines);
let spikes = trending.drainTrendingSignals();
for (let i = 0; i < 20 && spikes.length === 0; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 40));
spikes = trending.drainTrendingSignals();
}
return {
emittedTitles: spikes.map((signal) => signal.title),
hasWireDeskSpike: spikes.some((signal) => /wiredesk/i.test(signal.title)),
};
} finally {
trending.updateTrendingConfig(previousConfig);
}
});
expect(result.hasWireDeskSpike).toBe(false);
expect(result.emittedTitles.length).toBe(0);
});
test('suppresses month-name token spikes', async ({ page }) => {
await page.goto('/tests/runtime-harness.html');
const result = await page.evaluate(async () => {
const trending = await import('/src/services/trending-keywords.ts');
const previousConfig = trending.getTrendingConfig();
try {
trending.updateTrendingConfig({
blockedTerms: [],
minSpikeCount: 4,
spikeMultiplier: 3,
autoSummarize: false,
});
const now = new Date();
const headlines = [
{ source: 'Reuters', title: 'January qxavon ledger shift' },
{ source: 'AP', title: 'January brivon routing update' },
{ source: 'BBC', title: 'January caldren supply note' },
{ source: 'Bloomberg', title: 'January dernix cargo brief' },
{ source: 'WSJ', title: 'January eptara policy digest' },
].map((item) => ({ ...item, pubDate: now }));
trending.ingestHeadlines(headlines);
let spikes = trending.drainTrendingSignals();
for (let i = 0; i < 20 && spikes.length === 0; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 40));
spikes = trending.drainTrendingSignals();
}
return {
emittedTitles: spikes.map((signal) => signal.title),
hasJanuarySpike: spikes.some((signal) => /january/i.test(signal.title)),
};
} finally {
trending.updateTrendingConfig(previousConfig);
}
});
expect(result.hasJanuarySpike).toBe(false);
expect(result.emittedTitles.length).toBe(0);
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+254 -93
View File
@@ -11,16 +11,27 @@ type OverlaySnapshot = {
type VisualScenarioSummary = {
id: string;
variant: 'both' | 'full' | 'tech';
variant: 'both' | 'full' | 'tech' | 'finance';
};
type HarnessWindow = Window & {
__mapHarness?: {
ready: boolean;
variant: 'full' | 'tech';
variant: 'full' | 'tech' | 'finance';
seedAllDynamicData: () => void;
setProtestsScenario: (scenario: 'alpha' | 'beta') => void;
setPulseProtestsScenario: (
scenario:
| 'none'
| 'recent-acled-riot'
| 'recent-gdelt-riot'
| 'recent-protest'
) => void;
setNewsPulseScenario: (scenario: 'none' | 'recent' | 'stale') => void;
setHotspotActivityScenario: (scenario: 'none' | 'breaking') => void;
forcePulseStartupElapsed: () => void;
resetPulseStartupTime: () => void;
isPulseAnimationRunning: () => boolean;
setZoom: (zoom: number) => void;
setLayersForSnapshot: (enabledLayers: string[]) => void;
setCamera: (camera: { lon: number; lat: number; zoom: number }) => void;
@@ -29,8 +40,12 @@ type HarnessWindow = Window & {
prepareVisualScenario: (scenarioId: string) => boolean;
isVisualScenarioReady: (scenarioId: string) => boolean;
getDeckLayerSnapshot: () => LayerSnapshot[];
getLayerDataCount: (layerId: string) => number;
getLayerFirstScreenTransform: (layerId: string) => string | null;
getFirstProtestTitle: () => string | null;
getProtestClusterCount: () => number;
getOverlaySnapshot: () => OverlaySnapshot;
getClusterStateSize: () => number;
getCyberTooltipHtml: (indicator: string) => string;
};
};
@@ -49,6 +64,7 @@ const EXPECTED_FULL_DECK_LAYERS = [
'fires-layer',
'weather-layer',
'outages-layer',
'cyber-threats-layer',
'ais-density-layer',
'ais-disruptions-layer',
'ports-layer',
@@ -81,6 +97,7 @@ const EXPECTED_TECH_DECK_LAYERS = [
'fires-layer',
'weather-layer',
'outages-layer',
'cyber-threats-layer',
'ais-density-layer',
'ais-disruptions-layer',
'ports-layer',
@@ -100,10 +117,19 @@ const EXPECTED_TECH_DECK_LAYERS = [
'news-locations-layer',
];
const EXPECTED_FINANCE_DECK_LAYERS = [
...EXPECTED_FULL_DECK_LAYERS,
'stock-exchanges-layer',
'financial-centers-layer',
'central-banks-layer',
'commodity-hubs-layer',
'gulf-investments-layer',
];
const waitForHarnessReady = async (
page: import('@playwright/test').Page
): Promise<void> => {
await page.goto('/map-harness.html');
await page.goto('/tests/map-harness.html');
await expect(page.locator('.deckgl-map-wrapper')).toBeVisible();
await expect
.poll(async () => {
@@ -115,16 +141,6 @@ const waitForHarnessReady = async (
.toBe(true);
};
const getMarkerInlineTransform = async (
page: import('@playwright/test').Page,
selector: string
): Promise<string | null> => {
return await page.evaluate((sel) => {
const el = document.querySelector(sel) as HTMLElement | null;
return el?.style.transform ?? null;
}, selector);
};
const prepareVisualScenario = async (
page: import('@playwright/test').Page,
scenarioId: string
@@ -149,6 +165,8 @@ const prepareVisualScenario = async (
};
test.describe('DeckGL map harness', () => {
test.describe.configure({ retries: 1 });
test('serves requested runtime variant for this test run', async ({ page }) => {
await waitForHarnessReady(page);
@@ -157,7 +175,11 @@ test.describe('DeckGL map harness', () => {
return w.__mapHarness?.variant ?? 'full';
});
const expectedVariant = process.env.VITE_VARIANT === 'tech' ? 'tech' : 'full';
const expectedVariant = process.env.VITE_VARIANT === 'tech'
? 'tech'
: process.env.VITE_VARIANT === 'finance'
? 'finance'
: 'full';
expect(runtimeVariant).toBe(expectedVariant);
});
@@ -208,10 +230,11 @@ test.describe('DeckGL map harness', () => {
return w.__mapHarness?.variant ?? 'full';
});
const expectedDeckLayers =
variant === 'tech'
? EXPECTED_TECH_DECK_LAYERS
: EXPECTED_FULL_DECK_LAYERS;
const expectedDeckLayers = variant === 'tech'
? EXPECTED_TECH_DECK_LAYERS
: variant === 'finance'
? EXPECTED_FINANCE_DECK_LAYERS
: EXPECTED_FULL_DECK_LAYERS;
await expect
.poll(async () => {
@@ -230,7 +253,8 @@ test.describe('DeckGL map harness', () => {
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getOverlaySnapshot().protestMarkers ?? 0;
const layers = w.__mapHarness?.getDeckLayerSnapshot() ?? [];
return layers.find((layer) => layer.id === 'protest-clusters-layer')?.dataCount ?? 0;
});
}, { timeout: 20000 })
.toBeGreaterThan(0);
@@ -244,17 +268,24 @@ test.describe('DeckGL map harness', () => {
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getOverlaySnapshot().datacenterMarkers ?? 0;
const layers = w.__mapHarness?.getDeckLayerSnapshot() ?? [];
return layers.find((layer) => layer.id === 'datacenter-clusters-layer')?.dataCount ?? 0;
});
}, { timeout: 20000 })
.toBeGreaterThan(0);
if (variant === 'tech') {
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.setCamera({ lon: -122.42, lat: 37.77, zoom: 5.2 });
});
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getOverlaySnapshot().techHQMarkers ?? 0;
const layers = w.__mapHarness?.getDeckLayerSnapshot() ?? [];
return layers.find((layer) => layer.id === 'tech-hq-clusters-layer')?.dataCount ?? 0;
});
}, { timeout: 20000 })
.toBeGreaterThan(0);
@@ -263,13 +294,131 @@ test.describe('DeckGL map harness', () => {
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getOverlaySnapshot().techEventMarkers ?? 0;
const layers = w.__mapHarness?.getDeckLayerSnapshot() ?? [];
return layers.find((layer) => layer.id === 'tech-event-clusters-layer')?.dataCount ?? 0;
});
}, { timeout: 20000 })
.toBeGreaterThan(0);
}
});
test('renders GCC investments layer when enabled in finance variant', async ({ page }) => {
await waitForHarnessReady(page);
const variant = await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.variant ?? 'full';
});
test.skip(variant !== 'finance', 'Finance variant only');
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.seedAllDynamicData();
w.__mapHarness?.setLayersForSnapshot(['gulfInvestments']);
w.__mapHarness?.setCamera({ lon: 55.27, lat: 25.2, zoom: 4.2 });
});
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerDataCount('gulf-investments-layer') ?? 0;
});
}, { timeout: 15000 })
.toBeGreaterThan(0);
});
test('sanitizes cyber threat tooltip content', async ({ page }) => {
await waitForHarnessReady(page);
const html = await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getCyberTooltipHtml('<script>alert(1)</script>') ?? '';
});
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
expect(html).not.toContain('<script>');
});
test('suppresses pulse animation during startup cooldown even with recent signals', async ({
page,
}) => {
await waitForHarnessReady(page);
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.setHotspotActivityScenario('none');
w.__mapHarness?.setPulseProtestsScenario('none');
w.__mapHarness?.setNewsPulseScenario('none');
w.__mapHarness?.resetPulseStartupTime();
w.__mapHarness?.setNewsPulseScenario('recent');
});
await page.waitForTimeout(800);
const isRunning = await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
});
expect(isRunning).toBe(false);
});
test('starts and stops pulse on dynamic signals and ignores gdelt-only riot recency', async ({
page,
}) => {
await waitForHarnessReady(page);
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.seedAllDynamicData();
w.__mapHarness?.setHotspotActivityScenario('none');
w.__mapHarness?.setPulseProtestsScenario('none');
w.__mapHarness?.setNewsPulseScenario('none');
w.__mapHarness?.forcePulseStartupElapsed();
w.__mapHarness?.setPulseProtestsScenario('recent-gdelt-riot');
});
await page.waitForTimeout(600);
const gdeltPulseRunning = await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
});
expect(gdeltPulseRunning).toBe(false);
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.setPulseProtestsScenario('recent-acled-riot');
});
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
});
}, { timeout: 15000 })
.toBe(true);
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.resetPulseStartupTime();
w.__mapHarness?.setNewsPulseScenario('none');
w.__mapHarness?.setHotspotActivityScenario('none');
w.__mapHarness?.setPulseProtestsScenario('none');
});
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.isPulseAnimationRunning() ?? false;
});
}, { timeout: 12000 })
.toBe(false);
});
test('matches golden screenshots per layer and zoom', async ({ page }) => {
test.setTimeout(180_000);
@@ -317,14 +466,14 @@ test.describe('DeckGL map harness', () => {
}) => {
await waitForHarnessReady(page);
const protestMarker = page.locator('.protest-marker').first();
await expect(protestMarker).toBeVisible({ timeout: 15000 });
await protestMarker.click({ force: true });
await expect(page.locator('.map-popup .popup-description')).toContainText(
'Scenario Alpha Protest'
);
await page.locator('.map-popup .popup-close').click();
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getFirstProtestTitle() ?? '';
});
}, { timeout: 15000 })
.toContain('Scenario Alpha Protest');
await page.evaluate(() => {
const w = window as HarnessWindow;
@@ -335,19 +484,22 @@ test.describe('DeckGL map harness', () => {
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getClusterStateSize() ?? -1;
return w.__mapHarness?.getProtestClusterCount() ?? 0;
});
}, { timeout: 20000 })
.toBeGreaterThan(0);
await expect(protestMarker).toBeVisible({ timeout: 15000 });
await protestMarker.click({ force: true });
await expect(page.locator('.map-popup .popup-description')).toContainText(
'Scenario Beta Protest'
);
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getFirstProtestTitle() ?? '';
});
}, { timeout: 15000 })
.toContain('Scenario Beta Protest');
});
test('initializes cluster movement cache on first protest cluster render', async ({
test('populates protest clusters on first protest cluster render', async ({
page,
}) => {
await waitForHarnessReady(page);
@@ -363,7 +515,7 @@ test.describe('DeckGL map harness', () => {
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getOverlaySnapshot().protestMarkers ?? 0;
return w.__mapHarness?.getLayerDataCount('protest-clusters-layer') ?? 0;
});
}, { timeout: 20000 })
.toBeGreaterThan(0);
@@ -372,7 +524,7 @@ test.describe('DeckGL map harness', () => {
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getClusterStateSize() ?? -1;
return w.__mapHarness?.getProtestClusterCount() ?? 0;
});
}, { timeout: 20000 })
.toBeGreaterThan(0);
@@ -390,12 +542,19 @@ test.describe('DeckGL map harness', () => {
w.__mapHarness?.setCamera({ lon: 0.2, lat: 15.2, zoom: 4.2 });
});
const markerSelector = '.hotspot';
await expect(page.locator(markerSelector).first()).toBeVisible({
timeout: 15000,
});
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerDataCount('hotspots-layer') ?? 0;
});
}, { timeout: 15000 })
.toBeGreaterThan(0);
const beforeTransform = await getMarkerInlineTransform(page, markerSelector);
const beforeTransform = await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerFirstScreenTransform('hotspots-layer') ?? null;
});
expect(beforeTransform).not.toBeNull();
await page.evaluate(() => {
@@ -410,7 +569,10 @@ test.describe('DeckGL map harness', () => {
})
);
const afterTransform = await getMarkerInlineTransform(page, markerSelector);
const afterTransform = await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerFirstScreenTransform('hotspots-layer') ?? null;
});
expect(afterTransform).not.toBeNull();
expect(afterTransform).not.toBe(beforeTransform);
});
@@ -427,54 +589,41 @@ test.describe('DeckGL map harness', () => {
w.__mapHarness?.setCamera({ lon: 0.2, lat: 15.2, zoom: 4.2 });
});
const markerSelector = '.hotspot';
await expect(page.locator(markerSelector).first()).toBeVisible({
timeout: 15000,
});
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerDataCount('hotspots-layer') ?? 0;
});
}, { timeout: 15000 })
.toBeGreaterThan(0);
const result = await page.evaluate(async () => {
const beforeTransform = await page.evaluate(() => {
const w = window as HarnessWindow;
const marker = document.querySelector('.hotspot') as HTMLElement | null;
if (!w.__mapHarness || !marker) {
return { observed: false, styleMutations: -1, remaining: -1 };
}
return w.__mapHarness?.getLayerFirstScreenTransform('hotspots-layer') ?? null;
});
expect(beforeTransform).not.toBeNull();
let styleMutations = 0;
const observer = new MutationObserver((records) => {
for (const record of records) {
if (
record.type === 'attributes' &&
record.attributeName === 'style'
) {
styleMutations += 1;
}
}
});
observer.observe(marker, {
attributes: true,
attributeFilter: ['style'],
});
w.__mapHarness.setLayersForSnapshot([]);
w.__mapHarness.setCamera({ lon: 3.5, lat: 18.2, zoom: 4.8 });
await new Promise((resolve) => {
setTimeout(resolve, 140);
});
observer.disconnect();
return {
observed: true,
styleMutations,
remaining: document.querySelectorAll('.hotspot').length,
};
await page.evaluate(() => {
const w = window as HarnessWindow;
w.__mapHarness?.setLayersForSnapshot([]);
w.__mapHarness?.setCamera({ lon: 3.5, lat: 18.2, zoom: 4.8 });
});
expect(result.observed).toBe(true);
expect(result.styleMutations).toBe(0);
expect(result.remaining).toBe(0);
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerDataCount('hotspots-layer') ?? -1;
});
}, { timeout: 10000 })
.toBe(0);
const afterTransform = await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerFirstScreenTransform('hotspots-layer') ?? null;
});
expect(afterTransform).toBeNull();
});
test('reprojects protest overlay marker when panning at fixed zoom', async ({
@@ -490,10 +639,19 @@ test.describe('DeckGL map harness', () => {
await prepareVisualScenario(page, 'protests-z5');
const markerSelector = '.protest-marker';
await expect(page.locator(markerSelector).first()).toBeVisible();
await expect
.poll(async () => {
return await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerDataCount('protest-clusters-layer') ?? 0;
});
}, { timeout: 15000 })
.toBeGreaterThan(0);
const beforeTransform = await getMarkerInlineTransform(page, markerSelector);
const beforeTransform = await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerFirstScreenTransform('protest-clusters-layer') ?? null;
});
expect(beforeTransform).not.toBeNull();
await page.evaluate(() => {
@@ -503,7 +661,10 @@ test.describe('DeckGL map harness', () => {
await page.waitForTimeout(750);
const afterTransform = await getMarkerInlineTransform(page, markerSelector);
const afterTransform = await page.evaluate(() => {
const w = window as HarnessWindow;
return w.__mapHarness?.getLayerFirstScreenTransform('protest-clusters-layer') ?? null;
});
expect(afterTransform).not.toBeNull();
expect(afterTransform).not.toBe(beforeTransform);
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Some files were not shown because too many files have changed in this diff Show More