From 83d770a2b260689011eee654fb785307f63ef43c Mon Sep 17 00:00:00 2001 From: gadgethd <111318106+gadgethd@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:48:27 +0100 Subject: [PATCH] =?UTF-8?q?fix(ui):=20UKMesh=20visual=20QA=20fixes=20?= =?UTF-8?q?=E2=80=94=20cookie=20banner,=20map=20labels,=20feed,=20charts,?= =?UTF-8?q?=20site=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deployed fixes from visual QA of ukmesh.com / app.ukmesh.com / healthcheck.ukmesh.com: - Cookie consent: static full-width bottom bar with reserved page spacing (no longer overlays content) - Landing: Live Map nav marked external with arrow, hero buttons and secondary text contrast - Map app: readable place labels in both themes, packet feed top row no longer clipped, aligned feed columns, bordered header tool buttons - Site pages: dark-themed Region select, brighter topology graph, Repeaters empty state, Companions bar scale legend, themed Spam controls/checkbox, Install badges - Health: firmware chart label dedupe/rotation with Include Unknown toggle - Docs: mobile code blocks scrollable - Health Check: No data yet empty states, contrast, status pill a11y - Healthcheck overrides: merged empty-state and a11y changes into deployed bind-mounted files --- frontend/src/components/Map/MapLibreMap.tsx | 646 ++-- frontend/src/components/Map/mapConfig.ts | 154 +- .../src/components/Map/mapSourceLayers.ts | 267 ++ frontend/src/components/PacketFeed.tsx | 48 +- frontend/src/components/app/AppTopBar.tsx | 23 +- .../src/components/app/TimelineControl.tsx | 2 +- frontend/src/pages/StatusPage.tsx | 257 +- frontend/src/pages/TopologyPage.tsx | 87 +- frontend/src/pages/docs-pages.css | 30 +- frontend/src/pages/network-intelligence.css | 36 +- frontend/src/pages/shared/SiteLayout.tsx | 14 +- frontend/src/pages/site-content.css | 271 +- frontend/src/pages/site-shell.css | 30 +- frontend/src/pages/spam-page.css | 67 +- frontend/src/pages/ukmesh/UKCompanionPage.tsx | 78 +- frontend/src/pages/ukmesh/UKHomePage.tsx | 60 +- frontend/src/pages/ukmesh/UKInstallPage.tsx | 20 +- .../src/pages/ukmesh/UKRepeaterSearchPage.tsx | 266 +- frontend/src/styles/globals.css | 605 ++- frontend/src/styles/map-app.css | 325 +- healthcheck-overrides/app.js | 2077 ++++++++++ healthcheck-overrides/index.html | 16 +- healthcheck-overrides/share.html | 16 +- healthcheck-overrides/styles.css | 3343 +++++++++++++++++ 24 files changed, 7527 insertions(+), 1211 deletions(-) create mode 100644 frontend/src/components/Map/mapSourceLayers.ts create mode 100644 healthcheck-overrides/app.js create mode 100644 healthcheck-overrides/styles.css diff --git a/frontend/src/components/Map/MapLibreMap.tsx b/frontend/src/components/Map/MapLibreMap.tsx index 2bf5848..de2b340 100644 --- a/frontend/src/components/Map/MapLibreMap.tsx +++ b/frontend/src/components/Map/MapLibreMap.tsx @@ -27,6 +27,8 @@ import { DEFAULT_ZOOM, EMPTY_FC, MAP_ARC_REFRESH_INTERVAL_MS, + MAP_LABEL_COLORS, + MAP_RASTER_PAINT, MAP_REFRESH_INTERVAL_MS, MAP_STYLE, MAP_STYLE_LIGHT, @@ -65,40 +67,51 @@ import type { } from './types.js'; import { sampleElevationAt } from '../../utils/terrainSampler.js'; import { computeCustomLos } from '../../utils/customLos.js'; +import { ApiResponseError, fetchJson, withScopeParams, type ApiScope } from '../../utils/api.js'; +import { ScopedCache } from '../../utils/scopedCache.js'; +import { createVisibilityPoller } from '../../hooks/useVisibilityPoll.js'; -const NODE_LINK_CACHE_TTL_MS = 5 * 60_000; -const nodeLinksCache = new Map }>(); +const NODE_DOCK_RIGHT_PADDING = 372; -function fetchNodeLinks(nodeId: string): Promise { - const cached = nodeLinksCache.get(nodeId); - if (cached?.rows && Date.now() - (cached.fetchedAt ?? 0) < NODE_LINK_CACHE_TTL_MS) { - return Promise.resolve(cached.rows); - } - if (cached?.pending) return cached.pending; - - const pending = fetch(`/api/nodes/${encodeURIComponent(nodeId)}/links`, { - signal: AbortSignal.timeout(15_000), - }) - .then(async (response) => { - if (!response.ok) throw new Error(`links request failed: ${response.status}`); - const payload = await response.json() as NodeLink[]; - const rows = Array.isArray(payload) ? payload : []; - nodeLinksCache.set(nodeId, { rows, fetchedAt: Date.now() }); - return rows; - }) - .catch((error) => { - nodeLinksCache.delete(nodeId); - throw error; - }); - nodeLinksCache.set(nodeId, { ...cached, pending }); - return pending; +function mapPaddingForNode(selected: string | null): { top: number; right: number; bottom: number; left: number } { + const desktop = window.matchMedia('(min-width: 641px)').matches; + return { + top: 0, + right: selected && desktop ? NODE_DOCK_RIGHT_PADDING : 0, + bottom: 0, + left: 0, + }; } +const NODE_LINK_CACHE_TTL_MS = 5 * 60_000; +const nodeLinksCache = new ScopedCache({ + name: 'map-node-links', + ttlMs: NODE_LINK_CACHE_TTL_MS, + maxEntries: 512, + maxBytes: 8 * 1024 * 1024, + maxInflight: 8, +}); + +function fetchNodeLinks(nodeId: string, scopeKey: string, scope: ApiScope): Promise { + return nodeLinksCache.getOrLoad(scopeKey, nodeId.toUpperCase(), async () => { + const payload = await fetchJson( + withScopeParams(`/api/nodes/${encodeURIComponent(nodeId)}/links`, scope), + {}, + { timeoutMs: 15_000, maxBytes: 2 * 1024 * 1024 }, + ); + if (!Array.isArray(payload)) throw new Error('node links response was not an array'); + return payload as NodeLink[]; + }); +} + +import { + installMapSourcesAndLayers, +} from './mapSourceLayers.js'; // ── Main Component ──────────────────────────────────────────────────────────── export function MapLibreMap({ inferredNodes, - inferredActiveNodeIds: _inferredActiveNodeIds, + inferredActiveNodeIds, showLinks, showTerrain, showClientNodes, @@ -110,16 +123,23 @@ export function MapLibreMap({ onNodeSelect, onMapReady, mapLight, + network, + observer, + privacyGeneration, }: MapLibreMapProps) { + const requestScope = useMemo(() => ({ network, observer }), [network, observer]); + const requestScopeKey = `${network ?? 'all'}|${observer ?? 'all'}|privacy-${privacyGeneration}`; const containerRef = useRef(null); const mapRef = useRef(null); const mapLoadedRef = useRef(false); const nodesRef = useRef(nodeStore.getState().nodes); const coverageRef = useRef(coverageStore.getState().coverage); const selectedCoverageRef = useRef(null); + const coverageRequestRef = useRef(null); const viablePairsRef = useRef(linkStateStore.getState().viablePairsArr); const linkMetricsRef = useRef(linkStateStore.getState().linkMetrics); const inferredNodesRef = useRef(inferredNodes); + const inferredActiveNodeIdsRef = useRef(inferredActiveNodeIds); const showLinksRef = useRef(showLinks); const showTerrainRef = useRef(showTerrain); const showClientNodesRef = useRef(showClientNodes); @@ -149,7 +169,7 @@ export function MapLibreMap({ const handleCustomLosPointRef = useRef<(point: CustomLosPoint) => Promise>(null as any); // Planned repeater placement const plannedRepeatersRef = useRef([]); - const plannedPollRefs = useRef>(new Map()); + const plannedPollRefs = useRef void }>>(new Map()); // Plans whose LOS overlay has already been auto-applied (so we don't re-apply // it or fight a user who manually hid it from the popup). const plannedLosAppliedRef = useRef>(new Set()); @@ -181,13 +201,22 @@ export function MapLibreMap({ void import('maplibre-gl/dist/maplibre-gl.css'); }, []); + useEffect(() => { + coverageRequestRef.current?.abort(); + coverageRequestRef.current = null; + selectedCoverageRef.current = null; + setSelectedCoverageNodeId(null); + setCoverageLoadingNodeId(null); + setCoverageMessage(null); + }, [requestScopeKey]); + // -- Map theme (light/dark) ------------------------------------------------- useEffect(() => { const map = mapRef.current; if (map && mapLoadedRef.current) { const oldId = mapLight ? 'carto-dark' : 'carto-light'; const newId = mapLight ? 'carto-light' : 'carto-dark'; - const variant = mapLight ? 'light_all' : 'dark_all'; + const variant = mapLight ? 'light_nolabels' : 'dark_nolabels'; if (map.getSource(newId)) return; if (map.getLayer('background')) map.removeLayer('background'); if (map.getLayer('bg-fill')) map.removeLayer('bg-fill'); @@ -205,16 +234,41 @@ export function MapLibreMap({ // Insert bg-fill + basemap at the very bottom const firstLayer = map.getStyle().layers[0]?.id; map.addLayer( - { id: 'bg-fill', type: 'background', paint: { 'background-color': mapLight ? '#e8e8e8' : '#080d14' } }, + { id: 'bg-fill', type: 'background', paint: { 'background-color': mapLight ? '#edf2f7' : '#080d14' } }, firstLayer, ); map.addLayer( - { id: 'background', type: 'raster', source: newId }, - map.getStyle().layers[1]?.id, // after bg-fill, before everything else + { + id: 'background', + type: 'raster', + source: newId, + paint: MAP_RASTER_PAINT[mapLight ? 'light' : 'dark'], + }, + map.getStyle().layers[1]?.id, // after bg-fill, before vector labels ); + + const labelColors = mapLight ? MAP_LABEL_COLORS.light : MAP_LABEL_COLORS.dark; + for (const [layerId, colorKey] of [ + ['map-labels-place', 'place'], + ['map-labels-water', 'water'], + ['map-labels-road', 'road'], + ] as const) { + if (!map.getLayer(layerId)) continue; + map.setPaintProperty(layerId, 'text-color', labelColors[colorKey]); + map.setPaintProperty(layerId, 'text-halo-color', labelColors.halo); + map.setPaintProperty(layerId, 'text-halo-width', colorKey === 'place' ? 1.7 : 1.5); + map.setPaintProperty(layerId, 'text-halo-blur', 0.1); + } } }, [mapLight]); + // Keep the map's usable camera area clear of the right-side node dock. + useEffect(() => { + const map = mapRef.current; + if (!map || !mapLoadedRef.current) return; + map.setPadding(mapPaddingForNode(selectedNodeId)); + }, [selectedNodeId]); + // -- LOS profiles (client-side, multi-node, auto-expire) ------------------- const addLosLoading = useOverlayStore((state) => state.addLosLoading); @@ -257,7 +311,7 @@ export function MapLibreMap({ const ANTENNA_H = 10; const EXAG = TERRAIN_CONFIG.exaggeration; try { - const links = await fetchNodeLinks(nodeId); + const links = await fetchNodeLinks(nodeId, requestScopeKey, requestScope); const sourceNode = nodesRef.current.get(nodeId); if (!sourceNode || !hasCoords(sourceNode)) { setLosProfilesForNode(nodeId, []); @@ -292,7 +346,14 @@ export function MapLibreMap({ removeLosNode(nodeId); }, 15_000); losTimersRef.current.set(nodeId, handle); - }, [addLosLoading, setLosProfilesForNode, removeLosNode, clearLosTimer]); + }, [ + addLosLoading, + clearLosTimer, + removeLosNode, + requestScope, + requestScopeKey, + setLosProfilesForNode, + ]); // -- Custom LOS (two-point terrain-sampled LOS) ---------------------------- @@ -328,38 +389,78 @@ export function MapLibreMap({ const pollPlannedCoverage = useCallback((planId: string) => { if (!viewshedEnabledRef.current) return; - const iv = window.setInterval(() => { - void fetch(`/api/coverage/planned/${planId}`) - .then((r) => { - if (r.status === 404 || r.status === 410) { - return { - status: 'failed', - coverage: undefined, - } satisfies { status: string; coverage?: PlannedRepeater['coverage'] }; - } - if (!r.ok) throw new Error('planned coverage temporarily unavailable'); - return r.json() as Promise<{ status: string; coverage?: PlannedRepeater['coverage'] }>; - }) - .then((data) => { - if (data.status === 'ready') { - window.clearInterval(iv); - plannedPollRefs.current.delete(planId); - useOverlayStore.getState().updatePlannedRepeater(planId, { status: 'ready', coverage: data.coverage }); - } else if (data.status === 'failed') { - window.clearInterval(iv); - plannedPollRefs.current.delete(planId); - useOverlayStore.getState().updatePlannedRepeater(planId, { status: 'error' }); - } - }) - .catch(() => {}); - }, 2000); - plannedPollRefs.current.set(planId, iv); - }, []); + plannedPollRefs.current.get(planId)?.stop(); + + const deadlineAt = Date.now() + 2 * 60_000; + let deadlineTimer: number | null = null; + let poller: ReturnType | null = null; + let stopped = false; + const finish = (patch: Partial) => { + if (stopped) return; + stopped = true; + if (deadlineTimer !== null) window.clearTimeout(deadlineTimer); + poller?.stop(); + plannedPollRefs.current.delete(planId); + useOverlayStore.getState().updatePlannedRepeater(planId, patch); + }; + const handle = { + stop: () => { + if (stopped) return; + stopped = true; + if (deadlineTimer !== null) window.clearTimeout(deadlineTimer); + poller?.stop(); + }, + }; + plannedPollRefs.current.set(planId, handle); + + poller = createVisibilityPoller({ + poll: async (signal) => { + if (Date.now() >= deadlineAt) { + finish({ status: 'error' }); + return; + } + const data = await fetchJson<{ + status?: unknown; + coverage?: PlannedRepeater['coverage']; + }>( + withScopeParams(`/api/coverage/planned/${encodeURIComponent(planId)}`, requestScope), + { cache: 'no-store', signal }, + { timeoutMs: 8_000, maxBytes: 8 * 1024 * 1024 }, + ); + if (signal.aborted || stopped) return; + if (data.status === 'ready' && data.coverage) { + finish({ status: 'ready', coverage: data.coverage }); + } else if (data.status === 'failed') { + finish({ status: 'error' }); + } else if (data.status !== 'queued' && data.status !== 'processing') { + throw new Error('Planned coverage returned an unknown state'); + } + }, + intervalMs: 2_000, + timeoutMs: 8_000, + maxBackoffMs: 8_000, + jitterRatio: 0.1, + isVisible: () => document.visibilityState === 'visible', + subscribeVisibility: (listener) => { + document.addEventListener('visibilitychange', listener); + return () => document.removeEventListener('visibilitychange', listener); + }, + onError: (pollError) => { + if ( + Date.now() >= deadlineAt + || (pollError instanceof ApiResponseError && [404, 410].includes(pollError.status)) + ) { + finish({ status: 'error' }); + } + }, + }); + deadlineTimer = window.setTimeout(() => finish({ status: 'error' }), Math.max(1, deadlineAt - Date.now())); + }, [requestScope]); const handleRemovePlannedRepeater = useCallback((planId: string) => { - const iv = plannedPollRefs.current.get(planId); - if (iv !== undefined) { - window.clearInterval(iv); + const poll = plannedPollRefs.current.get(planId); + if (poll) { + poll.stop(); plannedPollRefs.current.delete(planId); } setPlannedPopupState((prev) => (prev?.planId === planId ? null : prev)); @@ -367,9 +468,12 @@ export function MapLibreMap({ useOverlayStore.getState().removeLosNode(planId); useOverlayStore.getState().removePlannedRepeater(planId); if (viewshedEnabledRef.current) { - void fetch(`/api/coverage/planned/${planId}`, { method: 'DELETE' }).catch(() => {}); + void fetch( + withScopeParams(`/api/coverage/planned/${encodeURIComponent(planId)}`, requestScope), + { method: 'DELETE' }, + ).catch(() => {}); } - }, []); + }, [requestScope]); // Rebuild the planned-link lines from the current plans + live node positions, // and toggle their visibility with the global Links toggle. @@ -460,19 +564,22 @@ export function MapLibreMap({ const placePlannedRepeater = useCallback(async (lat: number, lon: number) => { if (!viewshedEnabledRef.current) return; try { - const res = await fetch('/api/coverage/planned', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ lat, lon }), - }); - if (!res.ok) return; - const data = await res.json() as { plan_id: string }; + const data = await fetchJson<{ plan_id: string }>( + withScopeParams('/api/coverage/planned', requestScope), + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lat, lon }), + }, + { timeoutMs: 15_000, maxBytes: 64 * 1024 }, + ); + if (typeof data.plan_id !== 'string' || !data.plan_id) return; useOverlayStore.getState().addPlannedRepeater({ id: data.plan_id, lat, lon, status: 'queued' }); pollPlannedCoverage(data.plan_id); } catch { // non-fatal } - }, [pollPlannedCoverage]); + }, [pollPlannedCoverage, requestScope]); // Keep handler refs in sync for map event handlers useEffect(() => { @@ -504,12 +611,24 @@ export function MapLibreMap({ updatePlannedLinks(); }, [viewshedEnabled, plannedRepeaters, updatePlannedLinks]); - // Clean up all planned repeaters, intervals, and LOS overlays on unmount + // Stop old-scope jobs before they can publish into a new network/observer. useEffect(() => () => { - for (const [planId, iv] of plannedPollRefs.current) { - window.clearInterval(iv); + for (const [planId, poll] of plannedPollRefs.current) { + poll.stop(); + useOverlayStore.getState().updatePlannedRepeater(planId, { status: 'error' }); + } + plannedPollRefs.current.clear(); + }, [requestScopeKey]); + + // Clean up all planned repeaters, pollers, and LOS overlays on unmount + useEffect(() => () => { + for (const [planId, poll] of plannedPollRefs.current) { + poll.stop(); if (viewshedEnabledRef.current) { - void fetch(`/api/coverage/planned/${planId}`, { method: 'DELETE' }).catch(() => {}); + void fetch( + withScopeParams(`/api/coverage/planned/${encodeURIComponent(planId)}`, requestScope), + { method: 'DELETE' }, + ).catch(() => {}); } } plannedPollRefs.current.clear(); @@ -517,7 +636,7 @@ export function MapLibreMap({ useOverlayStore.getState().removeLosNode(planId); } plannedLosAppliedRef.current.clear(); - }, []); + }, [requestScope]); // Cursor crosshair while in custom LOS mode or plan repeater mode useEffect(() => { @@ -609,6 +728,9 @@ export function MapLibreMap({ clash.clashModeActive, clash.clashModeActive ? null : currentPathNodeIds, replayNodeIdsRef.current, + Date.now(), + inferredNodesRef.current, + inferredActiveNodeIdsRef.current, ); (mapRef.current.getSource('nodes') as maplibregl.GeoJSONSource | undefined)?.setData(nodeGeoJSON); } @@ -672,8 +794,8 @@ export function MapLibreMap({ setPlanRepeaterMode(false); setPlannedPopupState(null); plannedPopupRef.current?.remove(); - for (const [, iv] of plannedPollRefs.current) { - window.clearInterval(iv); + for (const [, poll] of plannedPollRefs.current) { + poll.stop(); } plannedPollRefs.current.clear(); for (const repeater of useOverlayStore.getState().plannedRepeaters) { @@ -730,328 +852,9 @@ export function MapLibreMap({ map.on('load', () => { mapLoadedRef.current = true; - // ── Node dots source + layer ─────────────────────────────────────────── - map.addSource('nodes', { type: 'geojson', data: EMPTY_FC }); - - map.addLayer({ - id: 'node-dots', - type: 'circle', - source: 'nodes', - filter: ['==', ['get', 'visible'], true], - paint: { - 'circle-radius': [ - 'interpolate', ['linear'], ['zoom'], - 6, 3, 9, 4, 11, 5, 13, 7, 16, 9, - ], - 'circle-color': [ - 'case', - ['==', ['get', 'hex_clash_state'], 'offender'], '#ef4444', - ['==', ['get', 'hex_clash_state'], 'relay'], '#22c55e', - ['get', 'replay_active'], '#fbbf24', - ['get', 'is_link_only_stale'], '#4b5563', - ['get', 'is_inferred'], '#7dd3fc', - ['get', 'is_stale'], '#6b7280', - ['!', ['get', 'is_online']], '#6b7280', - ['==', ['get', 'role'], 1], '#ff9f43', - ['==', ['get', 'role'], 3], '#a78bfa', - ['==', ['get', 'role'], 4], '#34d399', - '#00c4ff', // repeater (role 2 / default) - ], - 'circle-opacity': [ - 'case', - ['all', ['get', 'replay_mode'], ['!', ['get', 'replay_active']]], 0.12, - ['get', 'is_link_only_stale'], 0.22, - ['get', 'is_stale'], 0.4, - ['!', ['get', 'is_online']], 0.4, - ['get', 'is_inferred'], 0.7, - 1.0, - ], - 'circle-stroke-width': 0, - 'circle-stroke-color': '#00c4ff', - 'circle-stroke-opacity': 0.7, - }, + installMapSourcesAndLayers(map, { + showLinks: showLinksRef.current, }); - - // Observer quality is a separate, cached overlay so live node refreshes do - // not rebuild health metrics. A coloured ring keeps the role colour intact. - map.addSource('observer-health', { type: 'geojson', data: EMPTY_FC }); - map.addLayer({ - id: 'observer-health-rings', - type: 'circle', - source: 'observer-health', - paint: { - 'circle-radius': [ - 'interpolate', ['linear'], ['zoom'], - 6, 5.5, 9, 7, 11, 8.5, 13, 11, 16, 14, - ], - 'circle-color': 'rgba(0,0,0,0)', - 'circle-stroke-width': 2, - 'circle-stroke-color': [ - 'match', ['get', 'quality'], - 'good', '#22c55e', - 'watch', '#fbbf24', - '#ef4444', - ], - 'circle-stroke-opacity': 0.95, - }, - }); - void fetch('/api/observers/health', { signal: AbortSignal.timeout(10_000) }) - .then((response) => response.ok ? response.json() as Promise> : []) - .then((observers) => { - const source = map.getSource('observer-health') as maplibregl.GeoJSONSource | undefined; - source?.setData({ - type: 'FeatureCollection', - features: observers.map((observer) => ({ - type: 'Feature', - geometry: { type: 'Point', coordinates: [observer.lon, observer.lat] }, - properties: { - node_id: observer.node_id, - name: observer.name, - score: observer.score, - quality: observer.quality, - }, - })), - }); - }) - .catch(() => {}); - - // ── Selected-node highlight (recolour + ring) ────────────────────────── - // Two circle layers over node-dots, filtered to the selected node id. - // A soft halo underneath, a bright solid marker on top. - map.addLayer({ - id: 'node-dots-selected-halo', - type: 'circle', - source: 'nodes', - filter: ['==', ['get', 'node_id'], '__none__'], - paint: { - 'circle-radius': [ - 'interpolate', ['linear'], ['zoom'], - 6, 11, 9, 13, 11, 15, 13, 18, 16, 22, - ], - 'circle-color': '#22e0ff', - 'circle-opacity': 0.16, - 'circle-blur': 0.5, - }, - }); - map.addLayer({ - id: 'node-dots-selected', - type: 'circle', - source: 'nodes', - filter: ['==', ['get', 'node_id'], '__none__'], - paint: { - 'circle-radius': [ - 'interpolate', ['linear'], ['zoom'], - 6, 5, 9, 6.5, 11, 8, 13, 10, 16, 13, - ], - 'circle-color': '#8af4ff', - 'circle-opacity': 1, - 'circle-stroke-color': '#ffffff', - 'circle-stroke-width': 2.5, - 'circle-stroke-opacity': 0.95, - }, - }); - - // ── Privacy rings source + layer ─────────────────────────────────────── - map.addSource('privacy-rings', { type: 'geojson', data: EMPTY_FC }); - map.addLayer({ - id: 'privacy-rings-layer', - type: 'line', - source: 'privacy-rings', - paint: { - 'line-color': '#f59e0b', - 'line-width': 1.4, - 'line-opacity': 0.55, - 'line-dasharray': [4, 6], - }, - }); - - // ── Viable links source + layer ─────────────────────────────────────── - map.addSource('viable-links', { type: 'geojson', data: EMPTY_FC }); - map.addLayer({ - id: 'viable-links-layer', - type: 'line', - source: 'viable-links', - layout: { - visibility: 'none', - 'line-cap': 'round', - 'line-join': 'round', - }, - paint: { - 'line-color': ['get', 'color'], - 'line-width': ['get', 'width'], - 'line-opacity': ['get', 'opacity'], - }, - }); - - // ── Coverage source + layer ──────────────────────────────────────────── - map.addSource('coverage', { type: 'geojson', data: EMPTY_FC }); - map.addLayer({ - id: 'coverage-fill', - type: 'fill', - source: 'coverage', - layout: { visibility: 'none' }, - paint: { - 'fill-color': [ - 'match', ['get', 'band'], - 'green', '#22c55e', - 'amber', '#fbbf24', - 'red', '#ef4444', - '#22c55e', - ], - 'fill-opacity': [ - 'match', ['get', 'band'], - 'green', 0.22, - 'amber', 0.16, - 'red', 0.10, - 0.18, - ], - }, - }); - - // ── Clash lines source + layer ───────────────────────────────────────── - map.addSource('clash-lines', { type: 'geojson', data: EMPTY_FC }); - map.addLayer({ - id: 'clash-lines-layer', - type: 'line', - source: 'clash-lines', - layout: { visibility: 'none' }, - paint: { - 'line-color': '#f97316', - 'line-width': 2.2, - 'line-opacity': 0.9, - }, - }); - - // ── Planned coverage source + layers ────────────────────────────────── - map.addSource('planned-coverage', { type: 'geojson', data: EMPTY_FC }); - map.addLayer({ - id: 'planned-coverage-fill', - type: 'fill', - source: 'planned-coverage', - paint: { - 'fill-color': [ - 'match', ['get', 'band'], - 'green', '#2dd4bf', // teal-400 - 'amber', '#818cf8', // indigo-400 - 'red', '#c084fc', // purple-400 - '#2dd4bf', - ], - 'fill-opacity': [ - 'match', ['get', 'band'], - 'green', 0.30, - 'amber', 0.25, - 'red', 0.20, - 0.25, - ], - }, - }); - map.addLayer({ - id: 'planned-coverage-outline', - type: 'line', - source: 'planned-coverage', - paint: { - 'line-color': '#22d3ee', // cyan-400 - 'line-width': 1.5, - 'line-opacity': 0.6, - }, - }); - - // ── Predicted planned-repeater links source + layer ─────────────────── - // Dashed lines (coloured by predicted path loss) so they read as - // hypothetical, distinct from the solid observed-link lines. Visibility - // follows the global Links toggle. - map.addSource('planned-links', { type: 'geojson', data: EMPTY_FC }); - map.addLayer({ - id: 'planned-links-layer', - type: 'line', - source: 'planned-links', - layout: { - visibility: showLinksRef.current ? 'visible' : 'none', - 'line-cap': 'round', - 'line-join': 'round', - }, - paint: { - 'line-color': ['get', 'color'], - 'line-width': ['get', 'width'], - 'line-opacity': 0.9, - 'line-dasharray': [2, 1.5], - }, - }); - - // ── Planned repeater pins source + layers ────────────────────────────── - // Styled to match real repeater nodes (role 2, #00c4ff) but visually - // distinct via white stroke + glow halo + "Planned" label. - map.addSource('planned-pins', { type: 'geojson', data: EMPTY_FC }); - - // Halo: soft glow behind the pin - map.addLayer({ - id: 'planned-pins-halo', - type: 'circle', - source: 'planned-pins', - paint: { - 'circle-radius': [ - 'interpolate', ['linear'], ['zoom'], - 6, 8, 9, 11, 11, 14, 13, 18, 16, 22, - ], - 'circle-color': '#22d3ee', - 'circle-opacity': [ - 'match', ['get', 'status'], - 'ready', 0.20, - 0.10, - ], - 'circle-stroke-width': 0, - }, - }); - - // Core dot: same size/colour as a real online repeater, white stroke to mark as planned - map.addLayer({ - id: 'planned-pins-dot', - type: 'circle', - source: 'planned-pins', - paint: { - 'circle-radius': [ - 'interpolate', ['linear'], ['zoom'], - 6, 3, 9, 4, 11, 5, 13, 7, 16, 9, - ], - 'circle-color': [ - 'match', ['get', 'status'], - 'ready', '#00c4ff', // identical to real online repeater - '#4b5563', // dark grey while computing - ], - 'circle-opacity': [ - 'match', ['get', 'status'], - 'ready', 1.0, - 0.6, - ], - 'circle-stroke-color': '#ffffff', - 'circle-stroke-width': 2, - 'circle-stroke-opacity': 0.95, - }, - }); - - // Label: status ("Planned"/"Computing…") + the placement coordinates below the dot - map.addLayer({ - id: 'planned-pins-label', - type: 'symbol', - source: 'planned-pins', - layout: { - 'text-field': ['get', 'label'], - 'text-size': 10, - 'text-anchor': 'top', - 'text-offset': [0, 1.0], - 'text-allow-overlap': true, - 'text-ignore-placement': true, - }, - paint: { - 'text-color': '#22d3ee', - 'text-halo-color': 'rgba(0,0,0,0.8)', - 'text-halo-width': 1.2, - }, - }); - // ── Click handler ────────────────────────────────────────────────────── map.on('click', 'planned-pins-dot', (e) => { if (!viewshedEnabledRef.current) return; @@ -1150,6 +953,7 @@ export function MapLibreMap({ }); mapRef.current = map; + map.setPadding(mapPaddingForNode(selectedNodeIdRef.current)); onMapReady?.(map); refreshMapSources(); @@ -1196,8 +1000,9 @@ export function MapLibreMap({ useEffect(() => { inferredNodesRef.current = inferredNodes; + inferredActiveNodeIdsRef.current = inferredActiveNodeIds; scheduleRefresh({ nodes: true }); - }, [inferredNodes, scheduleRefresh]); + }, [inferredActiveNodeIds, inferredNodes, scheduleRefresh]); useEffect(() => { showLinksRef.current = showLinks; @@ -1310,6 +1115,8 @@ export function MapLibreMap({ if (!viewshedEnabled) return; if (coverageLoadingNodeId === nodeId) return; if (selectedCoverageNodeId === nodeId) { + coverageRequestRef.current?.abort(); + coverageRequestRef.current = null; selectedCoverageRef.current = null; setSelectedCoverageNodeId(null); setCoverageMessage(null); @@ -1317,32 +1124,48 @@ export function MapLibreMap({ return; } + coverageRequestRef.current?.abort(); + const controller = new AbortController(); + coverageRequestRef.current = controller; setCoverageLoadingNodeId(nodeId); setCoverageMessage(null); - void fetch(`/api/coverage/${encodeURIComponent(nodeId)}`, { cache: 'no-store' }) - .then(async (response) => { - const payload = await response.json().catch(() => ({})) as { status?: string; coverage?: NodeCoverage }; - if (response.status === 202 || payload.status === 'queued') { + void fetchJson<{ status?: string; coverage?: NodeCoverage }>( + withScopeParams(`/api/coverage/${encodeURIComponent(nodeId)}`, requestScope), + { cache: 'no-store', signal: controller.signal }, + { timeoutMs: 15_000, maxBytes: 8 * 1024 * 1024 }, + ) + .then((payload) => { + if (controller.signal.aborted || coverageRequestRef.current !== controller) return; + if (payload.status === 'queued') { selectedCoverageRef.current = null; setSelectedCoverageNodeId(null); setCoverageMessage('Coverage is being calculated.'); return; } - if (!response.ok || !payload.coverage) throw new Error('coverage unavailable'); + if (!payload.coverage) throw new Error('coverage unavailable'); selectedCoverageRef.current = payload.coverage; setSelectedCoverageNodeId(nodeId); setCoverageMessage(null); }) .catch(() => { + if (controller.signal.aborted || coverageRequestRef.current !== controller) return; selectedCoverageRef.current = null; setSelectedCoverageNodeId(null); setCoverageMessage('Coverage unavailable.'); }) .finally(() => { + if (coverageRequestRef.current !== controller) return; + coverageRequestRef.current = null; setCoverageLoadingNodeId(null); scheduleRefresh(); }); - }, [viewshedEnabled, coverageLoadingNodeId, selectedCoverageNodeId, scheduleRefresh]); + }, [ + viewshedEnabled, + coverageLoadingNodeId, + requestScope, + selectedCoverageNodeId, + scheduleRefresh, + ]); // -- Popup management ------------------------------------------------------ @@ -1358,11 +1181,11 @@ export function MapLibreMap({ if (!node) return; let cancelled = false; setPopupLinks(null); - void fetchNodeLinks(selectedNodeId) + void fetchNodeLinks(selectedNodeId, requestScopeKey, requestScope) .then((rows) => { if (!cancelled) setPopupLinks(rows); }) .catch(() => { if (!cancelled) setPopupLinks([]); }); return () => { cancelled = true; }; - }, [selectedNodeId, getNode]); // eslint-disable-line react-hooks/exhaustive-deps + }, [getNode, requestScope, requestScopeKey, selectedNodeId]); // Highlight the selected node on the map (bright recolour + ring). Cleared // when no selection. Uses setFilter so we never rebuild the whole node source. @@ -1612,6 +1435,9 @@ export function MapLibreMap({ losActive={popupLosActive} losLoading={popupLosLoading} onToggleLos={handleToggleLos} + network={network} + observer={observer} + privacyGeneration={privacyGeneration} /> diff --git a/frontend/src/components/Map/mapConfig.ts b/frontend/src/components/Map/mapConfig.ts index 0ab009d..97b1875 100644 --- a/frontend/src/components/Map/mapConfig.ts +++ b/frontend/src/components/Map/mapConfig.ts @@ -19,6 +19,132 @@ export const EMPTY_FC: GeoJSON.FeatureCollection = { export const TERRAIN_CONFIG = { source: 'terrain-dem', exaggeration: 3 }; +const CARTO_VECTOR_TILES = 'https://tiles.basemaps.cartocdn.com/vector/carto.streets/v1/tiles.json'; +export const CARTO_GLYPHS = 'https://tiles.basemaps.cartocdn.com/fonts/{fontstack}/{range}.pbf'; + +type MapLabelColors = { + place: string; + water: string; + road: string; + halo: string; +}; + +export const MAP_LABEL_COLORS = { + dark: { + place: '#f8fafc', + water: '#bfdbfe', + road: '#e2e8f0', + halo: '#080d14', + }, + light: { + place: '#1f2937', + water: '#1d4ed8', + road: '#334155', + halo: '#ffffff', + }, +} as const; + +// The no-label raster variants leave label rendering to the vector layers +// below, so labels stay readable in both themes instead of being baked into +// a tile with the wrong contrast. +export const MAP_RASTER_PAINT = { + dark: { + 'raster-contrast': 0.18, + 'raster-brightness-min': 0.02, + 'raster-brightness-max': 0.92, + }, + light: { + 'raster-contrast': 0.08, + 'raster-brightness-min': 0.04, + 'raster-brightness-max': 1, + }, +} as const; + +const CARTO_LABEL_SOURCE: maplibregl.VectorSourceSpecification = { + type: 'vector', + url: CARTO_VECTOR_TILES, +}; + +const mapLabelLayers = ( + colors: MapLabelColors, +): maplibregl.LayerSpecification[] => [ + { + id: 'map-labels-water', + type: 'symbol', + source: 'carto-labels', + 'source-layer': 'water_name', + minzoom: 5, + filter: ['all', ['has', 'name'], ['==', '$type', 'Point']], + layout: { + 'text-field': ['coalesce', ['get', 'name_en'], ['get', 'name']], + 'text-font': ['Open Sans Regular', 'Noto Sans Regular'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 5, 10, 10, 12, 14, 15], + 'text-padding': 2, + 'text-allow-overlap': false, + 'text-ignore-placement': false, + }, + paint: { + 'text-color': colors.water, + 'text-halo-color': colors.halo, + 'text-halo-width': 1.6, + 'text-halo-blur': 0.1, + }, + }, + { + id: 'map-labels-place', + type: 'symbol', + source: 'carto-labels', + 'source-layer': 'place', + minzoom: 5, + filter: [ + 'all', + ['has', 'name'], + ['==', '$type', 'Point'], + ['in', 'class', 'city', 'town', 'village', 'suburb', 'hamlet', 'municipality'], + ], + layout: { + 'text-field': ['coalesce', ['get', 'name_en'], ['get', 'name']], + 'text-font': ['Open Sans Regular', 'Noto Sans Regular'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 5, 9, 9, 10, 13, 13, 16, 15], + 'text-max-width': 10, + 'text-padding': 2, + 'text-allow-overlap': false, + 'text-ignore-placement': false, + }, + paint: { + 'text-color': colors.place, + 'text-halo-color': colors.halo, + 'text-halo-width': 1.7, + 'text-halo-blur': 0.1, + }, + }, + { + id: 'map-labels-road', + type: 'symbol', + source: 'carto-labels', + 'source-layer': 'transportation_name', + minzoom: 10, + filter: ['all', ['has', 'name'], ['==', '$type', 'LineString']], + layout: { + 'text-field': ['get', 'name'], + 'text-font': ['Open Sans Regular', 'Noto Sans Regular'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 10, 8, 14, 10, 17, 12], + 'symbol-placement': 'line', + 'symbol-spacing': 300, + 'text-padding': 2, + 'text-max-angle': 30, + 'text-allow-overlap': false, + 'text-ignore-placement': false, + }, + paint: { + 'text-color': colors.road, + 'text-halo-color': colors.halo, + 'text-halo-width': 1.4, + 'text-halo-blur': 0.1, + }, + }, +]; + export const TERRAIN_DEM_SOURCE: maplibregl.RasterDEMSourceSpecification = { type: 'raster-dem', tiles: ['/terrain-tiles/{z}/{x}/{y}.png'], @@ -34,20 +160,23 @@ export const MAP_STYLE: maplibregl.StyleSpecification = { 'carto-dark': { type: 'raster', tiles: [ - 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', - 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', - 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', - 'https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', + 'https://a.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png', + 'https://b.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png', + 'https://c.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png', + 'https://d.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png', ], tileSize: 256, attribution: '© OpenStreetMap © CARTO', maxzoom: 19, }, + 'carto-labels': CARTO_LABEL_SOURCE, }, + glyphs: CARTO_GLYPHS, layers: [ { id: 'bg-fill', type: 'background', paint: { 'background-color': '#080d14' } }, - { id: 'background', type: 'raster', source: 'carto-dark' }, + { id: 'background', type: 'raster', source: 'carto-dark', paint: MAP_RASTER_PAINT.dark }, + ...mapLabelLayers(MAP_LABEL_COLORS.dark), ], }; @@ -57,19 +186,22 @@ export const MAP_STYLE_LIGHT: maplibregl.StyleSpecification = { 'carto-light': { type: 'raster', tiles: [ - 'https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', - 'https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', - 'https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', - 'https://d.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', + 'https://a.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png', + 'https://b.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png', + 'https://c.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png', + 'https://d.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png', ], tileSize: 256, attribution: '© OpenStreetMap © CARTO', maxzoom: 19, }, + 'carto-labels': CARTO_LABEL_SOURCE, }, + glyphs: CARTO_GLYPHS, layers: [ - { id: 'bg-fill', type: 'background', paint: { 'background-color': '#e8e8e8' } }, - { id: 'background', type: 'raster', source: 'carto-light' }, + { id: 'bg-fill', type: 'background', paint: { 'background-color': '#edf2f7' } }, + { id: 'background', type: 'raster', source: 'carto-light', paint: MAP_RASTER_PAINT.light }, + ...mapLabelLayers(MAP_LABEL_COLORS.light), ], }; diff --git a/frontend/src/components/Map/mapSourceLayers.ts b/frontend/src/components/Map/mapSourceLayers.ts new file mode 100644 index 0000000..823409d --- /dev/null +++ b/frontend/src/components/Map/mapSourceLayers.ts @@ -0,0 +1,267 @@ +import type maplibregl from 'maplibre-gl'; +import { EMPTY_FC } from './mapConfig.js'; + +export function installMapSourcesAndLayers( + map: maplibregl.Map, + options: { showLinks: boolean }, +): void { + const { showLinks } = options; + // ── Node dots source + layer ─────────────────────────────────────────── + map.addSource('nodes', { type: 'geojson', data: EMPTY_FC }); + + map.addLayer({ + id: 'node-dots', + type: 'circle', + source: 'nodes', + filter: ['==', ['get', 'visible'], true], + paint: { + 'circle-radius': [ + 'interpolate', ['linear'], ['zoom'], + 6, 3, 9, 4, 11, 5, 13, 7, 16, 9, + ], + 'circle-color': [ + 'case', + ['==', ['get', 'hex_clash_state'], 'offender'], '#ef4444', + ['==', ['get', 'hex_clash_state'], 'relay'], '#22c55e', + ['get', 'replay_active'], '#fbbf24', + ['get', 'is_link_only_stale'], '#4b5563', + ['get', 'is_inferred'], '#7dd3fc', + ['get', 'is_stale'], '#6b7280', + ['!', ['get', 'is_online']], '#6b7280', + ['==', ['get', 'role'], 1], '#ff9f43', + ['==', ['get', 'role'], 3], '#a78bfa', + ['==', ['get', 'role'], 4], '#34d399', + '#00c4ff', // repeater (role 2 / default) + ], + 'circle-opacity': [ + 'case', + ['all', ['get', 'replay_mode'], ['!', ['get', 'replay_active']]], 0.12, + ['get', 'is_link_only_stale'], 0.22, + ['get', 'is_stale'], 0.4, + ['!', ['get', 'is_online']], 0.4, + ['get', 'is_inferred'], 0.7, + 1.0, + ], + 'circle-stroke-width': 0, + 'circle-stroke-color': '#00c4ff', + 'circle-stroke-opacity': 0.7, + }, + }); + + // ── Selected-node highlight (recolour + ring) ────────────────────────── + // Two circle layers over node-dots, filtered to the selected node id. + // A soft halo underneath, a bright solid marker on top. + map.addLayer({ + id: 'node-dots-selected-halo', + type: 'circle', + source: 'nodes', + filter: ['==', ['get', 'node_id'], '__none__'], + paint: { + 'circle-radius': [ + 'interpolate', ['linear'], ['zoom'], + 6, 11, 9, 13, 11, 15, 13, 18, 16, 22, + ], + 'circle-color': '#22e0ff', + 'circle-opacity': 0.16, + 'circle-blur': 0.5, + }, + }); + map.addLayer({ + id: 'node-dots-selected', + type: 'circle', + source: 'nodes', + filter: ['==', ['get', 'node_id'], '__none__'], + paint: { + 'circle-radius': [ + 'interpolate', ['linear'], ['zoom'], + 6, 5, 9, 6.5, 11, 8, 13, 10, 16, 13, + ], + 'circle-color': '#8af4ff', + 'circle-opacity': 1, + 'circle-stroke-color': '#ffffff', + 'circle-stroke-width': 2.5, + 'circle-stroke-opacity': 0.95, + }, + }); + + // ── Privacy rings source + layer ─────────────────────────────────────── + map.addSource('privacy-rings', { type: 'geojson', data: EMPTY_FC }); + map.addLayer({ + id: 'privacy-rings-layer', + type: 'line', + source: 'privacy-rings', + paint: { + 'line-color': '#f59e0b', + 'line-width': 1.4, + 'line-opacity': 0.55, + 'line-dasharray': [4, 6], + }, + }); + + // ── Viable links source + layer ─────────────────────────────────────── + map.addSource('viable-links', { type: 'geojson', data: EMPTY_FC }); + map.addLayer({ + id: 'viable-links-layer', + type: 'line', + source: 'viable-links', + layout: { + visibility: 'none', + 'line-cap': 'round', + 'line-join': 'round', + }, + paint: { + 'line-color': ['get', 'color'], + 'line-width': ['get', 'width'], + 'line-opacity': ['get', 'opacity'], + }, + }); + + // ── Coverage source + layer ──────────────────────────────────────────── + map.addSource('coverage', { type: 'geojson', data: EMPTY_FC }); + map.addLayer({ + id: 'coverage-fill', + type: 'fill', + source: 'coverage', + layout: { visibility: 'none' }, + paint: { + 'fill-color': [ + 'match', ['get', 'band'], + 'green', '#22c55e', + 'amber', '#fbbf24', + 'red', '#ef4444', + '#22c55e', + ], + 'fill-opacity': [ + 'match', ['get', 'band'], + 'green', 0.22, + 'amber', 0.16, + 'red', 0.10, + 0.18, + ], + }, + }); + + // ── Clash lines source + layer ───────────────────────────────────────── + map.addSource('clash-lines', { type: 'geojson', data: EMPTY_FC }); + map.addLayer({ + id: 'clash-lines-layer', + type: 'line', + source: 'clash-lines', + layout: { visibility: 'none' }, + paint: { + 'line-color': '#f97316', + 'line-width': 2.2, + 'line-opacity': 0.9, + }, + }); + + // ── Planned coverage source + layers ────────────────────────────────── + map.addSource('planned-coverage', { type: 'geojson', data: EMPTY_FC }); + map.addLayer({ + id: 'planned-coverage-fill', + type: 'fill', + source: 'planned-coverage', + paint: { + 'fill-color': [ + 'match', ['get', 'band'], + 'green', '#2dd4bf', // teal-400 + 'amber', '#818cf8', // indigo-400 + 'red', '#c084fc', // purple-400 + '#2dd4bf', + ], + 'fill-opacity': [ + 'match', ['get', 'band'], + 'green', 0.30, + 'amber', 0.25, + 'red', 0.20, + 0.25, + ], + }, + }); + map.addLayer({ + id: 'planned-coverage-outline', + type: 'line', + source: 'planned-coverage', + paint: { + 'line-color': '#22d3ee', // cyan-400 + 'line-width': 1.5, + 'line-opacity': 0.6, + }, + }); + + // ── Predicted planned-repeater links source + layer ─────────────────── + // Dashed lines (coloured by predicted path loss) so they read as + // hypothetical, distinct from the solid observed-link lines. Visibility + // follows the global Links toggle. + map.addSource('planned-links', { type: 'geojson', data: EMPTY_FC }); + map.addLayer({ + id: 'planned-links-layer', + type: 'line', + source: 'planned-links', + layout: { + visibility: showLinks ? 'visible' : 'none', + 'line-cap': 'round', + 'line-join': 'round', + }, + paint: { + 'line-color': ['get', 'color'], + 'line-width': ['get', 'width'], + 'line-opacity': 0.9, + 'line-dasharray': [2, 1.5], + }, + }); + + // ── Planned repeater pins source + layers ────────────────────────────── + // Styled to match real repeater nodes (role 2, #00c4ff) but visually + // distinct via a white stroke and glow halo. Shared map labels and glyphs + // belong to the base style; planned status text stays in the popup so + // temporary planning markers do not add clutter to the map. + map.addSource('planned-pins', { type: 'geojson', data: EMPTY_FC }); + + // Halo: soft glow behind the pin + map.addLayer({ + id: 'planned-pins-halo', + type: 'circle', + source: 'planned-pins', + paint: { + 'circle-radius': [ + 'interpolate', ['linear'], ['zoom'], + 6, 8, 9, 11, 11, 14, 13, 18, 16, 22, + ], + 'circle-color': '#22d3ee', + 'circle-opacity': [ + 'match', ['get', 'status'], + 'ready', 0.20, + 0.10, + ], + 'circle-stroke-width': 0, + }, + }); + + // Core dot: same size/colour as a real online repeater, white stroke to mark as planned + map.addLayer({ + id: 'planned-pins-dot', + type: 'circle', + source: 'planned-pins', + paint: { + 'circle-radius': [ + 'interpolate', ['linear'], ['zoom'], + 6, 3, 9, 4, 11, 5, 13, 7, 16, 9, + ], + 'circle-color': [ + 'match', ['get', 'status'], + 'ready', '#00c4ff', // identical to real online repeater + '#4b5563', // dark grey while computing + ], + 'circle-opacity': [ + 'match', ['get', 'status'], + 'ready', 1.0, + 0.6, + ], + 'circle-stroke-color': '#ffffff', + 'circle-stroke-width': 2, + 'circle-stroke-opacity': 0.95, + }, + }); + +} diff --git a/frontend/src/components/PacketFeed.tsx b/frontend/src/components/PacketFeed.tsx index 3736570..f62a9e8 100644 --- a/frontend/src/components/PacketFeed.tsx +++ b/frontend/src/components/PacketFeed.tsx @@ -52,28 +52,54 @@ const PacketFeedItem: React.FC = React.memo(({ onClick={() => onTogglePacket(p)} role="button" tabIndex={0} - onKeyDown={(event) => event.key === 'Enter' && onTogglePacket(p)} + aria-pressed={isPinned} + aria-label={`${isPinned ? 'Unpin' : 'Pin'} ${typeLabel} packet${display ? `: ${display}` : ''}`} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + onTogglePacket(p); + }} > - {observerIata && {observerIata}} - {p.pathHashSizeBytes !== undefined && p.pathHashSizeBytes > 0 && {p.pathHashSizeBytes}} - {typeLabel} - {advertBadge && {advertBadge}} - {display ?? '\u00A0'} - {p.hopCount !== undefined && p.hopCount > 0 && ↑{p.hopCount}} - + {observerIata ?? '—'} + 0 ? '' : ' packet-item__placeholder')} + aria-hidden={p.pathHashSizeBytes === undefined || p.pathHashSizeBytes <= 0} + title={p.pathHashSizeBytes !== undefined && p.pathHashSizeBytes > 0 ? String(p.pathHashSizeBytes) + ' path bytes' : undefined} + >{p.pathHashSizeBytes !== undefined && p.pathHashSizeBytes > 0 ? p.pathHashSizeBytes : '—'} + {typeLabel} + {advertBadge ?? '—'} + {display ?? '—'} + 0 ? '' : ' packet-item__placeholder')} + aria-hidden={p.hopCount === undefined || p.hopCount <= 0} + >{p.hopCount !== undefined && p.hopCount > 0 ? '↑' + p.hopCount : '—'} + {p.observerIds.length > 0 && {p.observerIds.length}rx} {p.txCount > 0 && {p.txCount}tx} - {isPinned && } + ); }); diff --git a/frontend/src/components/app/AppTopBar.tsx b/frontend/src/components/app/AppTopBar.tsx index 9c04d9f..b415bd4 100644 --- a/frontend/src/components/app/AppTopBar.tsx +++ b/frontend/src/components/app/AppTopBar.tsx @@ -10,8 +10,6 @@ type AppTopBarProps = { stats: DashboardStats; mapLight: boolean; onToggleMapTheme: () => void; - network: string; - onNetworkChange: (network: string) => void; annotation: string; onEditAnnotation: () => void; onShowShortcuts: () => void; @@ -47,8 +45,6 @@ export const AppTopBar: React.FC = ({ stats, mapLight, onToggleMapTheme, - network, - onNetworkChange, annotation, onEditAnnotation, onShowShortcuts, @@ -67,29 +63,26 @@ export const AppTopBar: React.FC = ({
- - - {open && (
diff --git a/frontend/src/pages/StatusPage.tsx b/frontend/src/pages/StatusPage.tsx index 06b15a4..be21c86 100644 --- a/frontend/src/pages/StatusPage.tsx +++ b/frontend/src/pages/StatusPage.tsx @@ -1,46 +1,160 @@ import React, { useEffect, useState } from 'react'; import { LoadingIndicator } from '../components/LoadingIndicator.js'; import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import { useRuntimeFeatures } from '../config/runtimeFeatures.js'; +import { getCurrentSite } from '../config/site.js'; +import { useVisibilityPoll } from '../hooks/useVisibilityPoll.js'; +import { fetchJson, withScopeParams } from '../utils/api.js'; import './network-intelligence.css'; -type HealthPayload = { +type PublicHealthPayload = { status: 'healthy' | 'degraded' | 'critical'; - problems: Array<{ code: string; severity: 'warning' | 'critical'; message: string }>; + generatedAt: string; maintenance: { active: boolean; message: string | null }; - ingest: { active_nodes: number; stale_nodes: number; global_last_packet_at: string | null; packet_age_minutes: number | null }; - operational_checks: Array<{ check_name: string; status: string; latency_ms: number; detail: string | null; ts: string }>; - database: { size_bytes: number; dead_rows: number; oldest_vacuum_at: string | null; tables_needing_vacuum: number; connection_count: number; max_connections: number; cache_hit_ratio: number }; - workers: Array<{ worker_name: string; status: string; queue_depth: number; processed_1h?: number; last_activity_at: string | null }>; + incidents: Array<{ code: string; severity: 'warning' | 'critical' }>; + components: { + ingest: { status: string }; + workers: { status: string }; + storage: { status: string }; + }; }; type FirmwarePayload = { total: number; versions: Array<{ hardware_model: string; firmware_version: string; count: number }> }; +type FirmwareDistributionRow = { + firmware_version: string; + count: number; + hardware_models: string[]; +}; -function formatBytes(value: number): string { - if (!Number.isFinite(value) || value <= 0) return 'Unknown'; - return `${(value / 1_073_741_824).toFixed(1)} GB`; +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasStringStatus(value: unknown): value is { status: string } { + return isRecord(value) && typeof value['status'] === 'string'; +} + +function isPublicHealthPayload(value: unknown): value is PublicHealthPayload { + if (!isRecord(value) || !isRecord(value['maintenance']) || !isRecord(value['components'])) return false; + const components = value['components']; + return isRecord(value) + && (value['status'] === 'healthy' || value['status'] === 'degraded' || value['status'] === 'critical') + && typeof value['generatedAt'] === 'string' + && typeof value['maintenance']['active'] === 'boolean' + && (typeof value['maintenance']['message'] === 'string' || value['maintenance']['message'] === null) + && Array.isArray(value['incidents']) + && value['incidents'].every((incident) => ( + isRecord(incident) + && typeof incident['code'] === 'string' + && (incident['severity'] === 'warning' || incident['severity'] === 'critical') + )) + && hasStringStatus(components['ingest']) + && hasStringStatus(components['workers']) + && hasStringStatus(components['storage']); +} + +function isFirmwarePayload(value: unknown): value is FirmwarePayload { + return isRecord(value) + && typeof value['total'] === 'number' + && Array.isArray(value['versions']); +} + +function aggregateFirmwareVersions(versions: FirmwarePayload['versions']): FirmwareDistributionRow[] { + const byVersion = new Map(); + for (const row of versions) { + const firmwareVersion = row.firmware_version || 'Unknown'; + const existing = byVersion.get(firmwareVersion); + if (existing) { + existing.count += row.count; + if (!existing.hardware_models.includes(row.hardware_model)) { + existing.hardware_models.push(row.hardware_model); + } + continue; + } + byVersion.set(firmwareVersion, { + firmware_version: firmwareVersion, + count: row.count, + hardware_models: [row.hardware_model], + }); + } + return Array.from(byVersion.values()); +} + +function isUnknownFirmwareVersion(version: string): boolean { + return version.toLowerCase() === 'unknown'; +} + +function statusPresentation(status: string): { dot: string; label: string } { + switch (status) { + case 'ok': return { dot: 'ok', label: 'Operational' }; + case 'running': return { dot: 'ok', label: 'Running' }; + case 'idle': return { dot: 'ok', label: 'Ready' }; + case 'warning': return { dot: 'warning', label: 'Needs attention' }; + case 'stale': return { dot: 'warning', label: 'Delayed' }; + case 'critical': return { dot: 'critical', label: 'Critical' }; + case 'failed': return { dot: 'critical', label: 'Failed' }; + default: return { dot: 'unknown', label: 'Checking' }; + } +} + +function reportTime(generatedAt: string): string { + const value = new Date(generatedAt); + return Number.isNaN(value.getTime()) + ? 'just now' + : value.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } export const StatusPage: React.FC = () => { - const [data, setData] = useState(null); + const site = getCurrentSite(); + const network = site.networkFilter ?? site.network; + const observer = site.observerId; + const { privacyGeneration } = useRuntimeFeatures(); + const [data, setData] = useState(null); const [error, setError] = useState(false); const [firmware, setFirmware] = useState(null); + const [includeUnknownFirmware, setIncludeUnknownFirmware] = useState(false); + useEffect(() => { - let active = true; - const load = () => fetch('/api/health', { cache: 'no-store' }) - .then((response) => response.ok ? response.json() as Promise : Promise.reject(new Error('health unavailable'))) - .then((next) => { if (active) { setData(next); setError(false); } }) - .catch(() => { if (active) setError(true); }); - void load(); - const timer = window.setInterval(() => void load(), 60_000); - return () => { active = false; window.clearInterval(timer); }; - }, []); - useEffect(() => { - const controller = new AbortController(); - fetch('/api/repeaters/firmware', { signal: controller.signal }) - .then((response) => response.ok ? response.json() as Promise : null) - .then(setFirmware) - .catch(() => {}); - return () => controller.abort(); - }, []); + setData(null); + setFirmware(null); + setError(false); + setIncludeUnknownFirmware(false); + }, [network, observer, privacyGeneration]); + + useVisibilityPoll(async (signal) => { + const next = await fetchJson( + withScopeParams('/api/health', { network, observer }), + { cache: 'no-store', signal }, + { timeoutMs: 15_000, maxBytes: 2 * 1024 * 1024, validate: isPublicHealthPayload }, + ); + if (signal.aborted) return; + setData(next); + setError(false); + }, { + scopeKey: `platform-health:${network}:${observer ?? 'all'}:${privacyGeneration}`, + intervalMs: 60_000, + timeoutMs: 15_000, + onError: () => setError(true), + }); + + useVisibilityPoll(async (signal) => { + const next = await fetchJson( + withScopeParams('/api/repeaters/firmware', { network, observer }), + { cache: 'no-store', signal }, + { timeoutMs: 15_000, maxBytes: 2 * 1024 * 1024, validate: isFirmwarePayload }, + ); + if (!signal.aborted) setFirmware(next); + }, { + scopeKey: `firmware-health:${network}:${observer ?? 'all'}:${privacyGeneration}`, + intervalMs: 10 * 60_000, + timeoutMs: 15_000, + }); + + const firmwareDistribution = firmware ? aggregateFirmwareVersions(firmware.versions) : []; + const unknownFirmware = firmwareDistribution.find((row) => isUnknownFirmwareVersion(row.firmware_version)); + const visibleFirmwareDistribution = includeUnknownFirmware + ? firmwareDistribution + : firmwareDistribution.filter((row) => !isUnknownFirmwareVersion(row.firmware_version)); + const xAxisInterval = Math.max(0, Math.ceil(visibleFirmwareDistribution.length / 8) - 1); return (
@@ -51,28 +165,87 @@ export const StatusPage: React.FC = () => { <>
{data.status === 'healthy' ? 'All monitored systems operational' : `Platform ${data.status}`} - Updated {new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + Updated {reportTime(data.generatedAt)}
{data.maintenance.active &&
Planned maintenance{data.maintenance.message ?? 'Maintenance is currently in progress.'}
} - {data.problems.length > 0 &&

Current notices

{data.problems.map((problem) =>
{problem.code.replace(/_/g, ' ')}{problem.message}
)}
} + {data.incidents.length > 0 &&

Current notices

{data.incidents.map((incident) =>
{incident.code.replace(/_/g, ' ')}{incident.severity === 'critical' ? 'A monitored service is disrupted.' : 'A monitored service needs attention.'}
)}
}
-

Public ingest

Active observer nodes
{data.ingest.active_nodes}
Stale observers
{data.ingest.stale_nodes}
Latest packet age
{data.ingest.packet_age_minutes == null ? 'Unknown' : `${data.ingest.packet_age_minutes} min`}
-

Synthetic journeys

{data.operational_checks.length === 0 ?

Monitoring is starting.

: data.operational_checks.map((check) =>
{check.check_name.replace(/_/g, ' ')}{check.latency_ms} ms
)}
-

Background workers

{data.workers.map((worker) =>
{worker.worker_name}{worker.queue_depth} queued · {worker.processed_1h ?? 0}/h · {worker.last_activity_at ? `seen ${Math.max(0, Math.floor((Date.now() - Date.parse(worker.last_activity_at)) / 60_000))}m ago` : 'no activity'}
)}
-

Database

Disk footprint
{formatBytes(data.database.size_bytes)}
Connections
{data.database.connection_count}/{data.database.max_connections}
Cache hit ratio
{data.database.cache_hit_ratio.toFixed(2)}%
Vacuum attention
{data.database.tables_needing_vacuum} tables
+ {([ + ['Public ingest', 'Packet intake and freshness', data.components.ingest.status], + ['Background workers', 'Scheduled processing services', data.components.workers.status], + ['Storage', 'Durable platform storage', data.components.storage.status], + ] as const).map(([title, description, status]) => { + const presentation = statusPresentation(status); + return ( +
+

{title}

+
+
+ + {presentation.label} + {description} +
+
+
+ ); + })}

Repeater firmware distribution

{firmware && firmware.versions.length > 0 ? ( - - - - - - [value, item.payload.hardware_model]} /> - - - + <> + {unknownFirmware && ( +

+ +

+ )} + {visibleFirmwareDistribution.length > 0 ? ( + <> + + + + + + [value.toLocaleString(), 'Repeaters']} /> + + + +
+ + + + + {visibleFirmwareDistribution.map((row) => ( + + + + + + ))} + +
Repeater firmware distribution{includeUnknownFirmware ? '' : ' (Unknown excluded)'}
FirmwareHardware modelsRepeaters
{row.firmware_version}{row.hardware_models.join(', ')}{row.count}
+
+ + ) : ( +

Only repeaters with unknown firmware were reported. Select "Include Unknown" to show that bucket.

+ )} + ) :

Firmware telemetry is not yet available.

}

Status values are deliberately aggregated. Hostnames, addresses, credentials, and private node identities are never included.

diff --git a/frontend/src/pages/TopologyPage.tsx b/frontend/src/pages/TopologyPage.tsx index bbc4277..489214f 100644 --- a/frontend/src/pages/TopologyPage.tsx +++ b/frontend/src/pages/TopologyPage.tsx @@ -2,7 +2,10 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, type Simulation, type SimulationNodeDatum } from 'd3-force'; import { LoadingIndicator } from '../components/LoadingIndicator.js'; import { getCurrentSite } from '../config/site.js'; -import { withScopeParams } from '../utils/api.js'; +import { useRuntimeFeatures } from '../config/runtimeFeatures.js'; +import { fetchJson, withScopeParams } from '../utils/api.js'; +import { createFrameSnapshotScheduler } from '../utils/frameSnapshotScheduler.js'; +import { filterTopologyLinks } from './topologyModel.js'; import './network-intelligence.css'; type TopologyNode = { @@ -48,6 +51,7 @@ type RfValidationPayload = { }; type PlotNode = TopologyNode & SimulationNodeDatum & { x: number; y: number }; +const TOPOLOGY_SNAPSHOT_INTERVAL_MS = 66; function compactNumber(value: number): string { return new Intl.NumberFormat('en-GB', { notation: 'compact', maximumFractionDigits: 1 }).format(value); @@ -55,6 +59,9 @@ function compactNumber(value: number): string { export const TopologyPage: React.FC = () => { const site = getCurrentSite(); + const { privacyGeneration } = useRuntimeFeatures(); + const network = site.networkFilter ?? site.network; + const observer = site.observerId; const [payload, setPayload] = useState(null); const [error, setError] = useState(null); const [selectedNodeId, setSelectedNodeId] = useState(null); @@ -66,26 +73,33 @@ export const TopologyPage: React.FC = () => { useEffect(() => { const controller = new AbortController(); - fetch(withScopeParams('/api/topology?limit=300', { network: site.networkFilter }), { signal: controller.signal }) - .then(async (response) => { - if (!response.ok) throw new Error(`Topology request failed (${response.status})`); - return response.json() as Promise; - }) + setPayload(null); + setError(null); + setSelectedNodeId(null); + fetchJson( + withScopeParams('/api/topology?limit=300', { network, observer }), + { signal: controller.signal, cache: 'no-store' }, + { timeoutMs: 15_000, maxBytes: 8 * 1024 * 1024 }, + ) .then(setPayload) .catch((reason: unknown) => { if ((reason as DOMException).name !== 'AbortError') setError((reason as Error).message); }); return () => controller.abort(); - }, [site.networkFilter]); + }, [network, observer, privacyGeneration]); useEffect(() => { const controller = new AbortController(); - fetch(withScopeParams('/api/rf-validation?limit=100', { network: site.networkFilter }), { signal: controller.signal }) - .then((response) => response.ok ? response.json() as Promise : Promise.reject(new Error('RF validation unavailable'))) + setRfValidation(null); + fetchJson( + withScopeParams('/api/rf-validation?limit=100', { network, observer }), + { signal: controller.signal, cache: 'no-store' }, + { timeoutMs: 15_000, maxBytes: 4 * 1024 * 1024 }, + ) .then(setRfValidation) .catch(() => setRfValidation(null)); return () => controller.abort(); - }, [site.networkFilter]); + }, [network, observer, privacyGeneration]); useEffect(() => { const located = (payload?.nodes ?? []).filter( @@ -108,17 +122,47 @@ export const TopologyPage: React.FC = () => { x: 40 + ((node.lon - minLon) / lonSpan) * 920, y: 560 - ((node.lat - minLat) / latSpan) * 520, })); + const simulationLinks = filterTopologyLinks( + nodes.map((node) => node.nodeId), + payload?.links ?? [], + ); + setPlotNodes(nodes.map((node) => ({ ...node }))); + const snapshotScheduler = createFrameSnapshotScheduler({ + minIntervalMs: TOPOLOGY_SNAPSHOT_INTERVAL_MS, + isVisible: () => document.visibilityState === 'visible', + emit: () => { + setPlotNodes(nodes.map((node) => ({ + ...node, + x: node.x ?? 500, + y: node.y ?? 300, + }))); + }, + }); const simulation = forceSimulation(nodes) .force('charge', forceManyBody().strength(-28)) .force('center', forceCenter(500, 300).strength(0.035)) .force('collision', forceCollide().radius((node) => Math.min(14, 5 + Math.sqrt(node.degree)))) .force('links', forceLink( - (payload?.links ?? []).map((link) => ({ source: link.source, target: link.target })), + simulationLinks.map((link) => ({ source: link.source, target: link.target })), ).id((node) => node.nodeId).distance(40).strength(0.08)) .alpha(0.5) - .on('tick', () => setPlotNodes(nodes.map((node) => ({ ...node, x: node.x ?? 500, y: node.y ?? 300 })))); + .on('tick', () => snapshotScheduler.noteMutation()); simulationRef.current = simulation; - return () => { simulation.stop(); simulationRef.current = null; }; + const onVisibilityChange = () => { + if (document.visibilityState === 'visible') { + simulation.restart(); + snapshotScheduler.noteMutation(); + } else { + simulation.stop(); + } + }; + document.addEventListener('visibilitychange', onVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', onVisibilityChange); + snapshotScheduler.stop(); + simulation.stop(); + simulationRef.current = null; + }; }, [payload]); const plot = useMemo(() => { @@ -152,8 +196,13 @@ export const TopologyPage: React.FC = () => { setStrongOnly(event.target.checked)} /> Multibyte evidence only -
- +
)}
diff --git a/frontend/src/pages/site-content.css b/frontend/src/pages/site-content.css index 793f7ba..f69383f 100644 --- a/frontend/src/pages/site-content.css +++ b/frontend/src/pages/site-content.css @@ -50,22 +50,17 @@ max-width: 760px; font-size: 16px; line-height: 1.7; - color: var(--text-secondary); + color: #b4c6d8; } .site-home__actions { display: flex; gap: 12px; flex-wrap: wrap; } -.site-home__panel, -.site-home__card, -.site-home__join, -.site-home__support { +.site-home__panel { background: var(--bg-panel); border: 1px solid var(--border); border-radius: 8px; -} -.site-home__panel { padding: 20px; display: grid; gap: 16px; @@ -82,7 +77,7 @@ .site-home__join p, .site-home__support p, .site-section__head p { - color: var(--text-secondary); + color: #b4c6d8; line-height: 1.65; margin: 0; } @@ -101,7 +96,7 @@ } .site-home__meta-row:first-child { border-top: 0; } .site-home__meta-row span { - color: var(--text-secondary); + color: #b4c6d8; } .site-home__meta-row strong { color: var(--text-primary); @@ -173,13 +168,29 @@ margin-bottom: 20px; max-width: 760px; } +.site-section__head > a { + color: var(--accent); + width: fit-content; +} +.site-section__head > a:hover { text-decoration-thickness: 2px; } .site-home__cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; } -.site-card, .site-home__card { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 20px; + display: grid; + gap: 12px; + transition: border-color var(--transition), background var(--transition); +} +.site-card { background: var(--bg-panel); border: 1px solid var(--border); border-radius: 8px; @@ -197,13 +208,18 @@ color: var(--text-primary); margin: 0; } -.site-card__body, -.site-home__card p { +.site-card__body { font-size: 14px; line-height: 1.65; color: var(--text-secondary); margin: 0; } +.site-home__card p { + font-size: 14px; + line-height: 1.65; + color: #b4c6d8; + margin: 0; +} .site-card__link, .site-home__card a:not(.site-btn) { font-size: 13px; @@ -228,7 +244,7 @@ border-radius: 8px; } .site-home__spec-row span { - color: var(--text-secondary); + color: #b4c6d8; font-size: 13px; } .site-home__spec-row strong { @@ -237,6 +253,9 @@ font-size: 18px; } .site-home__join { + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 8px; display: flex; justify-content: space-between; align-items: center; @@ -252,23 +271,6 @@ gap: 12px; flex-wrap: wrap; } -.site-home__support { - display: flex; - justify-content: space-between; - align-items: center; - gap: 24px; - flex-wrap: wrap; - padding: 20px; -} -.site-home__support > div:first-child { - max-width: 760px; -} -.site-home__support-actions { - display: flex; - gap: 12px; - flex-wrap: wrap; -} - .dev-monitor__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -352,7 +354,6 @@ border-collapse: collapse; } -.dev-monitor__table th, .dev-monitor__table td { text-align: left; padding: 10px 12px; @@ -362,6 +363,11 @@ } .dev-monitor__table th { + text-align: left; + padding: 10px 12px; + border-top: 1px solid var(--border-soft); + vertical-align: top; + font-size: 14px; color: var(--text-secondary); font-weight: 600; font-size: 12px; @@ -628,7 +634,6 @@ border-collapse: collapse; } -.dev-status-table th, .dev-status-table td { padding: 10px 12px; border-top: 1px solid var(--border-soft); @@ -638,6 +643,11 @@ } .dev-status-table th { + padding: 10px 12px; + border-top: 1px solid var(--border-soft); + text-align: left; + vertical-align: top; + font-size: 14px; color: var(--text-secondary); font-size: 12px; text-transform: uppercase; @@ -671,8 +681,7 @@ min-width: 0; } - .site-home__join, - .site-home__support { + .site-home__join { align-items: flex-start; } } @@ -690,20 +699,17 @@ .site-home__panel, .site-home__card, .site-home__join, - .site-home__support, .site-card { padding: 16px; } .site-home__actions, - .site-home__join-actions, - .site-home__support-actions { + .site-home__join-actions { width: 100%; } .site-home__actions .site-btn, - .site-home__join-actions .site-btn, - .site-home__support-actions .site-btn { + .site-home__join-actions .site-btn { flex: 1 1 100%; } } @@ -724,30 +730,181 @@ } .observer-registration button, .observer-registration p { grid-column: 1 / -1; } -.hw-card { text-align: left; cursor: pointer; color: inherit; } @media (max-width: 640px) { .observer-registration { grid-template-columns: 1fr; } } -.site-btn--donate { - border-color: var(--color-gold); - background: var(--color-gold); - color: #111827; - font-weight: 800; -} -.site-home-health { border-block: 1px solid var(--border); background: var(--bg-panel); } -.site-home-health__inner { display: flex; align-items: center; gap: 18px; padding-block: 13px; } -.site-home-health__inner > div { display: flex; align-items: center; gap: 8px; } -.site-home-health__inner > span { color: var(--text-secondary); } -.site-home-health__inner > a { margin-left: auto; color: var(--accent); } -.site-home-health__dot { width: 11px; height: 11px; border-radius: 50%; background: var(--color-amber); } -.site-home-health--healthy .site-home-health__dot { background: var(--color-green); } -.site-home-health--poor .site-home-health__dot { background: var(--color-red); } .site-home-feed { display: grid; gap: 8px; } .site-home-feed article { display: grid; grid-template-columns: 110px 1fr auto; gap: 12px; padding: 10px; border: 1px solid var(--border); border-radius: var(--radius-md); } -.site-home-feed time { color: var(--text-muted); } +.site-home-feed time { color: #b4c6d8; } @media (max-width: 640px) { - .site-home-health__inner { align-items: flex-start; flex-direction: column; gap: 5px; } - .site-home-health__inner > a { margin-left: 0; } .site-home-feed article { grid-template-columns: 1fr auto; } .site-home-feed article span { grid-column: 1 / -1; grid-row: 2; } } + +/* ── Landing page contrast and spacing ─────────────────────────────────── */ +.site-home__actions .site-btn--ghost { + background: rgba(13, 21, 32, 0.45); + border-color: rgba(138, 166, 196, 0.68); +} +.site-home__actions .site-btn--ghost:hover { + background: var(--bg-hover); + border-color: rgba(0, 196, 255, 0.72); +} +.site-home-feed > p { + color: #b4c6d8; +} +.site-stats-section .site-stat__label, +.site-stats-section .site-stat__suffix { + color: #b4c6d8; +} +.site-home-feed-section { + padding-bottom: 56px; +} + +/* ── Data pages ────────────────────────────────────────────────────────── */ +/* These selectors are scoped to the data-page markers so shared site chrome + and the landing-page sections remain owned by their respective styles. */ +.repeater-page .repeater-search-box { + width: 100%; + max-width: none; + margin-bottom: 20px; +} +.repeater-page .repeater-details-card__empty { + min-height: 0; + padding: 32px 24px; +} +.repeater-page .repeater-details-card__empty-icon { + width: 36px; + height: 36px; + margin-bottom: 10px; + opacity: 0.58; +} +.repeater-page .repeater-details-card__empty h3 { + margin-bottom: 6px; + font-size: 16px; +} +.repeater-page .repeater-details-card__empty p { + max-width: 48ch; + margin: 0 auto; + line-height: 1.45; +} +.site-layout:has(.repeater-page) .site-footer, +.site-layout:has(.companion-page) .site-footer { + color: var(--text-secondary); +} +.site-layout:has(.repeater-page) .site-footer a, +.site-layout:has(.companion-page) .site-footer a { color: var(--text-secondary); } +.site-layout:has(.repeater-page) .site-footer__sep, +.site-layout:has(.companion-page) .site-footer__sep { + color: var(--accent); + opacity: 0.82; +} + +.companion-page .companion-leaderboard__legend { + display: flex; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; + color: #b8c9dc; + font: 11px var(--font-mono); + letter-spacing: 0.04em; +} +.companion-page .companion-leaderboard { + position: relative; + max-height: min(680px, calc(100vh - 280px)); + padding: 2px 6px 36px 0; + overflow-y: auto; + scrollbar-color: #52728f #101b29; +} +.companion-page .companion-leaderboard::after { + position: absolute; + right: 6px; + bottom: 0; + left: 0; + height: 38px; + background: linear-gradient(to bottom, rgba(13, 21, 32, 0), var(--bg-panel)); + content: ''; + pointer-events: none; +} +.companion-page .companion-row { + grid-template-columns: 48px minmax(0, 1fr) max-content; + min-width: 0; + border-color: rgba(137, 180, 214, 0.32); +} +.companion-page .companion-row__rank, +.companion-page .companion-row__last, +.companion-page .companion-updated { color: #b8c9dc; } +.companion-page .companion-row__bar-track { + height: 8px; + background: rgba(137, 180, 214, 0.22); + border: 1px solid rgba(137, 180, 214, 0.25); + border-radius: 999px; +} +.companion-page .companion-row__bar { + background: linear-gradient(90deg, #28bfe7, #8be8ff); + border-radius: 999px; + box-shadow: 0 0 8px rgba(92, 219, 255, 0.34); +} + +@media (max-width: 560px) { + .companion-page .companion-leaderboard__legend { flex-direction: column; gap: 4px; } + .companion-page .companion-row { + grid-template-columns: 34px minmax(0, 1fr) max-content; + gap: 8px; + padding: 12px 10px; + } + .companion-page .companion-row__last { display: block; font-size: 10px; } + .companion-page .companion-leaderboard { max-height: min(560px, calc(100vh - 260px)); } +} + +.install-page .install-page__heading { + display: flex; + align-items: center; + gap: 12px; +} +.install-page .prose-step { + display: inline-grid; + min-width: 32px; + width: 32px; + height: 32px; + margin-right: 0; + place-items: center; + flex: 0 0 32px; + border: 1px solid rgba(92, 219, 255, 0.6); + border-radius: 50%; + background: rgba(0, 196, 255, 0.12); + color: #bcefff; + font-size: 13px; +} +.install-page .hw-card { + transition: border-color var(--transition), background var(--transition), box-shadow var(--transition); +} +.install-page .hw-card--recommended { + border-color: rgba(92, 219, 255, 0.82); + box-shadow: 0 0 0 1px rgba(92, 219, 255, 0.12), 0 8px 22px rgba(0, 196, 255, 0.1); +} +.install-page .hw-card--selected { + background: #102235; + border-color: var(--accent); +} +.install-page .hw-card__badge { + margin-bottom: 9px; + padding: 3px 8px; + border: 1px solid rgba(92, 219, 255, 0.72); + border-radius: 999px; + background: rgba(0, 196, 255, 0.16); + color: #bcefff; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; +} +.install-page .hw-card__detail { + color: #b8c9dc; + font-size: 13px; + line-height: 1.5; +} + +@media (max-width: 560px) { + .install-page .install-page__heading { align-items: flex-start; gap: 10px; } + .install-page .prose-step { min-width: 28px; width: 28px; height: 28px; flex-basis: 28px; } +} diff --git a/frontend/src/pages/site-shell.css b/frontend/src/pages/site-shell.css index 95fbfe8..ca60768 100644 --- a/frontend/src/pages/site-shell.css +++ b/frontend/src/pages/site-shell.css @@ -13,6 +13,19 @@ padding: 0 24px; } +.site-layout .site-footer { + color: #b4c6d8; +} +.site-layout .site-footer a { + color: #b4c6d8; +} +.site-layout .site-footer a:hover { + color: var(--text-primary); +} +.site-layout .site-footer__sep { + color: #66829e; +} + /* ── Navigation ────────────────────────────────────────────────────────── */ .site-nav { display: flex; @@ -50,7 +63,7 @@ } .site-nav__link { padding: 8px 10px; - color: var(--text-secondary); + color: #b4c6d8; text-decoration: none; font-size: 13px; border-radius: 6px; @@ -65,6 +78,18 @@ color: var(--accent); font-weight: 500; } +.site-nav__link--external { + color: var(--accent); + font-weight: 600; +} +.site-nav__link--external:hover { + color: #8be8ff; + background: rgba(0, 196, 255, 0.12); +} +.site-nav__external-icon { + margin-left: 3px; + font-size: 0.95em; +} .site-nav__app-btn { padding: 8px 12px; background: transparent; @@ -189,8 +214,7 @@ } } -/* Mobile controls wrapper (filter grid + search) — hidden on desktop */ -.mobile-controls { display: none; } +/* Mobile controls wrapper (filter grid + search) */ .mobile-search { display: none; } .site-nav__badge { display: inline-grid; diff --git a/frontend/src/pages/spam-page.css b/frontend/src/pages/spam-page.css index 14707d8..9b704c4 100644 --- a/frontend/src/pages/spam-page.css +++ b/frontend/src/pages/spam-page.css @@ -71,6 +71,7 @@ font-weight: 600; color: var(--text-primary); } +.sm-status__text { color: #f0f6ff; } .sm-status__dot { width: 10px; height: 10px; @@ -91,9 +92,9 @@ display: flex; gap: 8px; align-items: center; - color: var(--text-muted); + color: #b8c9dc; font-family: var(--font-mono); - font-size: 11px; + font-size: 12px; letter-spacing: 0.04em; } @@ -113,16 +114,40 @@ transition: color var(--transition); } .sm-toggle:hover { color: var(--text-primary); } -.sm-toggle input { accent-color: var(--accent); width: 14px; height: 14px; cursor: pointer; } +.sm-toggle input { + appearance: none; + -webkit-appearance: none; + display: grid; + width: 16px; + height: 16px; + margin: 0; + place-content: center; + border: 1px solid #7f9bb8; + border-radius: 4px; + background: #0d1520; + cursor: pointer; +} +.sm-toggle input::after { + content: ''; + width: 7px; + height: 4px; + border-left: 2px solid #06101c; + border-bottom: 2px solid #06101c; + transform: rotate(-45deg) scale(0); + transition: transform 120ms ease-in-out; +} +.sm-toggle input:checked { background: var(--accent); border-color: var(--accent); } +.sm-toggle input:checked::after { transform: rotate(-45deg) scale(1); } +.sm-toggle input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } /* ── Map ────────────────────────────────────────────────────────────────── */ .sm-mapwrap { margin: 0 0 28px; } .sm-maphint { font-family: var(--font-mono); - font-size: 10px; + font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; - color: var(--text-muted); + color: #b8c9dc; margin-bottom: 8px; } .sm-map { @@ -132,6 +157,15 @@ border: 1px solid var(--border); background: var(--bg-base); } +.sm-page .maplibregl-ctrl-group { + background: rgba(9, 17, 26, 0.94); + border: 1px solid rgba(137, 180, 214, 0.5); + border-radius: 6px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4); +} +.sm-page .maplibregl-ctrl-group button + button { border-top-color: rgba(137, 180, 214, 0.35); } +.sm-page .maplibregl-ctrl-group button:not(:disabled):hover { background-color: rgba(92, 219, 255, 0.14); } +.sm-page .maplibregl-ctrl-group button .maplibregl-ctrl-icon { filter: invert(1); opacity: 0.9; } /* ── Sections ───────────────────────────────────────────────────────────── */ .sm-section { margin: 28px 0; } @@ -393,20 +427,23 @@ font-size: 12px; margin-top: 8px; } -.sm-timeline th, .sm-timeline td { +.sm-timeline th { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; -} -.sm-timeline th { color: var(--text-secondary); font-weight: 400; text-transform: uppercase; font-size: 9px; letter-spacing: 0.1em; } -.sm-timeline td { color: var(--text-primary); } +.sm-timeline td { + text-align: left; + padding: 7px 10px; + border-bottom: 1px solid var(--border); + white-space: nowrap; color: var(--text-primary); +} /* ── Footer ─────────────────────────────────────────────────────────────── */ .sm-footer { @@ -448,13 +485,7 @@ @media (max-width: 340px) { .sm-grid { grid-template-columns: 1fr; } } -.sm-virtual-list { - max-height: min(760px, 75vh); - overflow: auto; - overscroll-behavior: contain; - scrollbar-gutter: stable; -} - -.sm-virtual-list .sm-card { - contain: layout paint; +.sm-incident-list { + display: grid; + gap: 12px; } diff --git a/frontend/src/pages/ukmesh/UKCompanionPage.tsx b/frontend/src/pages/ukmesh/UKCompanionPage.tsx index 0034a5c..3966210 100644 --- a/frontend/src/pages/ukmesh/UKCompanionPage.tsx +++ b/frontend/src/pages/ukmesh/UKCompanionPage.tsx @@ -1,8 +1,12 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useWebSocket } from '../../hooks/useWebSocket.js'; import type { WSMessage } from '../../hooks/useWebSocket.js'; import { LoadingIndicator } from '../../components/LoadingIndicator.js'; import { ObserverRegistrationForm } from '../../components/ObserverRegistrationForm.js'; +import { useRuntimeFeatures } from '../../config/runtimeFeatures.js'; +import { getCurrentSite } from '../../config/site.js'; +import { useVisibilityPoll } from '../../hooks/useVisibilityPoll.js'; +import { fetchJson, withScopeParams } from '../../utils/api.js'; interface CompanionEntry { sender: string; @@ -28,9 +32,23 @@ function timeAgo(iso: string): string { return `${Math.floor(secs / 86400)}d ago`; } -const WS_SCOPE = { network: 'ukmesh' }; +function isCompanionEntries(value: unknown): value is CompanionEntry[] { + return Array.isArray(value) && value.every((entry) => ( + typeof entry === 'object' + && entry !== null + && typeof (entry as Record)['sender'] === 'string' + && typeof (entry as Record)['message_count'] === 'number' + && typeof (entry as Record)['last_message_at'] === 'string' + )); +} export const UKCompanionPage: React.FC = () => { + const site = getCurrentSite(); + const network = site.networkFilter ?? site.network; + const observer = site.observerId; + const { privacyGeneration } = useRuntimeFeatures(); + const requestScope = useMemo(() => ({ network, observer }), [network, observer]); + const scopeKey = `${network}:${observer ?? 'all'}:${privacyGeneration}`; const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); const [lastUpdated, setLastUpdated] = useState(null); @@ -38,25 +56,33 @@ export const UKCompanionPage: React.FC = () => { // Track which packet hashes we've already counted to avoid double-counting const seenHashes = useRef(new Set()); - const fetchData = useCallback(() => { - fetch('/api/companion-activity?network=ukmesh') - .then(r => r.json()) - .then((data: CompanionEntry[]) => { - setEntries(Array.isArray(data) ? data : []); - setLastUpdated(new Date()); - setLoading(false); - // Reset live counter and seen hashes on each full resync - setLiveCount(0); - seenHashes.current.clear(); - }) - .catch(() => setLoading(false)); - }, []); - useEffect(() => { - fetchData(); - const interval = setInterval(fetchData, 60_000); - return () => clearInterval(interval); - }, [fetchData]); + setEntries([]); + setLoading(true); + setLastUpdated(null); + setLiveCount(0); + seenHashes.current.clear(); + }, [scopeKey]); + + useVisibilityPoll(async (signal) => { + const data = await fetchJson( + withScopeParams('/api/companion-activity', requestScope), + { cache: 'no-store', signal }, + { timeoutMs: 15_000, maxBytes: 2 * 1024 * 1024, validate: isCompanionEntries }, + ); + if (signal.aborted) return; + setEntries(data.slice(0, 2_000)); + setLastUpdated(new Date()); + setLoading(false); + // A full resync is authoritative for both the live delta and dedupe window. + setLiveCount(0); + seenHashes.current.clear(); + }, { + scopeKey: `companion-activity:${scopeKey}`, + intervalMs: 60_000, + timeoutMs: 15_000, + onError: () => setLoading(false), + }); const handleMessage = useCallback((msg: WSMessage) => { if (msg.type !== 'packet') return; @@ -85,12 +111,12 @@ export const UKCompanionPage: React.FC = () => { } else { next = [...prev, { sender, message_count: 1, last_message_at: now }]; } - return next.sort((a, b) => b.message_count - a.message_count); + return next.sort((a, b) => b.message_count - a.message_count).slice(0, 2_000); }); setLiveCount(n => n + 1); }, []); - useWebSocket(handleMessage, WS_SCOPE); + useWebSocket(handleMessage, requestScope); const topCount = entries[0]?.message_count ?? 1; @@ -107,13 +133,18 @@ export const UKCompanionPage: React.FC = () => { -
+
{loading ? ( ) : entries.length === 0 ? (

No data available.

) : ( + <> +
+ Activity scale + 100% = #1 - {topCount.toLocaleString()} msgs +
{entries.map((entry, i) => { const barPct = Math.max(4, Math.round((entry.message_count / topCount) * 100)); @@ -134,6 +165,7 @@ export const UKCompanionPage: React.FC = () => { ); })}
+ )} {lastUpdated && (

diff --git a/frontend/src/pages/ukmesh/UKHomePage.tsx b/frontend/src/pages/ukmesh/UKHomePage.tsx index 53eae0f..0ff7887 100644 --- a/frontend/src/pages/ukmesh/UKHomePage.tsx +++ b/frontend/src/pages/ukmesh/UKHomePage.tsx @@ -1,33 +1,19 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { Link } from 'react-router-dom'; +import React, { useEffect, useState } from 'react'; +import { Link } from 'react-router'; import { LiveStatsSection } from '../../components/LiveStatsSection.js'; import { getCurrentSite } from '../../config/site.js'; -const meshcoreDonationUrl = 'https://givealittle.co.nz/cause/help-us-save-meshcore'; -const meshcoreSupportPostUrl = 'https://blog.meshcore.io/2026/07/04/help-us-save-meshcore'; - export const UKHomePage: React.FC = () => { const site = getCurrentSite(); - const [regions, setRegions] = useState>([]); const [recent, setRecent] = useState>([]); useEffect(() => { const controller = new AbortController(); - void Promise.allSettled([ - fetch('/api/stats/charts?network=ukmesh', { signal: controller.signal }).then((response) => response.json()), - fetch('/api/packets/recent?network=ukmesh&limit=5', { signal: controller.signal }).then((response) => response.json()), - ]).then(([stats, packets]) => { - if (stats.status === 'fulfilled') setRegions(Array.isArray(stats.value?.observerRegions) ? stats.value.observerRegions : []); - if (packets.status === 'fulfilled') setRecent(Array.isArray(packets.value) ? packets.value : []); - }); + void fetch('/api/packets/recent?network=ukmesh&limit=5', { signal: controller.signal }) + .then((response) => response.json()) + .then((packets) => setRecent(Array.isArray(packets) ? packets : [])) + .catch(() => {}); return () => controller.abort(); }, []); - const regionalHealth = useMemo(() => { - if (regions.length === 0) return { status: 'watch', label: 'Health data starting', score: 0 }; - const score = Math.round(regions.reduce((sum, region) => sum + Number(region.health?.score ?? 0), 0) / regions.length); - return score >= 75 ? { status: 'healthy', label: 'Regions healthy', score } - : score >= 45 ? { status: 'watch', label: 'Regions need attention', score } - : { status: 'poor', label: 'Regional disruption', score }; - }, [regions]); return ( <> @@ -40,7 +26,6 @@ export const UKHomePage: React.FC = () => { supporting documentation behind the live map.

- Donate to MeshCore Open live map Install MeshCore Network stats @@ -71,17 +56,9 @@ export const UKHomePage: React.FC = () => {
-
-
-
{regionalHealth.label}
- {regionalHealth.score}% aggregate health · {regions.length} reporting regions - View regional detail → -
-
- -
+

Recent feed

Open live feed →
@@ -97,29 +74,6 @@ export const UKHomePage: React.FC = () => {
-
-
-
-
-

Support the creators of MeshCore

-

- The MeshCore team is crowdfunding legal costs to protect the project name and keep the core - firmware free, open-source, and community driven. Donations go through Givealittle, and the - MeshCore blog explains the background. -

-
- -
-
-
-
diff --git a/frontend/src/pages/ukmesh/UKInstallPage.tsx b/frontend/src/pages/ukmesh/UKInstallPage.tsx index 5b3f73d..fc9d4a7 100644 --- a/frontend/src/pages/ukmesh/UKInstallPage.tsx +++ b/frontend/src/pages/ukmesh/UKInstallPage.tsx @@ -14,10 +14,10 @@ export const UKInstallPage: React.FC = () => { return ( <> -
+
-

+

1 What you need

@@ -32,7 +32,7 @@ export const UKInstallPage: React.FC = () => { {HARDWARE.map((entry) =>
-

+

2 Flash the firmware

@@ -104,7 +104,7 @@ export const UKInstallPage: React.FC = () => {
-

+

3 Configure your node

@@ -141,7 +141,7 @@ export const UKInstallPage: React.FC = () => {
-

+

4 Get on the network

@@ -168,7 +168,7 @@ export const UKInstallPage: React.FC = () => {
-

+

5 Add an MQTT observer

@@ -179,13 +179,13 @@ export const UKInstallPage: React.FC = () => {
Access is by request. Message ibengr on Discord to get MQTT credentials before setting this up.
-
+
{'curl -fsSL https://raw.githubusercontent.com/Cisien/meshcoretomqtt/main/install.sh | bash'}

During setup, enable packet logging, choose the correct IATA code for your location, and add one extra broker with:

-
+
{`Server hostname/IP: mqtt.ukmesh.com
 Port [1883]: 443
 Use WebSockets transport? [y/N]: y
@@ -198,7 +198,7 @@ Password: `}

Topic format:

-
+
{'meshcore//{PUBLIC_KEY}/packets'}
diff --git a/frontend/src/pages/ukmesh/UKRepeaterSearchPage.tsx b/frontend/src/pages/ukmesh/UKRepeaterSearchPage.tsx index 68221db..b44b996 100644 --- a/frontend/src/pages/ukmesh/UKRepeaterSearchPage.tsx +++ b/frontend/src/pages/ukmesh/UKRepeaterSearchPage.tsx @@ -1,5 +1,10 @@ import React, { useState, useEffect, useMemo, useRef } from 'react'; import { LoadingIndicator } from '../../components/LoadingIndicator.js'; +import { getCurrentSite } from '../../config/site.js'; +import { useRuntimeFeatures } from '../../config/runtimeFeatures.js'; +import { fetchJson, withScopeParams } from '../../utils/api.js'; +import { ScopedCache } from '../../utils/scopedCache.js'; +import { Combobox, type ComboboxOption } from '../../components/ui/Combobox.js'; interface MeshNode { node_id: string; @@ -42,32 +47,107 @@ interface AdvertPacket { } type NodeDetailBundle = { links: NodeLink[]; history: PacketHistory[]; adverts: AdvertPacket[] }; +type PublicMapPage = { + nodes: MeshNode[]; + page: { + snapshot: string; + nextCursor: string | null; + complete: boolean; + returned: number; + rowLimit: number; + }; +}; const NODE_DETAIL_TTL_MS = 5 * 60_000; -const nodeDetailCache = new Map(); +const MAP_PAGE_LIMIT = 2000; +const MAP_MAX_PAGES = 100; +const nodeDetailCache = new ScopedCache({ + name: 'repeater-detail', + ttlMs: NODE_DETAIL_TTL_MS, + maxEntries: 128, + maxBytes: 12 * 1024 * 1024, + maxInflight: 4, +}); -async function fetchJsonWithTimeout(url: string, timeoutMs = 8_000): Promise { - const response = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return response.json() as Promise; +async function loadNodeDetails( + node: MeshNode, + network: string, + observer: string | undefined, + scopeKey: string, + signal: AbortSignal, +): Promise { + const cacheKey = node.node_id.toUpperCase(); + const publicKey = node.public_key ?? node.node_id; + return nodeDetailCache.getOrLoad(scopeKey, cacheKey, async () => { + const requestScope = { network, observer }; + const [links, history, adverts] = await Promise.all([ + fetchJson( + withScopeParams(`/api/nodes/${encodeURIComponent(node.node_id)}/links`, requestScope), + { signal, cache: 'no-store' }, + { timeoutMs: 8_000, maxBytes: 2 * 1024 * 1024 }, + ), + fetchJson( + withScopeParams(`/api/nodes/${encodeURIComponent(node.node_id)}/history?hours=24`, requestScope), + { signal, cache: 'no-store' }, + { timeoutMs: 8_000, maxBytes: 4 * 1024 * 1024 }, + ), + fetchJson( + withScopeParams(`/api/nodes/${encodeURIComponent(publicKey)}/adverts?hours=168`, requestScope), + { signal, cache: 'no-store' }, + { timeoutMs: 8_000, maxBytes: 2 * 1024 * 1024 }, + ), + ]); + return { + links: Array.isArray(links) ? links : [], + history: Array.isArray(history) ? history : [], + adverts: Array.isArray(adverts) ? adverts : [], + }; + }); } -async function loadNodeDetails(node: MeshNode): Promise { - const cacheKey = node.node_id.toUpperCase(); - const cached = nodeDetailCache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) return cached.value; - const publicKey = node.public_key ?? node.node_id; - const [links, history, adverts] = await Promise.all([ - fetchJsonWithTimeout(`/api/nodes/${node.node_id}/links`), - fetchJsonWithTimeout(`/api/nodes/${node.node_id}/history?hours=24`), - fetchJsonWithTimeout(`/api/nodes/${publicKey}/adverts?hours=168`), - ]); - const value = { - links: Array.isArray(links) ? links : [], - history: Array.isArray(history) ? history : [], - adverts: Array.isArray(adverts) ? adverts : [], - }; - nodeDetailCache.set(cacheKey, { expiresAt: Date.now() + NODE_DETAIL_TTL_MS, value }); - return value; +async function loadCompleteNodeSnapshot( + signal: AbortSignal, + network: string, + observer: string | undefined, +): Promise { + const nodes = new Map(); + let snapshot: string | null = null; + let cursor: string | null = null; + const seenCursors = new Set(); + + for (let pageNumber = 0; pageNumber < MAP_MAX_PAGES; pageNumber += 1) { + const params = new URLSearchParams({ + network, + fields: 'node_id,name,lat,lon,iata,role,last_seen,is_online,hardware_model,advert_count,elevation_m', + limit: String(MAP_PAGE_LIMIT), + }); + if (observer) params.set('observer', observer); + if (snapshot) params.set('snapshot', snapshot); + if (cursor) params.set('cursor', cursor); + const payload = await fetchJson( + `/api/nodes/map?${params}`, + { signal, cache: 'no-store' }, + { timeoutMs: 20_000, maxBytes: 12 * 1024 * 1024 }, + ); + if (!payload || !Array.isArray(payload.nodes) || !payload.page) { + throw new Error('Repeater index returned an invalid page'); + } + if (snapshot && payload.page.snapshot !== snapshot) { + throw new Error('Repeater index snapshot changed while paging'); + } + snapshot = payload.page.snapshot; + for (const node of payload.nodes) { + if (node && typeof node.node_id === 'string') { + nodes.set(node.node_id, { ...node, public_key: node.node_id }); + } + } + if (payload.page.complete) return [...nodes.values()]; + if (!payload.page.nextCursor || seenCursors.has(payload.page.nextCursor)) { + throw new Error('Repeater index did not provide a forward cursor'); + } + seenCursors.add(payload.page.nextCursor); + cursor = payload.page.nextCursor; + } + throw new Error('Repeater index exceeded its page budget'); } function timeAgo(iso: string): string { @@ -168,47 +248,54 @@ function formatTimeUntil(date: Date): string { } export const UKRepeaterSearchPage: React.FC = () => { + const site = getCurrentSite(); + const runtimeFeatures = useRuntimeFeatures(); + const network = site.networkFilter ?? site.network; + const observer = site.observerId; + const detailScopeKey = `${network}|${observer ?? 'all'}|privacy-${runtimeFeatures.privacyGeneration}`; const [searchQuery, setSearchQuery] = useState(''); const [showResults, setShowResults] = useState(false); const [nodes, setNodes] = useState([]); const [loadingNodes, setLoadingNodes] = useState(true); + const [nodesError, setNodesError] = useState(null); const [selectedNode, setSelectedNode] = useState(null); const [links, setLinks] = useState([]); const [history, setHistory] = useState([]); const [adverts, setAdverts] = useState([]); const [loadingDetails, setLoadingDetails] = useState(false); const [copiedKey, setCopiedKey] = useState(false); - const searchRef = useRef(null); + const selectionSequenceRef = useRef(0); + const selectionControllerRef = useRef(null); // Load nodes on mount useEffect(() => { - let cancelled = false; - fetch('/api/nodes/map?network=ukmesh&fields=node_id,name,lat,lon,iata,role,last_seen,is_online,hardware_model') - .then(r => r.json()) - .then(data => { - if (!cancelled) setNodes(Array.isArray(data) ? data.map((node: MeshNode) => ({ ...node, public_key: node.node_id })) : []); + const controller = new AbortController(); + setLoadingNodes(true); + setNodes([]); + setSelectedNode(null); + selectionControllerRef.current?.abort(); + loadCompleteNodeSnapshot(controller.signal, network, observer) + .then(loadedNodes => { + if (!controller.signal.aborted) { + setNodes(loadedNodes); + setNodesError(null); + } }) - .catch(() => { - if (!cancelled) setNodes([]); + .catch((error: unknown) => { + if (!controller.signal.aborted) { + setNodes([]); + setNodesError(error instanceof Error ? error.message : 'Could not load repeater index'); + } }) .finally(() => { - if (!cancelled) setLoadingNodes(false); + if (!controller.signal.aborted) setLoadingNodes(false); }); return () => { - cancelled = true; + controller.abort(); }; - }, []); + }, [network, observer, runtimeFeatures.privacyGeneration]); - // Click outside to close search dropdown - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (searchRef.current && !searchRef.current.contains(event.target as Node)) { - setShowResults(false); - } - }; - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); + useEffect(() => () => selectionControllerRef.current?.abort(), []); const searchResults = useMemo(() => { if (!searchQuery.trim()) return []; @@ -226,6 +313,19 @@ export const UKRepeaterSearchPage: React.FC = () => { }) .slice(0, 10); }, [searchQuery, nodes]); + const searchOptions = useMemo(() => searchResults.map((node) => ({ + id: node.node_id, + label: node.name ?? node.node_id, + content: ( + <> + {node.name || 'Unknown'} + + {node.iata ? `${node.iata} · ` : ''} + {node.public_key?.slice(0, 16)}... · {node.is_online ? 'Online' : 'Offline'} + + + ), + })), [searchResults]); // Calculate predicted next advert based on advert packets const prediction = useMemo(() => { @@ -234,6 +334,11 @@ export const UKRepeaterSearchPage: React.FC = () => { }, [adverts]); const selectNode = async (node: MeshNode) => { + const sequence = selectionSequenceRef.current + 1; + selectionSequenceRef.current = sequence; + selectionControllerRef.current?.abort(); + const controller = new AbortController(); + selectionControllerRef.current = controller; setSelectedNode(node); setSearchQuery(node.name || node.public_key?.slice(0, 16) || ''); setShowResults(false); @@ -244,14 +349,20 @@ export const UKRepeaterSearchPage: React.FC = () => { setCopiedKey(false); try { - const details = await loadNodeDetails(node); + const details = await loadNodeDetails(node, network, observer, detailScopeKey, controller.signal); + if (controller.signal.aborted || sequence !== selectionSequenceRef.current) return; setLinks(details.links); setHistory(details.history); setAdverts(details.adverts); } catch { // Ignore errors } finally { - setLoadingDetails(false); + if (selectionControllerRef.current === controller) { + selectionControllerRef.current = null; + } + if (!controller.signal.aborted && sequence === selectionSequenceRef.current) { + setLoadingDetails(false); + } } }; @@ -266,55 +377,57 @@ export const UKRepeaterSearchPage: React.FC = () => { return ( <> -
+
-
- { setSearchQuery(e.target.value); setShowResults(true); }} - onFocus={() => setShowResults(true)} + onValueChange={(value) => { + setSearchQuery(value); + setShowResults(true); + }} + onSelectionChange={(id) => { + const node = searchResults.find((entry) => entry.node_id === id); + if (node) void selectNode(node); + }} + options={searchOptions} placeholder="Search by repeater name, IATA code, or public key..." - className="repeater-search-box__input" + className="repeater-search-box" + inputClassName="repeater-search-box__input" + popoverClassName="repeater-search-box__results" + optionClassName="repeater-search-box__result" + isOpen={showResults} + onOpenChange={setShowResults} autoFocus - /> - {showResults && ( -
- {loadingNodes ? ( + emptyContent={loadingNodes ? (
+ ) : nodesError ? ( +
+ {nodesError} +
) : searchQuery && searchResults.length === 0 ? (
No repeaters found matching "{searchQuery}"
- ) : ( - searchResults.map(node => ( - - )) - )} - {searchResults.length > 0 && ( + ) : 'Type to search repeaters'} + footer={searchResults.length > 0 ? (
{searchResults.length} result{searchResults.length !== 1 ? 's' : ''}
- )} -
- )} -
+ ) : null} + /> {!selectedNode ? (
{loadingNodes ? ( + ) : nodesError ? ( +
+

Repeater index unavailable

+

{nodesError}

+
) : (
@@ -378,7 +491,8 @@ export const UKRepeaterSearchPage: React.FC = () => {
Position - {selectedNode.lat && selectedNode.lon + {selectedNode.lat !== undefined && selectedNode.lat !== null + && selectedNode.lon !== undefined && selectedNode.lon !== null ? `${selectedNode.lat.toFixed(5)}, ${selectedNode.lon.toFixed(5)}` : 'Unknown'} diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 2bd3f5e..97d28c3 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -90,7 +90,9 @@ html, body, #root { .app-shell { display: grid; grid-template-rows: 48px 1fr; - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr); + width: 100%; + min-width: 0; height: 100%; overflow: hidden; position: relative; @@ -102,6 +104,9 @@ html, body, #root { align-items: center; gap: 24px; padding: 0 20px; + width: 100%; + min-width: 0; + overflow: hidden; background: var(--bg-panel); border-bottom: 1px solid var(--border); z-index: 1000; @@ -261,7 +266,8 @@ button .loading-indicator--inline, @media (prefers-reduced-motion: reduce) { .loading-indicator__mark { - animation: loading-indicator-pulse 1.4s ease-in-out infinite; + animation: none; + border-top-color: var(--border-bright); } } @@ -310,18 +316,14 @@ button .loading-indicator--inline, border-color: var(--accent); background: var(--accent-dim); } - -.topbar__network, .topbar__tool-btn { height: 28px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-panel-alt); color: var(--text-secondary); - font: 10px var(--font-mono); + font: 10px var(--font-mono); padding: 0 8px; cursor: pointer; } -.topbar__network { max-width: 110px; padding: 0 7px; } -.topbar__tool-btn { padding: 0 8px; cursor: pointer; } .topbar__tool-btn:hover, .topbar__tool-btn--active { border-color: var(--accent); color: var(--accent); } .topbar__shortcut-btn { width: 28px; padding: 0; font-size: 14px; } @@ -422,139 +424,79 @@ button .loading-indicator--inline, opacity: 0.85; } -.stats-page__path-link { - appearance: none; - border: 0; - background: none; - padding: 0; - margin: 0; - color: var(--accent); - cursor: pointer; - font: inherit; - font-weight: 700; - text-align: left; - display: block; - max-width: 100%; - min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; +/* ── Cookie notice: keep consent in document flow, below the page chrome ── */ +.site-layout:has(.cookie-banner) .site-main { + padding-bottom: 40px; } - -.stats-page__path-link:hover { - text-decoration: underline; -} - -.stats-page__path-modal { - background: var(--bg-panel); - border: 1px solid var(--border-bright); - border-radius: 12px; - width: min(960px, 100%); - max-height: min(88vh, 900px); - padding: 24px; +.site-layout .cookie-banner { + position: static; + right: auto; + bottom: auto; + z-index: auto; + width: 100%; + max-width: none; display: flex; - flex-direction: column; - gap: 16px; - overflow-y: auto; - overscroll-behavior: contain; - box-sizing: border-box; -} - -.stats-page__path-modal-header { - display: flex; - align-items: flex-start; + align-items: center; justify-content: space-between; gap: 16px; -} - -.stats-page__path-modal-title { margin: 0; - font-family: var(--font-mono); - font-size: 16px; - color: var(--accent); - text-transform: uppercase; - letter-spacing: 0.08em; + padding: 10px clamp(16px, 4vw, 48px); + border: 0; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + border-radius: 0; + background: var(--bg-panel); + box-shadow: none; + backdrop-filter: none; } - -.stats-page__path-modal-sub { - margin: 6px 0 0; - color: var(--text-muted); - font-size: 13px; - overflow-wrap: anywhere; -} - -.stats-page__path-modal-close { - width: auto; - min-width: 110px; -} - -.stats-page__path-modal-map { - height: 420px; - border: 1px solid var(--border); - border-radius: 10px; - overflow: hidden; -} - -.stats-page__path-modal-list { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 10px; -} - -.stats-page__path-modal-node { +.site-layout .cookie-banner__body { display: flex; - align-items: center; - gap: 10px; - padding: 10px 12px; - border: 1px solid var(--border); - border-radius: 8px; - background: rgba(11, 23, 37, 0.55); + flex: 1 1 auto; + min-width: 0; + align-items: baseline; + flex-wrap: wrap; + gap: 2px 10px; } - -.stats-page__path-modal-node span { - display: inline-flex; - width: 22px; - height: 22px; - align-items: center; - justify-content: center; - border-radius: 999px; - background: rgba(0, 196, 255, 0.12); - color: var(--accent); - font-family: var(--font-mono); - font-size: 11px; - flex: 0 0 22px; -} - -.stats-page__path-modal-node strong { +.site-layout .cookie-banner__body strong { color: var(--text-primary); font-size: 13px; - line-height: 1.35; } - -.health-kv strong { - min-width: 0; - overflow-wrap: anywhere; -} - -.stats-page__path-node-label { - background: transparent; - border: 0; - box-shadow: none; - color: #ffffff; - font-family: var(--font-mono); - font-size: 11px; - font-weight: 700; +.site-layout .cookie-banner__body p { margin: 0; - padding: 0; + color: #b4c6d8; + font-size: 12px; + line-height: 1.45; } - -.stats-page__path-node-label::before { - display: none; +.site-layout .cookie-banner__button { + flex-shrink: 0; + min-width: 82px; + min-height: 32px; + padding: 7px 12px; + border: 1px solid rgba(0, 196, 255, 0.55); + border-radius: var(--radius); + background: var(--accent-dim); + color: var(--text-primary); + font-family: var(--font-mono); + font-size: 12px; + cursor: pointer; + transition: background var(--transition), border-color var(--transition); +} +.site-layout .cookie-banner__button:hover { + background: rgba(0, 196, 255, 0.22); + border-color: var(--accent-glow); } /* Mobile controls must not create an extra app-shell grid row on desktop. */ -.mobile-controls { - display: none; + +@media (max-width: 1200px) { + .topbar { + gap: 12px; + padding-inline: 12px; + } + + .topbar__stats { + display: none; + } } /* ══════════════════════════════════════════════════════════════════════════ */ @@ -563,52 +505,19 @@ button .loading-indicator--inline, @media (max-width: 640px) { /* ── Analytics app topbar ────────────────────────────────────────────── */ - .topbar { gap: 10px; padding: 0 12px; } + .topbar { gap: 6px; padding: 0 8px; } .topbar__stats { display: none; } .topbar__divider, .topbar__tool-btn:not(.topbar__shortcut-btn) { display: none; } - .topbar__network { margin-left: auto; max-width: 92px; } + .topbar__logo { gap: 4px; } + .topbar__shortcut-btn { margin-left: auto; } /* ── App shell: add rows for mobile controls + map ──────────────────── */ .app-shell { grid-template-rows: 48px auto 1fr; } /* ── Desktop filter panel: hide on mobile (replaced by mobile-controls) */ - .filter-panel { display: none; } /* ── Mobile controls: 2x2 filter grid + search, always in flow ───────── */ - .mobile-controls { - display: flex; - flex-direction: column; - background: var(--bg-panel); - border-bottom: 1px solid var(--border); - } - .mobile-controls > .map-modes { - padding: 8px 12px; - border-bottom: 1px solid var(--border); - } - .mobile-controls .map-modes__buttons { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } - .node-drawer { - position: fixed; - inset: auto 0 0; - width: 100%; - max-height: 62vh; - border-right: 0; - border-bottom: 0; - border-left: 0; - border-radius: 14px 14px 0 0; - } - .timeline-control { - position: absolute; - right: 16px; - bottom: 204px; - left: 16px; - width: auto; - transform: none; - } - .timeline-control__meta { flex-wrap: wrap; gap: 5px 12px; } - .planner-comparison { top: auto; right: 8px; bottom: 250px; left: 8px; width: auto; max-height: 42vh; overflow: auto; } .mobile-filter-grid { display: grid; grid-template-columns: 1fr 1fr; @@ -672,10 +581,8 @@ button .loading-indicator--inline, } /* ── In-map node search: hidden on mobile (replaced by mobile-search) ── */ - .map-area .node-search { display: none; } /* ── Packet feed: full width at bottom, max 4 items ─────────────────── */ - .packet-feed { width: calc(100% - 32px); max-height: 180px; } /* ── Website nav: keep bar fixed height, dropdown pops below it ─────── */ .site-nav { @@ -715,15 +622,20 @@ button .loading-indicator--inline, font-size: 14px; } - .cookie-banner { - left: 16px; - right: 16px; - bottom: 16px; - max-width: none; + .site-layout:has(.cookie-banner) .site-main { + padding-bottom: 24px; + } + .site-layout .cookie-banner { flex-direction: column; align-items: stretch; + gap: 10px; + padding: 12px 16px; } - .cookie-banner__button { width: 100%; } + .site-layout .cookie-banner__body { + display: grid; + gap: 4px; + } + .site-layout .cookie-banner__button { width: 100%; } /* ── Website content / sections ──────────────────────────────────────── */ .site-content { padding: 0 16px; } @@ -745,8 +657,7 @@ button .loading-indicator--inline, .site-page-hero--stats { margin-bottom: 12px; } - .site-home__join, - .site-home__support { align-items: flex-start; } + .site-home__join { align-items: flex-start; } .dev-monitor__grid { grid-template-columns: 1fr; } .dev-monitor__row { flex-direction: column; align-items: flex-start; } .dev-monitor__row strong { text-align: left; } @@ -759,31 +670,12 @@ button .loading-indicator--inline, .site-prose { padding: 24px 16px 56px; } .site-prose--wide { max-width: 100%; } .prose-section--muted { padding: 20px; } - .owner-summary-grid { grid-template-columns: 1fr; } - .owner-dashboard-grid { grid-template-columns: 1fr; } - .owner-panel--map, - .owner-panel--packets { grid-row: auto; grid-column: auto; } - .owner-alerts, - .owner-list { grid-template-columns: 1fr; } - .owner-list__row { grid-template-columns: 1fr; } - .owner-list__metrics { justify-content: flex-start; } - .owner-telemetry-strip { grid-template-columns: 1fr; } - .owner-roadmap { grid-template-columns: 1fr; } - .owner-head { flex-direction: column; align-items: flex-start; } - .owner-map { height: 260px; } } @media (max-width: 1180px) { .dev-telemetry__charts { grid-template-columns: repeat(2, minmax(0, 1fr)); } - - .owner-summary-grid.site-stats-grid--6 { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - .owner-dashboard-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } } @media (max-width: 860px) { @@ -791,112 +683,25 @@ button .loading-indicator--inline, grid-template-columns: 1fr; } - .owner-summary-grid.site-stats-grid--6 { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - /* ── Feed: mobile layout ─────────────────────────────────────── */ - .uk-feed-layout { - grid-template-columns: 1fr; - grid-template-rows: auto; - height: auto; - } /* Channels sidebar → horizontal filter pills */ - .uk-feed-channels { - border-right: none; - border-bottom: 1px solid var(--border); - display: flex; - flex-direction: row; - flex-wrap: wrap; - align-items: center; - padding: 6px 8px; - gap: 4px; - overflow-x: auto; - -webkit-overflow-scrolling: touch; - } - - .uk-feed-channels__header { display: none; } - .uk-feed-channels__divider { display: none; } - - .uk-feed-channel-item { - width: auto; - padding: 6px 12px; - border-radius: 999px; - border: 1px solid var(--border); - font-size: 13px; - flex-shrink: 0; - } - - .uk-feed-channel-toggle { - font-size: 13px; - padding: 6px 8px; - } /* Packet list — full height, no cap */ - .uk-feed-chat { - border-right: none; - max-height: none; - } /* Packet rows — bigger touch targets, simplified meta */ - .uk-feed-packet-row { - min-height: 56px; - padding: 10px 12px; - } - - .uk-feed-packet-row__meta { - grid-template-columns: auto auto auto 1fr; - gap: 6px; - } /* Hide hash on mobile — too long to be useful */ - .uk-feed-packet-row__hash { - display: none; - } /* Observer spans to the last grid cell and wraps naturally */ - .uk-feed-packet-row__observer { - font-size: 0.8rem; - } /* Inline map — shown on mobile below selected row */ - .uk-feed-inline-map { - display: block; - height: 240px; - border-bottom: 1px solid var(--border); - background: var(--bg-secondary); - } /* Hide the desktop right column entirely on mobile */ - .uk-feed-right { - display: none; - } /* Mobile stats bar — connection status + type tags, shown between channels and chat */ - .uk-feed-mobile-stats { - display: block; - padding: 8px 12px; - border-bottom: 1px solid var(--border); - background: var(--bg-panel); - } - - .uk-feed-mobile-stats .uk-feed-stats__row { - margin-bottom: 6px; - } /* Mobile detail strip — shown below inline map for selected packet */ - .uk-feed-mobile-detail { - display: block; - padding: 10px 12px; - background: var(--bg-panel-alt); - border-bottom: 1px solid var(--border); - } - - .owner-dashboard-grid { - grid-template-columns: 1fr; - grid-auto-rows: minmax(280px, auto); - } } /* Repeater Search Page */ @@ -923,10 +728,7 @@ button .loading-indicator--inline, } .repeater-search-box__results { - position: absolute; - top: 100%; - left: 0; - right: 0; + width: var(--trigger-width); background: var(--bg-panel); border: 1px solid var(--border); border-top: none; @@ -937,11 +739,10 @@ button .loading-indicator--inline, z-index: 100; } -.stats-page__pie-layout { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(140px, 1fr); - align-items: center; - gap: 16px; +.repeater-search-box__results .ui-combobox__listbox { + max-height: min(250px, 38dvh); + overflow-y: auto; + overscroll-behavior: contain; } @media (max-width: 640px) { @@ -953,32 +754,6 @@ button .loading-indicator--inline, max-height: calc(100dvh - 20px); padding: 20px; } - - .stats-page__path-modal { - max-height: calc(100dvh - 20px); - padding: 14px; - gap: 12px; - } - - .stats-page__path-modal-header { - flex-direction: column; - align-items: stretch; - gap: 10px; - } - - .stats-page__path-modal-close { - width: 100%; - min-width: 0; - } - - .stats-page__path-modal-map { - height: min(46dvh, 320px); - } - - .stats-page__path-modal-list, - .stats-page__pie-layout { - grid-template-columns: minmax(0, 1fr); - } } .repeater-search-box__result { @@ -997,7 +772,8 @@ button .loading-indicator--inline, border-bottom: none; } -.repeater-search-box__result:hover { +.repeater-search-box__result:hover, +.repeater-search-box__result[data-focused] { background: var(--bg-tertiary); } @@ -1069,14 +845,6 @@ button .loading-indicator--inline, border-bottom: none; } -.repeater-details-card__section h3 { - margin: 0 0 4px; - font-size: 16px; - font-weight: 600; - color: var(--text-primary); - letter-spacing: -0.01em; -} - .repeater-details-card__desc { margin: 0 0 16px; font-size: 13px; @@ -1183,7 +951,6 @@ button .loading-indicator--inline, min-width: 500px; } -.repeater-details-card__table th, .repeater-details-card__table td { padding: 10px 12px; text-align: left; @@ -1192,6 +959,10 @@ button .loading-indicator--inline, } .repeater-details-card__table th { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid var(--border); + white-space: nowrap; font-size: 11px; font-weight: 600; color: var(--text-secondary); @@ -1313,6 +1084,11 @@ button .loading-indicator--inline, } .repeater-details-card__section h3 { + margin: 0 0 4px; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + letter-spacing: -0.01em; display: flex; align-items: center; } @@ -1367,63 +1143,6 @@ button .loading-indicator--inline, } /* ── Map tool buttons (top-left cluster) ──────────────────────────────────── */ -.map-tools { - position: absolute; - top: 10px; - left: 10px; - z-index: 10; - display: flex; - flex-direction: column; - gap: 4px; -} - -.map-tools__btn { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - font-size: 12px; - font-family: var(--font-mono); - letter-spacing: 0.04em; - color: var(--text-primary); - background: rgba(8, 13, 20, 0.8); - border: 1px solid var(--border); - border-radius: var(--radius); - cursor: pointer; - transition: background var(--transition), border-color var(--transition), color var(--transition); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - white-space: nowrap; - line-height: 1; -} - -.map-tools__btn:hover { - background: var(--bg-hover); - border-color: var(--border-bright); -} - -.map-tools__btn--active { - background: var(--accent); - border-color: var(--accent); - color: #080d14; - font-weight: 600; -} - -.map-tools__btn--active:hover { - background: #33cfff; - border-color: #33cfff; - color: #080d14; -} - -.map-tools__btn svg { - flex-shrink: 0; -} - -@media (max-width: 640px), (pointer: coarse) { - .map-tools { - display: none !important; - } -} /* Packet type labels */ .repeater-details-card__packet-type { @@ -2295,3 +2014,133 @@ button .loading-indicator--inline, .regions-rel__bc-node { padding: 4px 8px; } .regions-spec__format-row { flex-direction: column; gap: 4px; } } +/* Explicit, deferred service-worker activation prompt. */ +.service-worker-update { + position: fixed; + z-index: 100000; + right: 16px; + bottom: 16px; + display: flex; + align-items: center; + gap: 10px; + max-width: min(620px, calc(100vw - 32px)); + padding: 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-panel); + color: var(--text-primary); + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.35); +} + +.service-worker-update > div { + display: grid; + gap: 2px; + margin-right: 6px; +} + +.service-worker-update span { + color: var(--text-secondary); + font-size: 0.85rem; +} + +.service-worker-update button { + min-width: 44px; + min-height: 44px; +} + +@media (max-width: 640px) { + .service-worker-update { + left: 12px; + right: 12px; + bottom: 12px; + align-items: stretch; + flex-wrap: wrap; + } + + .service-worker-update > div { + flex-basis: 100%; + } +} +.ui-visually-hidden { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +.ui-dialog-overlay { + position: fixed; + z-index: 99999; + inset: 0; + display: grid; + place-items: center; + padding: 16px; + background: rgba(0, 0, 0, 0.72); +} + +.ui-dialog-modal { + width: min(760px, 100%); + max-height: calc(100dvh - 32px); +} + +.ui-dialog { + max-height: calc(100dvh - 32px); + overflow: auto; + border-radius: 12px; + background: var(--bg-panel); + color: var(--text-primary); +} + +.ui-combobox__input-wrap { + display: flex; + align-items: stretch; +} + +.ui-combobox__popover { + min-width: var(--trigger-width); + max-height: min(420px, 60vh); + overflow: auto; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-panel); + color: var(--text-primary); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35); +} + +.ui-combobox__listbox { + outline: none; +} + +.ui-combobox__empty { + padding: 12px; + color: var(--text-secondary); +} + +:where(a, button, input, select, textarea, [tabindex]):focus-visible, +[data-focus-visible] { + outline: 3px solid var(--accent); + outline-offset: 2px; +} + +@media (pointer: coarse) { + :where(button, input, select, textarea, [role="button"], [role="tab"], [role="option"]) { + min-height: 44px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + transition-delay: 0ms !important; + } +} diff --git a/frontend/src/styles/map-app.css b/frontend/src/styles/map-app.css index a1f0e02..ec4d9eb 100644 --- a/frontend/src/styles/map-app.css +++ b/frontend/src/styles/map-app.css @@ -2,6 +2,8 @@ .map-layer { position: relative; overflow: hidden; + width: 100%; + min-width: 0; min-height: 0; } @@ -22,6 +24,8 @@ .map-area { position: relative; overflow: hidden; + width: 100%; + min-width: 0; height: 100%; } @@ -48,6 +52,7 @@ } .node-search__controls { display: flex; } +.node-search__combobox { flex: 1 1 auto; min-width: 0; } .node-search__controls .node-search__input { border-radius: 6px 0 0 6px; } .node-search__nearby { flex: 0 0 34px; @@ -79,10 +84,7 @@ .node-search__input:focus { border-color: var(--accent); } .node-search__results { - position: absolute; - top: calc(100% + 4px); - left: 0; - width: 100%; + width: var(--trigger-width); background: var(--bg-panel); border: 1px solid var(--border); border-radius: 6px; @@ -109,7 +111,8 @@ justify-content: space-between; align-items: center; } -.node-search__result:hover { background: var(--bg-hover); color: var(--accent); } +.node-search__result:hover, +.node-search__result[data-focused] { background: var(--bg-hover); color: var(--accent); } .node-search__result:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } .node-search__result-name { overflow: hidden; @@ -286,8 +289,7 @@ gap: 5px; } -.map-modes__button, -.map-modes__share { +.map-modes__button { border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-active); @@ -311,6 +313,14 @@ } .map-modes__share { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-active); + color: var(--text-secondary); + font: 600 10px/1.2 var(--font-mono); + padding: 7px 6px; + cursor: pointer; + transition: color var(--transition), border-color var(--transition), background var(--transition); width: 100%; background: transparent; } @@ -325,7 +335,7 @@ transition: background var(--transition); user-select: none; width: 100%; - border: 0; + border: 1px solid transparent; background: transparent; font: inherit; text-align: left; @@ -472,7 +482,19 @@ .node-dock__close:hover { color: var(--text-primary); border-color: var(--accent); } .node-dock__actions { display: flex; gap: 6px; padding: 10px 14px 0; } -.node-dock__watch, +.node-dock__watch { + flex: 1; + padding: 7px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-active); + font: 10px var(--font-mono); + letter-spacing: 0.06em; + cursor: pointer; + transition: color var(--transition), border-color var(--transition), background var(--transition); color: var(--amber); +} +.node-dock__watch:hover { border-color: var(--amber); } +.node-dock__watch--on { background: var(--amber-dim); border-color: var(--amber); } .node-dock__copy { flex: 1; padding: 7px; @@ -482,12 +504,8 @@ font: 10px var(--font-mono); letter-spacing: 0.06em; cursor: pointer; - transition: color var(--transition), border-color var(--transition), background var(--transition); + transition: color var(--transition), border-color var(--transition), background var(--transition); color: var(--text-secondary); } -.node-dock__watch { color: var(--amber); } -.node-dock__watch:hover { border-color: var(--amber); } -.node-dock__watch--on { background: var(--amber-dim); border-color: var(--amber); } -.node-dock__copy { color: var(--text-secondary); } .node-dock__copy:hover { color: var(--accent); border-color: var(--accent); } .node-dock__metrics { @@ -531,9 +549,9 @@ max-width: 560px; margin-inline: auto; transform: none; - border: 1px solid var(--border); + border: 1px solid rgba(251, 191, 36, 0.55); border-radius: var(--radius-lg); - background: rgba(8, 16, 27, 0.94); + background: rgba(8, 16, 27, 0.97); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45); backdrop-filter: blur(10px); } @@ -552,14 +570,21 @@ align-items: center; justify-content: space-between; width: 100%; + min-height: 36px; padding: 8px 12px; border: 0; background: transparent; - color: var(--text-secondary); + color: var(--text-primary); font: 10px var(--font-mono); cursor: pointer; } -.timeline-control__toggle strong { color: var(--amber); } +.timeline-control__toggle strong { + padding: 3px 8px; + border: 1px solid rgba(251, 191, 36, 0.42); + border-radius: 999px; + background: rgba(251, 191, 36, 0.1); + color: #ffd166; +} .timeline-control__body { padding: 4px 12px 10px; border-top: 1px solid var(--border); } .timeline-control__body input { width: 100%; accent-color: var(--amber); } .timeline-control__meta { display: flex; gap: 14px; margin-top: 3px; color: var(--text-secondary); font: 9px var(--font-mono); } @@ -574,17 +599,46 @@ .stats-page__observer-watch button:hover { color: var(--amber); border-color: var(--amber); } .watchlist-panel { margin-top: 8px; border-top: 1px solid var(--border); padding-top: 7px; } -.watchlist-panel summary { display: flex; justify-content: space-between; color: var(--text-secondary); font: 10px var(--font-mono); cursor: pointer; } -.watchlist-panel summary span { color: var(--amber); } +.watchlist-panel summary { + display: flex; + justify-content: flex-start; + gap: 8px; + color: var(--text-secondary); + font: 10px var(--font-mono); + cursor: pointer; +} +.watchlist-panel summary span { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border: 1px solid rgba(255, 179, 0, 0.38); + border-radius: 999px; + background: rgba(255, 179, 0, 0.1); + color: #ffd166; + font-weight: 700; + line-height: 1; +} .watchlist-panel p { margin: 7px 0 0; color: var(--text-muted); font-size: 11px; } .watchlist-panel ul { max-height: 170px; margin: 7px 0 0; padding: 0; overflow: auto; list-style: none; } .watchlist-panel li { display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 5px 2px; border-bottom: 1px solid var(--border); } -.watchlist-panel li > span { min-width: 0; overflow: hidden; color: var(--text-secondary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.watchlist-panel li > a { min-width: 0; overflow: hidden; color: var(--text-secondary); font-size: 11px; text-decoration: none; text-overflow: ellipsis; white-space: nowrap; } +.watchlist-panel li > a:hover, .watchlist-panel li > a:focus-visible { color: var(--amber); text-decoration: underline; } .watchlist-panel li small { display: block; color: var(--text-muted); font: 7px var(--font-mono); text-transform: uppercase; } .watchlist-panel li button { border: 0; background: transparent; color: var(--text-muted); cursor: pointer; } .watchlist-panel li button:hover { color: var(--danger); } -.packet-item__watch { flex: 0 0 auto; border: 0; background: transparent; color: var(--amber); cursor: pointer; } +.packet-item__watch { + width: 20px; + min-width: 20px; + padding: 0; + border: 0; + background: transparent; + color: var(--amber); + cursor: pointer; +} .planner-comparison { position: absolute; @@ -839,8 +893,7 @@ z-index: 5; } -.leaflet-popup-content-wrapper, -.maplibregl-popup-content { +.leaflet-popup-content-wrapper { background: var(--bg-panel) !important; border: 1px solid var(--border-bright) !important; border-radius: var(--radius-lg) !important; @@ -849,8 +902,7 @@ padding: 0 !important; } -.leaflet-popup-content, -.maplibregl-popup-content { +.leaflet-popup-content { margin: 0 !important; } @@ -860,6 +912,13 @@ } .maplibregl-popup-content { + background: var(--bg-panel) !important; + border: 1px solid var(--border-bright) !important; + border-radius: var(--radius-lg) !important; + box-shadow: 0 8px 32px rgba(0,0,0,0.7) !important; + color: var(--text-primary) !important; + padding: 0 !important; + margin: 0 !important; min-width: 200px; } @@ -1079,28 +1138,42 @@ bottom: 16px; left: 16px; z-index: 800; - width: 420px; + width: min(420px, calc(100vw - 32px)); max-height: 280px; overflow-y: auto; overflow-x: hidden; pointer-events: auto; display: flex; flex-direction: column; + padding: 8px 4px 2px 0; + border: 1px solid rgba(214, 232, 255, 0.28); + border-radius: var(--radius-lg); + background: rgba(8, 16, 27, 0.97); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.42); scrollbar-width: thin; - scrollbar-color: rgba(255,255,255,0.15) transparent; + scrollbar-color: rgba(214, 232, 255, 0.52) rgba(8, 16, 27, 0.7); + scroll-padding-top: 8px; +} +.packet-feed::-webkit-scrollbar { width: 8px; } +.packet-feed::-webkit-scrollbar-track { + background: rgba(8, 16, 27, 0.7); + border-radius: 4px; +} +.packet-feed::-webkit-scrollbar-thumb { + background: rgba(214, 232, 255, 0.52); + border: 2px solid rgba(8, 16, 27, 0.7); + border-radius: 4px; } -.packet-feed::-webkit-scrollbar { width: 4px; } -.packet-feed::-webkit-scrollbar-track { background: transparent; } -.packet-feed::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 2px; } .packet-item { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius); - padding: 7px 11px; - display: flex; - align-items: flex-start; - gap: 9px; + padding: 7px 9px; + display: grid; + grid-template-columns: 34px 28px 36px 36px minmax(0, 1fr) 30px 62px 20px 8px; + align-items: center; + column-gap: 6px; font-family: var(--font-mono); font-size: 11px; line-height: 1.35; @@ -1108,6 +1181,12 @@ margin-bottom: 3px; will-change: transform, opacity; } +.packet-item > * { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .packet-item--fading { opacity: 0; @@ -1132,10 +1211,13 @@ } .packet-item__pin { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; font-size: 7px; color: var(--accent); opacity: 0.7; - flex-shrink: 0; animation: pin-pulse 2s ease-in-out infinite; } @@ -1159,26 +1241,29 @@ } .packet-item__type { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; font-size: 11px; color: var(--accent); - min-width: 34px; - flex-shrink: 0; } .packet-item__path-bytes { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; font-size: 10px; color: #f87171; - min-width: 24px; - flex-shrink: 0; font-weight: 700; letter-spacing: 0.04em; } .packet-item__iata { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; font-size: 10px; color: var(--text-primary); - min-width: 30px; - flex-shrink: 0; } .packet-item__iata--all { @@ -1187,20 +1272,23 @@ } .packet-item__advert-badge { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; font-size: 11px; color: var(--amber); white-space: nowrap; - flex-shrink: 0; } .packet-item__summary { color: var(--text-secondary); font-size: 11px; - flex: 1; min-width: 0; - white-space: normal; - overflow-wrap: break-word; - word-break: break-word; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + overflow-wrap: normal; + word-break: normal; } .packet-item__summary--empty { @@ -1208,10 +1296,12 @@ } .packet-item__counts { + min-width: 0; display: flex; align-items: center; + justify-content: flex-end; gap: 3px; - flex-shrink: 0; + overflow: visible; } .count { @@ -1235,6 +1325,9 @@ } .packet-item__hops { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; font-size: 11px; color: var(--amber); } @@ -1314,7 +1407,6 @@ grid-template-rows: 1fr; } .map-annotation { top: 98px; max-width: calc(100vw - 30px); } - .filter-panel { display: none; } .filter-launcher { display: none; } .node-drawer, .node-dock { @@ -1363,7 +1455,6 @@ .app-shell[data-node-open="true"] .node-legend { display: none; } - .packet-feed { width: calc(100% - 32px); max-height: 180px; } } @@ -1485,7 +1576,7 @@ overflow-x: auto; margin: 0 0 8px; } -.node-popup__tabs button { +.node-popup__tabs [role='tab'] { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 4px 6px; @@ -1494,10 +1585,140 @@ font: 10px var(--font-mono); cursor: pointer; } -.node-popup__tabs button[aria-selected='true'] { +.node-popup__tabs [role='tab'][aria-selected='true'] { border-color: var(--accent); background: var(--accent-dim); color: var(--text-primary); } .node-popup__link-spark { width: 100%; margin-top: 4px; } .node-popup__muted { color: var(--text-muted); font-size: 11px; } +.mobile-controls { + display: none; +} +@media (max-width: 640px) { + .filter-panel { display: none; } + .mobile-controls { + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; + max-height: min(40dvh, 270px); + overflow-y: auto; + overscroll-behavior: contain; + background: var(--bg-panel); + border-bottom: 1px solid var(--border); + } + .mobile-controls > .map-modes { + padding: 8px 12px; + border-bottom: 1px solid var(--border); + } + .mobile-controls .map-modes__buttons { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .map-area .node-search { display: none; } + .packet-feed { width: calc(100% - 32px); max-height: 180px; } +} +.map-tools { + position: absolute; + top: 10px; + left: 10px; + z-index: 10; + display: flex; + flex-direction: column; + gap: 4px; +} + +.map-tools__btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + font-size: 12px; + font-family: var(--font-mono); + letter-spacing: 0.04em; + color: var(--text-primary); + background: rgba(8, 13, 20, 0.8); + border: 1px solid var(--border); + border-radius: var(--radius); + cursor: pointer; + transition: background var(--transition), border-color var(--transition), color var(--transition); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + white-space: nowrap; + line-height: 1; +} + +.map-tools__btn:hover { + background: var(--bg-hover); + border-color: var(--border-bright); +} + +.map-tools__btn--active { + background: var(--accent); + border-color: var(--accent); + color: #080d14; + font-weight: 600; +} + +.map-tools__btn--active:hover { + background: #33cfff; + border-color: #33cfff; + color: #080d14; +} + +.map-tools__btn svg { + flex-shrink: 0; +} + +@media (max-width: 640px), (pointer: coarse) { + .map-tools { + display: none !important; + } +} + +/* Agent B app chrome */ +.app-shell .topbar__tool-btn, +.app-shell .topbar__info-btn { + border-color: rgba(214, 232, 255, 0.48); + background: rgba(17, 28, 43, 0.94); + color: var(--text-primary); + box-shadow: 0 0 0 1px rgba(8, 16, 27, 0.35); +} +.app-shell .topbar__tool-btn:hover, +.app-shell .topbar__tool-btn:focus-visible, +.app-shell .topbar__info-btn:hover, +.app-shell .topbar__info-btn:focus-visible { + border-color: var(--accent); + color: var(--accent); + background: var(--accent-dim); +} +.app-shell .topbar__tool-btn--active { + border-color: var(--accent); + background: rgba(0, 196, 255, 0.18); + color: var(--accent); + box-shadow: inset 0 0 0 1px rgba(0, 196, 255, 0.2), 0 0 10px rgba(0, 196, 255, 0.12); +} +.app-shell .topbar__info-btn[aria-label='Data disclaimer'] { + color: var(--text-primary); + font-weight: 700; +} +.filter-row[aria-pressed='true'] { + border-color: rgba(0, 196, 255, 0.28); + background: rgba(0, 196, 255, 0.08); +} +.timeline-control--open .timeline-control__toggle { + background: rgba(251, 191, 36, 0.08); +} +.timeline-control__name { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-primary); + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} +.timeline-control__name > span { + color: var(--amber); + font-size: 14px; +} diff --git a/healthcheck-overrides/app.js b/healthcheck-overrides/app.js new file mode 100644 index 0000000..21c253a --- /dev/null +++ b/healthcheck-overrides/app.js @@ -0,0 +1,2077 @@ +const SESSION_STORAGE_KEY = 'mesh-health-check-session-id'; +const SESSION_HISTORY_STORAGE_KEY = 'mesh-health-check-session-history'; +const OBSERVER_ALLOWLIST_STORAGE_KEY = 'mesh-health-check-observer-allowlist'; +const UI_THEME_STORAGE_KEY = 'mesh-health-check-ui-theme'; +const ANALYZER_BASE_URL = 'https://analyzer.letsmesh.net/packets?packet_hash='; +const SHARE_ROUTE_PREFIX = '/share/'; +let deferredInstallPrompt = null; + +const ui = { + mqttPill: document.querySelector('#mqtt-pill'), + installAppButton: document.querySelector('#install-app-button'), + newSessionButton: document.querySelector('#new-session-button'), + copySessionCodeButton: document.querySelector('#copy-session-code'), + shareSessionButton: document.querySelector('#share-session'), + sessionCode: document.querySelector('#session-code'), + sessionInstructions: document.querySelector('#session-instructions'), + sessionShareNote: document.querySelector('#session-share-note'), + sessionStatus: document.querySelector('#session-status'), + sessionHash: document.querySelector('#session-hash'), + healthLabel: document.querySelector('#health-label'), + healthPercent: document.querySelector('#health-percent'), + observedCount: document.querySelector('#observed-count'), + repeaterCount: document.querySelector('#repeater-count'), + senderName: document.querySelector('#sender-name'), + channelName: document.querySelector('#channel-name'), + heroEyebrow: document.querySelector('#hero-eyebrow'), + heroTitle: document.querySelector('#hero-title'), + heroDescriptionPrefix: document.querySelector('#hero-description-prefix'), + heroDescriptionSuffix: document.querySelector('#hero-description-suffix'), + heroChannel: document.querySelector('#hero-channel'), + brokerName: document.querySelector('#broker-name'), + externalLink: document.querySelector('#external-link'), + repoNoteLink: document.querySelector('#repo-note-link'), + siteVersionNote: document.querySelector('#site-version-note'), + messagePreview: document.querySelector('#message-preview'), + expectedSource: document.querySelector('#expected-source'), + expectedObservers: document.querySelector('#expected-observers'), + observerAllowlistNote: document.querySelector('#observer-allowlist-note'), + regionFilter: document.querySelector('#region-filter'), + observerAllowlist: document.querySelector('#observer-allowlist'), + observerAllowlistClear: document.querySelector('#observer-allowlist-clear'), + uiThemeToggle: document.querySelector('#ui-theme-toggle'), + mapObserverNote: document.querySelector('#map-observer-note'), + mapEmpty: document.querySelector('#map-empty'), + observerMap: document.querySelector('#observer-map'), + activeObserverNote: document.querySelector('#active-observer-note'), + timelineSummary: document.querySelector('#timeline-summary'), + timelineScale: document.querySelector('#timeline-scale'), + timelineStartLabel: document.querySelector('#timeline-start-label'), + timelineEndLabel: document.querySelector('#timeline-end-label'), + receiptTimelineEmpty: document.querySelector('#receipt-timeline-empty'), + receiptTimeline: document.querySelector('#receipt-timeline'), + receiptsEmpty: document.querySelector('#receipts-empty'), + receipts: document.querySelector('#receipts'), + sessionHistory: document.querySelector('#session-history'), + networkWindow: document.querySelector('#network-window'), + networkState: document.querySelector('#network-state'), + observerDensity: document.querySelector('#observer-density'), + observerDensityLabel: document.querySelector('#observer-density-label'), + observerDensityDetail: document.querySelector('#observer-density-detail'), + observerSparkline: document.querySelector('#observer-sparkline'), + observerLoadSparkline: document.querySelector('#observer-load-sparkline'), + signalQuality: document.querySelector('#signal-quality'), + signalQualityCard: document.querySelector('#signal-quality-card'), + signalQualityLabel: document.querySelector('#signal-quality-label'), + signalSparkline: document.querySelector('#signal-sparkline'), + latencyScore: document.querySelector('#latency-score'), + latencyCard: document.querySelector('#receipt-spread-card'), + latencyLabel: document.querySelector('#latency-label'), + latencySparkline: document.querySelector('#latency-sparkline'), + detailDrawer: document.querySelector('#detail-drawer'), + drawerScrim: document.querySelector('#drawer-scrim'), + drawerMeta: document.querySelector('#drawer-meta'), + drawerTitle: document.querySelector('#drawer-title'), + drawerBody: document.querySelector('#drawer-body'), + drawerClose: document.querySelector('#drawer-close'), +}; + +const pageMode = document.body?.dataset?.pageMode || 'app'; +const mapObserverScope = document.body?.dataset?.mapObserverScope === 'expected' + ? 'expected' + : 'directory'; + +localStorage.removeItem(SESSION_STORAGE_KEY); +localStorage.removeItem(SESSION_HISTORY_STORAGE_KEY); + +const state = { + snapshot: null, + currentSessionId: sessionStorage.getItem(SESSION_STORAGE_KEY) || '', + sharedSessionId: sharedSessionIdFromLocation(), + sharedSessionMissing: false, + trackedSessionIds: loadTrackedSessionIds(), + selectedObserverKeys: loadSelectedObserverKeys(), + selectedRegionGroup: null, + selectedRegion: null, + uiTheme: loadUiTheme(), + sessions: new Map(), + socket: null, + socketRetryTimer: 0, + sessionRetargetTimer: 0, + refreshInFlight: false, + map: { + instance: null, + layer: null, + layerTheme: '', + markers: new Map(), + boundsKey: '', + }, + drawer: { + kind: '', + key: '', + }, +}; + +function loadTrackedSessionIds() { + try { + const raw = sessionStorage.getItem(SESSION_HISTORY_STORAGE_KEY); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter(Boolean) : []; + } catch { + return []; + } +} + +function saveTrackedSessionIds() { + sessionStorage.setItem( + SESSION_HISTORY_STORAGE_KEY, + JSON.stringify(state.trackedSessionIds), + ); +} + +function loadSelectedObserverKeys() { + try { + const raw = sessionStorage.getItem(OBSERVER_ALLOWLIST_STORAGE_KEY); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter(Boolean) : []; + } catch { + return []; + } +} + +function saveSelectedObserverKeys() { + sessionStorage.setItem( + OBSERVER_ALLOWLIST_STORAGE_KEY, + JSON.stringify(state.selectedObserverKeys), + ); +} + +function loadUiTheme() { + const stored = localStorage.getItem(UI_THEME_STORAGE_KEY); + return stored === 'light' ? 'light' : 'dark'; +} + +function saveUiTheme() { + localStorage.setItem(UI_THEME_STORAGE_KEY, state.uiTheme); +} + +function applyUiTheme() { + const activeTheme = state.uiTheme === 'dark' ? 'dark' : 'light'; + document.body.dataset.uiTheme = activeTheme; + document.documentElement.style.colorScheme = activeTheme; + if (ui.uiThemeToggle) { + ui.uiThemeToggle.textContent = activeTheme === 'dark' ? 'Light Mode' : 'Dark Mode'; + } + const metaThemeColor = document.querySelector('meta[name="theme-color"]'); + if (metaThemeColor) { + metaThemeColor.setAttribute('content', activeTheme === 'dark' ? '#07111d' : '#e9f2ff'); + } +} + +function dedupe(items) { + return [...new Set(items.filter(Boolean))]; +} + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function sharedSessionIdFromLocation() { + const path = window.location.pathname || ''; + if (!path.startsWith(SHARE_ROUTE_PREFIX)) { + return ''; + } + const encodedId = path.slice(SHARE_ROUTE_PREFIX.length).split('/')[0] || ''; + try { + return decodeURIComponent(encodedId).trim(); + } catch { + return encodedId.trim(); + } +} + +function isSharedRoute() { + return Boolean(state.sharedSessionId); +} + +function isSharePage() { + return pageMode === 'share'; +} + +function updateInstallButton() { + ui.installAppButton.classList.toggle('hidden', !deferredInstallPrompt); +} + +function observerDirectory() { + if (Array.isArray(state.snapshot?.observerDirectory) && state.snapshot.observerDirectory.length > 0) { + return state.snapshot.observerDirectory; + } + return Array.isArray(state.snapshot?.activeObservers) ? state.snapshot.activeObservers : []; +} + +function configuredDefaultObserverKeys() { + const defaults = Array.isArray(state.snapshot?.defaultObserverKeys) + ? state.snapshot.defaultObserverKeys + : []; + return dedupe(defaults); +} + +function shortObserverKey(key) { + const value = String(key || '').trim().toUpperCase(); + if (value.length <= 12) { + return value || '--'; + } + return `${value.slice(0, 6)}...${value.slice(-6)}`; +} + +function fallbackObserverRecord(key) { + return { + key, + hash: String(key || '').trim().toUpperCase().slice(0, 2) || '--', + label: shortObserverKey(key), + name: null, + lat: null, + lon: null, + hasLocation: false, + region: null, + regionGroup: null, + shortKey: shortObserverKey(key), + packetCount: 0, + firstSeenAt: 0, + lastPacketAt: 0, + isRetained: false, + isActive: false, + }; +} + +function configuredDefaultObservers() { + const defaults = Array.isArray(state.snapshot?.defaultObservers) + ? state.snapshot.defaultObservers.filter((observer) => observer?.key) + : []; + if (defaults.length > 0) { + return defaults; + } + return configuredDefaultObserverKeys().map((key) => fallbackObserverRecord(key)); +} + +function selectableObservers() { + const merged = new Map(); + for (const observer of configuredDefaultObservers()) { + merged.set(observer.key, { ...observer, isDefaultTarget: true }); + } + for (const observer of observerDirectory()) { + const existing = merged.get(observer.key) || {}; + merged.set(observer.key, { + ...existing, + ...observer, + isDefaultTarget: Boolean(existing.isDefaultTarget), + }); + } + return [...merged.values()]; +} + +function customSelectedObserverKeys() { + const available = new Set(selectableObservers().map((observer) => observer.key)); + return state.selectedObserverKeys.filter((key) => available.has(key)); +} + +function defaultObserverKeys() { + return configuredDefaultObserverKeys(); +} + +function usingDefaultObserverSet() { + return customSelectedObserverKeys().length === 0; +} + +function effectiveObserverKeysForCreate() { + return usingDefaultObserverSet() + ? defaultObserverKeys() + : customSelectedObserverKeys(); +} + +function defaultObserverTargetSummary() { + const source = String(state.snapshot?.defaultObserverSource || ''); + const count = defaultObserverKeys().length; + if (source === 'configured') { + return `Default: ${count} observer${count === 1 ? '' : 's'}.`; + } + return `Default: ${count} active observer${count === 1 ? '' : 's'}.`; +} + +function targetPreviewLabel() { + return usingDefaultObserverSet() ? 'Default set (next code)' : 'Custom set (next code)'; +} + +function sessionTargetKeys(session) { + return dedupe( + Array.isArray(session?.expectedObservers) + ? session.expectedObservers.map((observer) => observer?.key) + : [], + ); +} + +function sameKeys(left, right) { + const leftKeys = dedupe(left).sort(); + const rightKeys = dedupe(right).sort(); + if (leftKeys.length !== rightKeys.length) { + return false; + } + return leftKeys.every((key, index) => key === rightKeys[index]); +} + +function selectionDiffersFromSession(session) { + return !sameKeys(sessionTargetKeys(session), effectiveObserverKeysForCreate()); +} + +function sessionCanRetarget(session) { + return Boolean( + session + && !isSharePage() + && session.status === 'waiting' + && Number(session.useCount || 0) === 0 + && !session.messageHash + && !session.matchedAt, + ); +} + +function targetPreviewSession() { + const knownObservers = new Map( + selectableObservers().map((observer) => [observer.key, observer]), + ); + return { + expectedObservers: effectiveObserverKeysForCreate().map((key) => { + const observer = knownObservers.get(key) || fallbackObserverRecord(key); + return { + key, + hash: observer.hash || '--', + label: observer.label, + seen: false, + }; + }), + }; +} + +function sessionObserverSourceLabel(session) { + if (!session) { + return defaultObserverTargetSummary(); + } + if (session.allowlistEnabled) { + return 'Custom set'; + } + if (session.expectedObserverSource === 'configured') { + return 'Default set'; + } + if (session.expectedObserverSource === 'active-window') { + return 'Active set'; + } + if (session.expectedObserverSource === 'first-observer') { + return 'Matched observer'; + } + return 'Observer target'; +} + +function upsertTrackedSession(session) { + if (!session?.id) { + return; + } + state.sessions.set(session.id, session); + state.trackedSessionIds = [ + session.id, + ...state.trackedSessionIds.filter((id) => id !== session.id), + ].slice(0, 8); + saveTrackedSessionIds(); +} + +function removeTrackedSession(sessionId) { + state.sessions.delete(sessionId); + state.trackedSessionIds = state.trackedSessionIds.filter((id) => id !== sessionId); + saveTrackedSessionIds(); + if (state.currentSessionId === sessionId) { + state.currentSessionId = ''; + sessionStorage.removeItem(SESSION_STORAGE_KEY); + } +} + +async function apiFetch(url, options = {}) { + return fetch(url, { + credentials: 'same-origin', + ...options, + headers: { + ...(options.headers || {}), + }, + }); +} + +function formatTime(timestamp) { + if (!timestamp) { + return 'Pending'; + } + return new Date(timestamp).toLocaleTimeString([], { + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + }); +} + +function formatDateTime(timestamp) { + if (!timestamp) { + return 'Pending'; + } + return new Date(timestamp).toLocaleString([], { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +function formatElapsed(ms) { + const value = Math.max(0, Math.round(ms)); + if (value < 1000) { + return `${value} ms`; + } + if (value < 60000) { + const seconds = value / 1000; + return `${seconds >= 10 ? seconds.toFixed(0) : seconds.toFixed(1)} s`; + } + const minutes = Math.floor(value / 60000); + const seconds = Math.round((value % 60000) / 1000); + if (seconds === 60) { + return `${minutes + 1} min`; + } + return `${minutes} min ${seconds}s`; +} + +function formatWindow(seconds) { + const value = Math.max(0, Number(seconds) || 0); + if (value >= 3600) { + const hours = value / 3600; + return Number.isInteger(hours) ? `${hours}h window` : `${hours.toFixed(1)}h window`; + } + if (value >= 60) { + const minutes = value / 60; + return Number.isInteger(minutes) ? `${minutes}m window` : `${minutes.toFixed(1)}m window`; + } + return `${value}s window`; +} + +function renderSparkline(element, points, tone = 'neutral') { + if (!element) { + return; + } + const source = Array.isArray(points) && points.length > 0 + ? points + : [10, 12, 11, 14, 13, 12, 15, 12]; + const normalized = source.map((point) => clamp(point, 8, 100)); + const signature = `${tone}|${normalized.join(',')}`; + if (element.dataset.sparklineSignature === signature) { + return; + } + + element.innerHTML = ''; + element.dataset.tone = tone; + element.dataset.sparklineSignature = signature; + for (const [index, point] of normalized.entries()) { + const bar = document.createElement('span'); + bar.className = 'sparkline-bar'; + bar.style.height = `${point}%`; + bar.style.animationDelay = `${index * 40}ms`; + element.appendChild(bar); + } +} + +function scoreTone(score) { + if (!Number.isFinite(score)) { + return 'neutral'; + } + if (score >= 72) { + return 'good'; + } + if (score >= 45) { + return 'warning'; + } + return 'critical'; +} + +function scoreLabel(score) { + if (!Number.isFinite(score)) { + return 'Awaiting telemetry'; + } + if (score >= 72) { + return 'Nominal signal window'; + } + if (score >= 45) { + return 'Degraded signal window'; + } + return 'Critical signal window'; +} + +function receiptSignalScore(receipt) { + const components = []; + if (Number.isFinite(receipt?.rssi)) { + components.push(clamp(((Number(receipt.rssi) + 120) / 75) * 100, 0, 100)); + } + if (Number.isFinite(receipt?.snr)) { + components.push(clamp(((Number(receipt.snr) + 20) / 40) * 100, 0, 100)); + } + if (components.length === 0) { + return null; + } + return Math.round(components.reduce((sum, value) => sum + value, 0) / components.length); +} + +function transportSummary(snapshot) { + const activeCount = Number(snapshot?.observerStats?.activeCount || 0); + const windowSeconds = Number(snapshot?.observerStats?.windowSeconds || 0); + const directory = Array.isArray(snapshot?.observerDirectory) ? snapshot.observerDirectory : []; + const configuredCount = Math.max(directory.length, Number(snapshot?.observerStats?.configuredCount || 0)); + const maxPacketCount = Math.max( + 1, + ...directory.map((observer) => Number(observer?.packetCount || 0)), + ); + const activityBars = directory.slice(0, 8).map((observer) => { + const packetCount = Number(observer?.packetCount || 0); + return clamp((packetCount / maxPacketCount) * 100, 10, 100); + }); + const stateLabel = snapshot?.mqtt?.connected ? 'Live' : (isSharePage() ? 'Shared' : 'Offline'); + return { + stateLabel, + tone: directory.length > 0 + ? (snapshot?.mqtt?.connected ? 'good' : (isSharePage() ? 'warning' : 'warning')) + : 'neutral', + activityBars, + density: `${activeCount} / ${configuredCount || 0}`, + summary: directory.length > 0 + ? `${activeCount} active nodes · ${formatWindow(windowSeconds)}` + : 'Awaiting observer directory.', + detail: directory.length > 0 + ? `${directory.length} known observer${directory.length === 1 ? '' : 's'} on file.` + : 'No observer telemetry yet.', + }; +} + +function signalSummary(session) { + const receipts = Array.isArray(session?.receipts) ? [...session.receipts] : []; + const points = receipts + .map((receipt) => receiptSignalScore(receipt)) + .filter((value) => Number.isFinite(value)); + if (points.length === 0) { + return { + value: 'No data yet', + label: 'Waiting for a health check receipt.', + tone: 'neutral', + points: [], + empty: true, + }; + } + const average = Math.round(points.reduce((sum, value) => sum + value, 0) / points.length); + return { + value: `${average}%`, + label: scoreLabel(average), + tone: scoreTone(average), + points, + }; +} + +function latencySummary(session) { + const receipts = Array.isArray(session?.receipts) + ? [...session.receipts] + .filter((receipt) => receipt?.firstSeenAt) + .sort((left, right) => left.firstSeenAt - right.firstSeenAt) + : []; + if (receipts.length === 0) { + return { + value: 'No data yet', + label: 'Waiting for observer reports.', + tone: 'neutral', + points: [], + empty: true, + }; + } + const firstSeenAt = receipts[0].firstSeenAt; + const lastSeenAt = receipts[receipts.length - 1].firstSeenAt; + const spread = Math.max(0, lastSeenAt - firstSeenAt); + const points = receipts.map((receipt) => { + if (spread <= 0) { + return 100; + } + return clamp(((receipt.firstSeenAt - firstSeenAt) / spread) * 100, 10, 100); + }); + return { + value: spread > 0 ? `+${formatElapsed(spread)}` : '0 ms', + label: spread > 0 + ? `${receipts.length} observers across ${formatElapsed(spread)}` + : `${receipts.length} observer${receipts.length === 1 ? '' : 's'} at the same moment`, + tone: spread > 45000 ? 'critical' : spread > 12000 ? 'warning' : 'good', + points, + }; +} + +function renderGlanceMetrics(session) { + const snapshot = state.snapshot; + if (!snapshot) { + return; + } + + const transport = transportSummary(snapshot); + const signal = signalSummary(session); + const latency = latencySummary(session); + + if (ui.networkWindow) { + ui.networkWindow.textContent = transport.summary; + } + if (ui.networkState) { + ui.networkState.textContent = transport.stateLabel; + } + if (ui.observerDensity) { + ui.observerDensity.textContent = transport.density; + } + if (ui.observerDensityLabel) { + ui.observerDensityLabel.textContent = transport.summary; + } + if (ui.observerDensityDetail) { + ui.observerDensityDetail.textContent = transport.detail; + } + if (ui.signalQuality) { + ui.signalQuality.textContent = signal.value; + } + if (ui.signalQualityCard) { + ui.signalQualityCard.classList.toggle('glance-card--empty', Boolean(signal.empty)); + } + if (ui.signalQualityLabel) { + ui.signalQualityLabel.textContent = signal.label; + } + if (ui.latencyScore) { + ui.latencyScore.textContent = latency.value; + } + if (ui.latencyCard) { + ui.latencyCard.classList.toggle('glance-card--empty', Boolean(latency.empty)); + } + if (ui.latencyLabel) { + ui.latencyLabel.textContent = latency.label; + } + + renderSparkline(ui.observerSparkline, transport.activityBars, transport.tone); + renderSparkline(ui.observerLoadSparkline, transport.activityBars.slice().reverse(), transport.tone); + renderSparkline(ui.signalSparkline, signal.points, signal.tone); + renderSparkline(ui.latencySparkline, latency.points, latency.tone); +} + +function retentionNote() { + const seconds = Number(state.snapshot?.results?.retentionSeconds || 0); + if (!seconds) { + return 'Shared links stay available for a limited time.'; + } + const days = seconds / 86400; + if (Number.isInteger(days) && days >= 1) { + return `Shared links are kept for ${days} day${days === 1 ? '' : 's'}.`; + } + return `Shared links are kept for ${formatElapsed(seconds * 1000)}.`; +} + +function setSessionHash(hash) { + const value = String(hash || '').trim(); + if (!value) { + ui.sessionHash.textContent = 'Pending'; + ui.sessionHash.href = '#'; + ui.sessionHash.classList.add('pending'); + ui.sessionHash.removeAttribute('target'); + ui.sessionHash.removeAttribute('rel'); + return; + } + + ui.sessionHash.textContent = value; + ui.sessionHash.href = `${ANALYZER_BASE_URL}${encodeURIComponent(value)}`; + ui.sessionHash.classList.remove('pending'); + ui.sessionHash.setAttribute('target', '_blank'); + ui.sessionHash.setAttribute('rel', 'noopener noreferrer'); +} + +function healthClass(label) { + if (label === 'VERY HEALTHY' || label === 'GOOD') { + return 'status-good'; + } + if (label === 'FAIR') { + return 'status-fair'; + } + return 'status-poor'; +} + +function ringColor(label) { + if (label === 'VERY HEALTHY' || label === 'GOOD') return 'var(--good)'; + if (label === 'FAIR') return 'var(--fair)'; + if (!label || label === 'Waiting') return 'var(--accent-strong)'; + return 'var(--poor)'; +} + +function updateRing(percent, label) { + const color = ringColor(label); + const circumference = 314.16; + const offset = circumference * (1 - Math.max(0, Math.min(100, percent)) / 100); + document.documentElement.style.setProperty('--ring-color', color); + const fill = document.querySelector('.score-ring__fill'); + if (fill) { + fill.style.stroke = color; + fill.style.strokeDashoffset = offset; + } +} + +function redirectToLanding() { + window.location.href = '/'; +} + +async function registerPwa() { + if ('serviceWorker' in navigator) { + try { + await navigator.serviceWorker.register('/sw.js'); + } catch { + // ignore registration failures + } + } +} + +async function installApp() { + if (!deferredInstallPrompt) { + return; + } + deferredInstallPrompt.prompt(); + const choice = await deferredInstallPrompt.userChoice.catch(() => null); + if (choice?.outcome === 'accepted') { + deferredInstallPrompt = null; + } + updateInstallButton(); +} + +async function copyCurrentCode() { + const session = currentSession(); + const code = session?.code || ''; + if (!code) { + return; + } + + await copyText(code); + flashButtonText(ui.copySessionCodeButton, 'Copied'); +} + +async function copyText(value) { + try { + await navigator.clipboard.writeText(value); + } catch { + const helper = document.createElement('textarea'); + helper.value = value; + helper.setAttribute('readonly', ''); + helper.style.position = 'absolute'; + helper.style.left = '-9999px'; + document.body.appendChild(helper); + helper.select(); + document.execCommand('copy'); + helper.remove(); + } +} + +function flashButtonText(button, text) { + const originalText = button.textContent; + button.textContent = text; + window.setTimeout(() => { + button.textContent = originalText; + }, 1200); +} + +async function copySessionShareLink() { + const session = currentSession(); + const shareUrl = String(session?.shareUrl || '').trim(); + if (!shareUrl) { + return; + } + + const shareData = { + title: document.title, + text: `Observer coverage for ${session.code}`, + url: shareUrl, + }; + + if (typeof navigator.share === 'function') { + try { + await navigator.share(shareData); + flashButtonText(ui.shareSessionButton, 'Shared'); + return; + } catch (error) { + if (error?.name === 'AbortError') { + return; + } + } + } + + await copyText(shareUrl); + flashButtonText(ui.shareSessionButton, 'Link Copied'); +} + +async function createSession() { + if (state.sessionRetargetTimer) { + window.clearTimeout(state.sessionRetargetTimer); + state.sessionRetargetTimer = 0; + } + ui.newSessionButton.disabled = true; + try { + const response = await apiFetch('/api/sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + expectedObserverKeys: usingDefaultObserverSet() ? [] : customSelectedObserverKeys(), + }), + }); + const session = await response.json(); + if (response.status === 403 && session.error === 'turnstile_required') { + redirectToLanding(); + return; + } + if (!response.ok) { + throw new Error(session.error || 'Failed to create session'); + } + state.sharedSessionId = ''; + state.sharedSessionMissing = false; + state.currentSessionId = session.id; + sessionStorage.setItem(SESSION_STORAGE_KEY, session.id); + if (window.location.pathname !== '/app') { + window.history.replaceState({}, '', '/app'); + } + upsertTrackedSession(session); + render(); + } catch (error) { + ui.sessionInstructions.textContent = error.message; + } finally { + ui.newSessionButton.disabled = false; + } +} + +function scheduleSessionRetarget() { + if (state.sessionRetargetTimer) { + window.clearTimeout(state.sessionRetargetTimer); + } + state.sessionRetargetTimer = window.setTimeout(() => { + state.sessionRetargetTimer = 0; + const session = currentSession(); + if (!sessionCanRetarget(session) || !selectionDiffersFromSession(session)) { + render(); + return; + } + createSession(); + }, 200); +} + +function currentSession() { + const sessionId = state.sharedSessionId || state.currentSessionId; + if (!sessionId) { + return null; + } + return state.sessions.get(sessionId) || null; +} + +function renderExpectedObservers(session) { + ui.expectedObservers.innerHTML = ''; + const expected = Array.isArray(session?.expectedObservers) + ? session.expectedObservers + : []; + if (expected.length === 0) { + ui.expectedObservers.innerHTML = + '
Waiting for first receipt--
'; + return; + } + for (const observer of expected) { + const item = document.createElement('div'); + item.className = `observer-pill ${observer.seen ? 'seen' : 'waiting'}`; + item.innerHTML = ` +
+ ${escapeHtml(observer.label)} +
${escapeHtml(observer.hash || '')}
+
+ ${observer.seen ? 'Seen' : 'Standby'} + `; + ui.expectedObservers.appendChild(item); + } +} + +function renderRegionFilter() { + if (!ui.regionFilter) return; + const { hasGroups, regions } = regionFilterOptions(); + + if (regions.length === 0 || regions.every((entry) => entry.regions.length === 0)) { + ui.regionFilter.classList.add('hidden'); + ui.regionFilter.innerHTML = ''; + return; + } + ui.regionFilter.classList.remove('hidden'); + ui.regionFilter.innerHTML = ''; + + const groupRow = document.createElement('div'); + groupRow.className = 'region-filter__row'; + + const locatedObserverCount = regions.reduce((sum, entry) => sum + (entry.count || 0), 0); + groupRow.appendChild(createRegionButton({ + className: 'region-btn--all', + active: state.selectedRegionGroup === null && state.selectedRegion === null, + label: hasGroups ? 'All regions' : 'All', + count: locatedObserverCount, + onClick: () => { + state.selectedRegionGroup = null; + state.selectedRegion = null; + applyRegionSelection(); + }, + })); + + if (hasGroups) { + for (const entry of regions.filter((item) => item.group)) { + groupRow.appendChild(createRegionButton({ + className: 'region-btn--group', + active: state.selectedRegionGroup === entry.group && state.selectedRegion === null, + label: entry.group, + count: entry.count, + onClick: () => { + state.selectedRegionGroup = entry.group; + state.selectedRegion = null; + applyRegionSelection(); + }, + })); + } + } + ui.regionFilter.appendChild(groupRow); + + const selectedGroup = state.selectedRegionGroup + ? regions.find((entry) => entry.group === state.selectedRegionGroup) + : null; + const subregionSource = hasGroups + ? selectedGroup?.regions || [] + : regions.flatMap((entry) => entry.regions); + + if (subregionSource.length > 0) { + const subregionRow = document.createElement('div'); + subregionRow.className = 'region-filter__row region-filter__row--subregions'; + + if (selectedGroup) { + subregionRow.appendChild(createRegionButton({ + className: 'region-btn--child', + active: state.selectedRegion === null, + label: `All ${selectedGroup.group}`, + count: selectedGroup.count, + onClick: () => { + state.selectedRegion = null; + applyRegionSelection(); + }, + })); + } + + for (const region of subregionSource) { + subregionRow.appendChild(createRegionButton({ + className: 'region-btn--child', + active: state.selectedRegion === region.name, + label: region.name, + count: region.count, + onClick: () => { + state.selectedRegion = region.name; + if (!state.selectedRegionGroup && hasGroups) { + const parent = regions.find((entry) => entry.regions.some((item) => item.name === region.name)); + state.selectedRegionGroup = parent?.group || null; + } + applyRegionSelection(); + }, + })); + } + ui.regionFilter.appendChild(subregionRow); + } +} + +function regionFilterOptions(snapshot = state.snapshot) { + const hierarchy = Array.isArray(snapshot?.regionHierarchy) + ? snapshot.regionHierarchy.filter((entry) => Array.isArray(entry.regions) && entry.regions.length > 0) + : []; + const flatRegions = Array.isArray(snapshot?.availableRegions) + ? snapshot.availableRegions.map((name) => ({ name, count: 0 })) + : []; + const regions = hierarchy.length > 0 + ? hierarchy + : [{ group: '', count: flatRegions.length, regions: flatRegions }]; + const namedGroupCount = regions.filter((entry) => entry.group).length; + const hasUngroupedRegions = regions.some((entry) => !entry.group && entry.regions.length > 0); + const hasGroups = namedGroupCount > 1 || (namedGroupCount > 0 && hasUngroupedRegions); + return { hasGroups, regions }; +} + +function createRegionButton({ className, active, label, count, onClick }) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `region-btn ${className}${active ? ' active' : ''}`; + + const labelEl = document.createElement('span'); + labelEl.textContent = label; + btn.appendChild(labelEl); + + if (Number.isFinite(count) && count > 0) { + const countEl = document.createElement('span'); + countEl.className = 'region-btn__count'; + countEl.textContent = String(count); + btn.appendChild(countEl); + } + + btn.addEventListener('click', onClick); + return btn; +} + +function reconcileRegionSelection(snapshot) { + const { hasGroups, regions } = regionFilterOptions(snapshot); + if (!hasGroups && state.selectedRegionGroup !== null) { + state.selectedRegionGroup = null; + } + if (state.selectedRegionGroup !== null) { + const group = regions.find((entry) => entry.group === state.selectedRegionGroup); + if (!group) { + state.selectedRegionGroup = null; + state.selectedRegion = null; + state.selectedObserverKeys = []; + saveSelectedObserverKeys(); + return; + } + if (state.selectedRegion !== null && !group.regions.some((region) => region.name === state.selectedRegion)) { + state.selectedRegion = null; + } + state.selectedObserverKeys = observerKeysForRegionSelection(snapshot); + saveSelectedObserverKeys(); + return; + } + + if (state.selectedRegion !== null) { + const regionExists = regions.some((entry) => entry.regions.some((region) => region.name === state.selectedRegion)); + if (!regionExists) { + state.selectedRegion = null; + state.selectedObserverKeys = []; + saveSelectedObserverKeys(); + return; + } + if (hasGroups) { + const parent = regions.find((entry) => entry.regions.some((region) => region.name === state.selectedRegion)); + state.selectedRegionGroup = parent?.group || null; + } + state.selectedObserverKeys = observerKeysForRegionSelection(snapshot); + saveSelectedObserverKeys(); + } +} + +function applyRegionSelection() { + if (state.selectedRegionGroup === null && state.selectedRegion === null) { + state.selectedObserverKeys = []; + } else { + state.selectedObserverKeys = observerKeysForRegionSelection(state.snapshot); + } + saveSelectedObserverKeys(); + render(); + scheduleSessionRetarget(); +} + +function observerKeysForRegionSelection(snapshot = state.snapshot) { + const directory = Array.isArray(snapshot?.observerDirectory) ? snapshot.observerDirectory : []; + return directory + .filter((observer) => { + if (state.selectedRegion) { + return observer.region === state.selectedRegion + && (!state.selectedRegionGroup || observer.regionGroup === state.selectedRegionGroup); + } + return observer.regionGroup === state.selectedRegionGroup; + }) + .map((observer) => observer.key); +} + +function renderObserverAllowlist() { + renderRegionFilter(); + const directory = selectableObservers(); + const selected = new Set(effectiveObserverKeysForCreate()); + ui.observerAllowlist.innerHTML = ''; + ui.observerAllowlistClear.disabled = usingDefaultObserverSet(); + + if (directory.length === 0) { + ui.observerAllowlistNote.textContent = 'No observers available to select yet.'; + ui.observerAllowlist.innerHTML = + '
Observer choices appear as metadata and packets arrive.
'; + return; + } + + const selectedCount = selected.size; + ui.observerAllowlistNote.textContent = usingDefaultObserverSet() + ? defaultObserverTargetSummary() + : `Custom: ${selectedCount} observer${selectedCount === 1 ? '' : 's'}.`; + + for (const observer of directory) { + const item = document.createElement('label'); + item.className = `observer-option ${observer.isActive ? 'active' : 'inactive'}`; + const status = observer.isActive + ? 'active' + : observer.isRetained === false + ? 'not recently heard' + : 'idle'; + const locationLabel = observer.hasLocation ? 'mapped' : 'no map'; + item.innerHTML = ` + + + ${escapeHtml(observer.label)} + ${escapeHtml(observer.hash || '--')} · ${escapeHtml(observer.shortKey)} · ${escapeHtml(status)} + ${observer.packetCount || 0} packet${observer.packetCount === 1 ? '' : 's'} · ${locationLabel} + + `; + const checkbox = item.querySelector('input'); + checkbox.addEventListener('change', () => { + const next = new Set(effectiveObserverKeysForCreate()); + if (checkbox.checked) { + next.add(observer.key); + } else { + next.delete(observer.key); + } + state.selectedRegionGroup = null; + state.selectedRegion = null; + state.selectedObserverKeys = [...next]; + saveSelectedObserverKeys(); + render(); + scheduleSessionRetarget(); + }); + ui.observerAllowlist.appendChild(item); + } +} + +function applySiteBranding(snapshot) { + const site = snapshot?.site || {}; + const title = site.title || 'Mesh Health Check'; + const version = String(site.version || '').trim() || '0.0.0'; + const eyebrow = site.eyebrow || 'MeshCore Observer Coverage'; + const headline = site.headline || 'Check your mesh reach.'; + const repoUrl = site.repoUrl || 'https://github.com/yellowcooln/meshcore-health-check'; + const changesUrl = site.changesUrl || `${repoUrl}/blob/main/CHANGES.md`; + const externalUrl = String(site.externalLinkUrl || '').trim(); + const externalLabel = String(site.externalLinkLabel || '').trim() || 'External Link'; + const description = site.description + || 'Generate a test code, send it to the configured channel, and watch observer coverage build in real time.'; + const [prefix, ...suffixParts] = description.split('configured channel'); + const suffix = suffixParts.join('configured channel'); + + document.title = isSharePage() ? `${title} Shared Result` : title; + ui.repoNoteLink.href = repoUrl; + ui.siteVersionNote.href = changesUrl; + ui.siteVersionNote.textContent = `Version: v${version}`; + if (externalUrl) { + const externalActionLabel = /^open\b/i.test(externalLabel) + ? externalLabel + : `Open ${externalLabel}`; + ui.externalLink.href = externalUrl; + ui.externalLink.textContent = `${externalActionLabel} ↗`; + ui.externalLink.setAttribute('aria-label', `${externalActionLabel} in a new tab`); + ui.externalLink.classList.remove('hidden'); + } else { + ui.externalLink.href = '#'; + ui.externalLink.textContent = 'External Link'; + ui.externalLink.removeAttribute('aria-label'); + ui.externalLink.classList.add('hidden'); + } + if (isSharePage()) { + ui.heroEyebrow.textContent = 'Shared Result'; + ui.heroTitle.textContent = 'Observer coverage someone shared with you.'; + ui.heroDescriptionPrefix.textContent = 'This page is read-only. Review the result from'; + ui.heroDescriptionSuffix.textContent = 'or open the full dashboard to run your own check.'; + return; + } + ui.heroEyebrow.textContent = eyebrow; + ui.heroTitle.textContent = headline; + ui.heroDescriptionPrefix.textContent = (prefix || '').trimEnd() || 'Generate a test code, send it to'; + ui.heroDescriptionSuffix.textContent = (suffix || '').trimStart() + || 'and watch observer coverage build in real time.'; +} + +function mapKnownObservers(session) { + const directory = observerDirectory(); + const mergedDirectory = new Map(); + for (const observer of configuredDefaultObservers()) { + mergedDirectory.set(observer.key, observer); + } + for (const observer of directory) { + const existing = mergedDirectory.get(observer.key) || {}; + mergedDirectory.set(observer.key, { + ...existing, + ...observer, + }); + } + let source = [...mergedDirectory.values()]; + if (mapObserverScope === 'expected') { + const directoryByKey = new Map(source.map((observer) => [observer.key, observer])); + const expected = Array.isArray(session?.expectedObservers) + ? session.expectedObservers.filter((observer) => observer?.key) + : []; + if (expected.length > 0) { + source = expected.map((observer) => { + const known = directoryByKey.get(observer.key) || fallbackObserverRecord(observer.key); + return { + ...known, + ...observer, + lat: known.lat, + lon: known.lon, + hasLocation: known.hasLocation, + }; + }); + } + } + const seenKeys = new Set( + Array.isArray(session?.receipts) ? session.receipts.map((receipt) => receipt.observerKey) : [], + ); + return source + .filter((observer) => observer.lat != null && observer.lon != null) + .map((observer) => ({ + ...observer, + seen: Boolean(observer.seen) || seenKeys.has(observer.key), + })); +} + +function ensureObserverMap() { + if (state.map.instance || !ui.observerMap || !window.L) { + return state.map.instance; + } + state.map.instance = window.L.map(ui.observerMap, { + zoomControl: true, + attributionControl: true, + }); + state.map.instance.setView([20, 0], 2); + return state.map.instance; +} + +function currentTileLayer() { + if (!window.L) { + return null; + } + if (state.uiTheme === 'light') { + return window.L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + attribution: '© OpenStreetMap contributors', + }); + } + return window.L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + maxZoom: 19, + subdomains: 'abcd', + attribution: '© OpenStreetMap contributors © CARTO', + }); +} + +function markerIcon(observer) { + return window.L.divIcon({ + className: 'observer-map-icon-shell', + html: ``, + iconSize: [18, 18], + iconAnchor: [9, 9], + }); +} + +function renderObserverMap(session) { + const locatedObservers = mapKnownObservers(session); + const mapInstance = ensureObserverMap(); + + ui.mapObserverNote.textContent = locatedObservers.length > 0 + ? `${locatedObservers.filter((observer) => observer.seen).length}/${locatedObservers.length} mapped observers reached.` + : 'No observer coordinates yet. The map stays live and will populate as coordinates arrive.'; + ui.mapEmpty.classList.toggle('hidden', locatedObservers.length > 0); + + if (!mapInstance || !window.L) { + return; + } + + if (!state.map.layer || state.map.layerTheme !== state.uiTheme) { + const nextLayer = currentTileLayer(); + if (state.map.layer) { + mapInstance.removeLayer(state.map.layer); + } + state.map.layer = nextLayer; + state.map.layerTheme = state.uiTheme; + if (nextLayer) { + nextLayer.addTo(mapInstance); + } + } + + const activeKeys = new Set(locatedObservers.map((observer) => observer.key)); + for (const [key, marker] of state.map.markers.entries()) { + if (!activeKeys.has(key)) { + marker.remove(); + state.map.markers.delete(key); + } + } + + const bounds = []; + for (const observer of locatedObservers) { + const latLng = [observer.lat, observer.lon]; + bounds.push(latLng); + let marker = state.map.markers.get(observer.key); + if (!marker) { + marker = window.L.marker(latLng, { icon: markerIcon(observer) }).addTo(mapInstance); + state.map.markers.set(observer.key, marker); + } else { + marker.setLatLng(latLng); + marker.setIcon(markerIcon(observer)); + } + marker.bindPopup(` + ${observer.label}
+ ${observer.seen ? 'Seen by this check' : 'Not seen by this check'}
+ ${observer.hash || '--'} · ${observer.shortKey} + `); + } + + const boundsKey = locatedObservers.map((observer) => observer.key).join('|'); + if (bounds.length > 0 && boundsKey !== state.map.boundsKey) { + mapInstance.fitBounds(bounds, { padding: [26, 26], maxZoom: 10 }); + state.map.boundsKey = boundsKey; + } else if (bounds.length === 0 && state.map.boundsKey !== '__empty__') { + mapInstance.setView([20, 0], 2); + state.map.boundsKey = '__empty__'; + } + window.setTimeout(() => { + mapInstance.invalidateSize(); + }, 0); +} + +function renderReceipts(session) { + const receipts = Array.isArray(session?.receipts) ? session.receipts : []; + ui.receipts.innerHTML = ''; + ui.receiptsEmpty.classList.toggle('hidden', receipts.length > 0); + + for (const receipt of receipts) { + const card = document.createElement('article'); + card.className = 'receipt-card'; + card.dataset.observerKey = receipt.observerKey; + const signal = receiptSignalScore(receipt); + const signalTone = scoreTone(signal); + const pathMarkup = receipt.path.length > 0 + ? receipt.path.map((hop) => `${escapeHtml(hop)}`).join('') + : 'No path data'; + const metrics = [ + receipt.rssi != null ? `RSSI ${receipt.rssi}` : '', + receipt.snr != null ? `SNR ${receipt.snr}` : '', + receipt.duration != null ? `${receipt.duration} ms` : '', + ] + .filter(Boolean) + .join(' · '); + + card.innerHTML = ` +
+
+

${escapeHtml(receipt.observerLabel)}

+
${escapeHtml(receipt.observerHash || '')} · ${escapeHtml(receipt.observerShortKey)}
+
+
${formatTime(receipt.firstSeenAt)}
+
+

+ Seen ${receipt.count} time${receipt.count === 1 ? '' : 's'}${metrics ? ` · ${metrics}` : ''} +

+
+
+ Signal +
+ +
+ ${Number.isFinite(signal) ? `${signal}%` : '--'} +
+
+ Latency +
+ +
+ ${receipt.duration != null ? `${receipt.duration} ms` : 'n/a'} +
+
+
${pathMarkup}
+ `; + ui.receipts.appendChild(card); + } +} + +function renderReceiptTimeline(session) { + const receipts = Array.isArray(session?.receipts) + ? [...session.receipts] + .filter((receipt) => receipt?.firstSeenAt) + .sort((left, right) => left.firstSeenAt - right.firstSeenAt) + : []; + + ui.receiptTimeline.innerHTML = ''; + ui.receiptTimelineEmpty.classList.toggle('hidden', receipts.length > 0); + ui.timelineScale.classList.toggle('hidden', receipts.length === 0); + + if (receipts.length === 0) { + ui.timelineSummary.textContent = 'Waiting for observer reports'; + ui.timelineStartLabel.textContent = 'First receipt'; + ui.timelineEndLabel.textContent = 'Latest receipt'; + return; + } + + const firstSeenAt = receipts[0].firstSeenAt; + const lastSeenAt = receipts[receipts.length - 1].firstSeenAt; + const spread = Math.max(0, lastSeenAt - firstSeenAt); + + ui.timelineSummary.textContent = spread > 0 + ? `${receipts.length} observers across ${formatElapsed(spread)}` + : `${receipts.length} observer${receipts.length === 1 ? '' : 's'} at the same moment`; + ui.timelineStartLabel.textContent = formatTime(firstSeenAt); + ui.timelineEndLabel.textContent = spread > 0 ? `+${formatElapsed(spread)}` : 'same moment'; + + for (const receipt of receipts) { + const delta = Math.max(0, receipt.firstSeenAt - firstSeenAt); + const position = spread > 0 ? (delta / spread) * 100 : 0; + const row = document.createElement('article'); + row.className = 'timeline-row'; + row.innerHTML = ` +
+ ${receipt.observerLabel} + ${delta === 0 ? `First receipt · ${formatTime(receipt.firstSeenAt)}` : `+${formatElapsed(delta)} · ${formatTime(receipt.firstSeenAt)}`} +
+
+ + +
+ `; + ui.receiptTimeline.appendChild(row); + } +} + +function renderHistory(sessions) { + ui.sessionHistory.innerHTML = ''; + if (sessions.length === 0) { + ui.sessionHistory.innerHTML = + '
No previous checks in this browser session.
'; + return; + } + for (const session of sessions) { + const item = document.createElement('article'); + item.className = 'history-item'; + item.dataset.sessionId = session.id; + item.innerHTML = ` +
+
${escapeHtml(session.code)}
+

${session.observedCount}/${session.expectedCount} observers · ${escapeHtml(session.healthLabel)}

+
+
+ ${session.healthPercent}% +

${formatTime(session.createdAt)}

+
+ `; + ui.sessionHistory.appendChild(item); + } +} + +function drawerStat(label, value) { + return ` +
+ ${escapeHtml(label)} + ${escapeHtml(value)} +
+ `; +} + +function drawerListItem(title, detail, meta = '') { + return ` +
+ ${escapeHtml(title)} +

${escapeHtml(detail)}

+ ${meta ? `
${escapeHtml(meta)}
` : ''} +
+ `; +} + +function buildDrawerContent() { + const snapshot = state.snapshot; + const session = currentSession(); + const directory = selectableObservers(); + const mappedObservers = mapKnownObservers(session); + const receipts = Array.isArray(session?.receipts) ? session.receipts : []; + const historySessions = state.trackedSessionIds + .map((id) => state.sessions.get(id)) + .filter(Boolean); + const transport = snapshot ? transportSummary(snapshot) : null; + const signal = signalSummary(session); + const latency = latencySummary(session); + + switch (state.drawer.kind) { + case 'session': + return { + meta: 'Command Sequence', + title: session ? `Session ${session.code}` : 'Session Control', + body: ` +
+

Session Summary

+
+ ${drawerStat('Status', session?.status?.toUpperCase() || 'IDLE')} + ${drawerStat('Share Window', session ? formatDateTime(session.resultExpiresAt) : retentionNote())} + ${drawerStat('Uses Remaining', session ? String(session.usesRemaining) : '--')} + ${drawerStat('Hash', session?.messageHash || 'Pending')} +
+
+
+

Operator Instructions

+
${escapeHtml(session?.instructions || 'Create a session to start listening.')}
+
+
+

Matched Message

+
${escapeHtml(session?.messageBody || 'Waiting for an incoming message on the configured test channel.')}
+
+ `, + }; + case 'transport': + return { + meta: 'Transport Matrix', + title: 'Network Transport', + body: ` +
+

Live Transport

+
+ ${drawerStat('State', transport?.stateLabel || 'Offline')} + ${drawerStat('Broker', snapshot?.mqtt?.broker || 'Unknown')} + ${drawerStat('Channel', snapshot?.testChannel?.name ? `#${snapshot.testChannel.name}` : 'Unknown')} + ${drawerStat('Topics', Array.isArray(snapshot?.mqtt?.topics) ? String(snapshot.mqtt.topics.length) : '0')} +
+
+
+

Observer Window

+
+ ${drawerListItem( + 'Retention Window', + transport?.summary || 'Awaiting observer directory.', + transport?.detail || '', + )} +
+
+ `, + }; + case 'observers': + return { + meta: 'Target Matrix', + title: 'Observer Targeting', + body: directory.length > 0 + ? ` +
+

Target Summary

+
+ ${drawerStat('Default Source', snapshot?.defaultObserverSource || 'Unknown')} + ${drawerStat('Directory Size', String(directory.length))} + ${drawerStat('Selected Mode', usingDefaultObserverSet() ? 'Default set' : 'Custom set')} + ${drawerStat('Active Nodes', String(snapshot?.observerStats?.activeCount || 0))} +
+
+
+

Node Inventory

+
+ ${directory.map((observer) => drawerListItem( + observer.label, + `${observer.hash || '--'} · ${observer.shortKey}`, + `${observer.packetCount || 0} packet${observer.packetCount === 1 ? '' : 's'} · ${observer.hasLocation ? 'mapped' : 'no coordinates'} · ${observer.isActive ? 'active' : 'idle'}`, + )).join('')} +
+
+ ` + : ` +
+

Observer Targeting

+

No observers available yet. Node inventory appears as metadata and packets arrive.

+
+ `, + }; + case 'map': + return { + meta: 'Geo View', + title: 'Coverage Map', + body: mappedObservers.length > 0 + ? ` +
+

Mapped Observers

+
+ ${drawerStat('Mapped', String(mappedObservers.length))} + ${drawerStat('Reached', String(mappedObservers.filter((observer) => observer.seen).length))} + ${drawerStat('Theme', state.uiTheme === 'dark' ? 'Dark mode' : 'Light mode')} + ${drawerStat('Scope', mapObserverScope === 'expected' ? 'Expected' : 'Directory')} +
+
+
+

Coordinates

+
+ ${mappedObservers.map((observer) => drawerListItem( + observer.label, + `${observer.lat}, ${observer.lon}`, + `${observer.seen ? 'Seen by this check' : 'Not seen by this check'} · ${observer.hash || '--'}`, + )).join('')} +
+
+ ` + : ` +
+

Coverage Map

+

Waiting for observer coordinates.

+
+ `, + }; + case 'reports': + return { + meta: 'Signal Trace', + title: 'Technical Logs', + body: receipts.length > 0 + ? ` +
+

Receipt Summary

+
+ ${drawerStat('Signal Quality', signal.value)} + ${drawerStat('Spread', latency.value)} + ${drawerStat('Observer Reports', String(receipts.length))} + ${drawerStat('Sender', session?.sender || 'Pending')} +
+
+
+

Trace Lines

+
+ ${receipts.map((receipt) => ` +
+ ${escapeHtml(receipt.observerLabel)} + ${escapeHtml(formatTime(receipt.firstSeenAt))} + ${escapeHtml(receipt.messageHash || 'no-hash')} + ${escapeHtml((receipt.path || []).join(' -> ') || 'No path data')} +
+ `).join('')} +
+
+ ` + : ` +
+

Technical Logs

+

Timeline appears after the first observer report.

+
+ `, + }; + case 'history': + return { + meta: 'Session Archive', + title: 'Recent Sessions', + body: historySessions.length > 0 + ? ` +
+

Browser Session History

+
+ ${historySessions.map((entry) => drawerListItem( + entry.code, + `${entry.observedCount}/${entry.expectedCount} observers · ${entry.healthPercent}%`, + `${entry.healthLabel} · ${formatTime(entry.createdAt)}`, + )).join('')} +
+
+ ` + : ` +
+

Recent Sessions

+

No previous checks in this browser session.

+
+ `, + }; + case 'receipt': { + const receipt = receipts.find((entry) => entry.observerKey === state.drawer.key); + if (!receipt) { + return null; + } + return { + meta: 'Packet Detail', + title: receipt.observerLabel, + body: ` +
+

Receipt Metrics

+
+ ${drawerStat('First Seen', formatTime(receipt.firstSeenAt))} + ${drawerStat('Message Hash', receipt.messageHash || 'Pending')} + ${drawerStat('RSSI', receipt.rssi != null ? String(receipt.rssi) : 'n/a')} + ${drawerStat('SNR', receipt.snr != null ? String(receipt.snr) : 'n/a')} + ${drawerStat('Duration', receipt.duration != null ? `${receipt.duration} ms` : 'n/a')} + ${drawerStat('Packets', String(receipt.count))} +
+
+
+

Path Trace

+
${escapeHtml((receipt.path || []).join(' -> ') || 'No path data')}
+
+ `, + }; + } + default: + return null; + } +} + +function renderDrawer() { + if (!ui.detailDrawer || !ui.drawerBody || !ui.drawerMeta || !ui.drawerTitle) { + return; + } + if (!state.drawer.kind) { + ui.detailDrawer.setAttribute('aria-hidden', 'true'); + document.body.classList.remove('drawer-open'); + return; + } + const content = buildDrawerContent(); + if (!content) { + state.drawer.kind = ''; + state.drawer.key = ''; + ui.detailDrawer.setAttribute('aria-hidden', 'true'); + document.body.classList.remove('drawer-open'); + return; + } + ui.drawerMeta.textContent = content.meta; + ui.drawerTitle.textContent = content.title; + ui.drawerBody.innerHTML = content.body; + ui.detailDrawer.setAttribute('aria-hidden', 'false'); + document.body.classList.add('drawer-open'); +} + +function openDrawer(kind, key = '') { + state.drawer.kind = kind; + state.drawer.key = key; + renderDrawer(); +} + +function closeDrawer() { + state.drawer.kind = ''; + state.drawer.key = ''; + renderDrawer(); +} + +function render() { + const snapshot = state.snapshot; + if (!snapshot) { + return; + } + applyUiTheme(); + + const channelLabel = `#${snapshot.testChannel.name}`; + const historySessions = state.trackedSessionIds + .map((id) => state.sessions.get(id)) + .filter(Boolean); + + const session = currentSession(); + ui.newSessionButton.disabled = false; + ui.copySessionCodeButton.disabled = !session; + ui.shareSessionButton.disabled = !session?.shareUrl; + applySiteBranding(snapshot); + if (isSharePage()) { + ui.mqttPill.textContent = state.sharedSessionMissing ? 'Shared link expired' : 'Shared Result'; + ui.mqttPill.classList.remove('online'); + } else { + ui.mqttPill.textContent = snapshot.mqtt.connected ? 'MQTT online' : 'MQTT offline'; + ui.mqttPill.classList.toggle('online', snapshot.mqtt.connected); + } + ui.heroChannel.textContent = channelLabel; + ui.brokerName.textContent = snapshot.mqtt.broker; + ui.activeObserverNote.textContent = + `${snapshot.observerStats.activeCount} active observer${snapshot.observerStats.activeCount === 1 ? '' : 's'} in the last ${snapshot.observerStats.windowSeconds}s`; + if (!session) { + ui.sessionCode.textContent = 'No active code'; + ui.sessionInstructions.textContent = state.sharedSessionMissing + ? 'That shared result is no longer available.' + : 'Create a session to start listening.'; + ui.sessionShareNote.textContent = state.sharedSessionMissing + ? 'Shared results are removed after their retention window.' + : retentionNote(); + ui.sessionStatus.textContent = 'Idle'; + setSessionHash(''); + ui.healthLabel.textContent = 'Waiting'; + ui.healthLabel.className = ''; + ui.healthPercent.innerHTML = '0%'; + ui.observedCount.textContent = '0 / 0'; + ui.repeaterCount.textContent = '0'; + ui.senderName.textContent = 'Pending'; + ui.channelName.textContent = channelLabel; + ui.messagePreview.textContent = `Waiting for your ${channelLabel} message.`; + ui.messagePreview.title = ''; + ui.expectedSource.textContent = defaultObserverTargetSummary(); + renderObserverAllowlist(); + renderExpectedObservers(null); + renderObserverMap(null); + renderReceiptTimeline(null); + renderReceipts(null); + renderHistory(historySessions); + renderGlanceMetrics(null); + updateRing(0, 'Waiting'); + renderDrawer(); + return; + } + + if (!state.sharedSessionId) { + state.currentSessionId = session.id; + sessionStorage.setItem(SESSION_STORAGE_KEY, session.id); + } + + ui.sessionCode.textContent = session.code; + ui.sessionInstructions.textContent = session.instructions; + ui.sessionShareNote.textContent = `Share link available until ${formatDateTime(session.resultExpiresAt)}.`; + ui.sessionStatus.textContent = session.status.toUpperCase(); + setSessionHash(session.messageHash); + ui.healthLabel.textContent = session.healthLabel; + ui.healthLabel.className = healthClass(session.healthLabel); + ui.healthPercent.innerHTML = `${session.healthPercent}%`; + ui.observedCount.textContent = `${session.observedCount} / ${session.expectedCount}`; + ui.repeaterCount.textContent = String(session.repeaterCount || 0); + ui.senderName.textContent = session.sender || 'Pending'; + ui.channelName.textContent = session.channelName ? `#${session.channelName}` : channelLabel; + ui.messagePreview.textContent = session.messageBody || `Waiting for your ${channelLabel} message.`; + ui.messagePreview.title = session.messageBody || ''; + const showTargetPreview = selectionDiffersFromSession(session); + ui.expectedSource.textContent = showTargetPreview + ? targetPreviewLabel() + : sessionObserverSourceLabel(session); + + updateRing(session.healthPercent, session.healthLabel); + renderObserverAllowlist(); + renderExpectedObservers(showTargetPreview ? targetPreviewSession() : session); + renderObserverMap(session); + renderReceiptTimeline(session); + renderReceipts(session); + renderHistory(historySessions); + renderGlanceMetrics(session); + renderDrawer(); +} + +function applySnapshot(snapshot) { + const previousRegionGroup = state.selectedRegionGroup; + const previousRegion = state.selectedRegion; + const previousObserverKeys = state.selectedObserverKeys; + reconcileRegionSelection(snapshot); + state.snapshot = snapshot; + render(); + if ( + previousRegionGroup !== state.selectedRegionGroup + || previousRegion !== state.selectedRegion + || !sameKeys(previousObserverKeys, state.selectedObserverKeys) + ) { + scheduleSessionRetarget(); + } +} + +async function refreshTrackedSessions() { + const ids = dedupe([ + ...state.trackedSessionIds, + state.sharedSessionId, + ]); + if (ids.length === 0) { + return; + } + + const results = await Promise.all(ids.map(async (sessionId) => { + const response = await apiFetch(`/api/sessions/${sessionId}`); + if (response.status === 404) { + return { sessionId, missing: true }; + } + if (response.status === 403) { + return { sessionId, turnstileRequired: true }; + } + if (!response.ok) { + return { sessionId, failed: true }; + } + return { + sessionId, + session: await response.json(), + }; + })); + + for (const result of results) { + if (result.turnstileRequired) { + redirectToLanding(); + return; + } + if (result.missing) { + if (result.sessionId === state.sharedSessionId) { + state.sharedSessionMissing = true; + state.sessions.delete(result.sessionId); + } + removeTrackedSession(result.sessionId); + continue; + } + if (result.failed || !result.session) { + continue; + } + if (result.sessionId === state.sharedSessionId) { + state.sharedSessionMissing = false; + } + state.sessions.set(result.session.id, result.session); + } +} + +async function refreshFromServer() { + if (state.refreshInFlight) { + return; + } + state.refreshInFlight = true; + try { + const response = await apiFetch('/api/bootstrap'); + const snapshot = await response.json(); + if (snapshot.turnstile?.enabled && !snapshot.turnstile.verified && !isSharedRoute()) { + redirectToLanding(); + return; + } + applySnapshot(snapshot); + await refreshTrackedSessions(); + render(); + } finally { + state.refreshInFlight = false; + } +} + +function scheduleSocketReconnect() { + if (state.socket || state.socketRetryTimer) { + return; + } + state.socketRetryTimer = window.setTimeout(() => { + state.socketRetryTimer = 0; + connectSocket(); + }, 2000); +} + +function connectSocket() { + if (state.socket) { + return; + } + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const socket = new WebSocket(`${protocol}//${window.location.host}`); + state.socket = socket; + + socket.addEventListener('message', (event) => { + try { + const message = JSON.parse(event.data); + if (message.type === 'snapshot') { + applySnapshot(message.data); + refreshTrackedSessions().then(() => { + render(); + }); + } + } catch { + // ignore malformed frames + } + }); + + socket.addEventListener('close', () => { + state.socket = null; + scheduleSocketReconnect(); + }); +} + +async function bootstrap() { + await registerPwa(); + await refreshFromServer(); + if (!state.snapshot) { + return; + } + if (isSharedRoute()) { + render(); + } else if (!currentSession()) { + await createSession(); + } else { + render(); + } + connectSocket(); +} + +ui.newSessionButton.addEventListener('click', () => { + createSession(); +}); + +ui.installAppButton.addEventListener('click', () => { + installApp(); +}); + +ui.copySessionCodeButton.addEventListener('click', () => { + copyCurrentCode(); +}); + +ui.shareSessionButton.addEventListener('click', () => { + copySessionShareLink(); +}); + +ui.observerAllowlistClear.addEventListener('click', () => { + if (usingDefaultObserverSet()) { + return; + } + state.selectedRegionGroup = null; + state.selectedRegion = null; + state.selectedObserverKeys = []; + saveSelectedObserverKeys(); + render(); + scheduleSessionRetarget(); +}); + +if (ui.uiThemeToggle) { + ui.uiThemeToggle.addEventListener('click', () => { + state.uiTheme = state.uiTheme === 'dark' ? 'light' : 'dark'; + saveUiTheme(); + render(); + }); +} + +if (ui.drawerClose) { + ui.drawerClose.addEventListener('click', () => { + closeDrawer(); + }); +} + +if (ui.drawerScrim) { + ui.drawerScrim.addEventListener('click', () => { + closeDrawer(); + }); +} + +function targetIsPanelInteractive(target) { + return Boolean(target.closest( + 'button, a, input, label, .leaflet-container, .leaflet-control-container, .leaflet-popup, .leaflet-marker-pane, .detail-drawer', + )); +} + +document.addEventListener('click', (event) => { + if (!(event.target instanceof Element)) { + return; + } + const action = event.target.closest('[data-drawer-action]'); + if (action) { + event.preventDefault(); + openDrawer(action.dataset.drawerAction || ''); + return; + } + + const receiptCard = event.target.closest('.receipt-card[data-observer-key]'); + if (receiptCard && ui.receipts?.contains(receiptCard) && !targetIsPanelInteractive(event.target)) { + openDrawer('receipt', receiptCard.dataset.observerKey || ''); + return; + } + +}); + +window.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && state.drawer.kind) { + closeDrawer(); + } +}); + +bootstrap(); +window.setInterval(() => { + refreshFromServer(); +}, 5000); + +window.addEventListener('beforeinstallprompt', (event) => { + event.preventDefault(); + deferredInstallPrompt = event; + updateInstallButton(); +}); + +window.addEventListener('appinstalled', () => { + deferredInstallPrompt = null; + updateInstallButton(); +}); diff --git a/healthcheck-overrides/index.html b/healthcheck-overrides/index.html index bd75e70..39cf880 100644 --- a/healthcheck-overrides/index.html +++ b/healthcheck-overrides/index.html @@ -105,12 +105,12 @@ - +
- MQTT offline + MQTT offline
Observer Window Awaiting bootstrap @@ -120,7 +120,7 @@
-
+
Transport
@@ -129,7 +129,7 @@

Awaiting observer directory.

-
+
Node Count
@@ -142,18 +142,18 @@
Signal Quality
- -- + No data yet -

Awaiting telemetry.

+

Waiting for a health check receipt.

Receipt Spread
- -- + No data yet -

Awaiting receipt spread.

+

Waiting for observer reports.

diff --git a/healthcheck-overrides/share.html b/healthcheck-overrides/share.html index c41cf0f..7b12521 100644 --- a/healthcheck-overrides/share.html +++ b/healthcheck-overrides/share.html @@ -105,7 +105,7 @@ - + Run Your Own Check
- Shared Link + Shared Link
Share Mode Read-only diagnostics @@ -124,7 +124,7 @@
-
+
Transport
@@ -133,7 +133,7 @@

Awaiting observer directory.

-
+
Mapped Nodes
@@ -146,18 +146,18 @@
Signal Quality
- -- + No data yet -

Awaiting telemetry.

+

Waiting for a health check receipt.

Receipt Spread
- -- + No data yet -

Awaiting receipt spread.

+

Waiting for observer reports.

diff --git a/healthcheck-overrides/styles.css b/healthcheck-overrides/styles.css new file mode 100644 index 0000000..d08f7c7 --- /dev/null +++ b/healthcheck-overrides/styles.css @@ -0,0 +1,3343 @@ +:root { + --bg: #060a11; + --bg-deep: #0a1018; + --bg-alt: #121a26; + --panel: rgba(15, 22, 34, 0.86); + --panel-strong: rgba(13, 20, 31, 0.96); + --panel-soft: rgba(123, 177, 255, 0.07); + --panel-overlay: linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0.01)); + --line: rgba(173, 196, 228, 0.11); + --line-strong: rgba(173, 196, 228, 0.2); + --text: #edf2fa; + --muted: #a3b4cc; + --muted-strong: #c5d0df; + --accent: #8db6ff; + --accent-strong: #dbe7ff; + --good: #67f4a1; + --good-glow: rgba(103, 244, 161, 0.34); + --fair: #ffbe5c; + --fair-glow: rgba(255, 190, 92, 0.32); + --poor: #ff5f6d; + --poor-glow: rgba(255, 95, 109, 0.32); + --shadow: 0 24px 90px rgba(0, 0, 0, 0.34); + --radius-xl: 26px; + --radius-lg: 20px; + --radius-md: 16px; + --radius-sm: 12px; + --font-sans: "Geist", "Avenir Next", "Segoe UI Variable Text", "Segoe UI", sans-serif; + --font-mono: "JetBrains Mono", "SFMono-Regular", "SF Mono", ui-monospace, monospace; + --ring-color: var(--accent-strong); + color: var(--text); + font-family: var(--font-sans); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at 18% 0%, rgba(141, 182, 255, 0.14), transparent 26%), + radial-gradient(circle at 100% 12%, rgba(255, 190, 92, 0.08), transparent 20%), + linear-gradient(180deg, #070b12 0%, #0a0f17 52%, #070b12 100%); + color: var(--text); + font-family: var(--font-sans); + overflow-x: hidden; +} + +body::before, +body::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: -1; +} + +body::before { + background: + linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px); + background-size: 96px 96px; + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.85), transparent 100%); +} + +body::after { + background: + radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.08), transparent 24%), + radial-gradient(circle at 80% 35%, rgba(255, 255, 255, 0.05), transparent 18%), + radial-gradient(circle at 50% 70%, rgba(255, 255, 255, 0.06), transparent 20%); + opacity: 0.3; + filter: blur(90px); +} + +a { + color: inherit; +} + +a, +button, +input, +label, +.dock-nav__item, +.panel-lens, +.ghost-button, +.primary-button { + min-height: 44px; +} + +:focus-visible { + outline: 2px solid rgba(194, 245, 255, 0.88); + outline-offset: 2px; +} + +code { + padding: 0.2rem 0.38rem; + border: 1px solid rgba(194, 245, 255, 0.12); + border-radius: 0.55rem; + background: rgba(194, 245, 255, 0.06); + color: var(--accent-strong); + font-family: var(--font-mono); + font-size: 0.88em; +} + +.hidden { + display: none !important; +} + +.screen-glow { + position: fixed; + inset: auto 6% 8% auto; + width: min(34vw, 480px); + height: min(34vw, 480px); + border-radius: 999px; + background: radial-gradient(circle, rgba(141, 182, 255, 0.18), transparent 72%); + filter: blur(20px); + pointer-events: none; + z-index: 0; + animation: drift 18s ease-in-out infinite; +} + +.noc-shell { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: 1fr; + gap: 18px; + width: min(1480px, calc(100vw - 40px)); + margin: 0 auto; + padding: 24px 0 52px; +} + +.command-dock, +.toolbar, +.hero-panel, +.glance-card, +.panel, +.detail-drawer { + position: relative; + border: 1px solid var(--line); + background: var(--panel); + box-shadow: var(--shadow); + backdrop-filter: blur(26px) saturate(140%); + -webkit-backdrop-filter: blur(26px) saturate(140%); +} + +.command-dock::before, +.toolbar::before, +.hero-panel::before, +.glance-card::before, +.panel::before, +.detail-drawer::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + border: 1px solid rgba(255, 255, 255, 0.045); + background: var(--panel-overlay); + pointer-events: none; +} + +.command-dock { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(320px, 1.3fr) minmax(240px, 0.9fr); + gap: 18px 22px; + align-items: start; + padding: 24px 24px 20px; + border-radius: var(--radius-xl); + position: relative; + top: auto; + min-height: auto; +} + +.hero-band { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(320px, 0.92fr) minmax(180px, 0.4fr); + gap: 16px; + align-items: stretch; +} + +.hero-panel { + display: grid; + gap: 18px; + min-width: 0; + min-height: 100%; + padding: 26px 26px 22px; + border-radius: var(--radius-xl); + overflow: hidden; +} + +.intro-panel { + align-content: space-between; + background: + radial-gradient(circle at top left, rgba(141, 182, 255, 0.18), transparent 34%), + radial-gradient(circle at 88% 16%, rgba(103, 244, 161, 0.08), transparent 24%), + linear-gradient(180deg, rgba(18, 28, 42, 0.96), rgba(9, 14, 22, 0.96)); +} + +.intro-panel::after { + content: ""; + position: absolute; + inset: auto -14% -32% 26%; + height: 220px; + background: radial-gradient(circle, rgba(103, 244, 161, 0.12), transparent 72%); + filter: blur(22px); + pointer-events: none; +} + +.summary-panel { + grid-template-rows: auto auto 1fr; + background: + radial-gradient(circle at top right, rgba(255, 190, 92, 0.08), transparent 28%), + linear-gradient(180deg, rgba(18, 24, 36, 0.96), rgba(9, 14, 22, 0.98)); +} + +.dock-brand, +.toolbar, +.panel-header, +.timeline-row, +.receipt-head, +.history-item, +.observer-pill, +.drawer-head { + display: flex; + justify-content: space-between; + gap: 16px; +} + +.dock-brand { + align-items: flex-start; + gap: 14px; + padding-right: 8px; +} + +.brand-orb { + position: relative; + display: grid; + place-items: center; + flex: 0 0 50px; + width: 50px; + height: 50px; + border-radius: 14px; + border: 1px solid rgba(194, 245, 255, 0.16); + background: + linear-gradient(145deg, rgba(141, 182, 255, 0.2), rgba(255, 190, 92, 0.08)), + rgba(255, 255, 255, 0.05); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.1), + 0 0 30px rgba(141, 182, 255, 0.12); + overflow: hidden; +} + +.brand-orb img { + position: relative; + z-index: 1; + width: calc(100% - 8px); + height: calc(100% - 8px); + object-fit: contain; + border-radius: 12px; +} + +.brand-orb::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: radial-gradient(circle at 50% 50%, rgba(194, 245, 255, 0.18), transparent 72%); + animation: pulse 2.8s ease-in-out infinite; + pointer-events: none; +} + +.brand-copy, +.toolbar-copy, +.dock-note, +.dock-meta__item, +.panel-header > div, +.observer-main, +.receipt-head > div, +.drawer-head > div { + min-width: 0; +} + +.eyebrow, +.panel-label, +.meta-label, +.small-note, +.toolbar-label, +.landing-eyebrow, +.landing-status, +.landing-module__label { + margin: 0; + letter-spacing: 0.11em; + text-transform: uppercase; + font-size: 0.77rem; + color: var(--muted); +} + +.brand-copy h1, +.toolbar-copy h2, +.panel h2, +.panel h3, +.detail-drawer h2 { + margin: 0; + line-height: 1; + letter-spacing: -0.03em; +} + +.brand-copy h1 { + font-size: clamp(1.9rem, 3vw, 2.8rem); + text-wrap: balance; +} + +.dock-lede, +.toolbar-copy p, +.session-instructions, +.message-preview p, +.receipt-meta, +.history-item p, +.drawer-body p, +.observer-option-copy span, +.landing-copy, +.landing-module p { + color: var(--muted-strong); + line-height: 1.6; +} + +.dock-lede { + margin: 0; + max-width: 54ch; + font-size: 0.95rem; +} + +.dock-lede strong { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: var(--accent-strong); + font-family: var(--font-mono); +} + +.dock-lede strong::before { + content: ""; + width: 0.62rem; + height: 0.62rem; + border-radius: 50%; + background: var(--good); + box-shadow: 0 0 14px var(--good-glow); + animation: pulse 2.6s ease-in-out infinite; +} + +.dock-status-stack, +.dock-meta, +.glance-grid, +.command-grid, +.dashboard-grid, +.score-grid, +.session-meta, +.observer-selector, +.observer-badges, +.history-list, +.receipts, +.receipt-timeline, +.session-code-actions, +.control-utility-actions, +.drawer-body, +.drawer-stat-grid { + display: grid; + gap: 14px; +} + +.dock-status-stack { + justify-self: stretch; + align-content: start; + gap: 12px; + min-width: 0; +} + +.dock-note strong, +.dock-meta__item strong { + color: var(--text); + font-size: 0.92rem; +} + +.pill, +.ghost-button, +.primary-button, +.panel-lens { + appearance: none; + border-radius: 999px; + font: inherit; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 0.75rem; + width: fit-content; + padding: 0.72rem 1rem; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); + color: var(--muted-strong); + font-size: 0.82rem; +} + +.status-pill::before { + content: ""; + width: 0.66rem; + height: 0.66rem; + border-radius: 50%; + background: var(--poor); + box-shadow: 0 0 18px rgba(255, 95, 109, 0.26); + animation: pulse 2.4s ease-in-out infinite; +} + +.status-pill.online::before { + background: var(--good); + box-shadow: 0 0 22px var(--good-glow); +} + +body[data-page-mode="share"] .status-pill::before { + background: var(--accent); + box-shadow: 0 0 22px rgba(123, 214, 255, 0.24); +} + +.dock-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + justify-self: stretch; + align-self: end; +} + +.dock-meta__item { + min-width: 154px; + padding: 12px 14px; + border-radius: var(--radius-sm); + border: 1px solid rgba(255, 255, 255, 0.05); + background: rgba(255, 255, 255, 0.025); +} + +.workspace { + display: grid; + gap: 16px; + align-content: start; + position: relative; + isolation: isolate; +} + +.workspace > * { + position: relative; + z-index: 1; +} + +.splash-scene { + position: absolute; + inset: -72px -56px auto; + height: 920px; + overflow: hidden; + pointer-events: none; + z-index: 0; +} + +.splash-scene__mesh, +.splash-scene__beam, +.splash-scene__grid, +.splash-scene__ring, +.splash-scene__badge { + position: absolute; +} + +.splash-scene__mesh { + inset: 44px 6% auto 4%; + height: 510px; + border-radius: 44px; + background: + radial-gradient(circle at 14% 24%, rgba(111, 140, 255, 0.34), transparent 24%), + radial-gradient(circle at 78% 18%, rgba(94, 217, 255, 0.18), transparent 22%), + radial-gradient(circle at 64% 72%, rgba(103, 244, 161, 0.18), transparent 18%), + linear-gradient(135deg, rgba(11, 17, 28, 0.14), rgba(11, 17, 28, 0)); + filter: blur(12px); + opacity: 0.95; + animation: drift 22s ease-in-out infinite; +} + +.splash-scene__beam { + top: 92px; + right: -88px; + width: 520px; + height: 520px; + border-radius: 50%; + background: radial-gradient(circle, rgba(111, 140, 255, 0.18), transparent 68%); + filter: blur(14px); +} + +.splash-scene__grid { + inset: 82px 8% auto; + height: 460px; + border: 1px solid rgba(173, 196, 228, 0.08); + border-radius: 36px; + background: + linear-gradient(rgba(173, 196, 228, 0.06) 1px, transparent 1px), + linear-gradient(90deg, rgba(173, 196, 228, 0.06) 1px, transparent 1px), + radial-gradient(circle at 50% 50%, rgba(141, 182, 255, 0.12), transparent 56%); + background-size: 88px 88px, 88px 88px, 100% 100%; + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.94), transparent 100%); + opacity: 0.56; +} + +.splash-scene__ring { + border-radius: 50%; + border: 1px solid rgba(173, 196, 228, 0.12); + background: radial-gradient(circle, rgba(141, 182, 255, 0.05), transparent 70%); +} + +.splash-scene__ring--left { + top: 152px; + left: -54px; + width: 244px; + height: 244px; +} + +.splash-scene__ring--right { + top: 36px; + right: 8%; + width: 188px; + height: 188px; +} + +.splash-scene__badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.72rem 0.96rem; + border: 1px solid rgba(173, 196, 228, 0.12); + border-radius: 999px; + background: rgba(9, 14, 22, 0.58); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.24); + color: var(--muted-strong); + font-family: var(--font-mono); + font-size: 0.7rem; + letter-spacing: 0.12em; + text-transform: uppercase; + backdrop-filter: blur(18px) saturate(140%); + -webkit-backdrop-filter: blur(18px) saturate(140%); +} + +.splash-scene__badge::before { + content: ""; + width: 0.52rem; + height: 0.52rem; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 18px rgba(141, 182, 255, 0.38); +} + +.splash-scene__badge--one { + top: 118px; + right: 11%; +} + +.splash-scene__badge--two { + top: 412px; + left: 9%; +} + +.toolbar { + align-items: center; + padding: 20px 24px; + border-radius: var(--radius-xl); + background: rgba(15, 22, 34, 0.7); +} + +.toolbar-copy h2 { + font-size: clamp(1.45rem, 1.8vw, 2rem); +} + +.toolbar-copy p { + margin: 8px 0 0; + max-width: 62ch; + font-size: 0.93rem; +} + +.control-center-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 18px; +} + +.toolbar-actions, +.panel-actions, +.hero-actions { + display: inline-flex; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; +} + +.control-button, +.ghost-button, +.primary-button, +.panel-lens { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: + transform 180ms ease, + background 180ms ease, + border-color 180ms ease, + box-shadow 180ms ease, + opacity 180ms ease; +} + +.ghost-button, +.panel-lens { + padding: 0.74rem 0.95rem; + border: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.03); + color: var(--muted-strong); + text-decoration: none; +} + +.primary-button { + padding: 0.82rem 1.08rem; + border: 1px solid rgba(141, 182, 255, 0.16); + background: + linear-gradient(135deg, rgba(141, 182, 255, 0.2), rgba(141, 182, 255, 0.08)), + rgba(255, 255, 255, 0.05); + color: var(--accent-strong); + font-weight: 600; + text-decoration: none; + box-shadow: 0 10px 24px rgba(4, 10, 18, 0.24); +} + +.panel-lens { + padding: 0.5rem 0.85rem; + font-size: 0.78rem; + color: var(--muted); +} + +.ghost-button:hover, +.primary-button:hover, +.panel-lens:hover, +.ghost-button:focus-visible, +.primary-button:focus-visible, +.panel-lens:focus-visible { + transform: translateY(-1px); + outline: none; +} + +.ghost-button:hover, +.ghost-button:focus-visible, +.panel-lens:hover, +.panel-lens:focus-visible { + border-color: rgba(141, 182, 255, 0.14); + background: rgba(141, 182, 255, 0.06); + color: var(--text); +} + +.primary-button:hover, +.primary-button:focus-visible { + border-color: rgba(141, 182, 255, 0.24); + box-shadow: 0 14px 30px rgba(4, 10, 18, 0.3); +} + +.ghost-button:disabled, +.primary-button:disabled, +.panel-lens:disabled { + cursor: not-allowed; + opacity: 0.55; + transform: none; +} + +.glance-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; +} + +.glance-card { + min-width: 0; + padding: 18px 18px 16px; + border-radius: var(--radius-lg); + overflow: hidden; + background: rgba(16, 24, 36, 0.78); +} + +.glance-card:hover, +.panel:hover { + border-color: rgba(123, 214, 255, 0.18); +} + +.glance-card__head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.glance-card__value { + display: block; + margin-top: 18px; + font-size: clamp(1.75rem, 2.7vw, 2.3rem); + font-weight: 700; + letter-spacing: -0.04em; + color: var(--text); +} + +.sparkline { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + align-items: end; + gap: 6px; + min-height: 42px; + padding: 10px 0 0; +} + +.sparkline-bar { + min-height: 6px; + border-radius: 999px; + background: linear-gradient(180deg, rgba(141, 182, 255, 0.7), rgba(141, 182, 255, 0.12)); + box-shadow: none; + opacity: 0.7; + transform-origin: bottom; + animation: rise 420ms ease both; +} + +.sparkline[data-tone="good"] .sparkline-bar { + background: linear-gradient(180deg, rgba(103, 244, 161, 0.9), rgba(103, 244, 161, 0.18)); +} + +.sparkline[data-tone="warning"] .sparkline-bar { + background: linear-gradient(180deg, rgba(255, 190, 92, 0.9), rgba(255, 190, 92, 0.18)); +} + +.sparkline[data-tone="critical"] .sparkline-bar { + background: linear-gradient(180deg, rgba(255, 95, 109, 0.9), rgba(255, 95, 109, 0.18)); +} + +.sparkline[data-tone="neutral"] .sparkline-bar { + background: linear-gradient(180deg, rgba(197, 208, 223, 0.42), rgba(197, 208, 223, 0.08)); +} + +.command-grid { + grid-template-columns: minmax(0, 1fr); + gap: 16px; +} + +.dashboard-grid { + grid-template-columns: minmax(320px, 0.78fr) minmax(0, 1.22fr); + grid-template-areas: + "score map" + "observers map" + "reports history"; + gap: 16px; + align-items: start; +} + +.panel { + min-width: 0; + padding: 20px; + border-radius: var(--radius-xl); + background: rgba(15, 22, 34, 0.84); +} + +.command-panel { + background: + radial-gradient(circle at top right, rgba(141, 182, 255, 0.12), transparent 22%), + linear-gradient(135deg, rgba(141, 182, 255, 0.08), rgba(255, 190, 92, 0.04)), + rgba(12, 19, 29, 0.94); +} + +.hero-score-panel { + grid-area: score; + background: + radial-gradient(circle at top left, rgba(103, 244, 161, 0.1), transparent 26%), + linear-gradient(180deg, rgba(17, 24, 35, 0.95), rgba(10, 15, 24, 0.98)); +} + +.spotlight-panel { + grid-area: map; + background: + radial-gradient(circle at top left, rgba(141, 182, 255, 0.12), transparent 24%), + linear-gradient(180deg, rgba(14, 21, 33, 0.96), rgba(9, 14, 22, 0.98)); +} + +.observer-panel { + grid-area: observers; + display: grid; + align-content: start; + gap: 16px; +} + +.observer-panel .panel-header.slim { + align-items: flex-start; + row-gap: 10px; +} + +.observer-panel .panel-actions { + justify-content: flex-start; +} + +.observer-panel .panel-header > div { + display: grid; + gap: 6px; +} + +.observer-panel .allowlist-note, +.observer-panel #expected-source { + margin: -2px 0 0; +} + +.observer-panel .observer-selector, +.observer-panel .observer-badges { + margin-top: 2px; +} + +.reports-panel { + grid-area: reports; +} + +.history-panel { + grid-area: history; +} + +.workspace > * { + min-width: 0; +} + +.panel-header { + align-items: flex-start; + flex-wrap: wrap; +} + +.panel-header.slim { + align-items: center; +} + +.panel-divider { + height: 1px; + margin: 18px 0; + background: linear-gradient(90deg, transparent, rgba(194, 245, 255, 0.22), transparent); +} + +.observer-panel .panel-divider { + margin: 6px 0 2px; +} + +.session-code-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: stretch; + margin-top: 18px; +} + +.session-code-block { + min-width: 0; +} + +.session-code { + margin-top: 10px; + padding: 20px 22px; + border-radius: var(--radius-lg); + border: 1px solid rgba(141, 182, 255, 0.12); + background: + linear-gradient(135deg, rgba(141, 182, 255, 0.14), rgba(141, 182, 255, 0.04)), + rgba(255, 255, 255, 0.03); + color: var(--accent-strong); + font-family: var(--font-mono); + font-size: clamp(1.7rem, 4vw, 2.7rem); + font-weight: 700; + letter-spacing: 0.14em; + overflow-wrap: anywhere; + word-break: break-word; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.session-code-actions { + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-content: start; +} + +.control-utility-actions { + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); +} + +.hero-stage__brief .control-utility-actions { + position: absolute; + top: 30px; + right: 28px; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + gap: 10px; + width: min(210px, 34%); + max-width: 210px; +} + +.hero-stage__brief .toolbar-copy { + min-width: 0; + padding-right: clamp(0px, 22vw, 250px); +} + +.hero-stage__brief .control-utility-actions .control-button, +.hero-stage__brief .control-utility-actions a { + width: 100%; +} + +body[data-page-mode="share"] .hero-stage__brief .toolbar-copy { + min-width: 0; + padding-right: clamp(0px, 24vw, 280px); +} + +body[data-page-mode="share"] .hero-stage__brief .control-utility-actions { + position: absolute; + top: 30px; + right: 28px; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + width: min(240px, 38%); + max-width: 240px; +} + +body[data-page-mode="share"] .hero-stage__brief .control-utility-actions .control-button, +body[data-page-mode="share"] .hero-stage__brief .control-utility-actions a { + flex: 0 0 auto; + width: 100%; +} + +.copy-button { + min-width: 96px; +} + +.session-share-note { + margin: 0; +} + +.session-instructions, +#message-preview, +#sender-name, +#channel-name, +#broker-name, +#session-status, +#session-hash { + overflow-wrap: anywhere; + word-break: break-word; +} + +.session-meta { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: 2px; +} + +.score-layout { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 20px; + align-items: center; + margin-top: 16px; +} + +.score-ring { + position: relative; + display: grid; + place-items: center; + width: 132px; + height: 132px; + border-radius: 50%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.04), transparent 68%); + font-family: var(--font-mono); +} + +.score-ring__svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + transform: rotate(-90deg); +} + +.score-ring__track, +.score-ring__fill { + fill: none; + stroke-width: 8; +} + +.score-ring__track { + stroke: rgba(255, 255, 255, 0.08); +} + +.score-ring__fill { + stroke: var(--ring-color); + stroke-linecap: round; + stroke-dasharray: 314.16; + stroke-dashoffset: 314.16; + filter: drop-shadow(0 0 10px rgba(194, 245, 255, 0.2)); + transition: stroke-dashoffset 0.45s ease, stroke 0.35s ease; +} + +.score-num { + font-size: 1.7rem; + font-weight: 700; + color: var(--ring-color); + transition: color 0.35s ease; +} + +.score-unit { + vertical-align: super; + font-size: 0.74rem; + opacity: 0.76; +} + +.score-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.metric-card, +.history-item, +.receipt-card, +.observer-option, +.observer-pill, +.empty-state, +.drawer-card { + min-width: 0; + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.05); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.015)), + rgba(255, 255, 255, 0.02); +} + +.metric-card { + padding: 14px; +} + +.metric-card.compact { + padding: 15px 16px; +} + +.metric-label { + display: block; + margin-bottom: 6px; + color: var(--muted); + font-size: 0.86rem; +} + +.metric-card strong, +.history-item strong, +.observer-label, +.receipt-title { + display: block; + font-size: 1.02rem; + line-height: 1.28; +} + +.message-preview { + margin-top: 18px; + padding-top: 18px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +#message-preview { + margin: 10px 0 0; + max-height: 6.1em; + overflow: auto; + padding-right: 6px; + font-size: 0.94rem; + white-space: pre-wrap; +} + +.region-filter { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + margin-top: 12px; +} + +.region-filter__row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.region-filter__row--subregions { + padding: 8px 0 0 10px; + border-left: 2px solid rgba(230, 182, 74, 0.28); +} + +.region-btn { + appearance: none; + display: inline-flex; + align-items: center; + gap: 7px; + background: var(--panel-soft); + border: 1px solid var(--line); + border-radius: 8px; + color: var(--muted); + cursor: pointer; + font-size: 0.78rem; + font-weight: 600; + line-height: 1.2; + padding: 5px 12px; + transition: background 150ms, border-color 150ms, color 150ms; +} + +.region-btn--group { + font-size: 0.82rem; +} + +.region-btn--child { + font-size: 0.79rem; + padding: 4px 10px; +} + +.region-btn__count { + background: rgba(255, 255, 255, 0.06); + border-radius: 6px; + color: var(--text); + font-size: 0.68rem; + font-weight: 700; + line-height: 1; + min-width: 1.45em; + opacity: 0.8; + padding: 3px 5px; + text-align: center; +} + +.region-btn:hover { + background: rgba(255, 255, 255, 0.05); + color: var(--text); +} + +.region-btn.active { + background: rgba(111, 217, 106, 0.08); + border-color: rgba(111, 217, 106, 0.4); + color: var(--good); +} + +.region-btn.active .region-btn__count { + background: rgba(111, 217, 106, 0.14); + color: var(--good); +} + +.observer-panel { + display: grid; + align-content: start; + min-width: 0; +} + +.observer-selector, +.observer-badges { + min-width: 0; +} + +.observer-selector { + display: grid; + gap: 12px; + align-content: start; + max-height: 320px; + overflow: auto; + padding-right: 6px; + scrollbar-gutter: stable; +} + +.observer-badges { + display: grid; + gap: 12px; + align-content: start; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.observer-option { + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + gap: 12px; + align-items: start; + min-height: 80px; + padding: 16px; +} + +.observer-option input { + width: 16px; + height: 16px; + margin-top: 2px; + align-self: start; + accent-color: var(--accent); +} + +.observer-option-copy { + display: flex; + flex-direction: column; + align-items: flex-start; + min-width: 0; + gap: 8px; +} + +.observer-option-copy strong { + display: block; + width: 100%; + margin: 0; + font-size: 1rem; + line-height: 1.34; +} + +.observer-option-copy span { + display: block; + width: 100%; + margin: 0; + font-size: 0.92rem; + line-height: 1.45; +} + +.observer-option-copy strong, +.observer-option-copy span { + overflow-wrap: anywhere; + word-break: break-word; +} + +.observer-option.active { + border-color: rgba(141, 182, 255, 0.14); +} + +.observer-option.inactive { + opacity: 0.78; +} + +.observer-pill { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 14px; + align-items: start; + min-height: 92px; + padding: 16px; +} + +.observer-main { + display: grid; + min-width: 0; + align-content: start; + gap: 8px; +} + +.observer-label { + line-height: 1.34; +} + +.observer-pill .status { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.45rem 0.75rem; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.035); + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + align-self: start; + margin-top: 2px; +} + +.observer-pill .status::before { + content: ""; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 12px currentColor; +} + +.observer-pill.seen { + border-color: rgba(103, 244, 161, 0.14); + background: + linear-gradient(180deg, rgba(103, 244, 161, 0.08), rgba(103, 244, 161, 0.02)), + rgba(255, 255, 255, 0.03); +} + +.observer-pill.seen .status { + color: var(--good); +} + +.observer-pill.waiting .status { + color: var(--fair); +} + +.observer-hash, +.receipt-hash, +.history-code, +.drawer-mono, +.console-line code, +.receipt-path, +.dock-nav__item span { + font-family: var(--font-mono); +} + +.observer-hash, +.receipt-hash { + color: var(--muted); + font-size: 0.8rem; + line-height: 1.45; + overflow-wrap: anywhere; + word-break: break-word; +} + +.map-panel, +.reports-panel { + min-height: 0; +} + +.map-panel { + display: grid; + align-content: start; +} + +.observer-map { + min-height: 450px; + margin-top: 12px; + overflow: hidden; + border-radius: calc(var(--radius-lg) + 2px); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.spotlight-panel .observer-map { + min-height: 560px; +} + +.observer-map.hidden { + display: none; +} + +.leaflet-container { + background: radial-gradient(circle, rgba(7, 17, 29, 0.98), rgba(5, 11, 18, 0.98)); + color: var(--text); + font-family: var(--font-sans); +} + +.leaflet-control-zoom a, +.leaflet-control-attribution { + border: 1px solid rgba(194, 245, 255, 0.12) !important; + background: rgba(7, 17, 29, 0.82) !important; + color: var(--text) !important; +} + +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + background: rgba(8, 19, 34, 0.92); + color: var(--text); + border: 1px solid rgba(194, 245, 255, 0.12); + box-shadow: 0 18px 36px rgba(0, 0, 0, 0.32); +} + +.observer-map-icon-shell { + background: transparent !important; + border: 0 !important; +} + +.observer-map-icon { + position: relative; + display: block; + width: 18px; + height: 18px; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.8); + background: var(--fair); + box-shadow: 0 0 16px rgba(255, 190, 92, 0.34); +} + +.observer-map-icon::after { + content: ""; + position: absolute; + inset: -8px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.2); + opacity: 0.6; + animation: pulseRing 2.6s ease-out infinite; +} + +.observer-map-icon.seen { + background: var(--good); + box-shadow: 0 0 16px rgba(103, 244, 161, 0.36); +} + +.timeline-block { + display: grid; + gap: 14px; +} + +.timeline-scale { + display: flex; + justify-content: space-between; + gap: 12px; + font-family: var(--font-mono); + color: var(--muted); + font-size: 0.76rem; +} + +.receipt-timeline { + gap: 12px; +} + +.timeline-row { + align-items: center; + padding: 12px 14px; + border-radius: var(--radius-md); + border: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.025); +} + +.timeline-copy { + display: grid; + gap: 4px; + min-width: 0; +} + +.timeline-copy strong, +.timeline-copy span { + overflow-wrap: anywhere; + word-break: break-word; +} + +.timeline-copy span { + color: var(--muted); + font-size: 0.85rem; +} + +.timeline-track { + position: relative; + flex: 1 1 240px; + height: 0.82rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.05); + overflow: hidden; +} + +.timeline-fill { + position: absolute; + inset: 0 auto 0 0; + border-radius: inherit; + background: linear-gradient(90deg, rgba(123, 214, 255, 0.34), rgba(123, 214, 255, 0.12)); +} + +.timeline-dot { + position: absolute; + top: 50%; + width: 0.92rem; + height: 0.92rem; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.88); + background: var(--accent); + box-shadow: 0 0 20px rgba(123, 214, 255, 0.28); + transform: translate(-50%, -50%); +} + +.receipts { + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + margin-top: 16px; +} + +.receipt-card, +.history-item { + padding: 16px; +} + +.receipt-card { + cursor: pointer; + transition: + transform 180ms ease, + border-color 180ms ease, + background 180ms ease; +} + +.receipt-card:hover, +.history-item:hover { + transform: translateY(-2px); + border-color: rgba(141, 182, 255, 0.12); + background: + linear-gradient(180deg, rgba(141, 182, 255, 0.06), rgba(255, 255, 255, 0.02)), + rgba(255, 255, 255, 0.03); +} + +.receipt-head { + align-items: flex-start; + flex-wrap: wrap; +} + +.receipt-title { + margin: 0; +} + +.receipt-meta { + margin: 12px 0 0; +} + +.receipt-meter-grid { + display: grid; + gap: 10px; + margin-top: 12px; +} + +.receipt-meter { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + font-size: 0.82rem; + color: var(--muted-strong); +} + +.meter-track { + position: relative; + height: 0.52rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + overflow: hidden; +} + +.meter-track span { + position: absolute; + inset: 0 auto 0 0; + border-radius: inherit; + background: linear-gradient(90deg, rgba(123, 214, 255, 0.92), rgba(103, 244, 161, 0.78)); +} + +.meter-track.warning span { + background: linear-gradient(90deg, rgba(255, 190, 92, 0.92), rgba(255, 145, 69, 0.78)); +} + +.meter-track.critical span { + background: linear-gradient(90deg, rgba(255, 95, 109, 0.95), rgba(255, 110, 149, 0.78)); +} + +.receipt-path { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.receipt-path span { + display: inline-flex; + align-items: center; + padding: 0.46rem 0.66rem; + border-radius: 999px; + border: 1px solid rgba(194, 245, 255, 0.12); + background: rgba(194, 245, 255, 0.05); + color: var(--accent-strong); + font-size: 0.8rem; +} + +.history-list { + margin-top: 4px; +} + +.history-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; +} + +.history-code { + font-size: 0.86rem; + color: var(--accent-strong); +} + +.empty-state { + padding: 20px; + color: var(--muted-strong); +} + +.empty-state.compact { + padding: 16px; +} + +.hash-link { + color: var(--accent-strong); + text-decoration: none; + text-underline-offset: 0.2em; +} + +.hash-link:hover, +.hash-link:focus-visible { + color: #fff; + text-decoration: underline; + outline: none; +} + +.hash-link.pending { + color: var(--muted-strong); + pointer-events: none; + text-decoration: none; +} + +.status-good { + color: var(--good); + text-shadow: 0 0 18px rgba(103, 244, 161, 0.18); +} + +.status-fair { + color: var(--fair); + text-shadow: 0 0 18px rgba(255, 190, 92, 0.14); +} + +.status-poor { + color: var(--poor); + text-shadow: 0 0 18px rgba(255, 95, 109, 0.16); +} + +.site-footer { + padding: 4px 4px 0; +} + +.site-footer p { + margin: 0; +} + +.site-footer a { + color: var(--accent-strong); + text-decoration: none; +} + +.site-footer a:hover, +.site-footer a:focus-visible { + text-decoration: underline; + outline: none; +} + +.drawer-scrim { + position: fixed; + inset: 0; + background: rgba(3, 8, 14, 0.48); + opacity: 0; + pointer-events: none; + transition: opacity 220ms ease; + z-index: 39; +} + +.detail-drawer { + position: fixed; + top: 20px; + right: 20px; + bottom: 20px; + width: min(460px, calc(100vw - 24px)); + padding: 22px; + border-radius: 26px; + overflow: auto; + transform: translateX(calc(100% + 24px)); + opacity: 0; + transition: + transform 240ms ease, + opacity 240ms ease; + z-index: 40; +} + +.drawer-head { + position: sticky; + top: 0; + z-index: 2; + align-items: flex-start; + padding-bottom: 16px; + margin-bottom: 18px; + background: linear-gradient(180deg, rgba(8, 19, 34, 0.96), rgba(8, 19, 34, 0.78), transparent); +} + +.drawer-body { + align-content: start; +} + +.drawer-card { + padding: 16px; +} + +.drawer-card h3 { + margin: 0 0 12px; + font-size: 1rem; +} + +.drawer-stat-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.drawer-stat { + padding: 12px; + border-radius: var(--radius-sm); + border: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.03); +} + +.drawer-stat strong { + display: block; + margin-top: 6px; + font-size: 1rem; + overflow-wrap: anywhere; + word-break: break-word; +} + +.drawer-list, +.console-lines { + display: grid; + gap: 10px; +} + +.drawer-list__item, +.console-line { + min-width: 0; + padding: 12px; + border-radius: var(--radius-sm); + border: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.03); +} + +.drawer-list__item strong, +.console-line strong { + display: block; + margin-bottom: 6px; +} + +.console-line { + display: grid; + gap: 6px; + font-size: 0.88rem; + min-width: 0; +} + +.console-line span, +.console-line code { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; +} + +.console-line code { + display: block; + width: 100%; + white-space: pre-wrap; +} + +.drawer-codeblock { + margin: 0; + padding: 14px; + overflow: auto; + border-radius: var(--radius-sm); + border: 1px solid rgba(255, 255, 255, 0.07); + background: rgba(5, 11, 18, 0.76); + color: var(--accent-strong); + font-family: var(--font-mono); + font-size: 0.82rem; + line-height: 1.7; + white-space: pre-wrap; +} + +body.drawer-open { + overflow: hidden; +} + +body.drawer-open .drawer-scrim { + opacity: 1; + pointer-events: auto; +} + +body.drawer-open .detail-drawer { + transform: translateX(0); + opacity: 1; +} + +body[data-page-mode="share"] .share-hidden { + display: none !important; +} + +@keyframes pulse { + 0%, + 100% { + transform: scale(0.96); + opacity: 0.72; + } + 50% { + transform: scale(1.06); + opacity: 1; + } +} + +@keyframes pulseRing { + 0% { + transform: scale(0.86); + opacity: 0.85; + } + 100% { + transform: scale(1.2); + opacity: 0; + } +} + +@keyframes drift { + 0%, + 100% { + transform: translate3d(0, 0, 0); + } + 50% { + transform: translate3d(-18px, -12px, 0); + } +} + +@keyframes rise { + from { + transform: scaleY(0.25); + opacity: 0; + } + to { + transform: scaleY(1); + opacity: 0.95; + } +} + +@media (max-width: 1320px) { + .noc-shell { + width: min(1280px, calc(100vw - 28px)); + } + + .hero-band { + grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr); + } + + .hero-actions-stack { + grid-column: 1 / -1; + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .glance-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .dashboard-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 980px) { + .noc-shell { + width: min(1100px, calc(100vw - 24px)); + padding-top: 16px; + } + + .hero-band { + grid-template-columns: 1fr; + gap: 14px; + } + + .hero-actions-stack { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .dock-status-stack, + .dock-meta { + justify-self: stretch; + } + + .command-grid, + .dashboard-grid { + grid-template-columns: 1fr; + } + + .dashboard-grid { + grid-template-areas: + "score" + "map" + "observers" + "reports" + "history"; + } + + .spotlight-panel .observer-map { + min-height: 420px; + } +} + +@media (max-width: 720px) { + .noc-shell { + width: min(100vw - 16px, 100%); + gap: 16px; + padding-bottom: 28px; + } + + .hero-panel, + .panel { + border-radius: 22px; + } + + .hero-band { + gap: 14px; + } + + .hero-panel { + gap: 14px; + padding: 16px; + } + + .dock-brand { + gap: 12px; + align-items: start; + } + + .brand-orb { + flex-basis: 46px; + width: 46px; + height: 46px; + border-radius: 14px; + } + + .brand-orb span { + inset: 8px; + border-radius: 9px; + } + + .brand-copy h1 { + font-size: clamp(1.4rem, 8vw, 1.9rem); + } + + .dock-lede { + margin: 0; + font-size: 0.9rem; + line-height: 1.45; + } + + .dock-meta { + display: none; + } + + .glance-grid::-webkit-scrollbar { + display: none; + } + + .hero-actions-stack { + grid-template-columns: 1fr; + } + + .hero-actions-stack > * { + width: 100%; + } + + .toolbar-copy h2 { + font-size: 1.35rem; + } + + .control-center-head { + flex-direction: column; + } + + .hero-stage__brief .control-utility-actions { + position: static; + width: 100%; + max-width: none; + justify-content: flex-start; + } + + body[data-page-mode="share"] .hero-stage__brief .toolbar-copy { + padding-right: 0; + } + + .toolbar-copy p { + margin-top: 8px; + font-size: 0.9rem; + line-height: 1.45; + } + + .workspace { + gap: 14px; + } + + .hero-band { + order: 0; + } + + .command-grid { + order: 1; + } + + .glance-grid { + order: 2; + grid-template-columns: 1fr; + overflow-x: visible; + padding-bottom: 0; + scroll-snap-type: none; + } + + .glance-card { + scroll-snap-align: none; + padding: 16px; + } + + .dashboard-grid { + order: 3; + grid-template-areas: + "score" + "map" + "observers" + "reports" + "history"; + } + + .site-footer { + order: 4; + } + + .glance-card__value { + margin-top: 16px; + font-size: 1.7rem; + } + + .panel { + padding: 16px; + } + + .session-code-row, + .score-layout, + .history-item, + .timeline-row { + grid-template-columns: 1fr; + display: grid; + } + + .score-layout { + justify-items: start; + } + + .session-code { + font-size: clamp(1.45rem, 9vw, 2rem); + padding: 18px 16px; + } + + .session-code-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .session-code-actions > * { + width: 100%; + } + + .session-meta, + .drawer-stat-grid, + .score-grid { + grid-template-columns: 1fr; + } + + .observer-badges, + .receipts { + grid-template-columns: 1fr; + } + + .observer-map { + min-height: 320px; + } + + .timeline-track { + min-width: 0; + } + + .detail-drawer { + top: 8px; + right: 8px; + left: 8px; + bottom: 8px; + width: auto; + border-radius: 22px; + max-height: calc(100dvh - 16px); + } +} + +/* Dynamic editorial overrides */ + +.hero-stage { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1.18fr) minmax(320px, 0.82fr); + gap: 0; + overflow: hidden; + border: 1px solid rgba(173, 196, 228, 0.14); + border-radius: 30px; + background: + radial-gradient(circle at 0% 0%, rgba(103, 244, 161, 0.12), transparent 24%), + radial-gradient(circle at 88% 10%, rgba(141, 182, 255, 0.2), transparent 22%), + linear-gradient(135deg, rgba(11, 17, 28, 0.98), rgba(14, 19, 30, 0.94) 48%, rgba(8, 13, 21, 0.98)); + box-shadow: 0 36px 110px rgba(0, 0, 0, 0.34); +} + +.hero-stage::before, +.hero-stage::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; +} + +.hero-stage::before { + background: + linear-gradient(125deg, rgba(255, 255, 255, 0.02), transparent 28%), + repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.025) 0 1px, transparent 1px 96px); + opacity: 0.5; +} + +.hero-stage::after { + inset: auto -10% -40% 38%; + height: 260px; + background: radial-gradient(circle, rgba(255, 190, 92, 0.16), transparent 72%); + filter: blur(28px); +} + +.hero-stage > * { + position: relative; + z-index: 1; +} + +.hero-stage .hero-panel, +.hero-stage .telemetry-strip, +.hero-stage__action-rail { + background: transparent; + box-shadow: none; + backdrop-filter: none; + -webkit-backdrop-filter: none; +} + +.hero-stage .hero-panel, +.hero-stage .telemetry-strip__item { + border: 0; +} + +.hero-stage .hero-panel::before, +.hero-stage .telemetry-strip__item::before { + display: none; +} + +.hero-stage__lead, +.hero-stage__brief { + min-width: 0; + min-height: 100%; + padding: 30px 28px 24px; +} + +.hero-stage__lead { + display: grid; + gap: 18px; + align-content: start; +} + +.hero-stage__brief { + position: relative; + display: grid; + gap: 18px; + align-content: start; + border-left: 1px solid rgba(173, 196, 228, 0.1); + border-right: 1px solid rgba(173, 196, 228, 0.1); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.025), transparent 70%); +} + +.hero-stage__lead .brand-copy h1 { + font-size: clamp(2.25rem, 4vw, 4.2rem); + line-height: 0.94; +} + +.hero-stage__lead .dock-lede { + max-width: 62ch; + font-size: 0.98rem; +} + +.hero-stage__pulse { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; + align-items: start; + max-width: 38rem; +} + +.hero-stage__pulse-dot { + width: 0.78rem; + height: 0.78rem; + margin-top: 0.34rem; + border-radius: 50%; + background: var(--good); + box-shadow: 0 0 18px var(--good-glow); + animation: pulse 2.4s ease-in-out infinite; +} + +.hero-stage__pulse .small-note { + margin-top: 4px; +} + +.hero-stage__meta-strip { + grid-template-columns: 1fr; + gap: 12px; +} + +.hero-stage__meta-strip .dock-meta__item { + min-width: 0; + padding: 0; + border: 0; + border-left: 2px solid rgba(141, 182, 255, 0.18); + border-radius: 0; + background: transparent; + padding-left: 14px; +} + +.hero-stage__action-rail { + display: grid; + gap: 12px; + align-content: start; + padding: 20px 18px; +} + +.telemetry-strip { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0; + border-top: 1px solid rgba(173, 196, 228, 0.12); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.025), rgba(255, 255, 255, 0.01)); +} + +.telemetry-strip__item { + min-width: 0; + padding: 18px 22px 20px; + border-right: 1px solid rgba(173, 196, 228, 0.12); + border-radius: 0; + overflow: hidden; +} + +.telemetry-strip__item:last-child { + border-right: 0; +} + +.telemetry-strip .glance-card__value { + margin-top: 14px; + font-size: clamp(2rem, 3vw, 3.1rem); +} + +.telemetry-strip .sparkline { + max-width: 220px; +} + +.telemetry-strip .panel-lens { + padding: 0; + border: 0; + background: transparent; +} + +.marquee-panel { + overflow: hidden; + padding: 24px 26px; + background: + radial-gradient(circle at top right, rgba(141, 182, 255, 0.18), transparent 22%), + radial-gradient(circle at 12% 88%, rgba(103, 244, 161, 0.08), transparent 18%), + linear-gradient(135deg, rgba(19, 27, 40, 0.98), rgba(11, 16, 26, 0.96)); +} + +.marquee-panel::after { + content: ""; + position: absolute; + inset: auto -8% -36% 44%; + height: 240px; + background: radial-gradient(circle, rgba(141, 182, 255, 0.14), transparent 70%); + filter: blur(28px); + pointer-events: none; +} + +.marquee-layout { + display: grid; + grid-template-columns: minmax(0, 1.14fr) minmax(260px, 0.86fr); + gap: 24px; + align-items: start; + margin-top: 18px; +} + +.session-sequence { + display: grid; + gap: 14px; + min-width: 0; +} + +.health-pocket { + min-width: 0; + padding-left: 24px; + border-left: 1px solid rgba(173, 196, 228, 0.12); +} + +.health-pocket__head { + display: flex; + justify-content: space-between; + gap: 14px; + align-items: start; +} + +.health-pocket__head h2 { + margin: 2px 0 0; + font-size: clamp(1.7rem, 2.8vw, 2.8rem); + line-height: 0.95; +} + +.health-pocket__content { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 18px; + align-items: start; + margin-top: 16px; +} + +.health-pocket__stats { + gap: 12px; +} + +.marquee-panel .session-meta .metric-card, +.health-pocket .metric-card { + padding: 12px 0; + border-radius: 0; + border-width: 0 0 1px; + border-color: rgba(173, 196, 228, 0.12); + background: transparent; +} + +.marquee-panel .session-meta .metric-card:last-child, +.health-pocket .metric-card:nth-last-child(-n + 2) { + border-bottom: 0; +} + +.marquee-panel .message-preview { + margin-top: 20px; + padding-top: 18px; + border-top: 1px solid rgba(173, 196, 228, 0.12); +} + +.dashboard-grid { + grid-template-columns: minmax(0, 1.26fr) minmax(300px, 0.74fr); + grid-template-areas: + "map observers" + "ledger observers"; + align-items: stretch; +} + +.spotlight-panel { + grid-area: map; + padding: 18px 18px 20px; + background: + radial-gradient(circle at top left, rgba(141, 182, 255, 0.18), transparent 24%), + radial-gradient(circle at 82% 18%, rgba(103, 244, 161, 0.08), transparent 16%), + linear-gradient(180deg, rgba(15, 23, 36, 0.98), rgba(8, 13, 21, 0.98)); +} + +.spotlight-panel .observer-map { + min-height: 560px; + margin-top: 14px; + border-radius: 24px; +} + +.observer-panel { + grid-area: observers; + align-self: stretch; + min-height: 100%; + background: + radial-gradient(circle at 84% 12%, rgba(94, 217, 255, 0.08), transparent 20%), + linear-gradient(180deg, rgba(18, 24, 36, 0.96), rgba(10, 15, 24, 0.98)); +} + +.ledger-panel { + grid-area: ledger; + background: + radial-gradient(circle at top right, rgba(255, 190, 92, 0.12), transparent 24%), + radial-gradient(circle at 12% 88%, rgba(141, 182, 255, 0.08), transparent 18%), + linear-gradient(180deg, rgba(18, 25, 37, 0.98), rgba(10, 15, 24, 0.98)); +} + +.ledger-divider { + height: 1px; + margin: 24px 0 20px; + background: linear-gradient(90deg, transparent, rgba(173, 196, 228, 0.24), transparent); +} + +.ledger-panel .receipts, +.ledger-panel .history-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.ledger-panel .history-list { + margin-top: 0; +} + +.ledger-history-head { + margin-bottom: 12px; +} + +body[data-page-mode="share"] .ledger-panel .history-list { + display: none; +} + +@media (max-width: 1240px) { + .splash-scene { + inset: -56px -26px auto; + height: 780px; + } + + .splash-scene__badge--one { + right: 7%; + } + + .splash-scene__badge--two { + left: 6%; + } + + .hero-stage { + grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr); + } + + .hero-stage__brief { + border-right: 0; + } + + .hero-stage__action-rail { + grid-column: 1 / -1; + grid-template-columns: repeat(3, minmax(0, 1fr)); + padding-top: 16px; + border-top: 1px solid rgba(173, 196, 228, 0.12); + } + + .telemetry-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .telemetry-strip__item { + border-right: 1px solid rgba(173, 196, 228, 0.12); + border-top: 1px solid rgba(173, 196, 228, 0.12); + } + + .telemetry-strip__item:nth-child(2n) { + border-right: 0; + } + + .telemetry-strip__item:nth-child(-n + 2) { + border-top: 0; + } + + .marquee-layout, + .dashboard-grid { + grid-template-columns: 1fr; + } + + .health-pocket { + padding-left: 0; + padding-top: 20px; + border-left: 0; + border-top: 1px solid rgba(173, 196, 228, 0.12); + } + + .dashboard-grid { + grid-template-areas: + "map" + "observers" + "ledger"; + } + + .spotlight-panel .observer-map { + min-height: 440px; + } +} + +@media (max-width: 720px) { + .splash-scene { + inset: -36px -18px auto; + height: 430px; + } + + .splash-scene__mesh { + inset: 26px 0 auto; + height: 250px; + border-radius: 28px; + filter: blur(10px); + } + + .splash-scene__beam { + top: 26px; + right: -94px; + width: 260px; + height: 260px; + } + + .splash-scene__grid { + inset: 40px 8px auto; + height: 220px; + background-size: 48px 48px, 48px 48px, 100% 100%; + opacity: 0.42; + } + + .splash-scene__ring, + .splash-scene__badge { + display: none; + } + + .hero-stage { + grid-template-columns: 1fr; + border-radius: 24px; + } + + .hero-stage__lead, + .hero-stage__brief { + padding: 18px 16px; + } + + .hero-stage__brief { + border-left: 0; + border-right: 0; + border-top: 1px solid rgba(173, 196, 228, 0.12); + } + + .hero-stage__lead .brand-copy h1 { + font-size: clamp(1.6rem, 8vw, 2.35rem); + } + + .hero-stage__action-rail { + grid-template-columns: 1fr; + padding: 12px 16px 18px; + } + + .action-menu__panel { + top: auto; + bottom: calc(100% + 10px); + width: 100%; + } + + .telemetry-strip { + grid-template-columns: 1fr; + overflow-x: visible; + scrollbar-width: none; + } + + .telemetry-strip__item { + border-right: 0; + border-top: 1px solid rgba(173, 196, 228, 0.12); + } + + .marquee-panel { + padding: 16px; + } + + .health-pocket__content { + grid-template-columns: 1fr; + } + + .health-pocket .score-ring { + justify-self: start; + } + + .ledger-panel .receipts, + .ledger-panel .history-list { + grid-template-columns: 1fr; + } + + .spotlight-panel .observer-map { + min-height: 320px; + } +} + +body[data-ui-theme="light"] { + --bg: #f3f7fc; + --bg-deep: #fbfdff; + --bg-alt: #e9f0fb; + --panel: rgba(255, 255, 255, 0.82); + --panel-strong: rgba(255, 255, 255, 0.96); + --panel-soft: rgba(39, 98, 210, 0.08); + --panel-overlay: linear-gradient(180deg, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0.18)); + --line: rgba(32, 52, 84, 0.1); + --line-strong: rgba(32, 52, 84, 0.18); + --text: #182235; + --muted: #5f6f86; + --muted-strong: #33425b; + --accent: #2762d2; + --accent-strong: #163a87; + --good: #1c9f66; + --good-glow: rgba(28, 159, 102, 0.2); + --fair: #c98517; + --fair-glow: rgba(201, 133, 23, 0.16); + --poor: #d14c59; + --poor-glow: rgba(209, 76, 89, 0.16); + --shadow: 0 20px 54px rgba(52, 80, 121, 0.12); + --ring-color: #2762d2; + --hero-violet: #cad8ef; + --hero-coral: #d9b1b6; + --hero-lime: #d7e4c0; + --hero-blue: #2762d2; + background: + radial-gradient(circle at 10% 0%, rgba(113, 153, 220, 0.18), transparent 30%), + radial-gradient(circle at 100% 12%, rgba(189, 210, 239, 0.22), transparent 24%), + linear-gradient(180deg, #fbfdff 0%, #f5f8fc 52%, #eef3fa 100%); + color: var(--text); +} + +body[data-ui-theme="light"] .splash-scene { + opacity: 0.76; +} + +body[data-ui-theme="light"] .splash-scene__mesh { + background: + radial-gradient(circle at 16% 24%, rgba(121, 154, 214, 0.3), transparent 26%), + radial-gradient(circle at 78% 18%, rgba(39, 98, 210, 0.16), transparent 24%), + radial-gradient(circle at 62% 74%, rgba(166, 190, 224, 0.14), transparent 18%), + linear-gradient(135deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0)); +} + +body[data-ui-theme="light"] .splash-scene__beam { + background: radial-gradient(circle, rgba(39, 98, 210, 0.14), transparent 68%); +} + +body[data-ui-theme="light"] .splash-scene__grid { + border-color: rgba(39, 98, 210, 0.08); + background: + linear-gradient(rgba(39, 98, 210, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(39, 98, 210, 0.05) 1px, transparent 1px), + radial-gradient(circle at 50% 50%, rgba(166, 190, 224, 0.1), transparent 56%); +} + +body[data-ui-theme="light"] .splash-scene__ring { + border-color: rgba(39, 98, 210, 0.1); + background: radial-gradient(circle, rgba(121, 154, 214, 0.12), transparent 70%); +} + +body[data-ui-theme="light"] .splash-scene__badge { + border-color: rgba(39, 98, 210, 0.1); + background: rgba(255, 255, 255, 0.82); + color: #33425b; + box-shadow: 0 18px 40px rgba(52, 80, 121, 0.08); +} + +body[data-ui-theme="light"] .splash-scene__badge::before { + box-shadow: 0 0 18px rgba(39, 98, 210, 0.14); +} + +body[data-ui-theme="light"]::before { + background: + linear-gradient(rgba(39, 98, 210, 0.028) 1px, transparent 1px), + linear-gradient(90deg, rgba(39, 98, 210, 0.028) 1px, transparent 1px); + opacity: 0.55; + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.42), transparent 100%); +} + +body[data-ui-theme="light"]::after { + background: + radial-gradient(circle at 20% 20%, rgba(39, 98, 210, 0.12), transparent 22%), + radial-gradient(circle at 80% 35%, rgba(155, 183, 220, 0.1), transparent 18%), + radial-gradient(circle at 50% 70%, rgba(211, 224, 243, 0.16), transparent 18%); + opacity: 0.78; + filter: blur(80px); +} + +body[data-ui-theme="light"] .screen-glow { + background: radial-gradient(circle, rgba(121, 154, 214, 0.16), transparent 72%); +} + +body[data-ui-theme="light"] :focus-visible { + outline-color: rgba(49, 94, 208, 0.78); +} + +body[data-ui-theme="light"] code { + border-color: rgba(39, 98, 210, 0.1); + background: rgba(39, 98, 210, 0.08); + color: #1f4ca6; +} + +body[data-ui-theme="light"] .hero-stage { + border-color: rgba(32, 52, 84, 0.12); + background: + radial-gradient(circle at 12% 4%, rgba(190, 212, 242, 0.72), transparent 34%), + radial-gradient(circle at 86% 100%, rgba(223, 232, 245, 0.76), transparent 30%), + linear-gradient(140deg, rgba(255, 255, 255, 0.94), rgba(247, 251, 255, 0.94) 56%, rgba(241, 246, 252, 0.98)); + box-shadow: 0 24px 64px rgba(52, 80, 121, 0.14); +} + +body[data-ui-theme="light"] .pill, +body[data-ui-theme="light"] .dock-meta__item, +body[data-ui-theme="light"] .observer-pill .status, +body[data-ui-theme="light"] .timeline-track, +body[data-ui-theme="light"] .meter-track, +body[data-ui-theme="light"] .score-ring, +body[data-ui-theme="light"] .action-menu__panel, +body[data-ui-theme="light"] .drawer-head, +body[data-ui-theme="light"] .drawer-scrim { + border-color: rgba(43, 76, 124, 0.12); +} + +body[data-ui-theme="light"] .pill, +body[data-ui-theme="light"] .dock-meta__item, +body[data-ui-theme="light"] .observer-pill .status, +body[data-ui-theme="light"] .action-menu__panel, +body[data-ui-theme="light"] .drawer-head { + background: rgba(255, 255, 255, 0.86); + color: var(--text); +} + +body[data-ui-theme="light"] .drawer-scrim { + background: rgba(157, 177, 214, 0.28); +} + +body[data-ui-theme="light"] .brand-orb { + border-color: rgba(39, 98, 210, 0.14); + background: + linear-gradient(145deg, rgba(39, 98, 210, 0.14), rgba(190, 212, 242, 0.2)), + rgba(255, 255, 255, 0.76); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.56), + 0 0 24px rgba(39, 98, 210, 0.08); +} + +body[data-ui-theme="light"] .brand-orb::after { + background: radial-gradient(circle, rgba(39, 98, 210, 0.2), transparent 72%); +} + +body[data-ui-theme="light"] .score-ring { + background: radial-gradient(circle, rgba(39, 98, 210, 0.06), transparent 68%); +} + +body[data-ui-theme="light"] .score-ring__track { + stroke: rgba(43, 76, 124, 0.12); +} + +body[data-ui-theme="light"] .status-pill::before { + box-shadow: 0 0 14px rgba(209, 76, 89, 0.12); +} + +body[data-ui-theme="light"] .status-pill.online::before { + box-shadow: 0 0 16px rgba(28, 159, 102, 0.12); +} + +body[data-ui-theme="light"] .sparkline[data-tone="neutral"] .sparkline-bar { + background: linear-gradient(180deg, rgba(96, 114, 138, 0.32), rgba(96, 114, 138, 0.08)); +} + +body[data-ui-theme="light"] .timeline-track, +body[data-ui-theme="light"] .meter-track { + background: rgba(39, 98, 210, 0.08); +} + +body[data-ui-theme="light"] .timeline-fill { + background: linear-gradient(90deg, rgba(39, 98, 210, 0.24), rgba(39, 98, 210, 0.08)); +} + +body[data-ui-theme="light"] .timeline-dot { + border-color: rgba(255, 255, 255, 0.92); + box-shadow: 0 0 16px rgba(39, 98, 210, 0.14); +} + +body[data-ui-theme="light"] .action-menu__button, +body[data-ui-theme="light"] .action-menu__item { + color: #33425b; +} + +body[data-ui-theme="light"] .site-footer a, +body[data-ui-theme="light"] .hash-link { + color: #2762d2; +} + +body[data-ui-theme="light"] .hero-stage::before { + background: + linear-gradient(125deg, rgba(255, 255, 255, 0.4), transparent 28%), + repeating-linear-gradient(90deg, rgba(39, 98, 210, 0.03) 0 1px, transparent 1px 96px); +} + +body[data-ui-theme="light"] .hero-stage::after { + background: radial-gradient(circle, rgba(140, 171, 214, 0.14), transparent 72%); +} + +body[data-ui-theme="light"] .hero-stage__brief { + border-color: rgba(32, 52, 84, 0.12); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.28), rgba(217, 229, 246, 0.08) 100%); +} + +body[data-ui-theme="light"] .telemetry-strip { + border-top-color: rgba(32, 52, 84, 0.12); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.16)); +} + +body[data-ui-theme="light"] .telemetry-strip__item, +body[data-ui-theme="light"] .hero-stage__action-rail, +body[data-ui-theme="light"] .marquee-panel, +body[data-ui-theme="light"] .spotlight-panel, +body[data-ui-theme="light"] .observer-panel, +body[data-ui-theme="light"] .ledger-panel, +body[data-ui-theme="light"] .panel, +body[data-ui-theme="light"] .hero-panel, +body[data-ui-theme="light"] .glance-card, +body[data-ui-theme="light"] .detail-drawer { + border-color: rgba(43, 76, 124, 0.1); + color: var(--text); +} + +body[data-ui-theme="light"] .telemetry-strip__item { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(245, 249, 255, 0.62)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.52); +} + +body[data-ui-theme="light"] .telemetry-strip__item:nth-child(1) { + background: linear-gradient(180deg, rgba(215, 229, 248, 0.9), rgba(255, 255, 255, 0.76)); +} + +body[data-ui-theme="light"] .telemetry-strip__item:nth-child(2) { + background: linear-gradient(180deg, rgba(231, 237, 246, 0.88), rgba(255, 255, 255, 0.76)); +} + +body[data-ui-theme="light"] .telemetry-strip__item:nth-child(3) { + background: linear-gradient(180deg, rgba(236, 239, 245, 0.88), rgba(255, 255, 255, 0.78)); +} + +body[data-ui-theme="light"] .telemetry-strip__item:nth-child(4) { + background: linear-gradient(180deg, rgba(226, 234, 246, 0.9), rgba(255, 255, 255, 0.76)); +} + +body[data-ui-theme="light"] .marquee-panel, +body[data-ui-theme="light"] .spotlight-panel, +body[data-ui-theme="light"] .observer-panel, +body[data-ui-theme="light"] .ledger-panel { + box-shadow: 0 18px 46px rgba(52, 80, 121, 0.1); +} + +body[data-ui-theme="light"] .marquee-panel { + background: + linear-gradient(145deg, rgba(255, 255, 255, 0.92), rgba(247, 250, 255, 0.84) 52%, rgba(227, 235, 247, 0.3)); + box-shadow: + 0 18px 46px rgba(52, 80, 121, 0.1), + inset 0 4px 0 rgba(39, 98, 210, 0.38); +} + +body[data-ui-theme="light"] .spotlight-panel { + background: + linear-gradient(180deg, rgba(249, 251, 255, 0.92), rgba(237, 244, 253, 0.84)); + box-shadow: + 0 18px 46px rgba(52, 80, 121, 0.1), + inset 0 4px 0 rgba(39, 98, 210, 0.42); +} + +body[data-ui-theme="light"] .observer-panel { + background: + linear-gradient(180deg, rgba(252, 253, 255, 0.92), rgba(243, 248, 253, 0.84)); + box-shadow: + 0 18px 46px rgba(52, 80, 121, 0.1), + inset 0 4px 0 rgba(114, 149, 198, 0.38); +} + +body[data-ui-theme="light"] .ledger-panel { + background: + linear-gradient(180deg, rgba(252, 253, 255, 0.92), rgba(245, 248, 253, 0.84)); + box-shadow: + 0 18px 46px rgba(52, 80, 121, 0.1), + inset 0 4px 0 rgba(152, 177, 216, 0.42); +} + +body[data-ui-theme="light"] .marquee-panel::after { + background: radial-gradient(circle, rgba(39, 98, 210, 0.12), transparent 70%); +} + +body[data-ui-theme="light"] .health-pocket, +body[data-ui-theme="light"] .message-preview, +body[data-ui-theme="light"] .ledger-divider, +body[data-ui-theme="light"] .panel-divider, +body[data-ui-theme="light"] .hero-stage__brief, +body[data-ui-theme="light"] .telemetry-strip__item { + border-color: rgba(43, 76, 124, 0.12); +} + +body[data-ui-theme="light"] .ghost-button, +body[data-ui-theme="light"] .panel-lens { + border-color: rgba(39, 98, 210, 0.12); + background: rgba(255, 255, 255, 0.82); + color: #2b3950; +} + +body[data-ui-theme="light"] .primary-button { + border-color: rgba(39, 98, 210, 0.2); + background: linear-gradient(135deg, #2e6fe0, #2458bd); + color: #ffffff; + box-shadow: 0 12px 28px rgba(39, 98, 210, 0.22); +} + +body[data-ui-theme="light"] .ghost-button:hover, +body[data-ui-theme="light"] .ghost-button:focus-visible, +body[data-ui-theme="light"] .panel-lens:hover, +body[data-ui-theme="light"] .panel-lens:focus-visible { + border-color: rgba(39, 98, 210, 0.2); + background: rgba(39, 98, 210, 0.08); + color: #163a87; +} + +body[data-ui-theme="light"] .primary-button:hover, +body[data-ui-theme="light"] .primary-button:focus-visible { + border-color: rgba(39, 98, 210, 0.28); + box-shadow: 0 16px 34px rgba(39, 98, 210, 0.26); +} + +body[data-ui-theme="light"] .metric-card, +body[data-ui-theme="light"] .history-item, +body[data-ui-theme="light"] .receipt-card, +body[data-ui-theme="light"] .observer-option, +body[data-ui-theme="light"] .observer-pill, +body[data-ui-theme="light"] .empty-state, +body[data-ui-theme="light"] .drawer-card, +body[data-ui-theme="light"] .drawer-stat, +body[data-ui-theme="light"] .drawer-list__item, +body[data-ui-theme="light"] .console-line, +body[data-ui-theme="light"] .timeline-row { + border-color: rgba(32, 52, 84, 0.08); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(246, 249, 253, 0.52)), + rgba(255, 255, 255, 0.42); +} + +body[data-ui-theme="light"] .session-code { + border-color: rgba(39, 98, 210, 0.14); + background: + linear-gradient(135deg, rgba(39, 98, 210, 0.14), rgba(190, 212, 242, 0.08)), + rgba(255, 255, 255, 0.86); + color: #163a87; +} + +body[data-ui-theme="light"] .observer-map { + border-color: rgba(43, 76, 124, 0.12); +} + +body[data-ui-theme="light"] .leaflet-container { + background: linear-gradient(180deg, rgba(238, 244, 252, 0.96), rgba(247, 250, 255, 0.98)); +} + +body[data-ui-theme="light"] .leaflet-control-zoom a, +body[data-ui-theme="light"] .leaflet-control-attribution { + border-color: rgba(43, 76, 124, 0.12) !important; + background: rgba(255, 255, 255, 0.86) !important; + color: #1f4ca6 !important; +} + +body[data-ui-theme="light"] .leaflet-popup-content-wrapper, +body[data-ui-theme="light"] .leaflet-popup-tip { + background: rgba(255, 255, 255, 0.96); + color: var(--text); + border-color: rgba(43, 76, 124, 0.12); + box-shadow: 0 18px 36px rgba(53, 84, 133, 0.14); +} + +body[data-ui-theme="light"] .drawer-codeblock { + border-color: rgba(43, 76, 124, 0.08); + background: rgba(245, 249, 255, 0.92); + color: #1f4ca6; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* Health-check readability and small-screen safeguards. Keep operational labels legible without + changing the site's accent palette, and make the lower report sections easy to reach. */ +html { + scroll-padding-bottom: 3rem; +} + +body { + overflow-y: auto; +} + +.panel-label, +.meta-label, +.toolbar-label, +.eyebrow { + color: var(--muted); +} + +.small-note { + color: var(--muted-strong); + font-size: 0.78rem; +} + +.site-footer { + padding-bottom: 12px; +} + +.site-footer .small-note { + color: var(--muted-strong); + letter-spacing: 0.06em; +} + +.dock-lede strong::before { + display: none; +} + +.splash-scene__badge { + display: none; +} + +.status-pill { + pointer-events: none; + cursor: default; + border-radius: var(--radius-sm); + border-color: rgba(255, 255, 255, 0.14); + padding: 0.52rem 0.78rem; + font-family: var(--font-mono); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.status-pill.online { + border-color: rgba(103, 244, 161, 0.28); + color: var(--good); +} + +.external-link { + min-height: 0; + width: auto !important; + padding: 0.24rem 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + color: var(--accent); + font-family: var(--font-mono); + font-size: 0.76rem; + letter-spacing: 0.06em; + text-transform: uppercase; + text-decoration: none; + justify-content: flex-end; +} + +.external-link:hover, +.external-link:focus-visible { + transform: none; + border-color: transparent; + background: transparent; + color: var(--text); + text-decoration: underline; +} + +.glance-card--empty { + border-style: dashed; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0.008)), + rgba(9, 15, 24, 0.58); +} + +.glance-card--empty .glance-card__value { + color: var(--muted); + font-size: 0.98rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +.glance-card--empty .sparkline { + opacity: 0.24; +} + +.glance-card--empty .small-note { + color: var(--muted); +} + +.section-jump { + display: inline-flex; + align-items: center; + gap: 0.38rem; + flex: 0 0 auto; + min-height: 0; + padding: 0.32rem 0.56rem; + border: 1px solid rgba(141, 182, 255, 0.22); + border-radius: 999px; + color: var(--accent-strong); + font-family: var(--font-mono); + font-size: 0.68rem; + letter-spacing: 0.06em; + line-height: 1.25; + text-decoration: none; + text-transform: uppercase; +} + +.section-jump:hover, +.section-jump:focus-visible { + border-color: rgba(141, 182, 255, 0.42); + background: rgba(141, 182, 255, 0.08); + color: var(--text); +} + +#command-center, +#observer-reports { + scroll-margin-top: 18px; +} + +.noc-shell { + padding-bottom: 64px; + padding-bottom: max(64px, calc(44px + env(safe-area-inset-bottom))); +} + +.session-code { + display: flex; + min-height: 72px; + align-items: center; +} + +.sparkline { + height: 42px; + min-height: 0; +} + +@media (max-width: 720px) { + .noc-shell { + width: 100%; + gap: 12px; + padding: 10px 8px 76px; + padding-bottom: max(76px, calc(52px + env(safe-area-inset-bottom))); + } + + .workspace, + .command-grid, + .dashboard-grid { + gap: 12px; + } + + .hero-stage__lead, + .hero-stage__brief { + padding: 16px 14px; + } + + .hero-stage__lead { + gap: 12px; + } + + .dock-brand { + gap: 10px; + } + + .brand-orb { + flex-basis: 42px; + width: 42px; + height: 42px; + border-radius: 12px; + } + + .hero-stage__lead .brand-copy h1 { + font-size: clamp(1.5rem, 7.5vw, 2rem); + line-height: 1.02; + } + + .hero-stage__lead .dock-lede { + font-size: 0.86rem; + line-height: 1.42; + } + + .hero-stage__pulse { + gap: 9px; + } + + .hero-stage__pulse-dot { + width: 0.62rem; + height: 0.62rem; + margin-top: 0.25rem; + } + + .hero-stage__brief { + gap: 12px; + } + + .toolbar-copy h2 { + font-size: 1.15rem; + line-height: 1.1; + } + + .toolbar-copy p { + margin-top: 6px; + font-size: 0.82rem; + line-height: 1.42; + } + + .hero-stage__brief .control-utility-actions { + position: static; + width: 100%; + max-width: none; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + gap: 8px; + } + + .hero-stage__brief .control-utility-actions .control-button, + .hero-stage__brief .control-utility-actions a { + width: auto; + min-height: 38px; + } + + .hero-stage__brief .control-utility-actions .external-link { + min-height: 0; + } + + .control-button, + .ghost-button, + .primary-button { + min-height: 38px; + } + + .dock-status-stack { + gap: 8px; + } + + .telemetry-strip__item { + padding: 13px 14px 14px; + } + + .telemetry-strip .glance-card__value, + .glance-card__value { + margin-top: 8px; + font-size: 1.35rem; + line-height: 1.1; + } + + .glance-card--empty .glance-card__value { + margin-top: 10px; + font-size: 0.86rem; + } + + .sparkline { + height: 28px; + padding-top: 5px; + gap: 3px; + } + + .sparkline-bar { + min-height: 3px; + } + + .marquee-panel { + padding: 14px; + overflow: visible; + } + + .session-code-row { + gap: 14px; + } + + .session-code { + min-height: 64px; + padding: 14px; + font-size: clamp(1.35rem, 7vw, 1.85rem); + letter-spacing: 0.1em; + } + + .session-code-actions { + gap: 8px; + } + + .session-code-actions > * { + min-height: 38px; + } + + .session-instructions { + line-height: 1.45; + } + + .session-share-note { + font-size: 0.7rem; + } + + .health-pocket { + padding-top: 16px; + } + + .health-pocket__head h2 { + font-size: clamp(1.4rem, 6vw, 1.9rem); + } + + .health-pocket__content { + gap: 12px; + margin-top: 12px; + } + + .score-ring { + width: 108px; + height: 108px; + } + + .score-num { + font-size: 1.45rem; + } + + .health-pocket__stats { + gap: 8px; + } + + .health-pocket .metric-card { + padding: 10px 0; + } + + .section-jump { + max-width: 11rem; + padding: 0.24rem 0.42rem; + font-size: 0.63rem; + text-align: right; + } + + .panel { + padding: 14px; + } + + .panel h2 { + font-size: 1.2rem; + } + + .panel-header { + gap: 10px; + } + + .spotlight-panel .observer-map, + .observer-map { + min-height: clamp(220px, 58vw, 300px); + } + + .timeline-row { + padding: 10px 12px; + } + + .ledger-panel .receipts, + .ledger-panel .history-list { + grid-template-columns: 1fr; + } + + .site-footer { + padding-bottom: 8px; + } +}