+
+ {/* Overlay picker sheet */}
+ {layerPickerOpen && (
+ <>
+
setLayerPickerOpen(false)}
+ />
+
+
+
Map Layers
+ {overlayDefs.map((def: OverlayDef) => (
+
+ ))}
+
+ >
+ )}
+
+ {/* Cluster alert list sheet */}
+ {clusterAlerts.length > 0 && (
+ <>
+
setClusterAlerts([])}
+ />
+
+
+
+ Area Alerts ({clusterAlerts.length})
+
+
+ {clusterAlerts.map((alert) => (
+
+
+ {alert.canonical_url && (
+
+
+
+ )}
+
+ ))}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/mobile/MobileSearch.tsx b/src/mobile/MobileSearch.tsx
new file mode 100644
index 0000000..47d987b
--- /dev/null
+++ b/src/mobile/MobileSearch.tsx
@@ -0,0 +1,95 @@
+import { useSearch } from "@/hooks/useSearch";
+import { MobileAlertCard } from "./MobileAlertCard";
+import type { AlertCategory } from "@/types/alert";
+import { categoryLabels, categoryOrder } from "@/lib/severity";
+import { useState, useMemo } from "react";
+import { SearchX, WifiOff } from "lucide-react";
+
+interface Props {
+ onSelectAlert: (alertId: string) => void;
+}
+
+// Show a useful subset of categories as quick-filter chips
+const CHIP_CATEGORIES: AlertCategory[] = categoryOrder.slice(0, 10);
+
+export function MobileSearch({ onSelectAlert }: Props) {
+ const { query, setQuery, results, isSearching, isApiAvailable } = useSearch();
+ const [categoryFilter, setCategoryFilter] = useState
(null);
+
+ const filtered = useMemo(
+ () =>
+ categoryFilter
+ ? results.filter((a) => a.category === categoryFilter)
+ : results,
+ [results, categoryFilter],
+ );
+
+ if (isApiAvailable === false) {
+ return (
+
+
+ Search API unavailable
+ The collector API is not reachable
+
+ );
+ }
+
+ return (
+
+
+
setQuery(e.target.value)}
+ autoFocus
+ />
+
+
+ {CHIP_CATEGORIES.map((cat) => (
+
+ ))}
+
+
+
+
+ {isSearching && (
+
+ )}
+
+ {!isSearching && query.trim() && filtered.length === 0 && (
+
+
+ No results for "{query}"
+
+ )}
+
+ {!isSearching &&
+ filtered.map((alert) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/mobile/main.tsx b/src/mobile/main.tsx
new file mode 100644
index 0000000..a2f4c8d
--- /dev/null
+++ b/src/mobile/main.tsx
@@ -0,0 +1,13 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import "./mobile.css";
+import { MobileApp } from "./MobileApp";
+import { ErrorBoundary } from "@/components/ErrorBoundary";
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+ ,
+);
diff --git a/src/mobile/mobile.css b/src/mobile/mobile.css
new file mode 100644
index 0000000..2ab62cb
--- /dev/null
+++ b/src/mobile/mobile.css
@@ -0,0 +1,625 @@
+@import url("https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700;800&family=Roboto+Mono:wght@400;500;600&display=swap");
+@import "tailwindcss";
+@import "../theme.css";
+
+/* ── Safe area & viewport ────────────────────────────────────── */
+:root {
+ --sat: env(safe-area-inset-top, 0px);
+ --sab: env(safe-area-inset-bottom, 0px);
+ --header-h: 48px;
+ --nav-h: 56px;
+}
+
+html, body, #root {
+ height: 100%;
+ overflow: hidden;
+ overscroll-behavior: none;
+ -webkit-overflow-scrolling: touch;
+}
+
+body {
+ background: var(--color-siem-bg, #030610);
+ color: var(--color-siem-text, #e2e8f0);
+ font-family: "Montserrat", "Segoe UI", sans-serif;
+}
+
+/* ── Layout shell ────────────────────────────────────────────── */
+.mobile-shell {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ padding-top: var(--sat);
+ padding-bottom: var(--sab);
+}
+
+.mobile-header {
+ flex: 0 0 var(--header-h);
+ display: flex;
+ align-items: center;
+ padding: 0 12px;
+ gap: 8px;
+ border-bottom: 1px solid rgba(71, 85, 105, 0.3);
+ background: rgba(3, 6, 10, 0.92);
+ backdrop-filter: blur(8px);
+ z-index: 20;
+}
+
+.mobile-content {
+ flex: 1;
+ overflow: hidden;
+ position: relative;
+}
+
+.mobile-nav {
+ flex: 0 0 var(--nav-h);
+ display: flex;
+ align-items: center;
+ justify-content: space-around;
+ border-top: 1px solid rgba(71, 85, 105, 0.3);
+ background: rgba(3, 6, 10, 0.95);
+ backdrop-filter: blur(8px);
+ z-index: 20;
+}
+
+/* ── Bottom nav tabs ─────────────────────────────────────────── */
+.mobile-nav-tab {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ padding: 8px 20px; /* ↑ 6→8px vert, 16→20px horiz */
+ font-size: 10px;
+ color: #64748b;
+ transition: color 0.15s;
+ -webkit-tap-highlight-color: transparent;
+ user-select: none;
+ touch-action: manipulation;
+}
+
+.mobile-nav-tab.active {
+ color: #38bdf8;
+}
+
+.mobile-nav-tab svg {
+ width: 22px;
+ height: 22px;
+}
+
+/* ── Region / category pill ──────────────────────────────────── */
+.mobile-region-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 6px 10px; /* ↑ 4→6px vert, min 32px tall */
+ font-size: 12px;
+ font-weight: 600;
+ color: #94a3b8;
+ background: rgba(51, 65, 85, 0.35);
+ border: 1px solid rgba(71, 85, 105, 0.4);
+ border-radius: 999px;
+ -webkit-tap-highlight-color: transparent;
+ user-select: none;
+ touch-action: manipulation;
+ transition: background 0.15s;
+}
+
+.mobile-region-pill:active {
+ background: rgba(51, 65, 85, 0.6);
+}
+
+.mobile-region-pill--active {
+ background: rgba(56, 189, 248, 0.15);
+ border-color: rgba(56, 189, 248, 0.4);
+ color: #38bdf8;
+}
+
+/* ── Alert list ──────────────────────────────────────────────── */
+.mobile-alert-list {
+ height: 100%;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ overscroll-behavior-y: none;
+}
+
+.mobile-severity-pills {
+ display: flex;
+ gap: 6px;
+ padding: 10px 12px 6px;
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none; /* hide scrollbar Firefox */
+}
+.mobile-severity-pills::-webkit-scrollbar {
+ display: none; /* hide scrollbar WebKit */
+}
+
+.mobile-severity-pill {
+ flex-shrink: 0;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 6px 14px; /* ↑ 4→6px vert, 12→14px horiz */
+ min-height: 32px; /* ensure tappable height */
+ font-size: 12px; /* ↑ 11→12px */
+ font-weight: 600;
+ border-radius: 999px;
+ border: 1px solid rgba(71, 85, 105, 0.4);
+ background: transparent;
+ color: #94a3b8;
+ -webkit-tap-highlight-color: transparent;
+ user-select: none;
+ touch-action: manipulation;
+ transition: all 0.15s;
+}
+
+.mobile-severity-pill.active {
+ background: rgba(56, 189, 248, 0.15);
+ border-color: rgba(56, 189, 248, 0.4);
+ color: #38bdf8;
+}
+
+/* ── Alert card ──────────────────────────────────────────────── */
+.mobile-alert-card {
+ display: flex;
+ gap: 10px;
+ padding: 14px 12px; /* ↑ 12→14px vert */
+ border-bottom: 1px solid rgba(71, 85, 105, 0.15);
+ -webkit-tap-highlight-color: transparent;
+ user-select: none;
+ touch-action: manipulation;
+ transition: background 0.1s;
+}
+
+.mobile-alert-card:active {
+ background: rgba(51, 65, 85, 0.25);
+}
+
+.mobile-alert-sev {
+ flex: 0 0 4px;
+ border-radius: 2px;
+ align-self: stretch;
+}
+
+.mobile-alert-body {
+ flex: 1;
+ min-width: 0;
+}
+
+.mobile-alert-title {
+ font-size: 14px; /* ↑ 13→14px */
+ font-weight: 500;
+ line-height: 1.35;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.mobile-alert-meta {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 4px;
+ font-size: 12px; /* ↑ 11→12px */
+ color: #64748b;
+}
+
+.mobile-alert-badge {
+ display: inline-block;
+ padding: 2px 6px; /* ↑ 1→2px vert */
+ font-size: 10px; /* ↑ 9→10px */
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ border-radius: 3px;
+ color: #fff;
+}
+
+/* ── Pull-to-refresh ─────────────────────────────────────────── */
+.mobile-ptr-indicator {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ display: flex;
+ justify-content: center;
+ padding: 12px 0;
+ z-index: 10;
+ pointer-events: none;
+}
+
+.mobile-ptr-spinner {
+ width: 20px;
+ height: 20px;
+ border: 2px solid rgba(56, 189, 248, 0.3);
+ border-top-color: #38bdf8;
+ border-radius: 50%;
+ animation: mobile-spin 0.6s linear infinite;
+}
+
+@keyframes mobile-spin {
+ to { transform: rotate(360deg); }
+}
+
+/* ── Bottom sheet ────────────────────────────────────────────── */
+.mobile-sheet-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 999998;
+ -webkit-tap-highlight-color: transparent;
+}
+
+.mobile-sheet {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ max-height: 90dvh; /* ↑ vh→dvh */
+ background: rgba(15, 23, 42, 0.98);
+ border-top: 1px solid rgba(71, 85, 105, 0.4);
+ border-radius: 16px 16px 0 0;
+ z-index: 999999;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
+ will-change: transform;
+ padding-bottom: var(--sab);
+}
+
+.mobile-sheet-handle {
+ flex: 0 0 auto;
+ display: flex;
+ justify-content: center;
+ padding: 12px 0 8px; /* ↑ bigger drag target */
+ cursor: grab;
+ -webkit-tap-highlight-color: transparent;
+}
+
+.mobile-sheet-handle::after {
+ content: "";
+ width: 36px;
+ height: 4px;
+ border-radius: 2px;
+ background: #475569;
+}
+
+.mobile-sheet-content {
+ flex: 1;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ overscroll-behavior-y: contain;
+ padding: 0 16px 16px;
+}
+
+/* ── Search ──────────────────────────────────────────────────── */
+.mobile-search-input {
+ width: 100%;
+ padding: 12px 14px; /* ↑ 10/12 → 12/14 */
+ font-size: 16px; /* ↑ 15→16px prevents iOS zoom */
+ background: rgba(30, 41, 59, 0.6);
+ border: 1px solid rgba(71, 85, 105, 0.4);
+ border-radius: 10px;
+ color: #e2e8f0;
+ outline: none;
+ -webkit-appearance: none;
+}
+
+.mobile-search-input::placeholder {
+ color: #475569;
+}
+
+.mobile-search-input:focus {
+ border-color: rgba(56, 189, 248, 0.5);
+}
+
+.mobile-search-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px; /* ↑ 6→8px */
+ padding: 8px 0;
+}
+
+.mobile-search-chip {
+ padding: 6px 12px; /* ↑ 4/10 → 6/12 */
+ min-height: 32px; /* ensure tappable */
+ font-size: 12px; /* ↑ 11→12px */
+ border-radius: 999px;
+ border: 1px solid rgba(71, 85, 105, 0.4);
+ color: #94a3b8;
+ background: transparent;
+ -webkit-tap-highlight-color: transparent;
+ user-select: none;
+ touch-action: manipulation;
+}
+
+.mobile-search-chip.active {
+ background: rgba(56, 189, 248, 0.15);
+ border-color: rgba(56, 189, 248, 0.4);
+ color: #38bdf8;
+}
+
+/* ── Empty states ────────────────────────────────────────────── */
+.mobile-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: #475569;
+ font-size: 14px;
+ gap: 8px;
+ padding: 32px;
+ text-align: center;
+}
+
+/* ── Leaflet cluster overrides ──────────────────────────────── */
+.siem-cluster {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ font-family: "Roboto Mono", monospace;
+ font-weight: 600;
+ color: var(--color-siem-text, #e6edf5);
+ border: 2px solid color-mix(in srgb, var(--color-siem-accent, #E8630A) 45%, transparent);
+}
+
+.siem-cluster span {
+ line-height: 1;
+}
+
+.siem-cluster-small {
+ font-size: 11px;
+ background: color-mix(in srgb, var(--color-siem-panel, #0b1120) 88%, transparent);
+}
+
+.siem-cluster-medium {
+ font-size: 12px;
+ background: color-mix(in srgb, var(--color-siem-accent, #E8630A) 22%, transparent);
+}
+
+.siem-cluster-large {
+ font-size: 13px;
+ background: color-mix(in srgb, var(--color-siem-high, #f29d4b) 28%, transparent);
+ border-color: color-mix(in srgb, var(--color-siem-high, #f29d4b) 55%, transparent);
+}
+
+/* ── Map ─────────────────────────────────────────────────────── */
+.mobile-map {
+ height: 100%;
+ width: 100%;
+}
+
+.mobile-map .leaflet-container {
+ height: 100%;
+ width: 100%;
+ background: #030610;
+}
+
+/* ── Loading skeleton ────────────────────────────────────────── */
+.mobile-skeleton {
+ height: 64px;
+ margin: 0 12px;
+ border-radius: 8px;
+ background: linear-gradient(90deg, rgba(30,41,59,0.3) 25%, rgba(30,41,59,0.5) 50%, rgba(30,41,59,0.3) 75%);
+ background-size: 200% 100%;
+ animation: mobile-shimmer 1.5s infinite;
+}
+
+@keyframes mobile-shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+/* ── Clear / action buttons in sheets ────────────────────────── */
+.mobile-clear-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 36px;
+ min-width: 72px;
+ padding: 8px 14px;
+ font-size: 13px;
+ font-weight: 600;
+ color: #38bdf8;
+ background: rgba(56, 189, 248, 0.1);
+ border: 1px solid rgba(56, 189, 248, 0.25);
+ border-radius: 8px;
+ text-transform: none;
+ letter-spacing: 0;
+ -webkit-tap-highlight-color: transparent;
+ user-select: none;
+ touch-action: manipulation;
+ transition: background 0.15s;
+}
+
+.mobile-clear-btn:active {
+ background: rgba(56, 189, 248, 0.25);
+}
+
+/* ── Picker sheet (regions, layers, categories) ──────────────── */
+.mobile-picker-sheet {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ max-height: 70dvh; /* ↑ vh→dvh */
+ background: rgba(15, 23, 42, 0.98);
+ border-top: 1px solid rgba(71, 85, 105, 0.4);
+ border-radius: 16px 16px 0 0;
+ z-index: 999999;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ overscroll-behavior-y: contain;
+ padding-bottom: calc(var(--sab) + 8px);
+ animation: mobile-slide-up 0.25s cubic-bezier(0.32, 0.72, 0, 1);
+}
+
+@keyframes mobile-slide-up {
+ from { transform: translateY(100%); }
+ to { transform: translateY(0); }
+}
+
+.mobile-picker-title {
+ font-size: 13px;
+ font-weight: 700;
+ color: #94a3b8;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ padding: 4px 16px 8px;
+}
+
+.mobile-picker-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ padding: 14px 16px;
+ min-height: 48px; /* ensure 48px touch target */
+ font-size: 15px;
+ color: #cbd5e1;
+ border-bottom: 1px solid rgba(71, 85, 105, 0.15);
+ -webkit-tap-highlight-color: transparent;
+ user-select: none;
+ touch-action: manipulation;
+ transition: background 0.1s;
+}
+
+.mobile-picker-item:active {
+ background: rgba(51, 65, 85, 0.3);
+}
+
+.mobile-picker-item.active {
+ color: #38bdf8;
+}
+
+/* ── Layers FAB ──────────────────────────────────────────────── */
+.mobile-layers-fab {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ z-index: 10000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 44px; /* ↑ 40→44px */
+ height: 44px; /* ↑ 40→44px */
+ border-radius: 12px;
+ background: rgba(15, 23, 42, 0.85);
+ border: 1px solid rgba(71, 85, 105, 0.5);
+ color: #cbd5e1;
+ backdrop-filter: blur(8px);
+ -webkit-tap-highlight-color: transparent;
+ touch-action: manipulation;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+}
+
+.mobile-layers-fab:active {
+ background: rgba(30, 41, 59, 0.9);
+}
+
+/* ── Cluster alert list ───────────────────────────────────────── */
+.mobile-cluster-list {
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ overscroll-behavior-y: contain;
+ padding-bottom: 8px;
+}
+
+.mobile-cluster-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0 16px;
+ border-bottom: 1px solid rgba(71, 85, 105, 0.15);
+}
+
+.mobile-cluster-item-main {
+ flex: 1;
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 14px 0; /* ↑ 12→14px vert */
+ min-height: 48px; /* ensure touch target */
+ min-width: 0;
+ text-align: left;
+ -webkit-tap-highlight-color: transparent;
+ user-select: none;
+ touch-action: manipulation;
+}
+
+.mobile-cluster-item-main:active {
+ opacity: 0.7;
+}
+
+.mobile-cluster-sev {
+ flex-shrink: 0;
+ padding: 2px 6px; /* ↑ 5→6px horiz */
+ font-size: 10px; /* ↑ 9→10px */
+ font-weight: 700;
+ letter-spacing: 0.3px;
+ color: #fff;
+ border-radius: 3px;
+ margin-top: 2px;
+}
+
+.mobile-cluster-text {
+ flex: 1;
+ min-width: 0;
+}
+
+.mobile-cluster-title {
+ font-size: 14px; /* ↑ 13→14px */
+ line-height: 1.35;
+ color: #e2e8f0;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.mobile-cluster-meta {
+ font-size: 12px; /* ↑ 11→12px */
+ color: #64748b;
+ margin-top: 3px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.mobile-cluster-source {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 44px; /* ↑ 32→44px */
+ height: 44px; /* ↑ 32→44px */
+ color: #60a5fa;
+ border-radius: 8px;
+ -webkit-tap-highlight-color: transparent;
+ touch-action: manipulation;
+}
+
+.mobile-cluster-source:active {
+ background: rgba(96, 165, 250, 0.15);
+}
+
+/* ── Layers badge ────────────────────────────────────────────── */
+.mobile-layers-badge {
+ position: absolute;
+ top: -4px;
+ right: -4px;
+ min-width: 18px; /* ↑ 16→18px */
+ height: 18px; /* ↑ 16→18px */
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 4px;
+ font-size: 10px; /* ↑ 9→10px */
+ font-weight: 700;
+ color: #fff;
+ background: #38bdf8;
+ border-radius: 999px;
+}
diff --git a/src/mobile/useBottomSheet.ts b/src/mobile/useBottomSheet.ts
new file mode 100644
index 0000000..7be6e38
--- /dev/null
+++ b/src/mobile/useBottomSheet.ts
@@ -0,0 +1,63 @@
+import { useRef, useState, useCallback } from "react";
+
+export function useBottomSheet() {
+ const [isOpen, setIsOpen] = useState(false);
+ const sheetRef = useRef(null);
+ const startY = useRef(0);
+ const currentTranslate = useRef(0);
+ const dragging = useRef(false);
+
+ const open = useCallback(() => {
+ setIsOpen(true);
+ currentTranslate.current = 0;
+ }, []);
+
+ const close = useCallback(() => {
+ const el = sheetRef.current;
+ if (el) {
+ el.style.transform = "translateY(100%)";
+ }
+ setTimeout(() => {
+ setIsOpen(false);
+ currentTranslate.current = 0;
+ }, 300);
+ }, []);
+
+ const onDragStart = useCallback((e: React.TouchEvent) => {
+ startY.current = e.touches[0].clientY;
+ dragging.current = true;
+ const el = sheetRef.current;
+ if (el) el.style.transition = "none";
+ }, []);
+
+ const onDragMove = useCallback((e: React.TouchEvent) => {
+ if (!dragging.current) return;
+ const dy = e.touches[0].clientY - startY.current;
+ if (dy < 0) return; // Only allow drag down
+ currentTranslate.current = dy;
+ const el = sheetRef.current;
+ if (el) el.style.transform = `translateY(${dy}px)`;
+ }, []);
+
+ const onDragEnd = useCallback(() => {
+ dragging.current = false;
+ const el = sheetRef.current;
+ if (el) el.style.transition = "transform 0.3s cubic-bezier(0.32, 0.72, 0, 1)";
+ if (currentTranslate.current > 120) {
+ close();
+ } else {
+ currentTranslate.current = 0;
+ if (el) el.style.transform = "translateY(0)";
+ }
+ }, [close]);
+
+ return {
+ isOpen,
+ open,
+ close,
+ sheetRef,
+ onDragStart,
+ onDragMove,
+ onDragEnd,
+ };
+}
diff --git a/src/mobile/usePullToRefresh.ts b/src/mobile/usePullToRefresh.ts
new file mode 100644
index 0000000..fb7031c
--- /dev/null
+++ b/src/mobile/usePullToRefresh.ts
@@ -0,0 +1,51 @@
+import { useRef, useCallback, useState, type RefObject } from "react";
+
+const THRESHOLD = 60;
+
+export function usePullToRefresh(
+ scrollRef: RefObject,
+ onRefresh: () => void,
+) {
+ const [isRefreshing, setIsRefreshing] = useState(false);
+ const [pullDistance, setPullDistance] = useState(0);
+ const startY = useRef(0);
+ const pulling = useRef(false);
+
+ const onTouchStart = useCallback((e: React.TouchEvent) => {
+ const el = scrollRef.current;
+ if (!el || el.scrollTop > 0) return;
+ startY.current = e.touches[0].clientY;
+ pulling.current = true;
+ }, [scrollRef]);
+
+ const onTouchMove = useCallback((e: React.TouchEvent) => {
+ if (!pulling.current || isRefreshing) return;
+ const dy = e.touches[0].clientY - startY.current;
+ if (dy > 0) {
+ setPullDistance(Math.min(dy * 0.5, THRESHOLD * 1.5));
+ }
+ }, [isRefreshing]);
+
+ const onTouchEnd = useCallback(() => {
+ if (!pulling.current) return;
+ pulling.current = false;
+ if (pullDistance >= THRESHOLD && !isRefreshing) {
+ setIsRefreshing(true);
+ onRefresh();
+ setTimeout(() => {
+ setIsRefreshing(false);
+ setPullDistance(0);
+ }, 1000);
+ } else {
+ setPullDistance(0);
+ }
+ }, [pullDistance, isRefreshing, onRefresh]);
+
+ return {
+ isRefreshing,
+ pullDistance,
+ onTouchStart,
+ onTouchMove,
+ onTouchEnd,
+ };
+}
diff --git a/src/theme.css b/src/theme.css
new file mode 100644
index 0000000..0b668ad
--- /dev/null
+++ b/src/theme.css
@@ -0,0 +1,49 @@
+@theme {
+ /* ── Relative type scale (scales with html font-size) ──────────── */
+ --font-size-4xs: 0.615rem; /* ~8px at 13px root */
+ --font-size-3xs: 0.692rem; /* ~9px at 13px root */
+ --font-size-2xs: 0.77rem; /* ~10px at 13px root */
+ --font-size-xxs: 0.846rem; /* ~11px at 13px root */
+
+ /* ── Brand ──────────────────────────────────────────────────────── */
+ --color-siem-bg: #070E1A;
+ --color-siem-panel: #0b1120;
+ --color-siem-panel-strong: #131d2e;
+ --color-siem-border: #1e293b;
+ --color-siem-text: #e6edf5;
+ --color-siem-muted: #5A7B95;
+ --color-siem-accent: #E8630A;
+ --color-siem-accent-strong: #FF8533;
+ --color-siem-neutral: #9ca3af;
+
+ /* ── Severity ───────────────────────────────────────────────────── */
+ --color-siem-critical: #ff5d5d;
+ --color-siem-high: #f29d4b;
+ --color-siem-medium: #e3c867;
+ --color-siem-low: #4ccb8d;
+ --color-siem-info: #60a5fa;
+
+ /* ── Category ───────────────────────────────────────────────────── */
+ --color-cat-informational: #60a5fa;
+ --color-cat-cyber: #3b82f6;
+ --color-cat-education: #4f95a4;
+ --color-cat-humanitarian: #2f8c8c;
+ --color-cat-conflict: #725f95;
+ --color-cat-humsec: #3a7395;
+ --color-cat-wanted: #a14a5b;
+ --color-cat-missing: #aa8b43;
+ --color-cat-appeal: #5577a4;
+ --color-cat-fraud: #338c66;
+ --color-cat-safety: #5b6887;
+ --color-cat-terrorism: #a34c4c;
+ --color-cat-private: #8f6a46;
+ --color-cat-travel: #c27a3a;
+ --color-cat-health: #4ca38c;
+ --color-cat-intel: #7a6eab;
+ --color-cat-emergency: #b85c4a;
+ --color-cat-environment: #4a8b6e;
+ --color-cat-disease: #c45e8a;
+ --color-cat-maritime: #2a7a9b;
+ --color-cat-logistics: #8b6e4a;
+ --color-cat-legislative: #6b7f45;
+}
diff --git a/vite.config.ts b/vite.config.ts
index 20cc850..d3ab352 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -26,13 +26,32 @@ const appVersion =
export default defineConfig({
base: process.env.BASE_PATH ?? "/",
- plugins: [react(), tailwindcss()],
+ plugins: [
+ react(),
+ tailwindcss(),
+ {
+ name: "mobile-spa-fallback",
+ configureServer(server) {
+ server.middlewares.use((req, _res, next) => {
+ if (req.url && /^\/mobile(\/(?!index\.html).*)?\/?(\?.*)?$/.test(req.url)) {
+ req.url = "/mobile/index.html";
+ }
+ next();
+ });
+ },
+ },
+ ],
build: {
rollupOptions: {
+ input: {
+ main: path.resolve(__dirname, "index.html"),
+ mobile: path.resolve(__dirname, "mobile/index.html"),
+ },
output: {
manualChunks(id) {
if (!id.includes("node_modules")) return undefined;
- if (id.includes("leaflet") || id.includes("three")) return "vendor-map";
+ if (id.includes("three")) return "vendor-three";
+ if (id.includes("leaflet")) return "vendor-leaflet";
if (id.includes("react") || id.includes("scheduler")) return "vendor-react";
if (id.includes("lucide-react")) return "vendor-icons";
return "vendor";