Aligns package.json + VERSION to the release (both had drifted). This is the
first deployable release carrying the four /v1/world/* macro signals with the
insider charset fix proven against the live SEC feed.
The live EDGAR getcurrent atom feed is served as ISO-8859-1, not UTF-8, so
plain xml.Unmarshal errored ('encoding "ISO-8859-1" declared but
Decoder.CharsetReader is nil') and parseEdgarAtom returned zero entries — the
endpoint answered 200 with count:0 in prod. Give the decoder a CharsetReader
(golang.org/x/net/html/charset) so any declared charset decodes. The unit test
now uses an ISO-8859-1 fixture with a Latin-1 byte as a regression guard.
Proven against the live feed: 100 entries decode cleanly.
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.
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.
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.
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.
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.
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
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
- 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.
- 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).
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
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.
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.
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.
- 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.
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.
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.
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.
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
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
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
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
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.
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
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>
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
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.
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
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
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.
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
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
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.
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).
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
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
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
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.
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
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
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
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
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
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).
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.
.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.
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.
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.
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.
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'.
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.
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.
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
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
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
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
- 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
* 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>
* 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>
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
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>
/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
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
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
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
/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
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.'
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.
/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
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
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
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
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
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
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
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
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
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
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
- 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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 `'`. decodeEntities() runs first, then a
single re-escape on output — the browser shows `'`, never `'` and never the
double-escaped `&#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
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
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
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
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
- 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
- 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
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
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
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
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
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
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
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
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
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
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
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
- 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
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
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
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
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
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
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
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>
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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
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
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
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
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
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
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
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.
- 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
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.
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.
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.
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).
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.
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
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).
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.
- 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).
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.
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.
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.
- 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
- 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>
- 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)
- 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
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
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.
- 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
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
## 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.
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.
- LiveNewsPanel: player.mute/unMute may not exist before onReady (WORLDMONITOR-16)
- main.ts: add /Program failed to link/ noise filter (WORLDMONITOR-18)
- 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)
- 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)
- 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)
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.
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.
- withTransaction now returns undefined instead of throwing when
InvalidStateError persists after retry (transient browser event)
- Add .catch() to fire-and-forget cleanOldSnapshots() call
- 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
- 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
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.
Initializes @sentry/browser early in main.ts with environment
detection (production/preview/development). Disabled on localhost
and Tauri desktop. Traces sampled at 10%.
## 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)
## 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)
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>
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>
## 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
- CommunityWidget: add DOM check to prevent duplicate widgets on repeated loadNews() calls
- RuntimeConfigPanel: compare t() result against key path to suppress missing help translations
Main variant: NHK World + Nikkei Asia in asia category.
Finance variant: Nikkei Asia in markets category.
Added asia.nikkei.com to RSS proxy allowlist.
Main variant: NHK World + Nikkei Asia in asia category.
Finance variant: Nikkei Asia in markets category.
Added asia.nikkei.com to RSS proxy allowlist.
- 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
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.
- 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
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.
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).
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.
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.
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.
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.
"here" and other basic English words (pronouns, prepositions, adverbs)
were not in SUPPRESSED_TRENDING_TERMS, causing false keyword spike
findings for common words.
"here" and other basic English words (pronouns, prepositions, adverbs)
were not in SUPPRESSED_TRENDING_TERMS, causing false keyword spike
findings for common words.
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.
## 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)
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>
- 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
- 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
## 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 #91Closes#90🤖 Generated with [Claude Code](https://claude.com/claude-code)
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.
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.
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
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>
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.
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.
- 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
- 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
- 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
- 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
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.
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.
## 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)
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.
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>
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>
- 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>
- 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>
- 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)
- 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>
- 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>
- 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>
- 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>
- 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>
- 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
- 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>
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>
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
- 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>
- 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>
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>
- 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>
- 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>
- 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>
- 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>
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.
- 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
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.
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.
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
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
- 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
- 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
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.
Major security hardening: CORS enforcement on all API endpoints,
sidecar auth bypass fix, postMessage origin validation, CSP
tightening, and service worker stale cache fix.
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.
- 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
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.
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.
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.
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.
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
- 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
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.
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.
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
- 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
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.
- 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
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.
- 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
- 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
- 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
- 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)
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.
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.
- 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)
- 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
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.
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.
Builds macOS (ARM64 + x64) and Windows via tauri-apps/tauri-action.
Supports full/tech variants, optional code signing, and automatic
GitHub Release creation on tag push.
- Add worldmonitor.app to frame-src CSP in index.html (was only in
tauri.conf.json, causing iframe block)
- Add devtools feature and Help > Toggle Developer Tools menu item
- Try native YouTube JS API first, fall back to cloud bridge on Error 153
- Add pause-then-play workaround for WKWebView channel switching
Switch bridge page from raw iframe to YouTube IFrame Player API with
postMessage protocol for play/pause/mute commands. Use youtube-nocookie.com
host, add click-to-play overlay for WKWebView autoplay restrictions, and
route desktop embeds through cloud URL to avoid sidecar auth issues.
Prevents unauthorized local processes from accessing the sidecar on
localhost:46123. Token is generated at Tauri startup using RandomState
hasher, injected into sidecar env, and lazy-loaded by the frontend
fetch patch via get_local_api_token command.
Service-status endpoint remains public for health checks.
Co-authored-by: RinZ27 <RinZ27@users.noreply.github.com>
GeoJSON had France, Norway, and Kosovo sharing ISO code -99, causing hover
on any one to highlight all three. Fix ISO codes and switch hover filter
to unique name property. Add data validation tests.
- Add RUSI, Wilson Center, GMF, Stimson, CNAS, Lowy Institute feeds
- Add Arms Control Association, Bulletin of Atomic Scientists feeds
- Add FAO GIEWS food security alerts, EU ISS feeds
- Add www.iss.europa.eu to RSS proxy allowlist
- Add SOURCE_TIERS and SOURCE_TYPES entries for new feeds
Please review modified files (2) to ensure we are properly aligned on
goals/expectations. Cheers!
Jas
- Add RUSI, Wilson Center, GMF, Stimson, CNAS, Lowy Institute feeds
- Add Arms Control Association, Bulletin of Atomic Scientists feeds
- Add FAO GIEWS food security alerts, EU ISS feeds
- Add www.iss.europa.eu to RSS proxy allowlist
- Add SOURCE_TIERS and SOURCE_TYPES entries for new feeds
- Fix YouTube Error 153 by serving embed bridge from cloud URL (origin match)
- Fix channel switching when playerContainer detached from DOM
- Fix Fires panel infinite spinner when API returns 0 or fails
- Make TECH variant button open web URL instead of being disabled
- Fix circuit breaker caching empty results as success in 6 services
(polymarket, wingbits, military-flights, outages, conflicts, protests)
- Improve sidecar: cloud-preferred routing, failed import caching, log dedup
- Add FINNHUB_API_KEY and NASA_FIRMS_API_KEY to Tauri secret keys
- Add early 503 for missing ACLED token in risk-scores
- Add /api/classify-batch endpoint: classifies up to 20 headlines per Groq call
(reduces 182 individual API calls to ~10 batched calls, 90% rate limit savings)
- Update threat-classifier.ts: collect headlines in batch queue, flush every 500ms
or when batch reaches 20 items
- Route AIS snapshot through Railway directly when VITE_WS_RELAY_URL is set,
falling back to Vercel — eliminates 503 when WS_RELAY_URL not configured on Vercel
- Add /ucdp-events handler to ais-relay.cjs with 6h TTL cache,
background refresh, version discovery, and 30s per-page timeout
- Route client through Railway when VITE_WS_RELAY_URL is set
- Add 8s AbortController timeout to Vercel fallback fetchGedPage()
### Motivation
- Reduce main bundle pressure by splitting large third-party libraries
into logical chunks to improve load/ caching behavior during production
builds.
- Keep documentation accurate and ensure assets referenced from `docs/`
resolve correctly.
### Description
- Replaced static `manualChunks` object in `vite.config.ts` with a
function-based splitter that creates `ml` for ML deps
(`@xenova/transformers`, `onnxruntime-web`), `map` for map/visualization
deps (`@deck.gl`, `maplibre-gl`, `h3-js`), and preserves dedicated `d3`
and `topojson` chunks.
- Updated `docs/DOCUMENTATION.md` to bump the version badge to `2.1.4`
and fixed the dashboard image path to `../new-world-monitor.png` so it
loads when viewing files from the `docs/` folder.
- Changes applied to `vite.config.ts` and `docs/DOCUMENTATION.md` and
committed to the current branch.
### Testing
- Ran `npm run typecheck` and it completed successfully.
- Ran `npm run build` and the production build succeeded; rollup emitted
warnings about `eval` in a dependency and some chunks >500 kB but build
artifacts were produced.
- Ran `npm run test:e2e:full` which failed in this environment because
Playwright browser binaries are not installed (error: `Executable
doesn't exist ... chromium_headless_shell`).
------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698f0083f99083338bf9ea2ebbefd191)
### Motivation
- Make Tauri/Rust dependency resolution reproducible in
restricted-network CI or offline environments by providing a documented
vendored source option.
- Surface two supported packaging flows (online and restricted-network)
in the release docs so packagers know how to provision offline inputs.
- Improve validation reporting to distinguish external registry outages
from failures caused by missing offline artifacts so QA results are
actionable.
- Allow committing `Cargo.lock` for `src-tauri` once generated by
removing it from the local `.gitignore` so dependency graphs can be
pinned.
### Description
- Added `src-tauri/.cargo/config.toml` defining a `vendored-sources`
replacement that points to `src-tauri/vendor/` and included inline
instructions to `cargo vendor` for populating it.
- Updated `docs/RELEASE_PACKAGING.md` with a new section describing two
Rust dependency modes and example commands for (1) the standard online
build and (2) a restricted-network build that uses vendored crates or an
internal mirror.
- Updated `docs/TAURI_VALIDATION_REPORT.md` to classify failures into
**External environment outage** and **Expected failure: offline mode not
provisioned**, with guidance for both.
- Modified `src-tauri/.gitignore` to stop ignoring `Cargo.lock` so a
lockfile can be generated and committed when network or vendored
artifacts are available.
### Testing
- Ran `cd src-tauri && cargo generate-lockfile`, which failed due to
blocked access to `https://index.crates.io` with `403 CONNECT tunnel
failed` (environmental network restriction).
- Ran `cd src-tauri && cargo generate-lockfile --offline`, which failed
as expected because `src-tauri/vendor/` was not provisioned (`no
matching package named keyring found`).
- No additional automated tests were run on the modified files;
documentation and config changes were validated by applying the edits
and confirming file contents.
------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698ef3d2ac2c8333b576f1c85e6004a1)
### Motivation
- Provide a compact, maintainable model for tracking which UI features
are fully local vs require keys or cloud fallback so desktop parity work
can be prioritized.
- Surface desktop-specific acceptance checks and per-feature fallback
behavior in the UI so operators can quickly assess "desktop ready"
status.
- Consolidate the local-sidecar parity audit into a machine-readable
matrix and documented acceptance criteria.
### Description
- Added a new desktop parity service `src/services/desktop-readiness.ts`
that defines `DesktopParityFeature`, encodes high-value features
(LiveNews, Monitor, StrategicRisk, map layers, summaries, markets,
wingbits/relays), and exposes `getDesktopReadinessChecks`,
`getKeyBackedAvailabilitySummary`, and `getNonParityFeatures` helper
functions.
- Updated `src/components/ServiceStatusPanel.ts` to render a new
"Desktop readiness" section that shows acceptance-check progress,
key-backed feature availability (`available/total`), and a collapsible
list of non-parity fallbacks for operator visibility.
- Reworked `docs/local-backend-audit.md` into a prioritized parity
matrix with closure ordering and explicit desktop-ready acceptance
criteria (startup, map rendering, core intelligence panels, summaries,
market panel, and at least one live-tracking mode).
### Testing
- Ran type checking with `npm run typecheck`, which completed
successfully.
- Executed an automated browser smoke run (Playwright) to capture a
Service Status screenshot against the dev server, which completed and
produced a screenshot artifact successfully.
------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698ec64caff0833386d529e611b468f4)
### Motivation
- Provide a single, reproducible local packaging workflow for desktop
artifacts across macOS and Windows that supports both product variants
(full vs tech).
- Make signing/notarization flows explicit and safe by wiring optional
env-based hooks for Apple notarization and Windows Authenticode.
- Ensure outputs are variant-aware and documented so release engineers
can validate artifacts on clean machines.
### Description
- Add a reusable runner `scripts/desktop-package.mjs` that accepts `--os
<macos|windows>`, `--variant <full|tech>`, and an optional `--sign`
flag, pins bundler targets per-OS, and enforces required signing env
vars when signing is requested.
- Wire `package.json` to use the new runner for all `desktop📦*`
scripts and add a generic `desktop:package` entrypoint.
- Update `src-tauri/tauri.conf.json` and
`src-tauri/tauri.tech.conf.json` to keep explicit bundler targets and
align platform bundle settings (`macOS.hardenedRuntime`, and Windows
`digestAlgorithm`/`timestampUrl`).
- Expand `docs/RELEASE_PACKAGING.md` and update `README.md` with
reproducible per-OS commands, example env hooks for Apple notarization
and Windows Authenticode, variant-aware outputs, artifact locations, and
a clean-machine release checklist.
### Testing
- Ran TypeScript checks with `npm run typecheck`, which completed
successfully.
- Executed the packaging runner guard path with `node
scripts/desktop-package.mjs --os macos --variant full --sign`, which
failed as expected due to missing signing env vars and confirmed the
signing-enforcement checks are working.
------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698ec3ac04e0833397294cf0b28ac97e)
### Motivation
- Provide reproducible local packaging commands for both product
variants (full vs tech) across macOS and Windows.
- Make signing/notarization and Authenticode flows optional and
environment-driven so builds can be reproducible unsigned or signed when
keys are available.
- Ensure release artifacts and variant identities are documented and
easy to validate on a clean machine.
### Description
- Added deterministic packaging scripts to `package.json` for macOS
(`.app` + `.dmg`) and Windows (`NSIS .exe` + `.msi`) for both `full` and
`tech` variants and optional signed variants via environment variables.
- Updated `src-tauri/tauri.conf.json` and
`src-tauri/tauri.tech.conf.json` to explicitly target `app`, `dmg`,
`nsis`, and `msi`, and added a Windows signing/timestamp hint (`sha256`
+ timestamp URL).
- Added `docs/RELEASE_PACKAGING.md` containing per-OS packaging
commands, optional signing/notarization env hooks, artifact output
locations, and a clean-machine startup validation checklist.
- Linked the new packaging guide from `README.md` and surfaced the new
`npm run desktop📦*` commands in the contributor command block.
### Testing
- Verified the updated JSON files parse successfully with a `node -e`
JSON parse check (succeeded).
- Ran `npm run -s typecheck` (TypeScript `tsc --noEmit`) to ensure no
type regressions (succeeded).
------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698ebfa41cbc8333ba9aeeab79aa8a72)
### Motivation
- Ensure the app remains useful when cloud APIs are unreachable by
providing durable local fallbacks and clearer UI indicators for
stale/offline data.
- Persist recent feed items, latest computed risk snapshots, and
stale-tolerant map overlays for desktop builds so a packaged app can
operate without network.
- Surface data freshness and whether values are `live`, `cached`, or
`unavailable` in panels so analysts understand what they are seeing.
### Description
- Add a desktop-aware persistent cache service
(`src/services/persistent-cache.ts`) that uses Tauri `invoke` commands
(`read_cache_entry` / `write_cache_entry`) with `localStorage` as a
fallback for non-desktop runtimes.
- Extend the Tauri backend (`src-tauri/src/main.rs`) to provide
file-backed JSON cache storage under app data and register
`read_cache_entry` and `write_cache_entry` handlers.
- Wire persistent fallbacks into runtime services: RSS feeds
(`src/services/rss.ts`), cached risk scores
(`src/services/cached-risk-scores.ts`), earthquake overlay data
(`src/services/earthquakes.ts`), and the Insights world-brief snapshot
(`src/components/InsightsPanel.ts`).
- Enhance circuit-breaker semantics in `src/utils/circuit-breaker.ts` to
track `BreakerDataState` (modes: `live` / `cached` / `unavailable`) and
detect desktop offline mode so callers can show more precise status and
use cached fallbacks.
- Add panel-level data-state badges and helpers in
`src/components/Panel.ts` and apply them to `NewsPanel`,
`InsightsPanel`, and `StrategicRiskPanel`, plus styling in
`src/styles/main.css`.
- Include static resources in desktop bundle config by adding `../data`
and `../src/config` to `src-tauri/tauri.conf.json` so pre-bundled
datasets are available in production desktop builds.
### Testing
- Ran `npm run typecheck` (`tsc --noEmit`) which completed successfully.
- Started the dev server and exercised the UI; captured a screenshot
demonstrating panel freshness badges (artifact recorded during the run).
- `cargo check` could not complete in this environment due to
network/crates.io access being blocked (error: CONNECT tunnel failed /
403), so native Rust/Tauri build verification was not possible here.
------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698eb89d5b448333b14174125f76d3c4)
### Motivation
- Provide a local backend for the Tauri desktop app so core
functionality (news, summarization, markets, telemetry, status) does not
require Vercel edge functions and can run offline or with reduced cloud
dependency.
- Minimize frontend changes by exposing the same `/api/*` paths locally
and failing over to the cloud when handlers are missing or local
execution fails.
### Description
- Add a Node sidecar local API server at
`src-tauri/sidecar/local-api-server.mjs` that dispatches `/api/*` to
existing `api/*.js` handlers when present and proxies to the cloud
(`https://worldmonitor.app`) as a fallback.
- Start/stop the sidecar with the Tauri app lifecycle by launching it
from `src-tauri/src/main.rs` and managing the child process state.
- Update Tauri configuration in `src-tauri/tauri.conf.json` to allow the
local API origin (`http://127.0.0.1:46123`) in the CSP and to include
`../api` and `sidecar/local-api-server.mjs` as bundle resources.
- Desktop runtime routing changes in `src/services/runtime.ts`: default
desktop API base set to `http://127.0.0.1:46123`, added
`getRemoteApiBaseUrl()` and an `installRuntimeFetchPatch()` function
that patches `fetch` to route `/api/*` to the local sidecar with
automatic cloud fallback.
- Enable the runtime fetch patch at app start by calling
`installRuntimeFetchPatch()` from `src/main.ts`.
- Update `ServiceStatusPanel` (`src/components/ServiceStatusPanel.ts`)
to render local backend status and to show clear messaging when the
local backend is unavailable and the UI is using the cloud fallback.
- Add documentation `docs/local-backend-audit.md` listing prioritized
`/api/*` endpoints for desktop parity and describing the localization
strategy.
- Minor formatting run via `cargo fmt` adjusted `src-tauri/build.rs`.
### Testing
- `npm run typecheck` (`tsc --noEmit`) passed successfully.
- `cargo fmt` completed successfully and reformatted
`src-tauri/build.rs` where needed.
- `cargo check` failed in this environment due to network restrictions
while downloading crates index (environment-specific HTTP 403); this is
unrelated to the code changes themselves.
- Local sidecar smoke tests (automated invocation): launched `node
src-tauri/sidecar/local-api-server.mjs` and verified `GET
/api/local-status` and `GET /api/service-status` returned expected JSON
responses (local status included), demonstrating the sidecar dispatch
and health endpoints work in this environment.
- Playwright screenshot attempt failed because the dev server was not
reachable from the test environment (`net::ERR_EMPTY_RESPONSE`), so UI
screenshot validation could not be completed here.
------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_698ead50f548833398717fa3b8c92230)
Explains that `vercel dev` is needed instead of `npm run dev` for
the API edge functions to work. Covers three deployment options
(Vercel, local with CLI, static frontend only) and platform notes
for Raspberry Pi/ARM users. Addresses #44.
## Summary
Fixes visual lag where high-activity hotspot overlays drift from their
geographic positions during map panning operations.
## Problem
HTML overlays for high-activity hotspots (`level=high` or
`hasBreaking=true`) were only updating positions on a throttled 100ms
delay. During map drag operations, this caused overlays to maintain
their screen position while the map moved underneath, resulting in a
"drift and snap" effect where markers would float away from their true
locations then jump back when the update triggered.
## Solution
- Added `updateHotspotPositions()` method that synchronizes overlay
positions with the map viewport in real-time during movement
- Bound method to the map `'move'` event for frame-by-frame position
updates during drag operations
- Eliminated the 100ms throttle delay for position calculations during
active map movement
## Changes
- **Added** `updateHotspotPositions()` method for real-time overlay
position synchronization
- **Added** `'move'` event listener to trigger position updates during
map drag/pan
- **Fixed** drifting issue for high-activity and breaking hotspot
markers
- **Added** dev mode helper to force 3 random hotspots as `breaking` for
easier overlay behavior testing (for some reason they just weren't
appearing on localhost)
https://github.com/user-attachments/assets/a1cfe749-69bb-499c-b396-24054e7891cehttps://github.com/user-attachments/assets/59c4265a-0667-4ea9-a657-a4f67f983f1e
- Add updateHotspotPositions() method that updates overlay positions in real-time during map movement
- Call updateHotspotPositions() on 'move' event to prevent overlays from lagging behind their geographic positions
- Fixes issue where high-activity/breaking hotspot markers would drift during drag and snap back on release
- Add dev mode helper to force 3 random hotspots as breaking for testing overlay behavior
The HTML overlays for high-activity hotspots (level=high or hasBreaking=true) were only updating positions on a throttled/delayed basis (100ms), causing them to maintain screen position while the map moved underneath, then snap back when the update triggered. Now they update smoothly every frame during map movement.
- Implements layer caching to avoid rebuilding unchanged deck.gl layers
- Adds cluster element DOM caching with position-based invalidation
- Adds cluster result caching to prevent redundant spatial computations
- Replaces setInterval with requestAnimationFrame for news pulse animation
- Adds throttle, debounce, and rafSchedule utilities for render optimization
- Removes hover tooltips in favor of click-based interactions (better performance)
- Adds performance monitoring with console warnings for >16ms operations
- Implements smart layer rebuilding only on zoom threshold crossings
- Reduces cluster overlay opacity during map moves for smoother UX
- Configures deck.gl with interleaved=false and useDevicePixels=false for performance
Performance improvements:
- Reduces redundant layer rebuilds by ~90% during map interactions
- Eliminates tooltip hover callback overhead
- Prevents cluster DOM thrashing with element reuse
- 60fps target for map panning/zooming maintained
- Extract close price arrays from Yahoo Finance chart API for indices/commodities
- Switch CoinGecko crypto fetch to /coins/markets endpoint with 7d sparkline data
- Render inline SVG sparklines color-coded green/red by price direction
- Fix Vite dev proxy for CoinGecko (was hitting root instead of /api/v3/simple/price)
- Add endpoint=markets support to CoinGecko edge function
Three new crypto/market intelligence panels with self-fetching data:
- Market Radar (macro-signals): 7 signals from Yahoo Finance, mempool.space,
alternative.me — liquidity, flow structure, macro regime, BTC trend with
30d VWAP, hash rate, mining cost, fear & greed. SVG sparklines and donut gauge.
- BTC ETF Tracker (etf-flows): 10 spot Bitcoin ETFs via Yahoo Finance 5-day
chart with estimated net flows, volume, and price change per ETF.
- Stablecoins (stablecoin-markets): CoinGecko proxy for peg health monitoring
(ON PEG / SLIGHT DEPEG / DEPEGGED) and supply/volume breakdown.
All endpoints include CORS origin guard, input validation, in-memory caching
with stale fallback. Panels registered in both full and tech variants.
## Summary
- Stop destroying the YouTube player when the tab becomes hidden —
audio/video now continues in background tabs
- Suspend the 5-minute idle timer while the tab is hidden so it doesn't
silently kill background playback
- Idle timer resumes when the user returns to the tab
## Test plan
- [ ] Open the live news panel and start a stream
- [ ] Switch to another browser tab — verify audio continues playing
- [ ] Minimize the browser window — verify audio continues
- [ ] Return to the tab after >5 minutes — verify stream is still
playing
- [ ] Leave the tab visible but don't interact for 5 minutes — verify
idle pause still triggers
Previously, switching tabs or minimizing the window would immediately
destroy the YouTube player, cutting audio and video. Now the stream
continues playing when the tab is hidden, with the idle timer suspended
so it doesn't silently kill playback after 5 minutes in the background.
UCDP API fields are conflict_id, location, side_a, intensity_level etc.
(snake_case) — proxy was reading ConflictId, Location, IntensityLevel
(PascalCase), causing all values to be empty/0.
Also paginate through all results instead of just first page.
When Groq returns 500, the queue kept firing requests against a failing
API. Now 500+ errors pause the queue for 30s (429 stays 60s) and flush
pending jobs, preventing the retry storm visible in console.
- Chatham House RSS returns 403 from cloud IPs, use Google News fallback
- FAO FPMA feed returns HTML (Angular app), not RSS — replaced with fao.org/rss
- Updated rss-proxy allowlist domain
- Endpoint was conflict-event (singular), fixed to conflict-events (plural)
- app_identifier must be base64("name:email"), passed as query param
- Response fields are (events, fatalities, event_type) per row, not
pre-aggregated columns — fixed aggregation logic
- Remove Arab News and Times of Israel feeds (403 from cloud IPs)
Replace hardcoded conflict floor scores with real data from three sources:
- ACLED battles/explosions/violence against civilians (30-day events)
- UCDP conflict classification (war vs minor vs none, data-driven floors)
- HDX HAPI aggregated monthly conflict counts (fallback/validation)
New CII component 'conflict' weighted 30% of event score. Updated formula:
unrest(25%) + conflict(30%) + security(20%) + information(25%)
- Gradient background with left accent sidebar in level color
- Large 120px CII score with /100 + labeled score bar with tick marks
- Semicircle arc gauge on right with score + level badge
- Data indicator legend (threat, military, markets, convergence, signals)
- No-score fallback shows 4 feature cards with descriptions
- Bottom bar with W logo circle, brand text, and CTA button
- Subtle grid background, status pill, date stamp
- Story renderer: larger fonts, better contrast (#777 vs #555 headers,
#e0e0e0 vs #ddd text), visible separators (#222 vs #1a1a2e), removed
grid background noise, bigger score (72px), bigger headlines (26px)
- OG image: pass score & level params to og-story endpoint, improve
contrast, larger score text (96px), better fallback when no score
- Meta tags: include s= and l= query params in og:image URL
- /api/og-story: generates SVG OG image with country name, CII score, level
- /api/story: serves meta tags to social crawlers, redirects real users to SPA
- Deep links now point to /api/story for proper crawler support
- Pass CII score/level through URL params for dynamic OG images
- Add NASA FIRMS satellite fire detection map layer and panel
- Add temporal baseline anomaly detection (Welford's algorithm, Redis-backed)
- Wire signal aggregator with fires, temporal anomalies
- Remove 10 dead service files and unused markdown docs
- Deduplicate RSS feeds, clean up story templates
- Fix data freshness, status panel, and verification checklist
- Create og-image.png for social sharing meta tags
- Update README with signal aggregation, source tiering, edge architecture
- Bump version to 2.1.4
Add signal ingestion methods for all 9 new services:
- ingestSatelliteFires() - NASA FIRMS fire detection
- ingestDefenseActivity() - war analysis military activity
- ingestInfrastructureStatus() - IXP/cable/gateway status
- ingestSentimentAlerts() - social media sentiment shifts
Add 4 new SignalType enums:
- satellite_fire
- defense_activity
- infrastructure_status
- sentiment_shift
Update getSummary() and regional convergence to include new signal types.
This integrates all new intelligence services into the main signal feed.
Create src/services/api-server.ts:
- REST API with Express.js
- Authentication via API key (header or query param)
- Rate limiting (100 requests/minute)
- Endpoints: /stories, /signals, /reports, /countries, /categories
- Webhook service for event notifications
- Data export (JSON, CSV, Markdown)
- CORS support for external apps
This completes all features from OSINT research!
Create src/services/infrastructure-map.ts:
- Track IXPs, data centers, cable landings, gateways for 10 regions
- Infrastructure report per region (active/degraded/offline counts)
- Detect cable integrity concerns (Red Sea, Taiwan Strait)
- Convert to threat signals for intelligence feed
- 20+ nodes tracked across monitored countries
This addresses Priority 2.1 from OSINT research.
Create src/services/correlation-engine.ts:
- Temporal correlations (events within same time window, shared keywords)
- Spatial correlations (events within 500km)
- Thematic correlations (3+ events sharing theme)
- Cascade detection (A happens → B happens 6-48h later)
- Temporal patterns (recurring events detection)
- Geographic clustering (events within 100km)
Converts correlations to threat signals for intelligence feed.
This is the "brain" that finds hidden connections between events.
Create src/services/ai-detection.ts:
- Check for AI generation artifacts (EXIF, GPS, lighting, edges, patterns)
- Bellingcat\'s seven deadly sins of bad OSINT
- Quick verification checklist for content validation
- Helps users distinguish real news from AI-generated/propaganda
This addresses Priority 1.2 (Disinformation Detection) from OSINT research.
- Create src/services/firms-satellite.ts for NASA Fire Information API integration
- Fetch active fires, thermal anomalies in monitored regions (Ukraine, Iran, Israel/Gaza, etc.)
- Detect fire anomalies vs baseline activity
- Convert fire data to threat signals for intelligence feed
- Update OSINT_RESEARCH.md to mark as IMPLEMENTED
This adds satellite imagery capability to detect fires, construction, and thermal
activity in conflict zones - a key differentiator from other OSINT tools.
Researched top OSINT tools (Spiderfoot, Shodan, Babel X, Censys) and war analysis
methodologies to identify improvement opportunities for World Monitor.
Priority improvements identified:
- Satellite imagery integration (NASA FIRMS)
- Disinformation detection (Bellingcat framework)
- Social media sentiment tracking
- Internet infrastructure awareness
- Verification checklist UI
Created OSINT_RESEARCH.md with detailed analysis and next steps.
- Detect when activity levels deviate from historical baselines
- Z-score calculation for military, vessels, protests, news
- Alert when activity is 1.5x-3x normal for the time period
- Supports weekday and month-specific baselines
Deep links can't send images. Now: mobile uses Web Share API (sends
image natively), desktop copies image to clipboard then opens WhatsApp
Web so user can paste it.
Block story rendering when data sources haven't loaded yet
(hasSufficientData check + news cluster check). Shows toast
notification instead of generating an incomplete/stale story.
- Throttle classifyWithAI to 2 concurrent requests with 300ms delay
- Pause queue for 60s on 429, flush pending jobs to avoid memory leak
- Cache vessel theater counts in localStorage (30min TTL) so page
refresh instantly restores vessel data while AIS stream re-accumulates
Theater posture now considers Country Instability Index: CII ≥85 forces
critical, ≥70 forces elevated. Fixes Iran Theater appearing lower than
Baltic despite CII 92 and active crisis reporting. Also lowered Iran
naval thresholds (elevated:2, critical:5) to account for poor AIS
coverage in Persian Gulf where military vessels routinely go dark.
Shows classified event category (Conflict, Cyber, Protest, etc.) as a
colored badge on each news item, leveraging the Groq LLM classification
pipeline. Color matches threat level. General category hidden to reduce noise.
Thresholds were calibrated for classified-level data visibility (50+ aircraft for
Iran ELEVATED). Open-source trackers (OpenSky/Wingbits) see ~10-20% of actual
military flights. Lowered all theaters ~5-6x to match real detection rates.
Added localStorage persistence to cached-theater-posture so the Strategic Posture
panel shows last-known data instantly on page reload instead of starting empty.
Wire geo-hub keyword matching into RSS feed parsing to attach lat/lon
to news items, aggregate locations at cluster level, and render as
ScatterplotLayer on the deck.gl map colored by threat level.
Add fetchCountryMarkets() to Polymarket service with country-to-tag
mapping and variant matching, shown in CountryIntelModal with yes/no
bars when clicking a country.
Switch from volume-only market fetching to tag-based event queries
for better relevance. Variant-aware tags (geopolitical vs tech).
Deduplicates across tag queries, falls back to top-volume markets
only when needed. Add clickable links to Polymarket event pages.
- Add DFMGI.AE for UAE (DFM General Index)
- Use range=1mo instead of 5d to handle Sun-Thu trading weeks
- Take last ~5 trading days from monthly data for weekly % change
- Remove broken Qatar symbol (^QSI returns no data)
- Stock index API endpoint (Yahoo Finance, Redis 1h cache, ~50 countries)
- Stock chip in modal: shows weekly % change with green/red styling
- Pause deck.gl renders while country modal is open (fixes flickering)
- Suppress signal modal clicks while country modal is visible
- Single stock fetch reused for both UI chip and AI brief context
- Military signals now counted by geographic bounding box (lat/lon in country)
instead of operator flag — matches what posture panel shows
- Add country aliases for headline matching (Israel → israeli, gaza, hamas, idf...)
- Feed convergence scores + regional alerts to AI context
- Expanded prompt: 5 sections, 300-400 words, military posture analysis,
regional context, cross-signal correlation
- Bump max_tokens 500→900, cache version ci-v1→ci-v2
- Add 25+ country bounding boxes for geo-matching
Click empty map area to detect country via Nominatim reverse geocoding,
highlight it with GeoJSON boundaries, and show an AI-generated intel
brief (Groq, Redis-cached 2h) with CII score, active signals, and
contextual news headlines.
Prevents the jarring effect of all detected locations flashing
simultaneously when the app first loads. Flashes only trigger
after the initial feed collection completes.
Added navalThresholds per theater and recalcPostureWithVessels() that
uses "either triggers" logic: if aircraft OR vessels exceed their
respective thresholds, the theater level escalates. Previously only
aircraft counts were used, so 40 vessels + 10 aircraft showed "normal".
DeckGLMap.setCenter() uses maplibreMap.flyTo() (animated, 500ms) but
callers immediately followed with setZoom() which interrupted the
animation. Added optional zoom param to setCenter() and updated all
callers in App.ts to use setCenter(lat, lon, 4) instead of separate calls.
- PR #33: Disambiguate NAVY callsign by origin country (US vs UK)
- PR #34: Use typecode fallback in determineAircraftInfo + Wingbits
enrichment (guarded: only when type is still 'unknown')
- PR #35: Persist typeCode in enriched data (skip the broken
pre-filter removal and false-positive regex from original PR)
Closes#33, #34, #35
The pan formula was incorrectly dividing by zoom:
pan.x = width/(2*zoom) - pos[0] // Wrong
Corrected to:
pan.x = width/2 - pos[0] // Correct
The zoom-independent formula ensures clicking theaters in Strategic
Posture panel correctly centers on that region at any zoom level.
- Remove CCA from MILITARY_PREFIXES (Air China airline, not military)
- Remove duplicate IAF entry from Middle East section
- Move AIRLINE_CODES to module level Set for O(1) lookup
- Remove duplicate entries (ELY, THY, SWR)
- 20,396 verified military hex IDs from adsbexchange.com
- Updated daily by ADS-B Exchange community
- O(1) lookup using Set
- Combines with callsign detection for comprehensive coverage
Focus on ranges where military/civilian separation is clear:
- US (AE/AF), UK, France, Germany, NATO, Russia, China, Australia, Japan
For other countries, rely on callsign detection since hex ranges
include commercial aircraft mixed with military.
Military aircraft have specific transponder ID ranges by country.
This matches how OpenSky UI identifies military aircraft.
Now checks both callsign patterns AND hex ranges.
Ranges added for: USA, UK, France, Germany, Italy, NATO, Israel,
Saudi, UAE, Qatar, Turkey, Russia, China, Iran, India, Pakistan,
Australia, Japan, South Korea
- Remove UAE from military prefixes (conflicts with Emirates airline)
- Use more specific UAEAF, BAHAF, OMAAF for Gulf military
- Expand airline exclusion list with major carriers worldwide
- This improves accuracy by removing commercial flight false positives
- Add more US military prefixes (ARMY, NAVY, USAF, OPS, etc.)
- Add Middle East military prefixes (RSAF, UAE, EMIRI, etc.)
- Add NATO tactical callsigns (SWORD, LANCE, etc.)
- Add short tactical pattern (3 letters + 1-2 digits) with airline exclusion
- Exclude known airline codes (SVA, QTR, THY, etc.) from short patterns
The patterns [A-Z]{2,3}\d{3,4} and [A-Z]{3,4}\d{2,4} were matching
commercial airlines like PGT5873, IAW9011, IZG3020. New pattern only
matches 4+ letter callsigns like DUKE01, VIPER12 which are typical
military tactical callsigns.
BUG FIX: Wingbits API returns short field names like 'h' for icao24,
'f' for flight, 'la' for latitude, etc. The previous code was looking
for long names like 'icao24', 'callsign', 'latitude' which don't exist
in the response, causing all flights to be filtered out.
- Add /flights and /flights/batch endpoints to wingbits proxy
- Add fetchMilitaryFlightsFromWingbits() to theater-posture API
- Try OpenSky first, fallback to Wingbits on 429/failure
- Transform Wingbits data to match existing flight format
- Add 'source' field to response ('opensky' or 'wingbits')
- Switch Times of Israel from Railway to Vercel RSS proxy (already allowlisted)
- Increase stale cache TTL from 1 hour to 24 hours
- Add 7-day backup cache as last resort during prolonged outages
- Better error handling: try stale → backup → error
- Replace base Panel.showLoading() with radar sweep animation
- Add customizable message parameter to showLoading(message)
- Update CIIPanel to use unified loader
- Add panel-loading CSS classes matching Strategic Posture style
- Consistent green radar aesthetic across all loading states
### Motivation
- Ensure the variant-change reset removes the same panel-order key used
across the app and centralize the key to avoid future mismatches.
### Description
- Added a `PANEL_ORDER_KEY` constant (`'panel-order'`) to `src/App.ts`
and used `this.PANEL_ORDER_KEY` for reads/writes instead of the
scattered literals; replaced the mistaken `'worldmonitor-panel-order'`
removal with the unified key and updated migration code and
`getSavedPanelOrder`/`savePanelOrder`.
### Testing
- No automated tests were run for this change.
------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6977b8c537cc832eb4ef5d877cb282d6)
### Motivation
- Map share URLs must unambiguously reflect when every layer is disabled
so viewers get the exact layer state.
- Empty or missing `layers` query values were previously ambiguous, so
the URL builder and parser need to explicitly encode and interpret that
state.
### Description
- `parseMapUrlState` now treats a present `layers` parameter with value
`''` or `'none'` as “all off” and sets every key in `LAYER_KEYS` to
`false`, while preserving the existing fallback behavior when the
parameter is omitted.
- `buildMapUrl` always emits a `layers` query parameter, using `none`
when no layers are active and otherwise joining active keys with commas.
- The parser normalizes and trims the `layers` value before splitting to
robustly handle whitespace and empty tokens.
### Testing
- No automated tests were run.
------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6977b8cdf7dc832eae02f128875f8fed)
### Motivation
- Ensure share URLs fully capture map layer state by including every key
from the `MapLayers` type (geopolitical and tech layers).
- Keep `parseMapUrlState` and `buildMapUrl` consistent so both parsing
and serialization cover the same complete set of layers.
### Description
- Expanded the `LAYER_KEYS` array in `src/utils/urlState.ts` to include
`ais`, `protests`, `military`, `spaceports`, `minerals`, `startupHubs`,
`cloudRegions`, `accelerators`, `techHQs`, and `techEvents` so it
matches `MapLayers`.
- `parseMapUrlState` continues to use `LAYER_KEYS` to populate a
`MapLayers` object from the `layers` query param, now covering the full
set.
- `buildMapUrl` uses `LAYER_KEYS` to serialize active layers into the
`layers` query param, ensuring all layer flags are represented in share
URLs.
- Change is contained to `src/utils/urlState.ts` and committed.
### Testing
- No automated tests were run for this change.
------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6977b8c992e0832eb1acc9f4dd730f11)
The View Region button was navigating to wrong locations because
`if (lat && lon)` returns false when either coordinate is 0.
Changed to typeof check to properly handle all valid coordinates.
- Change default from 'fighter' to 'unknown' for unrecognized callsigns
- Add 'unknown' count to aircraft breakdown
- Show bombers, transport, drones, and 'other' in summary
- Fixes misleading '179 fighters' when most were unclassified
- Condense README from 3,896 lines to ~230 lines
- Move full documentation to docs/DOCUMENTATION.md
- Add social badges (stars, forks, license, last commit)
- Add 'Why World Monitor?' value proposition table
- Add Quick Start one-liner
- Use collapsible sections for data layers
- Add 'Support the Project' call to action
- Link to full docs throughout
Add useFallbackOnly flag to skip auto-detection for channels
where it returns wrong video IDs. These channels will now
always use their hardcoded fallback video IDs.
- Multi-step progress showing 4 World Bank indicators being fetched
- Animated globe with spinning ring
- Staggered pulse animation on each indicator row
- Shows "Analyzing 200+ countries" context
- Search both title and description for better keyword coverage
- Deduplicate results (article matching multiple monitors shown once)
- Show match count: "3 matches" or "Showing 10 of 25 matches"
- Show search scope when no matches: "No matches in X articles"
- Add v1.9 migration with strategic-posture in priority panels
- Add emoji fallback when no typed aircraft/vessel breakdown exists
- Use proper emoji variation selectors for airplane (✈️)
- hotspotBoost: 30 → 10 max
- newsUrgencyBoost: 15 → 5 max
- focalBoost: 20 → 8 max
- Total max boost: 65 → 23 points
Prevents multiple countries from hitting 100 simultaneously
- ACLED changed API endpoint from api.acleddata.com to acleddata.com/api/
- Add Bearer token authentication via ACLED_ACCESS_TOKEN env var
- Add baseline scores fallback when ACLED is unavailable (no more 500s)
- Better error logging for auth failures
Root cause: Circuit breaker's 5-min cache AND vesselCache were both
caching empty results, blocking fresh data from showing for 5 minutes.
Fixes:
- Add clearCache() method to circuit breaker
- Don't cache empty vessel results in vesselCache
- Clear both caches when stream initializes
- Clear both caches when first vessel arrives
- Add re-augmentation at 90s and 120s (in addition to 30s/60s)
UX improvements:
- Add live elapsed time counter during loading
- Add "30-60 seconds" guidance note
- Stop timer on render/error/destroy (no memory leaks)
Also fixes focal point headlines showing irrelevant content by
only including headlines where entity name appears in title.
- Add stale cache fallback when OpenSky is rate-limited (1h TTL)
- Return cached data instead of 500 error when API fails
- Improve empty/error state messages with context
- Add retry buttons to no-data states
- Show stale data warning when using cached fallback
- Korean Peninsula (covers Korean DMZ area)
- South China Sea (disputed waters)
- Eastern Mediterranean (Israel/Syria/Lebanon)
Bump cache key to v2 to invalidate old 4-theater cache.
- Add missing byOperator to API response (was causing crash)
- Deep clone postures to prevent cached data mutation
- Add drone detection patterns (RQ/MQ/REAPER/PREDATOR)
- Separate drone from reconnaissance in callsign detection
- Add vessel fields to TheaterPostureSummary type (destroyers, frigates,
carriers, submarines, patrol, auxiliaryVessels, totalVessels)
- Update API to return theater bounds for client-side vessel matching
- Panel fetches vessels client-side (AIS WebSocket) and merges with
server-side aircraft data (OpenSky API)
- Display shows AIR and NAVAL sections when vessels detected
- Add posture-section-label CSS for section headers
- Create /api/theater-posture.js endpoint that fetches military flights
from OpenSky, calculates theater postures, and caches in Upstash Redis
- Add cached-theater-posture.ts client service with deduplication
- Update StrategicPosturePanel to fetch from cached endpoint independently
- Update App.ts to use cached postures for critical alert banner
Benefits: Theater posture calculation shared across all users via Redis
cache (5-min TTL), panel works without military layer enabled
- Remove Trump mention from date context for tech variant
- Add variant-aware system prompts:
- Tech: "Focus ONLY on technology, startups, AI, funding..."
- Tech: "IGNORE political news, trade policy, tariffs..."
- Use tech-appropriate examples: "OpenAI announced...", "Series B..."
- Bump cache version to v3 to force new summaries
INTEL_SOURCES (Defense One, Breaking Defense, The War Zone, Janes,
Bellingcat) was being fetched for all variants and added to allNews,
which fed geopolitical content to AI insights in tech variant.
Now gated by SITE_VARIANT === 'full'.
- Gate gdelt-intel, cii, cascade, strategic-risk panels to full variant only
- Wire up TechReadinessPanel (was implemented but never connected):
- Add to TECH_PANELS config with priority 1
- Export from components index
- Instantiate in App.ts
- Reduces console noise and unnecessary data fetching in tech variant
Tech variant was fetching military flights, vessels, protests, and
outages even though it has no CII/focal point panels. This wasted
bandwidth and polluted console logs.
Changes:
- Gate loadIntelligenceSignals() to only run for SITE_VARIANT='full'
- Gate scheduled intelligence refresh to only run for full variant
- This stops: military flights, military vessels, protests (ACLED/GDELT),
outages, and AIS shipping data fetching for tech variant
Tech variant now only fetches data relevant to tech/startup news.
Issues fixed:
1. Tech variant insights panel was using geopolitical signal data
(military flights, protests, AIS) which polluted AI summaries
2. Insights panel was at priority 2 (bottom) for tech variant
Changes:
- panels.ts: Move insights to priority 1 (top) in TECH_PANELS
- InsightsPanel.ts: Skip signalAggregator and focalPointDetector
for tech variant - only summarize tech news without geo context
- App.ts: Add one-time migration to move insights to top for
existing tech variant users
Now tech variant AI insights only analyzes tech/startup news without
geopolitical military/protest/outage correlations.
The Redis cache key for summaries was built from headlines + mode +
geoContext but did NOT include the site variant (full vs tech). This
caused cross-site cache collisions where a summary generated for the
full variant could be returned to tech variant users (and vice versa).
Changes:
- Pass SITE_VARIANT from frontend to summarization API
- Include variant in cache key: `summary:{variant}:{hash}{geoHash}`
- Updated both groq-summarize and openrouter-summarize endpoints
Now cache keys are scoped per variant, preventing incorrect summaries.
The dev.events RSS feed is limited to 100 items sorted by "date added"
(not event date), causing major events like STEP Dubai to be pushed out
when newer events are added. Added a curated events list as fallback
for important conferences that may fall off the RSS feed:
- STEP Dubai 2026 (Feb 11-12) - 8,000+ attendees, AI economy focus
- GITEX Global 2026 (Dec 7-11) - World's largest tech show
- TOKEN2049 Dubai 2026 (Apr 29-30)
- Collision 2026 (Jun 22-25) - Toronto
- Web Summit 2026 (Nov 2-5) - Lisbon
Curated events are deduplicated with feed data to avoid duplicates.
- README: Title to "World Monitor v2", AI-powered description
- index.html: Title "Global Situation with AI Insights"
- All meta tags updated (og, twitter, JSON-LD)
- Added AI keywords and features
- Updated site.webmanifest with AI branding
The original documentation incorrectly described surge detection as
operator-count based. The actual algorithm uses:
- Baseline-based detection (2x historical activity = surge)
- Separate foreign presence detection for operators outside home regions
- Theater-based grouping with 48-hour baseline window
- Add getFocalPointForCountry() and getNewsCorrelationContext() to focal-point-detector
- Enhance foreignPresenceToSignal to include news correlation from focal points
- Map operator countries and affected regions to ISO codes for focal point lookup
- Update SignalModal to display correlated focal points and news headlines
- Add military_surge type label and styling for the signal modal
- Add CSS for focal point and news correlation sections in intelligence findings
- Add loadIntelligenceSignals() that ALWAYS fetches protests, military,
outages regardless of layer visibility
- Add intelligenceCache to prevent duplicate API calls when layers enabled
- Remove redundant layer-gated initial load tasks (handled by intelligence)
- Remove redundant refresh schedules for intelligence layers
- Add surge/foreign presence detection to loadIntelligenceSignals()
- Fix missing ACLED warning status in cached loadProtests() path
This ensures CII scores are accurate even when map layers are disabled.
Previously, Iran showed S:0 (no signals) because military layer was off.
Wait for MapLibre 'load' event before initializing deck.gl overlay.
Previously, deck.gl layers were created before MapLibre finished
initializing its view state, causing incorrect coordinate projections
on first render. After any view change, the map would correct itself.
- Add missing military vessel/flight cluster layers
- Add cluster tooltips and click type mappings
- Fix activity type mismatches (deployment/transport vs patrol/surveillance)
- Add congestion coloring to AIS density (orange for deltaPct >= 15)
- Add port type-based colors and tooltip icons
- Fix getHotspotLevels/setHotspotLevels to use h.name
- Fix setLayerReady to use 'active' class with layer state check
- Create gdacs.ts service fetching from GDACS API (earthquakes, floods,
tropical cyclones, volcanoes, wildfires, droughts)
- Filter to Orange/Red alerts only for relevance
- Merge GDACS events with EONET natural events with deduplication
- Color-code markers: red for Red alerts, orange for Orange alerts
- Prioritize GDACS data (more authoritative) over EONET for overlaps
Feature parity improvements:
- Add layer help popup with ? button (explains all layer types)
- Add LAYER_ZOOM_THRESHOLDS constant for zoom-based label visibility
- Add toggleLayer() public method to toggle layers on/off
- Make zoomIn()/zoomOut() public methods for external access
- Add military cluster data storage (militaryFlightClusters, militaryVesselClusters)
- Add getMilitaryFlightClusters() and getMilitaryVesselClusters() getters
- Store clusters in setMilitaryFlights/setMilitaryVessels methods
- Add news storage for related news lookup on hotspot click
- Add getRelatedNews() method matching old Map.ts behavior
- Fix click handler to show popup with related news + GDELT intel
- Add HTML overlays for high-activity hotspots with CSS pulsating animation
- Filter high-activity hotspots from deck.gl layer to avoid double-render
- Add escapeHtml for safety in HTML templates
- Add debug logging for panel persistence investigation
Problem: CII showed cached backend scores immediately, then updated
when focal points ready - showing misleading preliminary data.
Fix:
- Show "Analyzing intelligence..." state until focal points ready
- Only calculate and render after focal-points-ready event
- Remove unused cached scores logic (simpler, more accurate)
Problem: Once cached scores loaded, usedCachedScores=true forever,
blocking recalculation even when focal points became ready.
Fix: Add forceLocal parameter to refresh() - when focal-points-ready
fires, it now calls refresh(true) to force local recalculation with
focal point urgency boosts applied.
LLMs have outdated knowledge (pre-Jan 2025), causing errors like
"former President Trump" when Trump is current president.
Added dateContext to all summarization prompts with:
- Current date
- Trump is current president (second term, Jan 2025)
Moved TIER1_COUNTRIES to src/config/countries.ts to break the cycle:
country-instability → focal-point-detector → signal-aggregator → country-instability
Now:
- config/countries.ts (no dependencies)
- signal-aggregator imports from config/countries
- country-instability re-exports for backwards compatibility
getCountryScore() was missing the focalBoost that calculateCII() has.
This inconsistency would cause single-country lookups to return
different scores than the batch calculation.
Problem: CII was calculating before FocalPointDetector.analyze() ran,
so the urgency map was empty and focal boosts weren't applied.
Fix: InsightsPanel now dispatches 'focal-points-ready' event after
analysis completes. App.ts listens and triggers CII refresh.
This ensures Iran shows elevated/high when FocalPointDetector
marks it as critical, rather than staying at 38 (green).
Problem: Every panel summary started with 'Breaking news tonight:' because
the prompt said 'write like a news anchor opening the evening news'.
Fix: Rewrote prompts to:
- Explicitly forbid 'Breaking news', 'Good evening', 'Tonight' openings
- Remove TV news anchor framing entirely
- Instruct to start directly with subject: 'Iran's regime...', 'The US Treasury...'
- Focus on substance over style
Updated both groq-summarize.js and openrouter-summarize.js
CII now uses focal point urgency (critical/elevated/watch) to boost scores:
- critical focal point: +20 points
- elevated focal point: +10 points
This ensures countries like Iran show 'elevated' or 'high' when:
1. AI insights detect breaking news (I:80)
2. FocalPointDetector marks them as critical (news + signal correlation)
The CII now reflects the same intelligence the AI summarizer uses,
eliminating the disconnect where AI says 'armada, bloodshed' but
CII shows green/normal.
Problem: CII showed Iran as 'normal' (green, 38) despite AI insights
showing breaking news about 'US armada to Iran, deadly crackdown'.
This happened because S:0 (no military detections) diluted the I:80
(high news velocity).
Fix: Add newsUrgencyBoost that elevates scores when Information is high:
- I >= 70: +15 points (breaking news)
- I >= 50: +10 points (significant news)
- I >= 30: +5 points (moderate news)
Example with fix: Iran with I:80 now scores ~53 (elevated/orange)
instead of 38 (normal/green)
UN News and other servers return gzip-compressed RSS. The proxy was
concatenating binary chunks as strings, corrupting non-UTF8 bytes.
Fix: Use Buffer.concat() and decompress gzip/deflate responses.
Correlates news entities with map signals to identify "main characters"
appearing across multiple intelligence streams:
- Extract entities from all 80+ news sources via NER
- Cross-reference with map signals (flights, vessels, outages, protests)
- Score focal points where news + signals converge
- Generate rich AI context for correlation-aware summarization
New files:
- focal-point-detector.ts: Core detection and scoring service
- signal-aggregator.ts: Geographic signal aggregation
UI shows top focal points with urgency badges and signal icons.
AI prompts updated to leverage intelligence synthesis context.
- Rewrite prompts to vary output (no more "The dominant narrative...")
- Instruct model to lead with substance: location, action, impact
- Add explicit rule: NEVER start with meta-commentary
- New /api/risk-scores endpoint computes and caches scores in Redis
- Uses ACLED protest data + baseline geopolitical risk factors
- 10-minute cache TTL shared across all users
- CIIPanel and StrategicRiskPanel fetch cached scores on load
- Learning Mode banner hidden when cached scores available
- Eliminates 15-minute warmup for users
- Cluster datacenters at zoom < 5, show IconLayer at higher zoom
- Add renderDatacenterClusters() with HTML overlays
- Add datacenterCluster popup type showing total chips/power stats
- Add CSS styles for datacenter markers matching protest/techHQ pattern
- Show cluster badge with count, expand to individual on click
Base map tiles already display country names, so the custom
Country Labels layer was duplicating functionality.
Removed:
- COUNTRY_LABELS array (~80 countries) from geo.ts
- Toggle from layer controls in both Map.ts and DeckGLMap.ts
- renderCountryLabels() and createCountryLabelsLayer() methods
- 'countries' property from MapLayers type
- All countries config entries from panels and variants
- Related CSS styles
- Double MAX_RESULTS from 12 to 24
- Prioritize news over static infrastructure in search
- Update News24 URL to post-redirect destination (feeds.capi24.com)
- Add trailing slash to SCMP URL
- Add feeds.capi24.com to both proxy allowlists
Railway's RSS proxy had only 5 domains in allowlist, causing 403 errors.
Added all domains that use railwayRss() routing in feeds.ts:
- Al Arabiya, Arab News, Times of Israel, SCMP
- UN News, CISA, News24
- Plus IAEA, WHO, Crisis Group, Kyiv Independent, Moscow Times for future use
- CORS regex: now matches root domain (worldmonitor.app) not just subdomains
- Alert keywords: re-add important terms (military, drone strike, terror/cyber attack, evacuation order)
- Add /api/groq-summarize.js - Llama 3.3 70B (1000 req/day free)
- Add /api/openrouter-summarize.js - Fallback (50 req/day free)
- Create summarization service with fallback: Groq -> OpenRouter -> Browser T5
- Browser T5 only loads when cloud APIs fail (lazy loading)
- Add detailed progress bar with Step X/Y indicator
- Show provider badge (groq/openrouter) on World Brief
- Cache summaries for 30 min to respect rate limits
- Cooldown increased to 2 min between brief generations
Required env vars for Vercel:
- GROQ_API_KEY (get from console.groq.com)
- OPENROUTER_API_KEY (get from openrouter.ai)
- Show "Initializing ML models..." when worker not ready
- Show "Analyzing sentiment..." during sentiment classification
- Show "Generating world brief..." during summarization
- Add animated spinner and pulsing text for visual feedback
- Re-enable the ML worker import that was disabled
- Remove @xenova/transformers from rollup externals so it gets bundled
- Previous config had MLWorkerClass set to null, causing "not a constructor" error
- Switch summarization model from T5-small (45MB) to Flan-T5-base (250MB)
for significantly better quality summaries
- Add "World Brief" section to InsightsPanel that synthesizes top stories
into a coherent 2-sentence summary (vs useless per-headline summaries)
- Brief generation has 60s cooldown to avoid excessive model calls
- Add styled World Brief section with accent gradient
- Remove broken T5 summaries (model too weak for abstractive summarization)
- Focus on multi-source confirmed stories (2+ sources)
- Show fast-moving and alert stories
- Add stats bar: multi-source count, fast-moving count, alerts
- Improve sentiment visualization with bar and overall tone
- Filter out single-source noise
These layers existed in buildLayers() with mapLayers.X checks but had
no UI toggles, making them inaccessible. Added toggles for both in the
geopolitical variant layer panel.
https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
- Import APT_GROUPS and CRITICAL_MINERALS from config
- Add createAPTGroupsLayer() with red markers and yellow outline for cyber threat actors
- Add createMineralsLayer() with color-coded markers by mineral type (Lithium, Cobalt, Rare Earths, Nickel)
- APT Groups always visible in geopolitical variant (no toggle)
- Add minerals toggle to layer panel for geopolitical variant
- Add tooltips and click handlers for both layers
https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
- Add clusterMarkers method for grouping nearby markers
- Create HTML overlay container for cluster badges
- Render Tech HQs with emoji icons and cluster count badges
- Render Tech Events with calendar icons and clustering
- Render Protests with severity icons and clustering
- Clusters expand on click to show popup with all items
- Single items show individual popup on click
- Clustering radius adjusts based on zoom level
https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
- Remove FOCUS region selector from header (keep only map's view selector)
- Redesign legend as compact horizontal bar at bottom center
- Add proper shapes: triangles for bases, squares for datacenters, hexagons for nuclear
- Reduce whitespace in legend layout
https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
Replaced ScatterplotLayer circles with IconLayer for distinct shapes:
- Military bases: TRIANGULAR icons (color-coded by operator)
- Nuclear facilities: HEXAGONAL icons (yellow/orange)
- Datacenters: SQUARE icons (purple)
- Hotspots: Remain as circles (appropriate for threat indicators)
Each marker type now has a unique geometric shape for easy identification.
https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
- Nuclear: Yellow/orange hollow RING with inner dot (radiation symbol look)
- Datacenters: Purple filled circles with light border
- Bases: Color-coded by operator (US-NATO blue, Russia red, China orange, etc.)
- Removed non-working TextLayer emoji approach
Each marker type now has a distinct visual style beyond just color.
https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
Restored visual distinction for markers that had icons in the
original D3/SVG implementation:
- Datacenters: 🖥️ icon
- Nuclear facilities: ☢️ icon
Uses TextLayer overlaid on ScatterplotLayer to render emoji icons.
https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
Replaced separate Deck instance with MapboxOverlay which properly
integrates deck.gl into MapLibre's WebGL context. This fixes:
- Points moving when zooming/panning (view state sync)
- Coordinate offset issues (London appearing in France)
- Cursor issues (MapLibre now handles all interactions)
MapboxOverlay renders deck.gl layers directly into MapLibre's canvas,
eliminating all view state synchronization issues.
https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
The circular sync between deck.gl and MapLibre was causing the
deck.gl layer to be offset from the base map. Fixed by:
- Making deck.gl the single source of truth (has controller enabled)
- Removing MapLibre->deck.gl sync that caused circular updates
- Adding initial sync on map load to ensure alignment
https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
The source dataset had placeholder coordinates clustering all US
datacenters around Kansas/Oklahoma. Fixed actual locations for:
- Mt Pleasant, Wisconsin (42.7°N, -87.9°W)
- Meta New Albany, Ohio (40.1°N, -82.8°W)
- OpenAI/Microsoft Atlanta (33.7°N, -84.4°W)
- Applied Digital Ellendale, ND (46.0°N, -98.5°W)
- Nebius New Jersey (40.1°N, -74.4°W)
- CoreWeave Denton, TX (33.2°N, -97.1°W)
https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
The map now stays fixed on the user's selected region when:
- Clicking items from lists (conflicts, bases, pipelines, etc.)
- Flash location animations
- New data flowing in
Popups now appear at the projected screen position of items
rather than panning the map to center on them. If an item is
off-screen, the popup still appears at its projected position.
https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
The deck.gl canvas was blocking mouse events from reaching MapLibre
because it had pointer-events: auto for picking but controller: false.
Enable deck.gl's controller and sync view state changes back to MapLibre.
https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
Root cause: The timestamp element had both classes 'map-timestamp' and
'deckgl-timestamp'. These classes set conflicting positioning:
- .map-timestamp: bottom: 8px; right: 10px;
- .deckgl-timestamp: top: 10px; left: 50%;
When all four positioning values (top/bottom/left/right) are set on an
absolutely positioned element without explicit width/height, it stretches
to fill the space between those edges - creating a 724x315px dark overlay.
Fixes:
- Remove 'map-timestamp' class from timestamp element in DeckGLMap.ts
- Add explicit bottom: auto; right: auto; width: auto; height: auto;
to .deckgl-timestamp CSS to prevent any accidental stretching
https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
Changes:
- Add explicit z-index to MapLibre (1) and deck.gl overlay (2)
- Add overflow: hidden to wrapper
- Reverted to letting deck.gl create its own canvas
- Added effects: [] to disable any default deck.gl effects
- Set canvas background to transparent after deck initializes
- Added comprehensive CSS rules to ensure all canvas elements
and containers have transparent backgrounds
https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
Instead of relying on deck.gl's default canvas/context creation,
manually create canvas and WebGL context with explicit alpha support:
- Create canvas element with transparent background style
- Get WebGL2/WebGL context with alpha: true, premultipliedAlpha: true
- Set gl.clearColor(0, 0, 0, 0) for transparent clear
- Pass pre-created canvas and gl context to Deck constructor
This ensures the WebGL context is properly configured for alpha
transparency from the start, allowing the MapLibre base map to
show through the deck.gl overlay.
https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
Two-pronged approach to fix the dark semi-transparent overlay:
1. Add MapLibre background layer:
- Added 'background' layer with dark color (#0a0f0c) to MapLibre style
- This ensures no transparent gaps while tiles are loading
2. Set WebGL clear color to transparent:
- Added onWebGLInitialized callback to Deck initialization
- Explicitly set gl.clearColor(0, 0, 0, 0) for transparent background
- Enable proper alpha blending with gl.blendFunc
https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
Root cause: CONFLICT_ZONES config had inconsistent coordinate formats.
Red Sea Crisis and South Lebanon entries were in [lat, lon] format
while other entries were in [lon, lat] format (GeoJSON standard).
This caused polygons to render in completely wrong locations.
Fixes:
- Fix Red Sea Crisis coords: [[12,42]...] → [[42,12]...] (swap to [lon,lat])
- Fix Red Sea Crisis center: [14,43] → [43,14]
- Fix South Lebanon coords: [[33.0,35.1]...] → [[35.1,33.0]...]
- Fix South Lebanon center: [33.2,35.4] → [35.4,33.2]
- Add explicit transparent background to #deckgl-overlay CSS
https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
The MapPopup.show() method expects a PopupData object with:
- type: PopupType string identifying the popup renderer
- data: the actual data object to display
- x, y: click coordinates for popup positioning
Previously was passing raw objects which resulted in broken/missing popups.
Changes:
- Import PopupType from MapPopup
- Update handleClick to map layer IDs to PopupType and create proper PopupData
- Update all trigger methods (triggerConflictClick, triggerBaseClick, etc.)
to create proper PopupData with centered x/y coordinates
- Add getContainerCenter() helper for programmatic popup positioning
https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
- Fix coordinate swap bugs in createCablesLayer, createPipelinesLayer,
and createConflictZonesLayer - data is already [lon, lat] format
- Fix coordinate order in triggerConflictClick, triggerPipelineClick,
and triggerCableClick to pass (lat, lon) to setCenter correctly
- Fix handleClick to not show popup for hotspots (they have their own handler)
- Add missing tooltips for all layer types (cables, pipelines, conflicts,
natural events, weather, outages, AIS density, military flights,
waterways, economic centers, tech HQs, accelerators, cloud regions,
tech events)
https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
Implement Palantir-like interactive map experience using deck.gl with
MapLibre GL as the base map. Key changes:
- Add deck.gl (@deck.gl/core, @deck.gl/layers, @deck.gl/geo-layers) and
maplibre-gl dependencies for GPU-accelerated rendering
- Create DeckGLMap component with WebGL layers for:
- Undersea cables and pipelines (PathLayer)
- Military bases, nuclear facilities, datacenters (ScatterplotLayer)
- Conflict zones (GeoJsonLayer with polygons)
- Hotspots, earthquakes, weather, outages, protests
- AIS density, military vessels and flights
- Strategic waterways, economic centers
- Tech variant: startup hubs, tech HQs, accelerators, cloud regions
- Create MapContainer wrapper that conditionally renders:
- DeckGLMap (WebGL) on desktop with WebGL support
- Existing D3/SVG MapComponent on mobile for graceful degradation
- Add dark theme CSS styles for deck.gl controls, legend, layer toggles
- Import maplibre-gl CSS in main.ts
Desktop users now get smooth 60fps interactions with large datasets
while mobile users retain the optimized SVG experience.
https://claude.ai/code/session_01GTanC7R6aSQNsnijqJRUFz
2026-01-23 11:48:20 +00:00
771 changed files with 182177 additions and 8802 deletions
| 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.
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
- **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
- **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
- **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
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
constALLOWED_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:
# 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 |
| **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
- **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 |
| 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"
- **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 |
| 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.
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.
${isTech?'Focus: technology, startups, AI, cybersecurity. Most tech news is "low" or "info" unless it involves outages, breaches, or major disruptions.':'Focus: geopolitical events, conflicts, disasters, diplomacy. Classify by real-world severity and impact.'}
${isTech?'Focus: technology, startups, AI, cybersecurity. Most tech news is "low" or "info" unless it involves outages, breaches, or major disruptions.':'Focus: geopolitical events, conflicts, disasters, diplomacy. Classify by real-world severity and impact.'}
constsystemPrompt=`You are a senior intelligence analyst providing comprehensive country situation briefs. Current date: ${dateStr}. Donald Trump is the current US President (second term, inaugurated Jan 2025).
// Current date context for LLM (models may have outdated knowledge)
constisTechVariant=variant==='tech';
constdateContext=`Current date: ${newDate().toISOString().split('T')[0]}.${isTechVariant?'':' Donald Trump is the current US President (second term, inaugurated Jan 2025).'}`;
// Language instruction
constlangInstruction=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
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.${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}`;
// Current date context for LLM (models may have outdated knowledge)
constisTechVariant=variant==='tech';
constdateContext=`Current date: ${newDate().toISOString().split('T')[0]}.${isTechVariant?'':' Donald Trump is the current US President (second term, inaugurated Jan 2025).'}`;
// Language instruction
constlangInstruction=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
?`What's the key tech trend or development?\n${headlineText}${intelSection}`
:`What's the key pattern or risk?\n${headlineText}${intelSection}`;
}elseif(mode==='translate'){
consttargetLang=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:
-Maintaintheoriginaltoneandjournalisticstyle.
-DoNOTaddanyconversationalfiller.
-OutputONLYthetranslatedtext.`;
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.${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}`;
consttitle=`${countryName} Intelligence Brief | World Monitor`;
constdescription=`Real-time instability analysis for ${countryName}. Country Instability Index, military posture, threat classification, and prediction markets. Free, open-source geopolitical intelligence.`;
Desktop builds persist secrets in OS credential storage through Tauri command bindings backed by Rust `keyring` entries (`world-monitor` service namespace).
Secrets are **not stored in plaintext files** by the frontend.
## Degradation behavior
If required secrets are missing/disabled:
- Summarization: Groq/OpenRouter disabled, browser model fallback.
- FRED / EIA / Finnhub: economic, oil analytics, and stock data return empty state.
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.
- **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`.
- CI artifact/caching strategy that restores required package inputs before build.
See `docs/TAURI_VALIDATION_REPORT.md` for failure classification labels and troubleshooting flow.
## Packaging commands
To view script usage/help:
```bash
npm run desktop:package -- --help
```
### macOS (`.app` + `.dmg`)
```bash
npm run desktop:package:macos:full
npm run desktop:package:macos:tech
# or generic runner
npm run desktop:package -- --os macos --variant full
```
### Windows (`.exe` + `.msi`)
```bash
npm run desktop:package:windows:full
npm run desktop:package:windows:tech
# or generic runner
npm run desktop:package -- --os windows --variant tech
```
Bundler targets are pinned in both Tauri configs and enforced by packaging scripts:
- macOS: `app,dmg`
- Windows: `nsis,msi`
## Rust dependency modes (online vs restricted network)
From `src-tauri/`, the project supports two packaging paths:
### 1) Standard online build (default)
Use normal Cargo behavior (crates.io):
```bash
cd src-tauri
cargo generate-lockfile
cargo tauri build --config tauri.conf.json
```
### 2) Restricted-network build (pre-vendored or internal mirror)
An optional vendored source is defined in `src-tauri/.cargo/config.toml`. To use it, first prepare vendored crates on a machine that has registry access:
For CI or internal mirrors, publish `src-tauri/vendor/` as an artifact and restore it before the restricted-network build. If your organization uses an internal crates mirror instead of vendoring, point `source.crates-io.replace-with` to that mirror in CI-specific Cargo config and run the same build commands.
## Optional signing/notarization hooks
Unsigned packaging works by default.
If signing credentials are present in environment variables, Tauri will sign/notarize automatically during the same packaging commands.
### macOS Apple Developer signing + notarization
Set before packaging (Developer ID signature):
```bash
export TAURI_BUNDLE_MACOS_SIGNING_IDENTITY="Developer ID Application: Your Company (TEAMID)"
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
Run these checks first so failures are classified quickly:
1. npm registry reachability
- `npm ping`
2. crates.io sparse index reachability
- `curl -I https://index.crates.io/`
3. proxy configuration present when required by your network
If any of these checks fail, treat downstream desktop build failures as environment-level until the network path is fixed.
## Commands run
1. `npm ci` — failed because the environment blocks downloading the pinned `@tauri-apps/cli` package from npm (`403 Forbidden`).
2. `npm run typecheck` — succeeded.
3. `npm run build:full` — succeeded (warnings only).
4. `npm run desktop:build:full` — not runnable in this environment because `npm ci` failed, so the local `tauri` binary was unavailable (desktop scripts now fail fast with a clear `npm ci` remediation message when this occurs).
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.
## Failure classification for future QA
Use these labels in future reports so outcomes are actionable:
1. **External environment outage**
- Symptoms: npm/crates registry requests fail with transport/auth/network errors (403/5xx/timeout/DNS/proxy), independent of repository state.
- Action: retry in a healthy network or fix credentials/proxy/mirror availability.
2. **Expected failure: offline mode not provisioned**
- Symptoms: build is intentionally run without internet, but required offline inputs are missing (for Rust: no `vendor/` artifact, no internal mirror mapping, or offline override not enabled; for JS: no prepared package cache).
- Action: provision offline artifacts/mirror config first, enable offline override (`config.local.toml` or CLI `--config`), then rerun.
| P3 | OpenSky relay fallback path | `src/services/military-flights.ts` | `/api/opensky` | `api/opensky.js` | Requires cloud fallback | ✅ Relay fallback documented; no hard failure when relay is unavailable. |
## Non-parity closure actions completed
- Added **desktop readiness + non-parity fallback visibility** in `ServiceStatusPanel` so operators can see acceptance status and per-feature fallback behavior in desktop runtime.
- Kept local-sidecar strategy as the default path: desktop sidecar executes `api/*.js` handlers locally and only uses cloud fallback when handler execution or relay path fails.
## Desktop-ready acceptance criteria
A desktop build is considered **ready** when all checks below are green:
1. **Startup:** app launches and local sidecar health reports enabled.
2. **Map rendering:** map loads with local/static layers even when optional feeds are unavailable.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.