Market Atlas — Frontend (apps/web)
Next.js 16.3 (App Router, React 19.2, Tailwind v4, TypeScript strict). Package @market-atlas/web.
Run
bash
# dev (API must run on :8391 — `pnpm dev:api` at the repo root)
pnpm --filter @market-atlas/web dev # http://localhost:8390
pnpm --filter @market-atlas/web typecheck
pnpm --filter @market-atlas/web build # NEXT_DIST_DIR=.next-build to build while dev runs
pnpm --filter @market-atlas/web start # next start -p 8382 -H 127.0.0.1 (production, behind the API edge)
node apps/web/qa/screens.mjs [BASE_URL] # Playwright sweep: 30 routes × {390,1440} × {dark,light}: status, console errors, overflow, screenshotsEnvironment (root .env is loaded by next.config.ts): API_URL (server-side base, default http://127.0.0.1:8391), NEXT_PUBLIC_SITE_URL / MA_SITE_URL (canonical URLs, default https://www.market-atlas.co), optional NEXT_PUBLIC_WS_URL (browser WebSocket URL; derived automatically from API_URL in dev, same-origin /v1/stream in production), NEXT_DIST_DIR.
Data flow
- Server components call
api<T>(path)/apiEnvelope<T>/apiOptional<T>fromsrc/lib/api.tsagainstAPI_URL(no-store). A 404 from the API becomes NextnotFound(); other errors bubble toerror.tsx.apiOptionalreturnsnullfor non-critical widgets (health panel, changes, events on the home page). - Browser code always calls same-origin
/v1/...(src/lib/client-api.ts). In devnext.config.tsrewrites/v1/:path*→API_URL; in production the API process is the public edge (:8380): it serves/v1/*itself and proxies every other path to Next on127.0.0.1:8382. - Live stream —
src/lib/stream.ts: oneWebSocketper tab (MarketStreamClientsingleton), reference-counted channel subscriptions (subscribe/unsubscribemessages), reconnect with jittered backoff, batches parsed into a quote store keyed by instrument id plus an events ring buffer and a tape ring buffer. Listeners are notified at most every 100 ms and only for the keys that changed, so a table never re-renders per tick. Hooks:useMarketStream(channels)(keeps channels subscribed while mounted; returns connection state),useLiveQuote(id)(useSyncExternalStoreper instrument),useLiveEvents(max),useTape(max),useNow(ms). - Components merge SSR quotes with the stream: a streamed value is used only when its
receivedtimestamp is newer than the server quote'supdated_at(LivePrice,InstrumentHeader,InstrumentStats,InstrumentTablecells). Price cells flash subtly on change (.flash-up/.flash-down, 600 ms, disabled under reduced motion).
Routes
| Route | Data | Notes |
|---|---|---|
/ |
/v1/stats, /v1/markets, /v1/events, /v1/data-health, /v1/changes?window=1h |
hero + telemetry strip (polls stats every 5 s, animated counters), pulse rows, live tape (tape), live events (events:*), world map, movers, source health, what changed |
/live |
/v1/events?limit=100 + stream events:*, tape |
filters: asset class, country, event types, severity, min confidence, include quotes; pause/resume freezes the list |
/markets, /stocks, /etfs, /indices, /crypto, /forex, /rates, /commodities |
/v1/markets, /v1/instruments?asset_class=…"ed=1&sort=… |
ClassPage (server) → InstrumentTable (client, sortable, live via quotes:class:<CLASS>); sort/page/q/all in search params |
/instruments/[id] (+ /stocks/[symbol], /crypto/[symbol], /etfs/…, /indices/…, /forex/… redirects) |
/v1/instruments/:id, /v1/history/:id, /v1/quotes/:id/provenance, /v1/events?instrument=, /v1/filings?cik= |
sticky live header, lightweight-charts (1D/5D intraday from consensus bars + live updates; 1M…MAX daily), key stats, Why this price? = Source Coverage Matrix (refreshes every 5 s: observation type, Δ vs consensus in bp, role votes/confirms/validates/excluded with readable exclusion reasons, comparability class, shared-upstream note), CoverageBadge "N / T independent sources · tier · status" in the sticky header, section badge and "Observed by" card (/v1/coverage/:id), events, filings, venue, company, aliases, related; JSON-LD FinancialProduct |
/exchanges, /exchanges/[id] |
/v1/exchanges, /v1/exchanges/:id |
map + table; detail: state, next transition, sessions, holidays, breadth, gainers/losers/most active, events |
/countries, /countries/[code] |
/v1/countries, /v1/countries/:code |
atlas by region; detail: venues, indices, rates, FX pairs, equities, events |
/events, /events/[id], /halts, /filings |
/v1/events, /v1/events/:id, /v1/filings |
server-filtered tables (type, severity, instrument, country, before-cursor); halts page streams events:type:TRADING_HALT |
/sources, /connectors, /data-health, /status |
/v1/sources, /v1/connectors, /v1/data-health, /v1/status |
provenance directory (rights + real-time badges, role contributor/validator, "likely shares an upstream with …", no endpoints), operations table, health with 48 h sparklines and incidents, public status |
/coverage (`?tier=A |
B | C |
/search?q= + ⌘K palette |
/v1/search |
grouped results; palette debounced 140 ms, keyboard navigation |
/compare?ids=&resolution= |
/v1/compare |
rebased lines (lightweight-charts), stats table, correlation matrix, instrument picker via search |
/methodology, /developers, /licensing |
static (+ /v1/sources for attributions) |
copy consistent with apps/api/src/core/consensus.ts |
/admin |
/v1/admin/* with x-ma-admin-token from localStorage["ma-admin-token"] |
overview, connectors (pause/resume/restart/test, detail with health history/fingerprints/schema-change ack/recent observations/state), schema changes, divergence, storage, discovery probe |
robots.ts, sitemap.ts (segmented: static, exchanges, countries, per asset class), manifest.ts, icon.svg, not-found.tsx, error.tsx, segment loading.tsx (markets/events/filings) |
root loading.tsx deliberately absent so notFound() returns a real 404 status |
Components
components/layout:SiteHeader(desktop nav, ⌘K button, stream indicator, theme toggle, market clock strip NY/London/Frankfurt/Tokyo/HK/Sydney with session dots),MobileTabBar(Markets · Live · Search · Events · World),SiteFooter(disclaimer, attributions),SearchProvider/SearchDialog,ThemeToggle(persistsma-theme;THEME_SCRIPTapplies it before paint),LogoMark/Wordmark.components/ui:StatusBadge(data status / connector state / market state / severity tones),RightsBadge,LivePrice/ChangeCell,FreshnessLabel("Updated 320 ms ago" · "Delayed 15 min · 12s ago" · "At close · Fri 16:00 EDT" · "End of day · Sep 11" · "Stale · last update 4h ago" · "Withheld"),RelativeTime,ConfidenceMeter,DataTable(client, sortable, sticky header, responsive hide),Sparkline,Page/PageHeader/Section/Stat/Kv/Pill/Empty.components/market:TelemetryStrip,PulseRow,LiveTape,LiveEvents,EventRow,LiveFeed,InstrumentTable,ClassPage,InstrumentHeader,InstrumentStats,PriceChart,ProvenancePanel(coverage matrix),CoverageBadge,RedundancyPanel(homepage Source Redundancy block from/v1/stats.coverage),CompareView,WorldMap(d3-geo Natural Earth + world-atlas countries-110m, loaded lazily),HealthPanel,ChangesPanel.components/admin/AdminConsole.
Conventions
- Design tokens live in
globals.css(--canvas/--surface/--ink/--rule/--accent/--positive/--negative/--warning/--stale, light + dark onhtml[data-theme]); Tailwind utilities map through@theme inline(bg-surface,text-ink-2,border-rule…). Numbers use.mono/.tnum. Dense tables use.table-dense. - Formatting (
src/lib/format.ts): price precision by magnitude and asset class (FX 4–5 decimals, yieldsx.xx%, crypto sub-cent), compact volumes, signed percents with−,toMs()parses ISO / epoch / Postgres text timestamps (2026-09-11 19:50:00-04). - Never present stale as live:
data_statusdrives every badge and freshness label;withheldquotes render provenance but no value. - Responsive grids must declare base columns (
grid grid-cols-1 [&>*]:min-w-0 lg:grid-cols-…) — implicitautocolumns caused horizontal overflow at 390 px. QA script assertsscrollWidth ≤ clientWidth. - Server components never import
stream.ts; client components never importlib/api.ts(server-only).
Source Mesh v2 (2026-09-13)
lib/coverage.ts: labels for observation types (TRADE, QUOTE, OFFICIAL_FIX, EOD_CLOSE, STABLECOIN_PROXY…), comparability classes (LIVE / FIX / EOD),roleOf()(votes · confirms · validates · excluded), readablereasonLabel()(stale, not comparable, temporal mismatch, validation only, validator disagrees, outlier), tier labels/targets,formatDeltaBps/deltaTone.ConfidenceMeteracceptsproxies/validatorsand shows a small+Nmarker when a quote is confirmed by proxies or validators (instrument tables, header).- Validator contributions arrive with
value: null(withheld) and are rendered as "— validator" with agrees/disagrees fromdelta_bps; they are never shown as values. - Homepage:
RedundancyPanel(prominent) + mesh explainer; footer/sitemap/header include/coverage.